Compare commits
43
Commits
b3add0162d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b6a319e7 | ||
|
|
ace8aaaef6 | ||
|
|
944a1e348b | ||
|
|
ef947e5bb0 | ||
|
|
d00c76241c | ||
|
|
40ee9f1e83 | ||
|
|
9bc2e75fe5 | ||
|
|
1535481794 | ||
|
|
e34519113b | ||
|
|
2a8d34f85c | ||
|
|
21234e38cd | ||
|
|
a24ece9cf7 | ||
|
|
0137263441 | ||
|
|
e98b56eb03 | ||
|
|
2f5d2fc219 | ||
|
|
ac874e49e6 | ||
|
|
c3043e530a | ||
|
|
5c3c0e3de9 | ||
|
|
b074c1494e | ||
|
|
71c0d2122f | ||
|
|
d646c2f12f | ||
|
|
539e3eb58b | ||
|
|
59a392ee96 | ||
|
|
c1ee1d9dd9 | ||
|
|
ae85f23dd3 | ||
|
|
0e61f86eb5 | ||
|
|
92744c57de | ||
|
|
701ba67456 | ||
|
|
99a51e5a16 | ||
|
|
d57d2f62a0 | ||
|
|
3b5aee50e3 | ||
|
|
0a6dd0e419 | ||
|
|
0cd959a494 | ||
|
|
5f10b791d3 | ||
|
|
1a3b560678 | ||
|
|
7e610b5219 | ||
|
|
bbccccc195 | ||
|
|
a05a8ada92 | ||
|
|
567422f2e5 | ||
|
|
f0a6d1c8c8 | ||
|
|
ec4bf105c4 | ||
|
|
7eb6af5d5f | ||
|
|
e5af291269 |
@@ -0,0 +1,41 @@
|
|||||||
|
name: Set up Java and Gradle
|
||||||
|
description: >-
|
||||||
|
Installs the repository's pinned Temurin JDK, then configures Gradle through the official
|
||||||
|
setup-gradle action — which validates every checked-in wrapper jar and manages the Gradle cache.
|
||||||
|
Every Gradle job used to carry the JDK block verbatim, so the JDK patch level lived in fifty-nine
|
||||||
|
places; every job also carried a separate three-line wrapper-validation step, so the pinned action
|
||||||
|
SHA lived in forty.
|
||||||
|
|
||||||
|
# Wrapper validation is INSIDE this action now.
|
||||||
|
#
|
||||||
|
# It could not be before, and the reason was not a GitHub limitation: .github/scripts/
|
||||||
|
# verify-gradle-wrapper.sh read every workflow job and required it to contain, literally and in this
|
||||||
|
# order, an `actions/checkout@` step, the exact three-field pinned wrapper-validation step, and then
|
||||||
|
# the Gradle invocation. That literalness was the whole guard — "this job validated the wrapper" had
|
||||||
|
# to be answerable from the workflow file alone — and it is what made the step uninlineable.
|
||||||
|
#
|
||||||
|
# That script is gone (it also byte-hashed all twelve workflow files, so a comment change needed a
|
||||||
|
# hash update, while an attacker with write access would simply have updated both). The guarantee it
|
||||||
|
# was protecting is now the official action's own: `gradle/actions/setup-gradle` validates all
|
||||||
|
# wrapper jars by default (`validate-wrappers`, default true), and the action is pinned to a full
|
||||||
|
# commit SHA here — which GitHub's own hardening guide calls the only immutable action reference.
|
||||||
|
#
|
||||||
|
# `actions/checkout` still cannot move here: a `./.github/actions/...` reference is resolved from the
|
||||||
|
# checked-out working copy, so this file does not exist until checkout has already run. A composite
|
||||||
|
# action cannot contain the step that makes itself readable.
|
||||||
|
#
|
||||||
|
# So a Gradle job is two lines — checkout, then this action.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
# Gradle's own caching, not setup-java's `cache: gradle`. The two cache the same directory with
|
||||||
|
# different keys, and running both is how a job restores one cache and saves the other.
|
||||||
|
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
with:
|
||||||
|
build-scan-publish: false
|
||||||
|
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
This policy is enforced by
|
This policy is enforced by
|
||||||
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
||||||
[`dependency-review-config.yml`](dependency-review-config.yml),
|
[`dependency-review-config.yml`](dependency-review-config.yml),
|
||||||
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and
|
[`../.trivyignore.yaml`](../.trivyignore.yaml), CODEOWNERS, and
|
||||||
[`../renovate.json`](../renovate.json).
|
[`../renovate.json`](../renovate.json).
|
||||||
|
|
||||||
## Execution and platform boundary
|
## Execution and platform boundary
|
||||||
@@ -73,7 +73,7 @@ dependencies; stale mirrors can delay detection.
|
|||||||
|
|
||||||
The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it
|
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
|
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
|
non-empty rationale, and a future expiry no more than 90 days away. A CODEOWNERS reviewer validates
|
||||||
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
|
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
|
||||||
Neither control substitutes for the other.
|
Neither control substitutes for the other.
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- [ ] I ran the focused test for each changed leaf.
|
- [ ] I ran focused `:<changed-leaf>:check` tasks for the modules I changed.
|
||||||
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`.
|
- [ ] I ran `cd src && ./gradlew architectureCheck verifyPublicPathSnapshot verifyDependencyLocks` when the change touched repository structure, dependencies, or public paths.
|
||||||
- [ ] I did not add an unregistered production module dependency.
|
- [ ] I did not add an unregistered production module dependency.
|
||||||
- [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification.
|
- [ ] 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.
|
- [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days.
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
#!/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'
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: reusable-gradle
|
||||||
|
|
||||||
|
# One place that knows how a Gradle job starts.
|
||||||
|
#
|
||||||
|
# Every job in this repository opened with the same preamble: checkout, a three-line pinned
|
||||||
|
# wrapper-validation step, then the JDK/cache action. The wrapper step is gone (setup-gradle
|
||||||
|
# validates wrappers itself), and this workflow removes the rest of the repetition for the jobs whose
|
||||||
|
# only variation is the Gradle command they run.
|
||||||
|
#
|
||||||
|
# Jobs that need service containers, a matrix, artifact uploads or per-job env stay written out with
|
||||||
|
# `./.github/actions/setup-gradle-java`, because expressing those through `workflow_call` inputs
|
||||||
|
# means encoding YAML inside strings — which is how a "shared" workflow becomes less readable than
|
||||||
|
# the duplication it replaced.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tasks:
|
||||||
|
description: The Gradle task list, whitespace-separated.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
gradle-args:
|
||||||
|
description: Flags appended after the task list.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "--stacktrace"
|
||||||
|
working-directory:
|
||||||
|
description: Directory the wrapper is invoked from.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: src
|
||||||
|
timeout-minutes:
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 30
|
||||||
|
continue-on-error:
|
||||||
|
description: Run the job as an advisory signal rather than a gate.
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gradle:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||||
|
continue-on-error: ${{ inputs.continue-on-error }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run ${{ inputs.tasks }}
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
env:
|
||||||
|
GRADLE_TASKS: ${{ inputs.tasks }}
|
||||||
|
GRADLE_ARGS: ${{ inputs.gradle-args }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Word-split on purpose: both inputs are task/flag lists. They come from this repository's
|
||||||
|
# own workflow files, never from a pull request.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
./gradlew ${GRADLE_TASKS} ${GRADLE_ARGS}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
name: ci-quality-gates
|
name: ci-quality-gates
|
||||||
|
|
||||||
|
# The pull-request gate. Everything here blocks a merge.
|
||||||
|
#
|
||||||
|
# The job list used to include `gate-matrix-lint`, which ran .github/scripts/verify-gate-matrix.sh
|
||||||
|
# against .github/ci-gate-matrix.yml: a 1,025-line register of all 107 CI controls, checked for
|
||||||
|
# consistency against the Gradle task graph and this workflow by a 568-line shell script, which was
|
||||||
|
# itself checked by contract tests in :app-bootstrap. Adding one check meant editing Gradle, a
|
||||||
|
# workflow, the matrix, the verifier's expectations and a Java test. The information was already in
|
||||||
|
# the task graph and the job graph; the matrix was a third copy that had to be kept equal to both.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
@@ -33,78 +42,146 @@ jobs:
|
|||||||
echo "::error::${snapshot} exists locally but is not committed."
|
echo "::error::${snapshot} exists locally but is not committed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
with:
|
# `ci`, not `check`. A leaf's `check` is that leaf's — compile, its tests, Spotless, Checkstyle
|
||||||
distribution: temurin
|
# and Error Prone — and the repository-wide gates are named tasks of their own:
|
||||||
java-version: "21.0.11+10"
|
# ci = every leaf check + architectureCheck + qualityCheck + configContractCheck + qualificationCheck
|
||||||
cache: gradle
|
# so CI runs strictly more than it used to while `./gradlew :domain-core:check` runs strictly
|
||||||
cache-dependency-path: |
|
# less.
|
||||||
src/**/*.gradle
|
- name: Run the pull-request gate
|
||||||
src/**/gradle-wrapper.properties
|
|
||||||
src/**/gradle.lockfile
|
|
||||||
- name: Check quality, public paths, and dependency locks
|
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --stacktrace
|
run: ./gradlew :ci :verifyPublicPathSnapshot :verifyDependencyLocks --warning-mode=fail --stacktrace
|
||||||
|
# Named as its own step because nothing else runs it: `check` does not depend on
|
||||||
|
# graphqlStableTest, so the lane's required-class guard — the check that its module-boundary
|
||||||
|
# suite has not silently stopped being discovered — would protect nothing in CI.
|
||||||
|
- name: Qualify the GraphQL Stable lane
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --stacktrace
|
||||||
|
- name: Qualify opt-in inbound transports without skips
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :conditionalTransportQualification --stacktrace
|
||||||
|
|
||||||
sample-off:
|
build-logic:
|
||||||
|
# Included-build tests are independent of the main project task graph. Running them as a
|
||||||
|
# separate blocking job keeps plugin TestKit work off the quality-gates critical path.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
with:
|
- name: Test the build-logic convention plugins
|
||||||
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
|
working-directory: src
|
||||||
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
run: ./gradlew -p build-logic test --stacktrace
|
||||||
|
|
||||||
gate-matrix-lint:
|
redis-sdk:
|
||||||
|
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit
|
||||||
|
# provenance, connection isolation, and the executor guard. There is no real-server lane yet.
|
||||||
|
#
|
||||||
|
# `verifyConfigurationPropertiesProcessor` used to be in this list. It is deleted: the parity it
|
||||||
|
# enforced — a leaf declares Spring's configuration processor exactly when it owns
|
||||||
|
# @ConfigurationProperties — is now what applying `ca.spring-config` means.
|
||||||
|
# `verifyEnvKeys` is no longer named here either; it belongs to :app-bootstrap and runs through
|
||||||
|
# `configContractCheck`, which the quality-gates job covers.
|
||||||
|
uses: ./.github/workflows/_reusable-gradle.yml
|
||||||
|
with:
|
||||||
|
tasks: ":shared-contract:edgeRateLimitContractTest"
|
||||||
|
|
||||||
|
optional-platforms:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
- name: Verify the gate matrix against the repository
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
run: bash .github/scripts/verify-gate-matrix.sh
|
- name: Verify the optional gRPC platform build
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew -p optional-platforms ci --stacktrace
|
||||||
|
|
||||||
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
|
configuration-cache:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Store configuration cache for the everyday core build
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:domain-core:check
|
||||||
|
:application-core:check
|
||||||
|
--configuration-cache
|
||||||
|
--configuration-cache-problems=fail
|
||||||
|
--stacktrace
|
||||||
|
- name: Require configuration-cache reuse
|
||||||
|
working-directory: src
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
output="$({ ./gradlew :domain-core:check :application-core:check \
|
||||||
|
--configuration-cache --configuration-cache-problems=fail --stacktrace; } 2>&1)"
|
||||||
|
printf '%s\n' "${output}"
|
||||||
|
grep -Fq 'Reusing configuration cache.' <<<"${output}" || {
|
||||||
|
echo '::error::Gradle did not reuse the configuration cache on the second identical build.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
jpa-candidate-evidence:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Produce zero-skip JPA candidate manifests
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Retain content-addressed JPA candidate manifests
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: jpa-candidate-evidence-${{ github.sha }}
|
||||||
|
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# Advisory. The quarantine bucket runs so a flaky test is still executed and reported; it never
|
||||||
|
# blocks. The 14-day sunset registry that used to make an expired quarantine entry a build failure
|
||||||
|
# is gone — it was a 250-line YAML-and-Java parser guarding a registry with zero entries.
|
||||||
quarantine:
|
quarantine:
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/_reusable-gradle.yml
|
||||||
continue-on-error: true
|
with:
|
||||||
steps:
|
tasks: ":quarantineTest"
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
gradle-args: "--stacktrace"
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
continue-on-error: true
|
||||||
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:
|
release-gate:
|
||||||
needs:
|
needs:
|
||||||
- quality-gates
|
- quality-gates
|
||||||
- sample-off
|
- build-logic
|
||||||
- gate-matrix-lint
|
- redis-sdk
|
||||||
|
- jpa-candidate-evidence
|
||||||
|
- optional-platforms
|
||||||
|
- configuration-cache
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Require every current blocking job to succeed
|
- name: Require every current blocking job to succeed
|
||||||
env:
|
env:
|
||||||
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
BUILD_LOGIC_RESULT: ${{ needs.build-logic.result }}
|
||||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
REDIS_RESULT: ${{ needs.redis-sdk.result }}
|
||||||
|
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||||
|
OPTIONAL_PLATFORMS_RESULT: ${{ needs.optional-platforms.result }}
|
||||||
|
CONFIGURATION_CACHE_RESULT: ${{ needs.configuration-cache.result }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
for result in "${QUALITY_RESULT}" "${SAMPLE_OFF_RESULT}" "${MATRIX_RESULT}"; do
|
for result in \
|
||||||
|
"${QUALITY_RESULT}" \
|
||||||
|
"${BUILD_LOGIC_RESULT}" \
|
||||||
|
"${REDIS_RESULT}" \
|
||||||
|
"${JPA_CANDIDATE_RESULT}" \
|
||||||
|
"${OPTIONAL_PLATFORMS_RESULT}" \
|
||||||
|
"${CONFIGURATION_CACHE_RESULT}"; do
|
||||||
if [[ "${result}" != "success" ]]; then
|
if [[ "${result}" != "success" ]]; then
|
||||||
echo "::error::release-gate: required job result was ${result}"
|
echo "::error::release-gate: required job result was ${result}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -35,15 +35,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
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
|
- name: Submit the resolved Gradle dependency graph
|
||||||
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
|
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
|
||||||
with:
|
with:
|
||||||
@@ -176,7 +168,10 @@ jobs:
|
|||||||
trivy-kev.json | sort -u > found-cves.txt
|
trivy-kev.json | sort -u > found-cves.txt
|
||||||
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
|
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
|
||||||
kev.json | sort -u > kev-cves.txt
|
kev.json | sort -u > kev-cves.txt
|
||||||
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
|
# No `|| true`. comm exits non-zero only when it cannot read or order its inputs, and
|
||||||
|
# swallowing that would have turned an unreadable CVE list into an empty intersection and
|
||||||
|
# printed "no catalog match" — a KEV cross-check that passes because it never ran.
|
||||||
|
hits="$(comm -12 found-cves.txt kev-cves.txt)"
|
||||||
if [[ -n "${hits}" ]]; then
|
if [[ -n "${hits}" ]]; then
|
||||||
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
|
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
|
||||||
printf '%s\n' "${hits}"
|
printf '%s\n' "${hits}"
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: fileserver-certification
|
||||||
|
|
||||||
|
# The certification a release must clear. Its job list is deliberately the same shape as the support
|
||||||
|
# matrix: nothing may be advertised at a support level whose evidence job is absent here.
|
||||||
|
#
|
||||||
|
# Named "certification", not "release", and the name is the point. This workflow proves a storage
|
||||||
|
# topology, a support matrix and a telemetry redaction claim. It deploys nothing and holds no cluster
|
||||||
|
# credential. Calling it `fileserver-release.yml` read as if GitHub Actions released the fileserver,
|
||||||
|
# which is the CI/CD boundary this repository has now fixed in docs/ci-cd/boundary.md: GitHub Actions
|
||||||
|
# tests, scans and publishes artifacts; Argo CD deploys.
|
||||||
|
#
|
||||||
|
# It used to be workflow_dispatch only, which made that sentence false: the four jobs below are the
|
||||||
|
# only place the fileserver support matrix, the PVC manifest and the telemetry redaction proof are
|
||||||
|
# checked, and a release tag reached none of them unless somebody remembered to press a button.
|
||||||
|
#
|
||||||
|
# `v*` is the only release tag. The adapter-scoped `fileserver-v*` pattern is gone: this repository
|
||||||
|
# has one deployable unit (app-bootstrap), so an adapter-scoped tag could only ever run a subset of
|
||||||
|
# the release gates and call the result a release — the tag-namespace split that release.yml exists
|
||||||
|
# to end.
|
||||||
|
#
|
||||||
|
# These four jobs stay in their own file, and not in release.yml, for one mechanical reason:
|
||||||
|
# FileserverDocumentationCoverageTest reads job ids out of `.github/workflows/fileserver-*.yml` and
|
||||||
|
# requires every `fileserver-...` job docs/fileserver/support-matrix.md names to be defined in one
|
||||||
|
# of them. Renaming the file or moving these jobs needs that document changed in the same change.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-full-verification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the architecture-wide dependency and module verification
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:verifyCleanArchitectureDependencies
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Run the complete fileserver suite across every leaf
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:check
|
||||||
|
:adapter:inbound:web:check
|
||||||
|
:adapter:outbound:fileserver:check
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-documentation-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-pvc-certification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
# This job checks the manifest, and only the manifest. It deliberately does not apply anything
|
||||||
|
# to a cluster.
|
||||||
|
#
|
||||||
|
# There used to be a second step here that applied the job to a release cluster when
|
||||||
|
# secrets.FILESERVER_PVC_KUBECONFIG was set and `exit 0`-ed with a ::warning:: when it was
|
||||||
|
# not. With no secret configured — which is every fork of this template and was this
|
||||||
|
# repository — the step printed a warning and the job went green under the name
|
||||||
|
# "fileserver-pvc-certification", so a release read as ReadWriteOnce-certified against a
|
||||||
|
# cluster nothing had ever touched. It also wrote a `certified` output that no job, step or
|
||||||
|
# script in this repository read.
|
||||||
|
#
|
||||||
|
# The cluster result comes from an operator running infra/fileserver/kubernetes/
|
||||||
|
# pvc-certification-job.yaml against a real cluster and recording it in
|
||||||
|
# docs/fileserver/storage-certification.md. That is registered as
|
||||||
|
# docs/fileserver/storage-certification.md, and the absence of a cluster result is stated
|
||||||
|
# there rather than hidden behind a green check.
|
||||||
|
- name: Check the certification manifest still says what the claim depends on
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
test -f "$manifest"
|
||||||
|
grep -q 'kind: PersistentVolumeClaim' "$manifest"
|
||||||
|
grep -q 'kind: Job' "$manifest"
|
||||||
|
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
|
||||||
|
# mode would certify a topology the support matrix says is uncertified.
|
||||||
|
grep -q 'ReadWriteOnce' "$manifest"
|
||||||
|
! grep -q 'ReadWriteMany' "$manifest"
|
||||||
|
|
||||||
|
fileserver-sensitive-telemetry-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test --tests '*FileserverObservabilityTest'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
name: fileserver-nightly
|
||||||
|
|
||||||
|
# The environments that cannot run on every pull request: a real network filesystem, a foreign
|
||||||
|
# filesystem, and the long-running fault matrices. They are nightly rather than skipped because a
|
||||||
|
# green pull-request run is not certification of any of them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 18 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-nfs-ambiguity:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
FILESERVER_NFS_TESTS: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Start the NFSv4 certification environment
|
||||||
|
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
|
||||||
|
- name: Run the network-filesystem ambiguity suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Tear down the NFS environment
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f infra/fileserver/nfs/compose.yml down -v
|
||||||
|
|
||||||
|
fileserver-process-kill-matrix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the crash matrix and reconciliation suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
|
||||||
|
:application-core:test --tests '*FileReconciliationServiceTest'
|
||||||
|
--rerun-tasks
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-large-file-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the large-file and slow-client suites under a constrained heap
|
||||||
|
working-directory: src
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Xmx512m
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
|
||||||
|
--rerun-tasks
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-multi-instance-lease:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Prove no run commits bytes from a stale lease
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test --tests '*MultiInstanceWriterLeaseTest'
|
||||||
|
--rerun-tasks
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
name: fileserver-pr
|
||||||
|
|
||||||
|
# Every claim in docs/fileserver/support-matrix.md that says "Stable" is backed by a job here.
|
||||||
|
# A support level with no job behind it is a marketing claim, not an engineering one, and
|
||||||
|
# DocumentationCoverageTest fails the build when the two drift apart.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/application-core/src/**/fileserver/**'
|
||||||
|
- 'src/adapter/inbound/web/src/**/fileserver/**'
|
||||||
|
- 'src/adapter/outbound/fileserver/**'
|
||||||
|
- 'src/adapter/outbound/persistence-jpa/src/**/fileserver/**'
|
||||||
|
- 'src/app-bootstrap/src/**/fileserver/**'
|
||||||
|
- 'docs/fileserver/**'
|
||||||
|
# The capability is not only its Java files. A change to the bound settings, the shipped
|
||||||
|
# environment, the registry that documents it, or the container that has to give it a
|
||||||
|
# writable volume changes how it behaves at runtime just as surely — and those were the
|
||||||
|
# exact files that could previously ship unverified.
|
||||||
|
- 'src/app-bootstrap/src/main/resources/application.yml'
|
||||||
|
- 'src/.env'
|
||||||
|
- 'docs/registries/env-keys.yaml'
|
||||||
|
- 'src/Dockerfile'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'infra/fileserver/nginx/**'
|
||||||
|
- 'infra/fileserver/kubernetes/**'
|
||||||
|
- 'infra/fileserver/nfs/**'
|
||||||
|
- '.github/workflows/fileserver-pr.yml'
|
||||||
|
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||||
|
# it changes what this gate runs.
|
||||||
|
- '.github/actions/setup-gradle-java/action.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-unit-and-architecture:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the fileserver application and architecture suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test
|
||||||
|
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --tests '*Fileserver*'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-local-ext4-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Certify the local content store against the shared contract
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-http-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the servlet and reactive transport contracts
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-security-suite:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the path, filename, range, and problem-detail hardening suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
|
||||||
|
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-bounded-memory:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Prove transfer cost does not scale with file size
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||||
|
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
name: integration-main
|
||||||
|
|
||||||
|
# Stage 2: is the merged state healthy.
|
||||||
|
#
|
||||||
|
# The question this stage answers is different from stage 1's. Stage 1 asks whether a diff is safe
|
||||||
|
# and blocks a merge; stage 2 asks whether main is healthy and does not — the merge has already
|
||||||
|
# happened. That difference is the point, and it is what lets a control exist without being an
|
||||||
|
# obstacle: a gate here still fails loudly, it just fails after the thing it is reporting on.
|
||||||
|
#
|
||||||
|
# Two kinds of work live here.
|
||||||
|
#
|
||||||
|
# 1. The lanes that need a machine that is not simultaneously compiling something else — load,
|
||||||
|
# abuse, graceful shutdown, TCP fault injection, resource bounds. They were web-nightly.yml and
|
||||||
|
# httpclient-nightly.yml, two module-shaped files whose only real difference was the cadence they
|
||||||
|
# shared. They now run on every push to main as well as nightly, which is strictly more often
|
||||||
|
# than before.
|
||||||
|
#
|
||||||
|
# 2. Lanes that were registered in Gradle and invoked by nothing. Ten Gradle tasks — six MongoDB
|
||||||
|
# container lanes, app-bootstrap's Testcontainers `integrationTest`, and the three messaging
|
||||||
|
# evidence tasks that `verifyMessagingContracts` reaches — existed, failed closed, and executed
|
||||||
|
# in no workflow. A lane nobody runs is not coverage; it is a file that looks like coverage. They
|
||||||
|
# are here rather than in stage 1 because every one of them either starts containers or re-runs
|
||||||
|
# suites the PR gate already covers, and the pull-request budget is minutes for the whole gate.
|
||||||
|
#
|
||||||
|
# What is deliberately NOT here: the web and WebSocket "Advanced capability" nightly lanes that used
|
||||||
|
# to exist as web-advanced-nightly.yml and websocket-advanced-nightly.yml. Both leaves' build files
|
||||||
|
# say it outright — "They also run inside `test`, deliberately ... excluding them from the PR gate to
|
||||||
|
# make this lane look meaningful would mean the PR gate stopped covering a fifth of the leaf" — so
|
||||||
|
# `webAdvancedTest` and `websocketAdvancedTest` select tagged tests that `:<leaf>:test` already runs,
|
||||||
|
# and `:<leaf>:test` runs inside the root `check` on every pull request and every push to main. The
|
||||||
|
# strict lanes themselves survive in release.yml, where their fail-on-nothing-discovered guard is
|
||||||
|
# worth a job.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
schedule:
|
||||||
|
# 03:00 UTC. Late enough that the day's merges are in, early enough that a failure is triaged
|
||||||
|
# before the next working day starts.
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
# The documentation-drift gates that used to run here are gone rather than demoted.
|
||||||
|
#
|
||||||
|
# They were four hand-written parsers: README shell blocks compared against the Gradle task graph,
|
||||||
|
# runbook identifiers compared against every declared Java type, a leaf count written in prose
|
||||||
|
# compared against the registry, and a Markdown table compared against the declared source sets.
|
||||||
|
# Each was a custom parser for a file format nobody controls, and each made a documentation edit a
|
||||||
|
# precondition for a build. A stale sentence is a defect, but it is not one a build can be failed
|
||||||
|
# for, and link-check.yml already answers the one documentation question with a stable machine
|
||||||
|
# answer: does this link resolve.
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Load, abuse and graceful shutdown measure behaviour that degrades gradually rather than breaking
|
||||||
|
# outright — which is exactly the kind of regression a per-PR gate never catches.
|
||||||
|
web-load-abuse-and-shutdown:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the load, abuse and shutdown lanes on every container
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test
|
||||||
|
:adapter:inbound:web:webJettyCompatTest
|
||||||
|
:adapter:inbound:web:webFluxContractTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Publish the test reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: web-integration-reports
|
||||||
|
path: src/adapter/inbound/web/build/reports/tests/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# Needs a container runtime and real time (design §29). Separated from the per-PR gate rather than
|
||||||
|
# made optional inside it: a lane that cannot run here fails, it does not skip.
|
||||||
|
httpclient-fault-injection:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Inject TCP faults against a real upstream
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
# A project property rather than a command-line flag, so the run command stays a plain,
|
||||||
|
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Certify pool, streaming, retry, and rotation bounds
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-http3-experimental:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Experimental by design (D-08): the result is reported, never used to block a merge. Registered
|
||||||
|
# advisory so that "this job cannot fail the
|
||||||
|
# build" is written down rather than inferred from a field two hundred lines into a workflow.
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Exercise the experimental HTTP/3 opt-in
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:test
|
||||||
|
-Phttp3.tests.enabled=true
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# The six Docker-backed MongoDB lanes. Until now they ran in no workflow at all: the leaf excludes
|
||||||
|
# every one of their tags from `test` (build.gradle "Docker-backed lanes are excluded from the
|
||||||
|
# default unit run"), `check` gains only the hermetic `mongoStableContractTest`, and the only thing
|
||||||
|
# that named them was scripts/verify-mongodb-platform.sh, which nothing in .github invokes. Six
|
||||||
|
# lanes that fail closed without Docker, and no machine with Docker was ever asked to run them.
|
||||||
|
#
|
||||||
|
# Stage 2 rather than stage 1 because each lane starts real MongoDB containers — mongo:8.0.16,
|
||||||
|
# mongo:7.0.28 and a Toxiproxy in front of a three-node replica set. That is minutes per lane, and
|
||||||
|
# the pull-request budget is minutes for the whole gate.
|
||||||
|
#
|
||||||
|
# One single-line `./gradlew <task>` step per lane, not one folded command running six, because
|
||||||
|
# These command lines name each lane explicitly so that a lane which stops being invoked is
|
||||||
|
# actually executed — a folded command would leave six matrix rows unverifiable. It also means a
|
||||||
|
# red replica-set lane does not hide the compatibility lane behind it.
|
||||||
|
mongo-container-lanes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
# Reuse would hand the failover lane a replica set another lane had already faulted.
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Single-node replica set contract lane
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:persistence-mongo:mongoReplicaSetTest --stacktrace
|
||||||
|
- name: Run MongoDB integration lanes
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-mongo:mongoReplicaSetTest
|
||||||
|
:adapter:outbound:persistence-mongo:mongoFailoverTest
|
||||||
|
:adapter:outbound:persistence-mongo:mongoMigrationTest
|
||||||
|
:adapter:outbound:persistence-mongo:mongoCompatibilityTest
|
||||||
|
:adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest
|
||||||
|
:adapter:outbound:persistence-mongo:mongoPerformanceTest
|
||||||
|
--stacktrace
|
||||||
|
- name: Publish the MongoDB lane reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: mongo-lane-reports
|
||||||
|
path: src/adapter/outbound/persistence-mongo/build/reports/tests/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# The messaging contract evidence DAG. `verifyMessagingContracts` is the root of a chain that ran
|
||||||
|
# nowhere: it depends on four production qualification tasks (application-core,
|
||||||
|
# shared-contract and two in adapter:outbound:messaging), each of which depends on
|
||||||
|
# `prepareMessagingContractEvidence`; it is finalizedBy
|
||||||
|
# `validateMessagingContractsEvidenceManifestSchema`; and it depends on
|
||||||
|
# `validateMessagingJsonSchemaV1EvidenceManifestSchema`, which depends on
|
||||||
|
# `verifyMessagingJsonSchemaV1`. Strict qualification tasks are registered outside `check` by
|
||||||
|
# design (ca.strict-qualification.gradle), so none of the seven was reachable from any workflow.
|
||||||
|
#
|
||||||
|
# The schema validators are the part that matters. They re-read the manifest bytes the run just
|
||||||
|
# wrote and validate them against config/messaging/evidence/build-evidence-manifest-v1.schema.json
|
||||||
|
# — a manifest that claims a qualification nobody executed is exactly the failure they exist to
|
||||||
|
# catch, and until now nothing executed them either.
|
||||||
|
#
|
||||||
|
# Stage 2 rather than stage 1: no containers, but it runs four qualification suites across three
|
||||||
|
# leaves plus two JavaExec validators, and the tests it re-runs are already inside the PR gate's
|
||||||
|
# `check`. What this job adds is the evidence manifest, which is a main-branch artifact.
|
||||||
|
messaging-contract-evidence:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Qualify the messaging contract, catalog, binding and schema evidence
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :verifyMessagingContracts --stacktrace
|
||||||
|
- name: Publish the messaging evidence manifest
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: messaging-contract-evidence
|
||||||
|
path: src/build/messaging-evidence/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# app-bootstrap's Testcontainers lane. The leaf gave it a source set of its own precisely so that
|
||||||
|
# `./gradlew :app-bootstrap:test` would not require a Docker daemon — and the consequence nobody
|
||||||
|
# closed is that a source set outside `test` is also outside `check`, so the real-PostgreSQL
|
||||||
|
# outbox and idempotency contracts compiled on every build and executed on none.
|
||||||
|
bootstrap-integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the real-PostgreSQL integration contracts
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :app-bootstrap:integrationTest --stacktrace
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: jpa-next
|
||||||
|
|
||||||
|
# Advisory compatibility probes for future JPA/Hibernate/PostgreSQL majors. These targets are not
|
||||||
|
# resolved by the current build, so the artifact records NOT_EXECUTABLE rather than implying that a
|
||||||
|
# green policy test is compatibility evidence.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * 1'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compatibility:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- id: jakarta-persistence-4
|
||||||
|
target: Jakarta Persistence 4
|
||||||
|
coordinate: jakarta.persistence:jakarta.persistence-api:4.x
|
||||||
|
test: "*CompatibilityLaneDefinitionTest"
|
||||||
|
reason: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it
|
||||||
|
- id: hibernate-8
|
||||||
|
target: Hibernate 8
|
||||||
|
coordinate: org.hibernate.orm:hibernate-core:8.x
|
||||||
|
test: "*HibernateCompatibilityPolicyTest"
|
||||||
|
reason: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it
|
||||||
|
- id: postgresql-19
|
||||||
|
target: PostgreSQL 19
|
||||||
|
coordinate: postgres:19-alpine
|
||||||
|
test: "*ExperimentalPromotionGateTest"
|
||||||
|
reason: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the current-runtime policy probe
|
||||||
|
id: compatibility-probe
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:test
|
||||||
|
--tests '${{ matrix.test }}'
|
||||||
|
--stacktrace
|
||||||
|
- name: Record what this lane did and did not execute
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
PROBE_OUTCOME: ${{ steps.compatibility-probe.outcome }}
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
TARGET_COORDINATE: ${{ matrix.coordinate }}
|
||||||
|
REASON: ${{ matrix.reason }}
|
||||||
|
run: |
|
||||||
|
mkdir -p compatibility-evidence
|
||||||
|
{
|
||||||
|
echo "target=${TARGET}"
|
||||||
|
echo "target-coordinate=${TARGET_COORDINATE}"
|
||||||
|
echo "status=NOT_EXECUTABLE"
|
||||||
|
echo "probe-result=${PROBE_OUTCOME}"
|
||||||
|
echo "reason=${REASON}"
|
||||||
|
echo "what-ran=the current runtime's own policy and lane-definition tests"
|
||||||
|
echo "sha=${{ github.sha }}"
|
||||||
|
} > compatibility-evidence/status.properties
|
||||||
|
echo "::notice::${TARGET} compatibility is NOT_EXECUTABLE: ${REASON}"
|
||||||
|
- name: Upload the compatibility status
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: compatibility-status-${{ matrix.id }}
|
||||||
|
path: compatibility-evidence/status.properties
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: jpa-nightly
|
||||||
|
|
||||||
|
# The suites that are too slow, too Docker-heavy, or too machine-dependent for a PR, and the middle
|
||||||
|
# of the PostgreSQL matrix.
|
||||||
|
#
|
||||||
|
# The failure-injection lane is the one that matters most and is easiest to lose: it is the only
|
||||||
|
# place the commit-ambiguity scenarios run, and they are the only evidence that a lost commit
|
||||||
|
# acknowledgement produces completion-unknown rather than a retry.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
# 02:30 UTC daily.
|
||||||
|
- cron: '30 2 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
jpa-full-matrix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
postgresql: ["16", "17", "18"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||||
|
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
jpa-failure-injection:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformFailureTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
jpa-query-plan-and-security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the query plan and database security suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
jpa-pool-pressure:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Verify pool saturation and REQUIRES_NEW connection behaviour
|
||||||
|
working-directory: src
|
||||||
|
# A behaviour contract, not a measurement. This step used to switch assertions off with an
|
||||||
|
# explicit property and call the result a certification, so the only threshold it ever
|
||||||
|
# asserted was that thresholds were not being asserted. What
|
||||||
|
# it checks now — that REQUIRES_NEW needs two connections per concurrent thread, that a
|
||||||
|
# saturated pool reports its pending count, that a caller waits rather than proceeding
|
||||||
|
# without a connection — is true on any runner, so there is nothing to switch off.
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: jpa-r2-evidence
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
jpa-r2-evidence:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
JPA_EVIDENCE_PROFILE: r2
|
||||||
|
JPA_EVIDENCE_CI_JOB: >-
|
||||||
|
actions:${{ github.workflow }}:${{ github.run_id }}:${{ github.job }}
|
||||||
|
JPA_EVIDENCE_ARTIFACT_LOCATION: >-
|
||||||
|
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Verify the production-profile JPA R2 manifest DAG
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
|
||||||
|
-PjpaEvidenceProfile=r2
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Retain JPA R2 attempt manifests
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: jpa-r2-evidence-${{ github.sha }}-${{ github.run_id }}
|
||||||
|
path: src/adapter/outbound/persistence-jpa/build/jpa-evidence/manifests
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: jpa-release
|
||||||
|
|
||||||
|
# The release registry is the gate-task source: jpaReleaseQualification reads its blocking gates,
|
||||||
|
# while JpaReleaseRenderingTest holds this file's matrix and promotion lists to the registry's Stable
|
||||||
|
# majors and verifyJpaReleaseGateTasks resolves each declared task against the real Gradle graph.
|
||||||
|
# CI therefore owns release scheduling, not a second JPA gate-task inventory.
|
||||||
|
#
|
||||||
|
# The matrix below is therefore not free to drift: editing it without editing the registry fails the
|
||||||
|
# unit lane.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# One job per PostgreSQL major, because one job for three majors was one job for one major.
|
||||||
|
#
|
||||||
|
# `-Pjpa.matrix.versions=16,17,18` reached JpaPlatformContractSupport.start(), which started
|
||||||
|
# selectedVersions().get(0) — so twenty-eight integration classes ran against PG16 and nothing
|
||||||
|
# ran against 17 or 18, while docs/jpa/support-matrix.md recorded all three as "full contract
|
||||||
|
# suite, release lane". A JSONB mapping, a Hibernate dialect difference or a Flyway upgrade that
|
||||||
|
# only breaks on 18 shipped with a green release.
|
||||||
|
#
|
||||||
|
# start() now fails closed on a multi-version selection, so the fan-out is not optional: the
|
||||||
|
# matrix is the only way the three majors get covered, and removing a major from it removes the
|
||||||
|
# evidence rather than quietly reusing another major's.
|
||||||
|
jpa-release-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
postgresql: ["16", "17", "18"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the JPA database qualification set on PostgreSQL ${{ matrix.postgresql }}
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
jpaReleaseQualification
|
||||||
|
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||||
|
--stacktrace
|
||||||
|
- name: Record which major this evidence covers
|
||||||
|
if: always()
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
mkdir -p build/jpa-release-evidence
|
||||||
|
{
|
||||||
|
echo "sha=${{ github.sha }}"
|
||||||
|
echo "ref=${{ github.ref }}"
|
||||||
|
echo "postgresql-major=${{ matrix.postgresql }}"
|
||||||
|
echo "task-set=jpa-database-qualification"
|
||||||
|
} > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties"
|
||||||
|
- name: Upload the release evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: jpa-release-evidence-pg${{ matrix.postgresql }}
|
||||||
|
path: |
|
||||||
|
src/build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties
|
||||||
|
src/adapter/outbound/persistence-jpa/build/test-results/**/*.xml
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The promotion decision. Three majors' evidence, and all three must come from this SHA — an
|
||||||
|
# aggregate that accepted a re-run artifact from another commit would promote a release on
|
||||||
|
# evidence produced by different code.
|
||||||
|
jpa-release-promotion:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
needs: jpa-release-gate
|
||||||
|
steps:
|
||||||
|
- name: Download every major's evidence
|
||||||
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
|
||||||
|
with:
|
||||||
|
pattern: jpa-release-evidence-pg*
|
||||||
|
path: evidence
|
||||||
|
- name: Require all three majors, all from this SHA
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
missing=0
|
||||||
|
for major in 16 17 18; do
|
||||||
|
manifest=$(find evidence -name "manifest-${major}.properties" -print -quit)
|
||||||
|
if [ -z "${manifest}" ]; then
|
||||||
|
echo "::error::no release evidence for PostgreSQL ${major}"
|
||||||
|
missing=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
sha=$(sed -n 's/^sha=//p' "${manifest}")
|
||||||
|
if [ "${sha}" != "${{ github.sha }}" ]; then
|
||||||
|
echo "::error::PostgreSQL ${major} evidence is from ${sha}, not ${{ github.sha }}"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "${missing}" -ne 0 ]; then
|
||||||
|
echo "::error::the release gate covers three PostgreSQL majors; promotion needs all three"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PostgreSQL 16, 17 and 18 evidence all present and all from ${{ github.sha }}."
|
||||||
|
|
||||||
|
jpa-architecture-and-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Verify architecture boundaries and the support matrix
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:verifyJpaReleaseGateTasks
|
||||||
|
:verifyJpaReadinessRegistry
|
||||||
|
:verifyCleanArchitectureDependencies
|
||||||
|
checkstyleMain
|
||||||
|
:app-bootstrap:architectureTest
|
||||||
|
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
@@ -5,6 +5,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "README.md"
|
- "README.md"
|
||||||
- "src/README.md"
|
- "src/README.md"
|
||||||
|
- "src/**/README.md"
|
||||||
|
- "src/**/CLAUDE.md"
|
||||||
- "docs/**/*.md"
|
- "docs/**/*.md"
|
||||||
- ".github/**/*.md"
|
- ".github/**/*.md"
|
||||||
- ".github/workflows/link-check.yml"
|
- ".github/workflows/link-check.yml"
|
||||||
@@ -13,6 +15,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "README.md"
|
- "README.md"
|
||||||
- "src/README.md"
|
- "src/README.md"
|
||||||
|
- "src/**/README.md"
|
||||||
|
- "src/**/CLAUDE.md"
|
||||||
- "docs/**/*.md"
|
- "docs/**/*.md"
|
||||||
- ".github/**/*.md"
|
- ".github/**/*.md"
|
||||||
- ".github/workflows/link-check.yml"
|
- ".github/workflows/link-check.yml"
|
||||||
@@ -38,6 +42,8 @@ jobs:
|
|||||||
--root-dir .
|
--root-dir .
|
||||||
README.md
|
README.md
|
||||||
src/README.md
|
src/README.md
|
||||||
|
'src/**/README.md'
|
||||||
|
'src/**/CLAUDE.md'
|
||||||
'docs/**/*.md'
|
'docs/**/*.md'
|
||||||
'.github/**/*.md'
|
'.github/**/*.md'
|
||||||
fail: true
|
fail: true
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# The messaging platform's broker certification lane.
|
||||||
|
#
|
||||||
|
# Separate from ci-quality-gates.yml because it needs a container runtime and several minutes of it.
|
||||||
|
# The lane deliberately carries no Docker guard: every other container suite in the messaging tree
|
||||||
|
# skips with a stated reason when Docker is absent, and a certification lane that skipped would
|
||||||
|
# report success for a broker nobody started — which is the exact claim the evidence exists to rule
|
||||||
|
# out.
|
||||||
|
#
|
||||||
|
# The job runs the evidence gate rather than the lane, and the gate depends on the lane. What it
|
||||||
|
# proves is not only that the scenarios pass but that the committed manifest
|
||||||
|
# (messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl) is what this
|
||||||
|
# run produced, so "certified against a live broker" cannot be restored by editing a file.
|
||||||
|
name: messaging-certification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "src/messaging/**"
|
||||||
|
- ".github/workflows/messaging-certification.yml"
|
||||||
|
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||||
|
# it changes what this gate runs.
|
||||||
|
- ".github/actions/setup-gradle-java/action.yml"
|
||||||
|
schedule:
|
||||||
|
- cron: "41 4 * * 3"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Reuse would hand one scenario the broker another scenario had already faulted.
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
broker-certification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Certify the Kafka adapter against a real broker
|
||||||
|
working-directory: src
|
||||||
|
# GITHUB_SHA is read by the lane and written into every evidence line, because "certified"
|
||||||
|
# is a claim about one source tree.
|
||||||
|
run: ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --stacktrace
|
||||||
|
- name: Publish the certification evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: messaging-broker-certification-evidence
|
||||||
|
path: src/messaging/messaging-kafka/build/messaging-certification/
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
name: notification-platform
|
||||||
|
|
||||||
|
# Verification tiers for the Notification Delivery Platform.
|
||||||
|
#
|
||||||
|
# The PR tier is deliberately free of any external provider. A gate that depends on a third-party
|
||||||
|
# sandbox fails for reasons that have nothing to do with the change under review, and a gate people
|
||||||
|
# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where
|
||||||
|
# a failure is an environment signal rather than a merge blocker.
|
||||||
|
#
|
||||||
|
# Every job that invokes Gradle validates the wrapper first with the repository's pinned action;
|
||||||
|
# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a
|
||||||
|
# compromised wrapper from turning any workflow run into arbitrary code execution.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
# The filter used to stop at the four notification source trees, so a change to the
|
||||||
|
# composition root, the settings binding, the schema migrations, or the evidence manifest
|
||||||
|
# ran none of this — and those are exactly the surfaces that decide whether the platform
|
||||||
|
# assembles, binds and migrates at all.
|
||||||
|
- 'src/application-core/src/**/notification/**'
|
||||||
|
- 'src/adapter/outbound/notification/**'
|
||||||
|
- 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
|
||||||
|
- 'src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/**'
|
||||||
|
- 'src/adapter/inbound/web/src/**/notification/**'
|
||||||
|
- 'src/app-bootstrap/src/**/notification/**'
|
||||||
|
- 'src/app-bootstrap/src/main/resources/application*.yml'
|
||||||
|
- 'src/gradle/notification-*.gradle'
|
||||||
|
- 'src/config/architecture/modules.json'
|
||||||
|
- 'src/.env'
|
||||||
|
- 'docs/notification/**'
|
||||||
|
- 'infra/notification/**'
|
||||||
|
- '.github/workflows/notification-platform.yml'
|
||||||
|
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||||
|
# it changes what this gate runs.
|
||||||
|
- '.github/actions/setup-gradle-java/action.yml'
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
schedule:
|
||||||
|
# Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier.
|
||||||
|
- cron: '0 17 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: notification-platform-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr:
|
||||||
|
name: contract (Java 21, no external provider)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Compile and format check
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
|
||||||
|
- name: Application contracts
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :application-core:test --console=plain
|
||||||
|
- name: Provider contract suite
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:notification:test --console=plain
|
||||||
|
- name: Persistence and web
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain
|
||||||
|
# The PR tier never touched a database, so every claim about migrations, claim atomicity and
|
||||||
|
# lease fencing rested on a fake. Docker is available on this runner; the lane fails closed
|
||||||
|
# when the container cannot start, because a skipped contract reports success for a database
|
||||||
|
# nobody tested.
|
||||||
|
- name: Notification schema and claim contracts (real PostgreSQL)
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest --console=plain
|
||||||
|
- name: Notification migration upgrade (real PostgreSQL)
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest --console=plain
|
||||||
|
- name: Architecture gates
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
./gradlew :verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :app-bootstrap:architectureTest --console=plain
|
||||||
|
- name: Configuration surface
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
./gradlew :app-bootstrap:verifyEnvKeys :verifyPublicPathSnapshot --console=plain
|
||||||
|
./gradlew :verifyNotificationApiSurface :verifyNotificationConfiguration --console=plain
|
||||||
|
# A support grade is a promise about production behaviour. This refuses one the pipeline
|
||||||
|
# cannot back — the check that would have caught five channels reading "Stable" while no
|
||||||
|
# request had ever left the process.
|
||||||
|
- name: Evidence manifest
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :verifyNotificationEvidence --console=plain
|
||||||
|
- name: Static analysis
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
|
||||||
|
|
||||||
|
nightly-chaos:
|
||||||
|
name: chaos (ambiguity, restart recovery, callback burst)
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
# This job is named for ambiguity, restart recovery and callback burst. It used to run a
|
||||||
|
# unit-test filter and then `test` — neither of which restarts anything or bursts anything —
|
||||||
|
# so the job name was the only place those three properties existed.
|
||||||
|
- name: Ambiguity and fault harness
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
|
||||||
|
- name: Concurrency and rotation races
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:notification:test --tests '*ConcurrencyTest' --tests '*ProviderRuntimeStateTest' --console=plain
|
||||||
|
- name: Restart recovery and lease fencing (real PostgreSQL)
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
|
||||||
|
- name: Notification regression suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test
|
||||||
|
:adapter:outbound:notification:test
|
||||||
|
:adapter:outbound:persistence-jpa:test
|
||||||
|
:adapter:inbound:web:test
|
||||||
|
--console=plain
|
||||||
|
# A filter that matches nothing passes. Each --tests filter above names a class that exists
|
||||||
|
# today; if one is renamed the job must fail rather than quietly stop covering it.
|
||||||
|
- name: Every named suite actually ran
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for suite in ChaosSecurity CrossProviderContractSuite ConcurrencyTest ProviderRuntimeStateTest; do
|
||||||
|
if ! find . -path '*/build/test-results/*' -name "*${suite}*.xml" | grep -q .; then
|
||||||
|
echo "no test results for ${suite}: the filter matched nothing and the job passed vacuously" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# There is no provider-sandbox job. It ran only on workflow_dispatch and could not succeed by
|
||||||
|
# any path: with no credentials its first step exit 1-ed, and with credentials the only test it
|
||||||
|
# ran was ProviderSandboxSmokeTest, whose body is an unconditional fail() saying a real sandbox
|
||||||
|
# call is not implemented. Its credential check read secrets.NOTIFICATION_SANDBOX_CREDENTIALS,
|
||||||
|
# which nothing in this repository consumes — the test reads NOTIFICATION_SANDBOX_ENABLED — so
|
||||||
|
# any non-empty string satisfied it and was then dropped.
|
||||||
|
#
|
||||||
|
# The unimplemented state is still stated in two places that do not depend on a workflow:
|
||||||
|
# ProviderSandboxSmokeTest itself, and the unsatisfied provider-wire-qualified claim in
|
||||||
|
# docs/notification/evidence-manifest.json, which verifyNotificationEvidence enforces inside
|
||||||
|
# check. When a real sandbox call is implemented, the job comes back with it.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
name: object-storage-qualification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "23 3 * * 2"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
run_protected_aws:
|
||||||
|
description: Run the protected AWS sandbox qualification lane
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
# AwsS3DirectTransferQualificationTest requires a second, separate authority
|
||||||
|
# (OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED) before the direct-transfer lane may run, and
|
||||||
|
# the job never supplied it. objectStorageAwsQualificationTest is a strict qualification task
|
||||||
|
# that requires both of its classes, so the lane could not be run to a pass from any input:
|
||||||
|
# dispatching it always failed on the missing variable. The authority now exists as its own
|
||||||
|
# input rather than as a constant, which is what "separate" was supposed to mean.
|
||||||
|
run_protected_aws_direct_mutation:
|
||||||
|
description: Also authorize the direct-transfer mutation lane against the sandbox bucket
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
minio-managed-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run exact-release MinIO managed contract
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --stacktrace
|
||||||
|
|
||||||
|
minio-managed-fault:
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --stacktrace
|
||||||
|
|
||||||
|
aws-managed-common-subset:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch'
|
||||||
|
&& inputs.run_protected_aws
|
||||||
|
&& inputs.run_protected_aws_direct_mutation
|
||||||
|
environment: object-storage-aws-qualification
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
|
||||||
|
OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED: ${{ inputs.run_protected_aws_direct_mutation }}
|
||||||
|
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
|
||||||
|
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
|
||||||
|
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run protected AWS common-subset qualification
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --stacktrace
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
name: pr-adapters
|
||||||
|
|
||||||
|
# Stage 1, the adapter half: the lanes a pull request must clear that `ci-quality-gates.yml` cannot
|
||||||
|
# reach.
|
||||||
|
#
|
||||||
|
# It replaces web-pr.yml, websocket-pr.yml, httpclient-contract.yml and jpa-pr.yml, which were four
|
||||||
|
# files split by module rather than by stage. Splitting by module is what made the duplication
|
||||||
|
# invisible: each file opened with its own "unit and architecture" job running
|
||||||
|
# `:<leaf>:test verifyCleanArchitectureDependencies`, and all four of those were already inside the
|
||||||
|
# root `check` that ci-quality-gates.yml runs on every pull request with no path filter. Four jobs,
|
||||||
|
# four runners, four Gradle configurations, zero additional coverage. They are gone; what is left
|
||||||
|
# here is only what `check` does not run.
|
||||||
|
#
|
||||||
|
# What `check` does not run, and therefore what this file is for:
|
||||||
|
# * lanes with their own source set — a second servlet container, a real Nginx, Reactor Netty;
|
||||||
|
# * lanes selected by a tag that `test` excludes — the cross-stack parity recording comparison;
|
||||||
|
# * lanes parameterised per run — one PostgreSQL major per job, one HTTP transport per job.
|
||||||
|
# Each of those genuinely cannot run inside `check`, which is the test for whether a job belongs
|
||||||
|
# here at all.
|
||||||
|
#
|
||||||
|
# Path filtering is per job rather than per workflow. The four files it replaces each carried an
|
||||||
|
# `on.pull_request.paths` list, so the whole file was skipped or run as a unit; a change touching
|
||||||
|
# web and JPA started two workflows and a change touching neither still started none. Here one
|
||||||
|
# `changes` job computes the answer once from the pull request's own diff and every lane reads it.
|
||||||
|
# The filter is a plain `git diff` rather than a filter action: this repository pins every action by
|
||||||
|
# commit SHA and adding a third-party action to compute a boolean is a supply-chain decision, not a
|
||||||
|
# convenience.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# One diff, read once. `workflow_dispatch` answers "everything changed", because a manual run is
|
||||||
|
# somebody asking for the lanes and there is no base ref to compare against.
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
web: ${{ steps.filter.outputs.web }}
|
||||||
|
websocket: ${{ steps.filter.outputs.websocket }}
|
||||||
|
httpclient: ${{ steps.filter.outputs.httpclient }}
|
||||||
|
jpa: ${{ steps.filter.outputs.jpa }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
# Both endpoints of the pull request's diff have to be present locally; the default
|
||||||
|
# shallow fetch has neither the base commit nor the merge base.
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Decide which adapter lanes this diff can affect
|
||||||
|
id: filter
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" != 'pull_request' ]; then
|
||||||
|
changed='ALL'
|
||||||
|
else
|
||||||
|
if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
|
||||||
|
echo "::error::pull request diff endpoints are missing; refusing to report no lanes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
changed="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")"
|
||||||
|
fi
|
||||||
|
# Fail closed rather than reporting "nothing changed": an empty diff on a pull request
|
||||||
|
# means the comparison did not work, and a filter that answers false on a broken
|
||||||
|
# comparison silently turns every lane below off.
|
||||||
|
if [ "${changed}" != 'ALL' ] && [ -z "${changed}" ]; then
|
||||||
|
echo "::error::the pull request diff is empty; the comparison did not run"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'changed files:\n%s\n' "${changed}"
|
||||||
|
emit() {
|
||||||
|
lane="$1"
|
||||||
|
shift
|
||||||
|
if [ "${changed}" = 'ALL' ]; then
|
||||||
|
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
||||||
|
printf 'lane %s: true (manual run)\n' "${lane}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for pattern in "$@"; do
|
||||||
|
if printf '%s\n' "${changed}" | grep -qE -- "${pattern}"; then
|
||||||
|
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
||||||
|
printf 'lane %s: true (%s)\n' "${lane}" "${pattern}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s=false\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
||||||
|
printf 'lane %s: false\n' "${lane}"
|
||||||
|
}
|
||||||
|
# This workflow and the composite action every lane below uses are in every lane's path
|
||||||
|
# set: a change to either changes what the lanes do, and a gate that does not re-run when
|
||||||
|
# its own definition changes is a gate nobody has seen run in its current form.
|
||||||
|
common='^\.github/workflows/pr-adapters\.yml$|^\.github/actions/'
|
||||||
|
emit web \
|
||||||
|
'^src/adapter/inbound/web/' \
|
||||||
|
'^src/application-core/src/.*/operation/' \
|
||||||
|
'^src/application-core/src/.*/idempotency/' \
|
||||||
|
'^src/adapter/outbound/persistence-jpa/src/.*/operation/' \
|
||||||
|
'^docs/web/' \
|
||||||
|
"${common}"
|
||||||
|
emit websocket \
|
||||||
|
'^src/adapter/inbound/websocket/' \
|
||||||
|
'^docs/websocket/' \
|
||||||
|
"${common}"
|
||||||
|
emit httpclient \
|
||||||
|
'^src/adapter/outbound/httpclient/' \
|
||||||
|
'^src/app-bootstrap/src/.*/httpclient/' \
|
||||||
|
'^docs/httpclient/' \
|
||||||
|
'^scripts/verify-httpclient-docs\.py$' \
|
||||||
|
"${common}"
|
||||||
|
emit jpa \
|
||||||
|
'^src/adapter/outbound/persistence-jpa/' \
|
||||||
|
'^src/app-bootstrap/src/.*/jpa/' \
|
||||||
|
'^src/config/architecture/modules\.json$' \
|
||||||
|
'^docs/jpa/' \
|
||||||
|
'^docs/adr/ADR-JPA-' \
|
||||||
|
'^infra/jpa/' \
|
||||||
|
"${common}"
|
||||||
|
|
||||||
|
# The parity gate depends on all three recording lanes and fails when one is missing, so it runs
|
||||||
|
# them itself rather than trusting a previous job to have left the recordings behind. Its tag is
|
||||||
|
# excluded from `test`, which is why `check` cannot cover it.
|
||||||
|
web-cross-stack-parity:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Compare the wire contract across Tomcat, Jetty and Reactor Netty
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:webCrossStackParityTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Publish the parity recordings
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: web-contract-parity
|
||||||
|
path: src/adapter/inbound/web/build/web-contract-parity/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy
|
||||||
|
# contract that quietly passes without a proxy has been certifying nothing since whenever the
|
||||||
|
# container runtime last broke.
|
||||||
|
web-nginx-proxy-contract:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the proxy, prefix and spoofing contract behind a real Nginx
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:webNginxProxyTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
websocket-container-matrix:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.websocket == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the runtime contract on the second servlet container
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:websocket:websocketJettyTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most
|
||||||
|
# common WebSocket deployment failure and it is invisible from either side alone.
|
||||||
|
websocket-nginx-contract:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.websocket == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the upgrade and forwarded-header contract behind a real Nginx
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:websocket:websocketNginxTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# One transport per job, so a transport that stops satisfying the shared contract fails on its own
|
||||||
|
# row instead of disappearing into an aggregate run. `check` runs this lane once, unparameterised.
|
||||||
|
httpclient-stable-contract:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.httpclient == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
transport: [apache, jdk, reactor]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Certify one transport against the shared contract
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||||
|
-Phttpclient.contract.transports=${{ matrix.transport }}
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# Only the Spring 7.0 lane. httpClientSecurityTest, httpClientBlockHoundTest and
|
||||||
|
# spring62ApiSurfaceScan used to run here too; all three are `dependsOn` of this leaf's `check`
|
||||||
|
# (src/adapter/outbound/httpclient/build.gradle), so ci-quality-gates.yml already ran them on the
|
||||||
|
# same pull request. spring70CompatibilityTest is deliberately outside `check` and is what is left.
|
||||||
|
httpclient-security-and-compatibility:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.httpclient == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the next-major Spring compatibility lane
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# 16 and 18 — the ends of the Stable matrix. 17 runs in the integration stage. What this does not
|
||||||
|
# do is skip the container lane on a runner without Docker: PostgreSqlContainerFactory throws,
|
||||||
|
# because a skipped contract reports success for a database nobody tested.
|
||||||
|
jpa-postgresql-contract:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.jpa == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
postgresql: ["16", "18"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||||
|
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
jpa-migration-smoke:
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.jpa == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the migration upgrade smoke scenarios
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Redis SDK topology evidence.
|
||||||
|
#
|
||||||
|
# The lanes in infra/redis-sdk answer what the deterministic in-memory gateway cannot — Sentinel
|
||||||
|
# promotion behaviour, Cluster redirects, ACL coverage. docs/redis/support-matrix.md records which
|
||||||
|
# lane produced which evidence, and RedisSupportMatrixTest refuses an evidence claim that does not
|
||||||
|
# name the test class behind it.
|
||||||
|
#
|
||||||
|
# Three cadences, because the cost and the question differ:
|
||||||
|
#
|
||||||
|
# pull_request standalone only, current supported version. The cheapest lane that can still
|
||||||
|
# catch "this change cannot talk to a real Redis at all". A PR gate that starts
|
||||||
|
# three topologies is a PR gate people learn to ignore.
|
||||||
|
# schedule the full supported-version x topology matrix, nightly. This is where Sentinel
|
||||||
|
# promotion and Cluster redirect evidence comes from.
|
||||||
|
# workflow_dispatch one lane on demand, for reproducing a specific failure.
|
||||||
|
#
|
||||||
|
# A release candidate uses the nightly matrix run for its tag: `release-candidate` selects the full
|
||||||
|
# matrix on demand so an RC does not have to wait for the next scheduled run.
|
||||||
|
#
|
||||||
|
# Each lane has its own endpoint. A sentinel is not a data node and a cluster node is not the whole
|
||||||
|
# cluster, so the address, port, and (for Sentinel) the monitored primary's name are per-lane rather
|
||||||
|
# than one hardcoded 6379 that happens to be right for standalone only.
|
||||||
|
#
|
||||||
|
# The Gradle task is fail-closed on its own account: an unknown mode, a missing endpoint, a lane
|
||||||
|
# with no tagged test class, and a run that executed zero tests are all errors. This workflow does
|
||||||
|
# not need to re-check those, but it does have to keep the evidence, which is why every run uploads
|
||||||
|
# the JUnit XML together with the commit SHA, the server version and the resolved image digest. An
|
||||||
|
# evidence artifact that cannot say which image produced it is not evidence.
|
||||||
|
name: redis-sdk-topology
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "src/adapter/outbound/cache-redis/**"
|
||||||
|
- "infra/redis-sdk/**"
|
||||||
|
- ".github/workflows/redis-sdk-topology.yml"
|
||||||
|
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||||
|
# it changes what this gate runs.
|
||||||
|
- ".github/actions/setup-gradle-java/action.yml"
|
||||||
|
schedule:
|
||||||
|
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
|
||||||
|
- cron: "30 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
topology:
|
||||||
|
description: standalone, sentinel, cluster, tls, or release-candidate for the full matrix
|
||||||
|
required: true
|
||||||
|
default: standalone
|
||||||
|
type: choice
|
||||||
|
options: [standalone, sentinel, cluster, tls, release-candidate]
|
||||||
|
redis_version:
|
||||||
|
description: server version tag
|
||||||
|
required: true
|
||||||
|
default: "7.4"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The matrix is computed rather than duplicated per trigger, so adding a supported version is one
|
||||||
|
# edit and no trigger can silently keep testing an old set.
|
||||||
|
lanes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.select.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- id: select
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "${{ github.event_name }}" in
|
||||||
|
pull_request)
|
||||||
|
matrix='{"include":[{"topology":"standalone","redis_version":"7.4"}]}'
|
||||||
|
;;
|
||||||
|
schedule)
|
||||||
|
matrix='{"include":[
|
||||||
|
{"topology":"standalone","redis_version":"7.2"},
|
||||||
|
{"topology":"standalone","redis_version":"7.4"},
|
||||||
|
{"topology":"standalone","redis_version":"8.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.4"},
|
||||||
|
{"topology":"sentinel","redis_version":"8.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.4"},
|
||||||
|
{"topology":"cluster","redis_version":"8.2"},
|
||||||
|
{"topology":"tls","redis_version":"7.4"},
|
||||||
|
{"topology":"tls","redis_version":"8.2"}]}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ "${{ inputs.topology }}" = "release-candidate" ]; then
|
||||||
|
matrix='{"include":[
|
||||||
|
{"topology":"standalone","redis_version":"7.2"},
|
||||||
|
{"topology":"standalone","redis_version":"7.4"},
|
||||||
|
{"topology":"standalone","redis_version":"8.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.4"},
|
||||||
|
{"topology":"sentinel","redis_version":"8.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.4"},
|
||||||
|
{"topology":"cluster","redis_version":"8.2"},
|
||||||
|
{"topology":"tls","redis_version":"7.4"},
|
||||||
|
{"topology":"tls","redis_version":"8.2"}]}'
|
||||||
|
else
|
||||||
|
matrix='{"include":[{"topology":"${{ inputs.topology }}","redis_version":"${{ inputs.redis_version }}"}]}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'matrix=%s\n' "$(printf '%s' "$matrix" | tr -d '\n ')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
topology-evidence:
|
||||||
|
needs: lanes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Start the topology
|
||||||
|
env:
|
||||||
|
REDIS_VERSION: ${{ matrix.redis_version }}
|
||||||
|
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" up -d --wait
|
||||||
|
- name: Record the image digest
|
||||||
|
id: image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
|
||||||
|
# reproduced once the tag moves.
|
||||||
|
#
|
||||||
|
# This used to end in `|| echo 'unresolved'`, which absorbed the failure that `set -e` was
|
||||||
|
# there to catch: the manifest below recorded `image_digest=unresolved`, the upload
|
||||||
|
# satisfied `if-no-files-found: error`, and the lane went green holding exactly the
|
||||||
|
# artifact this workflow's header calls "not evidence". Compose pulls the image in the
|
||||||
|
# step before this one, so RepoDigests is populated; if it is not, the run has nothing to
|
||||||
|
# certify and says so.
|
||||||
|
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
|
||||||
|
"redis:${{ matrix.redis_version }}")"
|
||||||
|
if [[ -z "$digest" ]]; then
|
||||||
|
echo "::error::no repository digest for redis:${{ matrix.redis_version }}; this run cannot say which image produced its evidence"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Run the topology contracts
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case '${{ matrix.topology }}' in
|
||||||
|
standalone) port=6379; extra='' ;;
|
||||||
|
sentinel) port=27010; extra='-Predis.topology.master=skeleton' ;;
|
||||||
|
cluster) port=7100; extra='' ;;
|
||||||
|
# The TLS lane's CA is generated at start-up, so the trust material is extracted from
|
||||||
|
# the lane rather than checked in. A checked-in key is a secret in the repository
|
||||||
|
# however loudly the file is named "test".
|
||||||
|
tls)
|
||||||
|
port=6390
|
||||||
|
docker compose -f ../infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt "$RUNNER_TEMP/redis-lane-ca.pem"
|
||||||
|
extra="-Predis.topology.trust-material=$RUNNER_TEMP/redis-lane-ca.pem"
|
||||||
|
;;
|
||||||
|
*) echo "unknown topology"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
./gradlew :adapter:outbound:cache-redis:redisTopologyTest --console=plain \
|
||||||
|
-Predis.topology.host=localhost \
|
||||||
|
-Predis.topology.port="$port" \
|
||||||
|
-Predis.topology.mode='${{ matrix.topology }}' \
|
||||||
|
$extra
|
||||||
|
- name: Write the evidence manifest
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
out=src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest
|
||||||
|
mkdir -p "$out"
|
||||||
|
cat > "$out/evidence-manifest.txt" <<MANIFEST
|
||||||
|
commit=${{ github.sha }}
|
||||||
|
workflow_run=${{ github.run_id }}
|
||||||
|
trigger=${{ github.event_name }}
|
||||||
|
topology=${{ matrix.topology }}
|
||||||
|
redis_version=${{ matrix.redis_version }}
|
||||||
|
image_digest=${{ steps.image.outputs.digest }}
|
||||||
|
MANIFEST
|
||||||
|
- name: Preserve the evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: redis-topology-${{ matrix.topology }}-${{ matrix.redis_version }}
|
||||||
|
path: |
|
||||||
|
src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest/**
|
||||||
|
src/adapter/outbound/cache-redis/build/reports/tests/redisTopologyTest/**
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
- name: Stop the topology
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" down -v
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Stage 3: produce a deployable artifact.
|
||||||
|
#
|
||||||
|
# One workflow, because there is one deployable unit. `app-bootstrap` is the composition root and
|
||||||
|
# the only thing a cluster runs; the adapters are leaves of that artifact, not independently
|
||||||
|
# shippable services. Eight files used to answer a release tag — web-release, web-advanced-release,
|
||||||
|
# websocket-release, httpclient-release, container-release, and the three that still have to live
|
||||||
|
# apart (see below) — and between them they ran `verifyCleanArchitectureDependencies` six times and
|
||||||
|
# `:app-bootstrap:test` four times for one release, on separate runners, with no job in any of them
|
||||||
|
# able to wait on a job in another.
|
||||||
|
#
|
||||||
|
# Tag scheme: `v*` only. The adapter-scoped patterns (`web-v*`, `websocket-v*`, `fileserver-v*`) are
|
||||||
|
# gone. They were the namespace-split bug: tagging `v1.2.3` and tagging `web-v1.2.3` ran different
|
||||||
|
# sets of gates, so a release could choose which gate it cleared, and the adapter-scoped half could
|
||||||
|
# not build an image because there is no per-adapter image to build.
|
||||||
|
#
|
||||||
|
# Two release workflows still stand outside this file, both for a mechanical reason rather than a
|
||||||
|
# design one:
|
||||||
|
# * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL
|
||||||
|
# matrix and promotion list to src/config/jpa/release-registry.json.
|
||||||
|
# * fileserver-certification.yml — FileserverDocumentationCoverageTest requires every job id named
|
||||||
|
# in docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`.
|
||||||
|
# It is named "certification" rather than "release" on purpose: it certifies a storage topology
|
||||||
|
# and a support matrix, it deploys nothing, and the CI/CD boundary in docs/ci-cd/boundary.md
|
||||||
|
# says GitHub Actions does not deploy.
|
||||||
|
# Folding either one in needs its src-side test (and, for fileserver, the support document) changed
|
||||||
|
# in the same commit. Until then the image job below cannot wait on them — a stated gap.
|
||||||
|
#
|
||||||
|
# The image job DOES now wait on the evidence jobs in this file, which is new: while the image build
|
||||||
|
# lived in its own workflow it could publish while a sibling suite was still running or already red,
|
||||||
|
# because `needs:` does not reach across workflows.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Never cancel a release in flight. A half-pushed manifest is worse than a slow one, and two runs
|
||||||
|
# for the same tag would race for the same registry tags.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The architecture-wide verification, once. Each of the four release workflows this file replaces
|
||||||
|
# ran `verifyCleanArchitectureDependencies` on its own runner, and three of them also ran the
|
||||||
|
# bootstrap architecture suite; the answers were identical because the input was one commit.
|
||||||
|
architecture-and-surface:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Verify architecture boundaries and the published surfaces
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:verifyCleanArchitectureDependencies
|
||||||
|
:verifyPublicPathSnapshot
|
||||||
|
:app-bootstrap:verifyEnvKeys
|
||||||
|
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
# Every web lane that `check` cannot reach. webCrossStackParityTest depends on `test`,
|
||||||
|
# webJettyCompatTest and webFluxContractTest, so naming it runs all four — which is what
|
||||||
|
# web-advanced-release.yml spent a separate 90-minute job doing by naming the three by hand.
|
||||||
|
#
|
||||||
|
# webAdvancedTest is here rather than in a nightly of its own. Its tests run inside
|
||||||
|
# `:adapter:inbound:web:test` by design, so the lane adds exactly one thing: it fails closed when
|
||||||
|
# the `web-advanced` tag selects nothing. That is worth asserting at a release and is not worth a
|
||||||
|
# workflow file and a runner every night.
|
||||||
|
web-stable-release-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run every web lane, Stable and Advanced
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:webCrossStackParityTest
|
||||||
|
:adapter:inbound:web:webNginxProxyTest
|
||||||
|
:adapter:inbound:web:webAdvancedTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Publish the release evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: web-release-evidence
|
||||||
|
path: |
|
||||||
|
src/adapter/inbound/web/build/web-contract-parity/
|
||||||
|
src/adapter/inbound/web/build/reports/tests/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
websocket-stable-release-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run every websocket lane, Stable and Advanced
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:websocket:test
|
||||||
|
:adapter:inbound:websocket:websocketJettyTest
|
||||||
|
:adapter:inbound:websocket:websocketNginxTest
|
||||||
|
:adapter:inbound:websocket:websocketTransportQualificationTest
|
||||||
|
:adapter:inbound:websocket:websocketAdvancedTest
|
||||||
|
|
||||||
|
--stacktrace
|
||||||
|
- name: Publish the release evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: websocket-release-evidence
|
||||||
|
path: src/adapter/inbound/websocket/build/reports/tests/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The three gRPC certification lanes. Their tests already run on every pull request — the
|
||||||
|
# `grpc-inprocess`, `grpc-netty` and `grpc-fault` tags are NOT excluded from
|
||||||
|
# `:grpc:grpc-testkit:test` (only `grpc-performance` is), and that task runs inside the root
|
||||||
|
# `check`. So this job adds exactly what the web and WebSocket Advanced lanes above add: the lane
|
||||||
|
# fails closed when its tag selects nothing, which is the one thing a tag-filtered suite inside
|
||||||
|
# `test` cannot tell you. A renamed or deleted @Tag would otherwise leave the in-process,
|
||||||
|
# transport and fault evidence grades claiming coverage that stopped existing.
|
||||||
|
#
|
||||||
|
# Release rather than nightly, for the same reason web-stable-release-gate is: these lanes need no
|
||||||
|
# container and no fixed cadence — grpcNettyContractTest opens an ephemeral socket, not a broker —
|
||||||
|
# so the guard is worth asserting once per tag and is not worth a runner every night.
|
||||||
|
#
|
||||||
|
# grpcPerformanceTest is deliberately absent. The leaf excludes it from `test` and says why: "a
|
||||||
|
# measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks
|
||||||
|
# for it, by name." It is recorded as a manual entrypoint in the repository README instead.
|
||||||
|
grpc-stable-release-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the gRPC Stable certification lanes
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew -p optional-platforms
|
||||||
|
:grpc:grpc-testkit:grpcInProcessContractTest
|
||||||
|
:grpc:grpc-testkit:grpcNettyContractTest
|
||||||
|
:grpc:grpc-testkit:grpcFaultTest
|
||||||
|
--stacktrace
|
||||||
|
- name: Publish the gRPC release evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: grpc-release-evidence
|
||||||
|
path: src/grpc/grpc-testkit/build/reports/tests/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# Each gate runs as its own single-line `./gradlew <task>` step so that a failure names the gate
|
||||||
|
# rather than a folded command. The architecture dependency gate that used to end this list is now
|
||||||
|
# architecture-and-surface above; it was the fourth copy of the same invocation.
|
||||||
|
httpclient-release-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src
|
||||||
|
env:
|
||||||
|
# A project property rather than a command-line flag, so each run command stays a plain,
|
||||||
|
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
|
- name: Run the HTTP client release qualification graph
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:test
|
||||||
|
:adapter:outbound:httpclient:spring62ApiSurfaceScan
|
||||||
|
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||||
|
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||||
|
:adapter:outbound:httpclient:httpClientSecurityTest
|
||||||
|
:adapter:outbound:httpclient:httpClientBlockHoundTest
|
||||||
|
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||||
|
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
|
||||||
|
httpclient-documentation:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Verify documentation matches the code
|
||||||
|
run: python3 scripts/verify-httpclient-docs.py
|
||||||
|
|
||||||
|
app-image-release:
|
||||||
|
needs:
|
||||||
|
- architecture-and-surface
|
||||||
|
- web-stable-release-gate
|
||||||
|
- websocket-stable-release-gate
|
||||||
|
- grpc-stable-release-gate
|
||||||
|
- httpclient-release-gate
|
||||||
|
- httpclient-documentation
|
||||||
|
# Job-level, because a job that declares `permissions:` replaces the workflow set entirely: this
|
||||||
|
# is the only job that writes anything anywhere, and `packages: write` stops at its boundary.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
# The builder stage inside src/Dockerfile runs this repository's Gradle wrapper to produce the
|
||||||
|
# JAR that becomes the image. Validating the wrapper here checks the thing that is about to
|
||||||
|
# execute, before it executes, rather than after an image already exists.
|
||||||
|
# The tag is the release identity; everything below derives from it. A tag that does not parse
|
||||||
|
# stops the release here, rather than producing an image named after whatever ref happened to
|
||||||
|
# be checked out.
|
||||||
|
#
|
||||||
|
# GHCR rejects an uppercase path, and this repository's owner is mixed case — the naive
|
||||||
|
# `ghcr.io/${{ github.repository }}` fails at push time with a message about the manifest
|
||||||
|
# rather than about the case, so the lowercasing is explicit and the result is asserted.
|
||||||
|
- name: Resolve the release coordinates
|
||||||
|
env:
|
||||||
|
CONFIGURED_IMAGE_NAME: ${{ vars.APP_IMAGE_NAME }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
readonly REGISTRY='ghcr.io'
|
||||||
|
if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then
|
||||||
|
echo "::error::container-release runs only for a release tag; ref type was ${GITHUB_REF_TYPE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
release_tag="${GITHUB_REF_NAME}"
|
||||||
|
# Bare MAJOR.MINOR.PATCH, because src/build.gradle's release-version guard refuses a
|
||||||
|
# pre-release or build suffix and the image tag must be the same string the JAR reports.
|
||||||
|
if [[ ! "${release_tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
|
echo "::error::release tag must be vMAJOR.MINOR.PATCH with no suffix; got '${release_tag}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
release_version="${BASH_REMATCH[1]}"
|
||||||
|
owner_path="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
image_name="${CONFIGURED_IMAGE_NAME:-${owner_path}/caskeleton}"
|
||||||
|
image_repository="${REGISTRY}/${image_name}"
|
||||||
|
if [[ "${image_repository}" != "${image_repository,,}" ]]; then
|
||||||
|
echo "::error::image repository must be lowercase; got '${image_repository}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${image_repository}" =~ [[:space:]] || "${image_repository}" == *:* ]]; then
|
||||||
|
echo "::error::image repository must carry no tag and no whitespace; got '${image_repository}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf 'REGISTRY=%s\n' "${REGISTRY}"
|
||||||
|
printf 'RELEASE_VERSION=%s\n' "${release_version}"
|
||||||
|
printf 'BUILD_VERSION=%s+%s\n' "${release_version}" "${GITHUB_SHA}"
|
||||||
|
printf 'IMAGE_REPOSITORY=%s\n' "${image_repository}"
|
||||||
|
printf 'IMAGE_VERSION_TAG=%s\n' "${release_version}"
|
||||||
|
printf 'IMAGE_REVISION_TAG=sha-%s\n' "${GITHUB_SHA}"
|
||||||
|
printf 'SOURCE_URL=%s/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
printf 'container-release: %s -> %s:%s and %s:sha-%s\n' \
|
||||||
|
"${release_tag}" "${image_repository}" "${release_version}" \
|
||||||
|
"${image_repository}" "${GITHUB_SHA}"
|
||||||
|
# Byte-identical to the install in dependency-vulnerability.yml, deliberately: the same
|
||||||
|
# checksum-pinned binary at the same version scans the filesystem and the image, so the two
|
||||||
|
# gates cannot disagree because one of them silently moved to a newer database schema.
|
||||||
|
#
|
||||||
|
# This repository installs its scanner rather than calling a scanner action, which is why no
|
||||||
|
# third-party action appears in this workflow: a pinned tarball with an asserted SHA-256 is a
|
||||||
|
# supply-chain claim that can be checked offline, and an action pinned to a commit is not.
|
||||||
|
- 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}"
|
||||||
|
# SOURCE_DATE_EPOCH is the commit time, not the wall clock, so the image metadata is a function
|
||||||
|
# of the commit rather than of when the runner happened to pick the job up. Verified locally,
|
||||||
|
# and worth stating exactly because it is easy to overclaim: BuildKit uses it for the image
|
||||||
|
# config `created` field and for every history timestamp — both came back as the commit time —
|
||||||
|
# and it does NOT rewrite file mtimes inside the layers. Those still carry the build time, so
|
||||||
|
# two builds of the same commit agree on metadata but their layer digests still differ.
|
||||||
|
# Byte-identical layers additionally need `--output type=image,rewrite-timestamp=true`, which
|
||||||
|
# needs the containerd image store; that is a runner-capability change, not a flag to add
|
||||||
|
# untested to the one job that publishes releases.
|
||||||
|
#
|
||||||
|
# The OCI `created` label comes from the same commit for the same reason: `date -u` there would
|
||||||
|
# have made every rebuild a different image for no reason anybody could see.
|
||||||
|
#
|
||||||
|
# Both base images are already digest-pinned inside src/Dockerfile, and so is the Dockerfile
|
||||||
|
# frontend in its `# syntax` directive, so nothing in this build resolves a floating tag.
|
||||||
|
- name: Build the release image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
|
||||||
|
export SOURCE_DATE_EPOCH
|
||||||
|
created="$(git log -1 --format=%cI)"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s (%s)\n' "${SOURCE_DATE_EPOCH}" "${created}"
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--file src/Dockerfile \
|
||||||
|
--tag "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
|
||||||
|
--tag "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" \
|
||||||
|
--build-arg RELEASE_VERSION="${RELEASE_VERSION}" \
|
||||||
|
--build-arg BUILD_VERSION="${BUILD_VERSION}" \
|
||||||
|
--build-arg GIT_SHA="${GITHUB_SHA}" \
|
||||||
|
--build-arg SOURCE_URL="${SOURCE_URL}" \
|
||||||
|
--label org.opencontainers.image.created="${created}" \
|
||||||
|
src
|
||||||
|
docker image inspect \
|
||||||
|
--format 'built {{.Id}} ({{.Size}} bytes, {{len .RootFS.Layers}} layers)' \
|
||||||
|
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||||
|
# Generated before the blocking scan, and uploaded before it too, so the inventory of what is
|
||||||
|
# in the image survives the run that refuses to publish it. An SBOM you only get on a green
|
||||||
|
# build is an SBOM you cannot use to answer "what was in the one that failed".
|
||||||
|
- name: Generate the image SBOM
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
trivy image \
|
||||||
|
--format cyclonedx \
|
||||||
|
--scanners license \
|
||||||
|
--output image-sbom.cdx.json \
|
||||||
|
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||||
|
test -s image-sbom.cdx.json
|
||||||
|
- name: Upload the image SBOM
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: container-release-sbom
|
||||||
|
path: image-sbom.cdx.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
# The same policy dependency-vulnerability.yml applies to the filesystem, applied to the thing
|
||||||
|
# that actually ships: CRITICAL and HIGH block, everything else is reported. The filesystem
|
||||||
|
# scan cannot see the base image's OS packages, which is most of an image's attack surface, so
|
||||||
|
# a green trivy-fs has never been evidence about the artifact.
|
||||||
|
#
|
||||||
|
# --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression
|
||||||
|
# source. Each entry carries a rationale and an expiry by policy, reviewed through CODEOWNERS
|
||||||
|
# (.github/dependency-vulnerability-policy.md); an inline --skip or a second ignore file would
|
||||||
|
# be a suppression nobody reviews.
|
||||||
|
#
|
||||||
|
# This step is the reason `docker push` is further down. A vulnerable image that was pushed and
|
||||||
|
# then reported is already pullable by everything that watches the tag.
|
||||||
|
- name: Block High and Critical vulnerabilities in the release image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
trivy image \
|
||||||
|
--scanners vuln,license \
|
||||||
|
--severity CRITICAL,HIGH \
|
||||||
|
--exit-code 1 \
|
||||||
|
--ignorefile .trivyignore.yaml \
|
||||||
|
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||||
|
- name: Report Medium and Low vulnerabilities in the release image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
trivy image \
|
||||||
|
--scanners vuln,license \
|
||||||
|
--severity MEDIUM,LOW \
|
||||||
|
--exit-code 0 \
|
||||||
|
--ignorefile .trivyignore.yaml \
|
||||||
|
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||||
|
- name: Sign in to the container registry
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s' "${REGISTRY_TOKEN}" \
|
||||||
|
| docker login "${REGISTRY}" --username "${GITHUB_ACTOR}" --password-stdin
|
||||||
|
# Two tags, one digest. The semver tag is what a human reads and what a release note cites; the
|
||||||
|
# sha- tag is the one that can never be moved to different content, because the git SHA it
|
||||||
|
# names is the only commit that can produce it.
|
||||||
|
#
|
||||||
|
# Neither is what a manifest should pin. Both are mutable names in a registry: a later push can
|
||||||
|
# point `1.2.3` at something else, and nothing about a tag tells a cluster it did not. The
|
||||||
|
# digest recorded below is immutable by construction, and it is the field the GitOps repository
|
||||||
|
# pins — the tags exist so a person can find the digest, not so a cluster can resolve one.
|
||||||
|
- name: Push the release and revision tags
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker push "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||||
|
docker push "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}"
|
||||||
|
# awk rather than `grep | head`, deliberately. Under `set -e` with `pipefail`, a grep that
|
||||||
|
# matches nothing exits 1 and kills the step right here — so the explicit check below,
|
||||||
|
# and its message, would never run and the failure would surface as a bare exit code.
|
||||||
|
# awk exits 0 whether or not it matched, which leaves the empty case for us to report.
|
||||||
|
pinned_reference="$(
|
||||||
|
docker image inspect \
|
||||||
|
--format '{{range .RepoDigests}}{{println .}}{{end}}' \
|
||||||
|
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
|
||||||
|
| awk -v prefix="${IMAGE_REPOSITORY}@sha256:" \
|
||||||
|
'index($0, prefix) == 1 { print; exit }'
|
||||||
|
)"
|
||||||
|
if [[ -z "${pinned_reference}" ]]; then
|
||||||
|
echo "::error::no registry digest for ${IMAGE_REPOSITORY} after push"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'PINNED_REFERENCE=%s\n' "${pinned_reference}" >> "${GITHUB_ENV}"
|
||||||
|
printf 'container-release: pushed %s\n' "${pinned_reference}"
|
||||||
|
# The handoff to the GitOps repository, in a form a person and a script can both read. It is
|
||||||
|
# written to the job summary as well as to an artifact because the summary is where somebody
|
||||||
|
# looks first and the artifact is what survives the ninety days a release audit asks about.
|
||||||
|
- name: Record the immutable image reference
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
digest="${PINNED_REFERENCE#*@}"
|
||||||
|
{
|
||||||
|
printf 'release_tag: %s\n' "${GITHUB_REF_NAME}"
|
||||||
|
printf 'git_sha: %s\n' "${GITHUB_SHA}"
|
||||||
|
printf 'image_repository: %s\n' "${IMAGE_REPOSITORY}"
|
||||||
|
printf 'version_tag: %s\n' "${IMAGE_VERSION_TAG}"
|
||||||
|
printf 'revision_tag: %s\n' "${IMAGE_REVISION_TAG}"
|
||||||
|
printf 'digest: %s\n' "${digest}"
|
||||||
|
printf 'pinned_reference: %s\n' "${PINNED_REFERENCE}"
|
||||||
|
} > image-release.txt
|
||||||
|
{
|
||||||
|
printf '### container-release\n\n'
|
||||||
|
printf 'Pin this in the GitOps manifest as the container image:\n\n'
|
||||||
|
printf '```\n%s\n```\n\n' "${PINNED_REFERENCE}"
|
||||||
|
printf -- '- release tag: `%s`\n' "${GITHUB_REF_NAME}"
|
||||||
|
printf -- '- version tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_VERSION_TAG}"
|
||||||
|
printf -- '- revision tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_REVISION_TAG}"
|
||||||
|
} >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
cat image-release.txt
|
||||||
|
- name: Upload the immutable image reference
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||||
|
with:
|
||||||
|
name: container-release-image-reference
|
||||||
|
path: image-release.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.vscode/
|
||||||
|
src/**/bin/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Operator input, not a build input. The examples beside it are the tracked contract;
|
||||||
|
# verifyEnvKeys reads the registry, the profile YAMLs and .env.example, never a real one.
|
||||||
|
src/.env*
|
||||||
|
!src/.env.example
|
||||||
|
!src/.env.local.example
|
||||||
|
|
||||||
|
# Written per run by the runtime-smoke wrapper; never committed.
|
||||||
|
src/.env.lane
|
||||||
+15
-7
@@ -1,13 +1,21 @@
|
|||||||
# Structured Trivy suppression baseline.
|
# Structured Trivy suppression baseline.
|
||||||
#
|
#
|
||||||
# This repository-root file is the only CI suppression source. Every future entry must include:
|
# This repository-root file is the only CI suppression source. Every Trivy invocation must name it
|
||||||
# id: advisory, license, misconfiguration, or secret identifier
|
# with `--ignorefile .trivyignore.yaml`; ad-hoc ignore files and inline bypasses are not allowed.
|
||||||
# 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
|
# Every entry must carry:
|
||||||
# reviewer control. Every Trivy invocation must also name this file with
|
# id: advisory, license, misconfiguration, or secret identifier
|
||||||
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
|
# statement: non-empty accepted-risk or false-positive rationale
|
||||||
|
# expired_at: future YYYY-MM-DD, no more than 90 days from review
|
||||||
|
#
|
||||||
|
# Enforced by review, not by a build task. `verifyTrivyignore` used to be a 105-line hand-written
|
||||||
|
# YAML parser in the root build — indentation tracking, inline-scalar handling, quote stripping — and
|
||||||
|
# what it guarded was this file, which has been empty since it was created. A suppression is added by
|
||||||
|
# a human and merged by a CODEOWNERS reviewer (.github/dependency-vulnerability-policy.md); that
|
||||||
|
# reviewer is the control, and a parser that has never seen an entry is not a second one.
|
||||||
|
#
|
||||||
|
# If this file ever carries entries and they start drifting, that is the moment to automate the
|
||||||
|
# check — against real entries, with a real YAML library. Not before.
|
||||||
|
|
||||||
vulnerabilities: []
|
vulnerabilities: []
|
||||||
licenses: []
|
licenses: []
|
||||||
|
|||||||
@@ -49,11 +49,22 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
|
|||||||
|
|
||||||
## Gradle 정책 권위
|
## Gradle 정책 권위
|
||||||
|
|
||||||
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
|
||||||
Gradle path, 허용 production project dependency edge
|
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||||
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
|
||||||
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 이걸 강제하는 태스크는 없다:
|
||||||
architecture-wide verification task
|
`verifyDocumentedLeafCount`는 삭제됐다. 문서에 적힌 수가 틀린 것은 결함이지만 빌드를 실패시킬
|
||||||
|
사유는 아니고, 그 태스크는 모든 `CLAUDE.md`와 `build.gradle`을 정규식으로 훑는 파서였다.
|
||||||
|
- `src/settings.gradle`: 16줄. `ca.architecture-registry` 설정 플러그인이 registry를 읽어
|
||||||
|
project를 include/mapping 한다. registry가 project 목록이 될 수 없는 경우(중복 ID, 저장소 밖
|
||||||
|
경로, 없는 디렉터리)만 여기서 실패한다. 허용되지 않는 edge 같은 아키텍처 규칙은
|
||||||
|
`verifyCleanArchitectureDependencies`가 답한다 — settings에서 죽으면 실행할 수 있는 태스크가
|
||||||
|
하나도 없다.
|
||||||
|
- `src/build-logic/`: convention plugin. leaf는 `ca.java-library` / `ca.spring-library` /
|
||||||
|
`ca.platform-module` 중 자기 성격을 선언하고, 그 플러그인이 toolchain·락·정적분석·테스트
|
||||||
|
기본값을 준다. `ca.architecture`가 아키텍처 검증 태스크를 소유한다.
|
||||||
|
- `src/build.gradle`: 루트 라이프사이클(`ci`, `releaseCheck`, `qualityCheck`,
|
||||||
|
`configContractCheck`, `integrationCheck`)과 버전/리비전
|
||||||
|
|
||||||
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를
|
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를
|
||||||
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
||||||
@@ -101,7 +112,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
|
|||||||
|
|
||||||
## 모듈 책임
|
## 모듈 책임
|
||||||
|
|
||||||
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성은
|
모든 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
|
||||||
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
||||||
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
||||||
`src/**/CLAUDE.md`를 함께 읽는다.
|
`src/**/CLAUDE.md`를 함께 읽는다.
|
||||||
@@ -172,16 +183,19 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
./gradlew <owner-gradle-path>:test --console=plain
|
./gradlew <owner-gradle-path>:check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne
|
||||||
./gradlew test
|
./gradlew check # 모든 leaf의 check
|
||||||
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성
|
||||||
./gradlew verifyCleanArchitectureDependencies
|
./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다)
|
||||||
|
./gradlew ci # PR 게이트 = 위 셋 + configContractCheck
|
||||||
./gradlew verifyPublicPathSnapshot
|
./gradlew verifyPublicPathSnapshot
|
||||||
./gradlew verifyEnvKeys
|
./gradlew :app-bootstrap:verifyEnvKeys
|
||||||
```
|
```
|
||||||
|
|
||||||
|
leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다.
|
||||||
|
|
||||||
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
||||||
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
|
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
|
||||||
|
|
||||||
## 설정과 런타임
|
## 설정과 런타임
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template.
|
Repository guidance for the Java 21 + Spring Boot 4.0.8 Clean Architecture template.
|
||||||
|
|
||||||
## Prime Directive
|
## Prime Directive
|
||||||
|
|
||||||
@@ -21,8 +21,10 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
|
|||||||
|
|
||||||
## Gradle policy authorities
|
## Gradle policy authorities
|
||||||
|
|
||||||
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
|
- `src/config/architecture/modules.json`: every registered leaf identity, repository-relative source
|
||||||
paths, Gradle paths, and allowed production project dependency edges.
|
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
|
||||||
|
memberships of both composition roots. The registry owns the leaf list and its size; no document
|
||||||
|
restates the count, because a number written in prose drifts the moment a leaf is added.
|
||||||
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
|
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
|
||||||
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
||||||
verification tasks.
|
verification tasks.
|
||||||
@@ -42,8 +44,10 @@ count.
|
|||||||
|
|
||||||
## Module families
|
## Module families
|
||||||
|
|
||||||
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
|
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
|
||||||
families; the nearest `src/**/CLAUDE.md` owns local rules.
|
families; the nearest `src/**/CLAUDE.md` owns local rules. No task enforces this any more:
|
||||||
|
`verifyDocumentedLeafCount` was deleted along with the other documentation-drift parsers. A stated
|
||||||
|
count that disagrees with the registry is a defect, not a build failure — so do not state one.
|
||||||
|
|
||||||
| Family | Responsibility | Stable dependency direction |
|
| Family | Responsibility | Stable dependency direction |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -53,11 +57,22 @@ families; the nearest `src/**/CLAUDE.md` owns local rules.
|
|||||||
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
|
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
|
||||||
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
|
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
|
||||||
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
|
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
|
||||||
|
| `messaging:*` | Vendored messaging platform: a product with its own API, SPI, adapters and composition boundary, not a layer of this application | `messaging:*` only — it depends on no `domain-core`, `application-core`, or `shared-contract` type |
|
||||||
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
|
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
|
||||||
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
||||||
|
|
||||||
|
The `messaging:*` family is the one entry that is not a Clean Architecture layer, and it is listed so
|
||||||
|
that the exception is stated rather than inferred from a directory. It is a vendored library — its
|
||||||
|
own `*-api` leaves are its ports, its broker leaves are its adapters, its starter is its composition
|
||||||
|
root — and the messaging module review (`docs/reviews/2026-08-14-messaging-module-code-review.md`
|
||||||
|
MSG-023 §6.2) chose that layout deliberately over folding it into `adapter:outbound:*`. This
|
||||||
|
application is supposed to reach it the way it reaches any library: through an application-owned port
|
||||||
|
satisfied by an anti-corruption bridge in `adapter:outbound:messaging`. That bridge does not exist
|
||||||
|
yet (MSG-015), so today the composition root wires the starter directly; `src/messaging/CLAUDE.md`
|
||||||
|
holds the detail.
|
||||||
|
|
||||||
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
||||||
Read its `gradle_path` and `allowed_dependencies` from
|
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
|
||||||
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||||
|
|
||||||
## Layer workflow
|
## Layer workflow
|
||||||
@@ -91,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from
|
|||||||
Architecture-wide commands:
|
Architecture-wide commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
./gradlew architectureCheck --console=plain
|
||||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain
|
||||||
./gradlew verifyPublicPathSnapshot --console=plain
|
./gradlew verifyPublicPathSnapshot --console=plain
|
||||||
./gradlew verifyEnvKeys --console=plain
|
./gradlew :app-bootstrap:verifyEnvKeys --console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires
|
A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone.
|
||||||
the controller's workflow authorization.
|
Repository-wide questions have their own names: `architectureCheck` (dependency direction, runtime
|
||||||
|
membership, application-core purity, Git-carryable sources), `qualityCheck` (SpotBugs, FindSecBugs),
|
||||||
|
`configContractCheck` (the environment contract), `integrationCheck` (the declared strict test
|
||||||
|
lanes). `ci` is check + architectureCheck + qualityCheck + configContractCheck; `releaseCheck` adds
|
||||||
|
provenance, archive hygiene and the public-path snapshot.
|
||||||
|
|
||||||
|
Use public-path and env-key checks only when their surfaces changed. Full `test`, `check` or `ci`
|
||||||
|
requires the controller's workflow authorization.
|
||||||
|
|
||||||
## Advisory and reporting
|
## Advisory and reporting
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
|||||||
|
|
||||||
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
|
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
|
||||||
|
|
||||||
|
### 프로파일별 데이터스토어
|
||||||
|
|
||||||
|
`bootstrap`은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 `local` 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :app-bootstrap:bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
| 프로파일 | 데이터스토어 | 스키마 소유자 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `local` (bootRun 기본) | H2 in-memory | Hibernate `create-drop` |
|
||||||
|
| `dev` | PostgreSQL | Flyway |
|
||||||
|
| `prod` | PostgreSQL | Flyway |
|
||||||
|
|
||||||
|
`local`은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 [src/app-bootstrap/src/main/resources/](src/app-bootstrap/src/main/resources/)의 `application-{local,dev,prod}.yml`이, 상세 설명은 [src/README.md](src/README.md)가 소유합니다.
|
||||||
|
|
||||||
## 새 프로젝트로 시작하기
|
## 새 프로젝트로 시작하기
|
||||||
|
|
||||||
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
|
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
|
||||||
@@ -73,14 +90,20 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
|||||||
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
||||||
4. 모듈 이름과 경계는 그대로 유지합니다.
|
4. 모듈 이름과 경계는 그대로 유지합니다.
|
||||||
|
|
||||||
검증은 sample-on과 sample-off를 모두 통과시킵니다.
|
검증은 먼저 composition root의 빠른 테스트와 sample-off 계약을 확인합니다. 루트에서
|
||||||
|
`./gradlew test`를 호출하면 등록된 모든 하위 프로젝트의 `test`를 실행하므로 일상적인 로컬
|
||||||
|
피드백 명령으로 사용하지 않습니다. 저장소 전체 qualification은 CI 또는 명시적인 `ci` task가
|
||||||
|
담당합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
./gradlew test
|
./gradlew :app-bootstrap:test
|
||||||
./gradlew :app-bootstrap:sampleOffTest
|
./gradlew :app-bootstrap:sampleOffTest
|
||||||
|
./gradlew architectureCheck
|
||||||
```
|
```
|
||||||
|
|
||||||
|
병합 전 저장소 전체 검증이 필요하면 `./gradlew ci`를 실행합니다.
|
||||||
|
|
||||||
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
|
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
|
||||||
|
|
||||||
## 아키텍처 규칙과 검증
|
## 아키텍처 규칙과 검증
|
||||||
@@ -105,6 +128,35 @@ cd src
|
|||||||
|
|
||||||
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
|
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
|
||||||
|
|
||||||
|
## 수동 전용 Gradle 태스크
|
||||||
|
|
||||||
|
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
|
||||||
|
적어 둔다.
|
||||||
|
|
||||||
|
여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의
|
||||||
|
```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는
|
||||||
|
Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의
|
||||||
|
함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :grpc:grpc-testkit:grpcPerformanceTest
|
||||||
|
./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange
|
||||||
|
./gradlew :app-bootstrap:sampleOffCompile
|
||||||
|
```
|
||||||
|
|
||||||
|
- `grpcPerformanceTest` — latency percentile·saturation·drain budget을 **측정**한다. 공유 CI
|
||||||
|
runner의 측정값은 흔들리고, 흔들리는 게이트는 결국 꺼진다. leaf `build.gradle`이 이 태스크의
|
||||||
|
태그를 `test`에서 제외하는 이유도 같다. 성능 회귀가 의심될 때 사람이 이름으로 부른다.
|
||||||
|
- `openapiCheckSnapshot` — 드리프트 검사 자체는 이미 자동으로 돈다. 이 태스크가 감싸는
|
||||||
|
`OpenApiDriftContractTest`는 `:sample-portfolio:test`의 일부이고, 그건 `check` 안이며 stage 1에서
|
||||||
|
실행된다. 이 태스크의 고유한 역할은 `-PapproveOpenApiChange`로 **커밋된 스냅샷을 다시 만드는 것**
|
||||||
|
— 의도된 API 변경을 사람이 승인하는 지점이다. 자동으로 돌리면 승인이 승인이 아니게 된다.
|
||||||
|
- `sampleOffCompile` — `sampleOffTest` 소스셋을 **컴파일만** 한다. CI가 돌리는
|
||||||
|
`:app-bootstrap:sampleOffTest`(stage 1, `ci-quality-gates.yml`의 `sample-off` 잡)는 같은 소스셋을
|
||||||
|
컴파일한 뒤 실행까지 하므로, CI에 따로 넣으면 진부분집합을 한 번 더 도는 것이다. 남겨 둔 이유는
|
||||||
|
sample 제거 작업 중 테스트를 기다리지 않고 컴파일만 빠르게 확인하는 로컬 루프가 실재하기 때문이다.
|
||||||
|
|
||||||
## 더 알아보기
|
## 더 알아보기
|
||||||
|
|
||||||
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
|
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
|
||||||
|
|||||||
+41
-1
@@ -18,11 +18,30 @@ services:
|
|||||||
app:
|
app:
|
||||||
# Relax read-only constraint for local development.
|
# Relax read-only constraint for local development.
|
||||||
read_only: false
|
read_only: false
|
||||||
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs
|
# `!override`, not a plain empty list. An empty sequence merges with the base sequence rather
|
||||||
|
# than replacing it, so the base's /var/tmp/heap tmpfs survived and collided with the bind mount
|
||||||
|
# below — Compose refuses to have the same target twice and will not silently pick one. That is
|
||||||
|
# the right refusal: a heap dump written into a tmpfs dies with the container that produced it,
|
||||||
|
# which is the one moment somebody wants the file.
|
||||||
|
#
|
||||||
|
# `!override` needs Compose >= 2.24.4. Whether the collision is actually gone is checked in the
|
||||||
|
# merged model rather than assumed from this line.
|
||||||
|
tmpfs: !override []
|
||||||
# More memory for dev profiling / heap dumps.
|
# More memory for dev profiling / heap dumps.
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
memswap_limit: 1g
|
memswap_limit: 1g
|
||||||
environment:
|
environment:
|
||||||
|
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
|
||||||
|
# environment the application believes it is in, and the two drifting is how a dev stack ends
|
||||||
|
# up running local's settings.
|
||||||
|
SPRING_PROFILES_ACTIVE: "dev"
|
||||||
|
# The datasource address, owned here like the local and prod-smoke overlays own theirs. It was
|
||||||
|
# the only one of the three missing, and the gap was invisible while the qualification wrapper
|
||||||
|
# supplied a URL to every lane: the dev stack ran on a value that came from the test harness
|
||||||
|
# rather than from the file that describes the dev environment. With the wrapper no longer
|
||||||
|
# setting it — it was overriding prod's sslmode=verify-full URL — dev had none at all and
|
||||||
|
# Flyway was handed the literal string "${APP_DATASOURCE_URL}".
|
||||||
|
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
|
||||||
TZ: "UTC"
|
TZ: "UTC"
|
||||||
LANG: "C.UTF-8"
|
LANG: "C.UTF-8"
|
||||||
LC_ALL: "C.UTF-8"
|
LC_ALL: "C.UTF-8"
|
||||||
@@ -53,9 +72,30 @@ services:
|
|||||||
# Do not restart automatically so crash loops stay visible.
|
# Do not restart automatically so crash loops stay visible.
|
||||||
restart: "no"
|
restart: "no"
|
||||||
# Optional: mount heap dump directory to host for dev analysis.
|
# Optional: mount heap dump directory to host for dev analysis.
|
||||||
|
# The base declares /var/tmp/heap as a tmpfs, which is right for an ephemeral runtime and wrong
|
||||||
|
# for dev: a heap dump written into a tmpfs dies with the container that produced it, which is
|
||||||
|
# the one moment somebody wants the file. Compose refuses to have both, and correctly — it will
|
||||||
|
# not silently pick one — so the tmpfs list is replaced rather than appended to.
|
||||||
|
#
|
||||||
|
# `!override` needs Compose >= 2.24.4. An empty sequence is not assumed to delete the base
|
||||||
|
# sequence by itself; scripts/verify-compose-profile-contracts.sh checks mount-target uniqueness
|
||||||
|
# in the merged model, which is what actually proves the collision is gone.
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ./tmp/heap-dumps
|
source: ./tmp/heap-dumps
|
||||||
target: /var/tmp/heap
|
target: /var/tmp/heap
|
||||||
bind:
|
bind:
|
||||||
create_host_path: true
|
create_host_path: true
|
||||||
|
# The same network the shared infrastructure lives on. The local overlay joins it and the dev
|
||||||
|
# overlay did not, so a dev lane that started PostgreSQL beside the application put the two on
|
||||||
|
# different networks: `UnknownHostException: db`, from a container that was running and healthy
|
||||||
|
# a metre away. Compose puts a service with no `networks:` on `default`, which is a network of
|
||||||
|
# its own making — so the omission reads as a working stack until something has to resolve a
|
||||||
|
# name across it.
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Defined in docker-compose.infra.yml, where the services that share it live.
|
||||||
|
caskeleton-infra:
|
||||||
|
external: false
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Shared infrastructure, owned here and nowhere else.
|
||||||
|
#
|
||||||
|
# Environment overlays (local, dev, prod-smoke) describe how the application runs. This file
|
||||||
|
# describes what it runs against. Keeping the two apart is why `local` could stop meaning "the app
|
||||||
|
# plus a database" and start meaning "the app, with whichever services the lane asked for".
|
||||||
|
#
|
||||||
|
# Every service carries a Compose profile, so nothing here starts unless a lane names it. A profile
|
||||||
|
# selects services; it never implies a Spring profile. The lane definitions live in
|
||||||
|
# src/config/runtime/compose-profile-contracts.json, and scripts/verify-compose-profile-contracts.sh
|
||||||
|
# checks this file against them.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---- PostgreSQL --------------------------------------------------------------
|
||||||
|
db:
|
||||||
|
profiles:
|
||||||
|
- local-jpa
|
||||||
|
- local-messaging-outbox
|
||||||
|
- local-notification-ingest
|
||||||
|
- local-notification-serving
|
||||||
|
- local-notification-handoff
|
||||||
|
- shared-infra
|
||||||
|
- prod-smoke
|
||||||
|
- all-adapters
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
|
||||||
|
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
|
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||||
|
TZ: "UTC"
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: caskeleton-db-data
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5433:5432"
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ---- MongoDB -----------------------------------------------------------------
|
||||||
|
# A replica set of one. Single-node is still a replica set: transactions and change streams need
|
||||||
|
# one, and a standalone mongod that "works for reads" is a deployment that discovers the
|
||||||
|
# difference at the first transaction.
|
||||||
|
mongo:
|
||||||
|
profiles:
|
||||||
|
- local-mongo
|
||||||
|
- all-adapters
|
||||||
|
image: mongo:7
|
||||||
|
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: caskeleton-mongo-data
|
||||||
|
target: /data/db
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
mongo-rs-init:
|
||||||
|
profiles:
|
||||||
|
- local-mongo
|
||||||
|
- all-adapters
|
||||||
|
image: mongo:7
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
# Idempotent: rs.initiate() on an already-initiated set returns an error this swallows, so the
|
||||||
|
# lane can be re-run against a surviving volume without a manual reset.
|
||||||
|
command:
|
||||||
|
- mongosh
|
||||||
|
- --host
|
||||||
|
- mongo
|
||||||
|
- --quiet
|
||||||
|
- --eval
|
||||||
|
- >-
|
||||||
|
try { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }
|
||||||
|
catch (e) { if (!/already initialized/i.test(e.message)) { throw e } }
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ---- Kafka -------------------------------------------------------------------
|
||||||
|
kafka:
|
||||||
|
profiles:
|
||||||
|
- local-messaging
|
||||||
|
- local-messaging-outbox
|
||||||
|
- all-adapters
|
||||||
|
image: apache/kafka:3.8.0
|
||||||
|
environment:
|
||||||
|
KAFKA_NODE_ID: "1"
|
||||||
|
KAFKA_PROCESS_ROLES: "broker,controller"
|
||||||
|
KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092"
|
||||||
|
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
|
||||||
|
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
|
||||||
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
|
||||||
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1"
|
||||||
|
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1"
|
||||||
|
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1"
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:9092"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ---- Mailpit — the reference SMTP provider for notification serving ----------
|
||||||
|
mailpit:
|
||||||
|
profiles:
|
||||||
|
- local-notification-serving
|
||||||
|
- local-notification-handoff
|
||||||
|
- all-adapters
|
||||||
|
image: axllent/mailpit:v1.21
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: "1"
|
||||||
|
# MP_SMTP_AUTH_ALLOW_INSECURE is deliberately absent, and Mailpit refuses to start with both:
|
||||||
|
# "TLS cannot be required with --smtp-auth-allow-insecure". It existed to permit credentials
|
||||||
|
# over a plaintext connection, which is exactly what requiring STARTTLS removes the need for —
|
||||||
|
# any AUTH now happens inside the TLS session.
|
||||||
|
# STARTTLS, required. Not a hardening extra: SmtpProviderProperties.TlsMode has two members and
|
||||||
|
# neither is plaintext, so the platform cannot describe an unencrypted relay at all. A lane that
|
||||||
|
# wanted a plaintext Mailpit would be asking for a transport the type refuses to express, and
|
||||||
|
# the honest way to satisfy it is to give the relay a certificate.
|
||||||
|
MP_SMTP_TLS_CERT: /run/mailpit-tls/server.crt
|
||||||
|
MP_SMTP_TLS_KEY: /run/mailpit-tls/server.key
|
||||||
|
MP_SMTP_REQUIRE_STARTTLS: "true"
|
||||||
|
volumes:
|
||||||
|
# Generated per run by the qualification wrapper for the host name `mailpit`, and removed on
|
||||||
|
# teardown, exactly like the PostgreSQL lane certificate. A committed test certificate is a
|
||||||
|
# private key in Git.
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/mailpit/tls
|
||||||
|
target: /run/mailpit-tls
|
||||||
|
read_only: true
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/mailpit", "readyz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# ---- MinIO -------------------------------------------------------------------
|
||||||
|
minio:
|
||||||
|
profiles:
|
||||||
|
- shared-infra
|
||||||
|
- prod-smoke
|
||||||
|
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
|
||||||
|
command: ["server", "/data"]
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
|
||||||
|
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: caskeleton-minio-data
|
||||||
|
target: /data
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Bucket and policy bootstrap. Not a substitute for the round trip: creating a bucket proves the
|
||||||
|
# server accepts an admin command, not that an object survives being written and read back.
|
||||||
|
minio-init:
|
||||||
|
profiles:
|
||||||
|
- shared-infra
|
||||||
|
- prod-smoke
|
||||||
|
image: minio/mc:RELEASE.2024-09-16T17-43-14Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "/opt/minio/bucket-bootstrap.sh"]
|
||||||
|
environment:
|
||||||
|
MINIO_ENDPOINT: "http://minio:9000"
|
||||||
|
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
|
||||||
|
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
|
||||||
|
MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/minio/init
|
||||||
|
target: /opt/minio
|
||||||
|
read_only: true
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ---- Keycloak ----------------------------------------------------------------
|
||||||
|
keycloak:
|
||||||
|
profiles:
|
||||||
|
- local-graphql
|
||||||
|
- local-notification-ingest
|
||||||
|
- local-notification-serving
|
||||||
|
- local-notification-handoff
|
||||||
|
- shared-infra
|
||||||
|
- prod-smoke
|
||||||
|
- all-adapters
|
||||||
|
image: quay.io/keycloak/keycloak:26.0
|
||||||
|
# The wrapper reads the client secret from a mounted file and execs kc.sh. The realm artifact
|
||||||
|
# carries only a ${...} reference, so no secret value is in Git, in the rendered config, or on a
|
||||||
|
# command line.
|
||||||
|
entrypoint: ["/bin/bash", "/opt/keycloak-entrypoint/entrypoint.sh"]
|
||||||
|
environment:
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: "${KEYCLOAK_ADMIN:-admin}"
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}"
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/keycloak/entrypoint.sh
|
||||||
|
target: /opt/keycloak-entrypoint/entrypoint.sh
|
||||||
|
read_only: true
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/keycloak/realms
|
||||||
|
target: /opt/keycloak/data/import
|
||||||
|
read_only: true
|
||||||
|
secrets:
|
||||||
|
- keycloak-graphql-smoke-client-secret
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- "CMD-SHELL"
|
||||||
|
- "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ---- Capability schema streams ------------------------------------------------
|
||||||
|
# Two pre-start one-shots, in this order, because a capability stream is an operator sequence
|
||||||
|
# rather than a property.
|
||||||
|
#
|
||||||
|
# Install: each stream under db/migration/jpa keeps its own Flyway history table — they all declare
|
||||||
|
# a V1, so one Flyway pointed at all of them fails outright — and each registers itself
|
||||||
|
# INSTALLED_INACTIVE.
|
||||||
|
#
|
||||||
|
# Promote: an operator sanctions the installed schema, and the application refuses to start until
|
||||||
|
# that has happened. That is the fail-closed half of the same design, so it cannot be folded into
|
||||||
|
# the install step without making "the tables exist" and "this is sanctioned" the same event.
|
||||||
|
#
|
||||||
|
# They are also two images because they must be: flyway/flyway ships no psql, so the promotion
|
||||||
|
# could not have run in the migration container at all.
|
||||||
|
#
|
||||||
|
# Both run before `up`, not with the smoke clients after it — the application is what they are a
|
||||||
|
# precondition for. The lane contract's preStartServices carries that ordering.
|
||||||
|
db-migrate-capabilities:
|
||||||
|
profiles:
|
||||||
|
- local-notification-ingest
|
||||||
|
- local-notification-serving
|
||||||
|
- local-notification-handoff
|
||||||
|
- all-adapters
|
||||||
|
image: flyway/flyway:11.1.0
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "/opt/capability-streams/apply-capability-streams.sh"]
|
||||||
|
environment:
|
||||||
|
PGHOST: "db"
|
||||||
|
PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
|
PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||||
|
PGDATABASE: "${POSTGRES_DB:-ca_skeleton}"
|
||||||
|
CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/postgres/apply-capability-streams.sh
|
||||||
|
target: /opt/capability-streams/apply-capability-streams.sh
|
||||||
|
read_only: true
|
||||||
|
# The whole migration tree, not just db/migration/jpa: the application's own postgresql stream
|
||||||
|
# has to be installed first, or the capability tables arrive in a schema whose flyway_schema_history
|
||||||
|
# does not exist yet and the application refuses to start — which is its baseline-on-migrate: false
|
||||||
|
# policy working as designed.
|
||||||
|
- type: bind
|
||||||
|
source: ./src/adapter/outbound/persistence-jpa/src/main/resources/db/migration
|
||||||
|
target: /flyway/sql
|
||||||
|
read_only: true
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
db-promote-capabilities:
|
||||||
|
profiles:
|
||||||
|
- local-notification-ingest
|
||||||
|
- local-notification-serving
|
||||||
|
- local-notification-handoff
|
||||||
|
- all-adapters
|
||||||
|
image: postgres:16-alpine
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["/bin/sh", "/opt/capability-streams/promote-capability-streams.sh"]
|
||||||
|
environment:
|
||||||
|
PGHOST: "db"
|
||||||
|
PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
|
PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||||
|
PGDATABASE: "${POSTGRES_DB:-ca_skeleton}"
|
||||||
|
CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/postgres/promote-capability-streams.sh
|
||||||
|
target: /opt/capability-streams/promote-capability-streams.sh
|
||||||
|
read_only: true
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# The GraphQL transport, as a request. auth-smoke proves a token can be obtained and that public
|
||||||
|
# health answers; this proves /graphql is guarded and that an authenticated query executes.
|
||||||
|
graphql-smoke:
|
||||||
|
profiles:
|
||||||
|
- local-graphql
|
||||||
|
- all-adapters
|
||||||
|
image: curlimages/curl:8.10.1
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
# uid 0 for the mounted 0600 client secret, same as auth-smoke.
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "/opt/graphql-smoke/graphql-smoke.sh"]
|
||||||
|
environment:
|
||||||
|
APP_BASE_URL: "http://app:8080"
|
||||||
|
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||||
|
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
|
||||||
|
# Spring for GraphQL serves its own endpoint through a router function rather than an
|
||||||
|
# annotated controller, so the presentation base-path prefix does not apply to it.
|
||||||
|
GRAPHQL_PATH: "${GRAPHQL_PATH:-/graphql}"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/graphql/smoke
|
||||||
|
target: /opt/graphql-smoke
|
||||||
|
read_only: true
|
||||||
|
secrets:
|
||||||
|
- keycloak-graphql-smoke-client-secret
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ---- One-shot smoke clients --------------------------------------------------
|
||||||
|
# Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or
|
||||||
|
# non-zero one fails its lane rather than being treated as "not applicable".
|
||||||
|
auth-smoke:
|
||||||
|
profiles:
|
||||||
|
- local-graphql
|
||||||
|
- shared-infra
|
||||||
|
- prod-smoke
|
||||||
|
- all-adapters
|
||||||
|
image: curlimages/curl:8.10.1
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
# The client secret is written on the host at mode 0600 by the qualification wrapper and mounted
|
||||||
|
# in. The Keycloak image happens to run as the same uid the wrapper writes as; this image runs as
|
||||||
|
# uid 100, so it read "Permission denied" and the lane failed on the smoke client rather than on
|
||||||
|
# anything it was checking. Compose ignores the secret's uid/gid/mode options outside swarm, so
|
||||||
|
# the container reads it as root instead. The two alternatives are both worse: loosening the host
|
||||||
|
# file to world-readable leaves a credential readable by every process on the machine, and passing
|
||||||
|
# the value as an environment variable puts it in `docker compose config` output and in ps.
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "/opt/auth-smoke/auth-smoke.sh"]
|
||||||
|
environment:
|
||||||
|
# The same issuer URL the application is given. A token obtained from one URL and validated
|
||||||
|
# against another proves nothing, and localhost means a different host inside each container.
|
||||||
|
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||||
|
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
|
||||||
|
APP_BASE_URL: "http://app:8080"
|
||||||
|
# Supplied per runtime, because the same endpoint has two addresses: application-local.yml
|
||||||
|
# pins presentation.api-base-path to /api and the shipped default is /v1. The qualification
|
||||||
|
# wrapper exports the value that matches the lane's Spring runtime.
|
||||||
|
APP_HEALTH_PATH: "${APP_HEALTH_PATH:-/v1/healthcheck}"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/keycloak/smoke
|
||||||
|
target: /opt/auth-smoke
|
||||||
|
read_only: true
|
||||||
|
secrets:
|
||||||
|
- keycloak-graphql-smoke-client-secret
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# The server image, not the mc client image: minio/mc ships no sed, grep or cmp, and the round-trip
|
||||||
|
# client needs a digest tool. See infra/minio/smoke/object-storage-smoke.sh for how that went
|
||||||
|
# unnoticed. The lane already pulls this image for the server itself.
|
||||||
|
object-storage-smoke:
|
||||||
|
profiles:
|
||||||
|
- shared-infra
|
||||||
|
- prod-smoke
|
||||||
|
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
|
||||||
|
depends_on:
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
entrypoint: ["/bin/sh", "/opt/minio-smoke/object-storage-smoke.sh"]
|
||||||
|
environment:
|
||||||
|
MINIO_ENDPOINT: "http://minio:9000"
|
||||||
|
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
|
||||||
|
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
|
||||||
|
MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/minio/smoke
|
||||||
|
target: /opt/minio-smoke
|
||||||
|
read_only: true
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
notification-smoke:
|
||||||
|
profiles:
|
||||||
|
- local-notification-ingest
|
||||||
|
- local-notification-serving
|
||||||
|
- local-notification-handoff
|
||||||
|
- all-adapters
|
||||||
|
image: curlimages/curl:8.10.1
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
# uid 0 for the same reason auth-smoke uses it: the mounted client secret is mode 0600 on the
|
||||||
|
# host and this image otherwise runs as uid 100, which reads "Permission denied". The lane then
|
||||||
|
# fails on the smoke client rather than on anything it was checking.
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "/opt/notification-smoke/notification-smoke.sh"]
|
||||||
|
environment:
|
||||||
|
APP_BASE_URL: "http://app:8080"
|
||||||
|
MAILPIT_BASE_URL: "http://mailpit:8025"
|
||||||
|
# ingest | serving | handoff-verify — which phase of the lane this invocation is.
|
||||||
|
#
|
||||||
|
# No default, deliberately. It defaulted to `ingest`, and local-notification-serving therefore
|
||||||
|
# ran the ingest assertions — "accepted, and nothing was delivered" — against an application in
|
||||||
|
# SERVING mode. The lane passed while testing the opposite of what it is named for, and would
|
||||||
|
# have kept passing for as long as the check happened to run before the dispatch worker. An
|
||||||
|
# unset value now renders empty and the client refuses it.
|
||||||
|
# `:-` and not a value: an explicit empty default keeps Compose from warning about an unset
|
||||||
|
# variable on every lane that never runs this client, while still rendering empty so the
|
||||||
|
# client refuses it.
|
||||||
|
NOTIFICATION_SMOKE_PHASE: "${NOTIFICATION_SMOKE_PHASE:-}"
|
||||||
|
# Submission and template publication are authenticated like every other non-public path, so
|
||||||
|
# this client obtains a token the same way auth-smoke does — client credentials against the
|
||||||
|
# same issuer URL the application validates against.
|
||||||
|
APP_BASE_PATH: "${APP_BASE_PATH:-/api}"
|
||||||
|
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||||
|
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/notification/smoke
|
||||||
|
target: /opt/notification-smoke
|
||||||
|
read_only: true
|
||||||
|
# The handoff lane runs this client twice in one project and the second run needs the request
|
||||||
|
# id the first accepted, so the state lives in a named volume that outlives a `run --rm`
|
||||||
|
# container and is removed with the project by the teardown's --volumes.
|
||||||
|
#
|
||||||
|
# Its own path, not a subdirectory of the script mount above: a volume nested inside a
|
||||||
|
# read-only bind cannot be created, because the runtime has to mkdir the mountpoint in a
|
||||||
|
# filesystem it was just told is read-only.
|
||||||
|
- type: volume
|
||||||
|
source: caskeleton-notification-smoke-state
|
||||||
|
target: /opt/notification-smoke-state
|
||||||
|
secrets:
|
||||||
|
- keycloak-graphql-smoke-client-secret
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caskeleton-infra:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caskeleton-notification-smoke-state:
|
||||||
|
driver: local
|
||||||
|
caskeleton-db-data:
|
||||||
|
driver: local
|
||||||
|
caskeleton-mongo-data:
|
||||||
|
driver: local
|
||||||
|
caskeleton-minio-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
# Written per run at mode 0600 by the qualification wrapper and removed on teardown. The realm
|
||||||
|
# artifact references it by name; the value never reaches Git, a rendered config, a command line,
|
||||||
|
# or an evidence file.
|
||||||
|
keycloak-graphql-smoke-client-secret:
|
||||||
|
file: ./infra/keycloak/secrets/graphql-smoke-client-secret
|
||||||
+22
-41
@@ -5,19 +5,32 @@
|
|||||||
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
|
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
|
||||||
#
|
#
|
||||||
# Local intent:
|
# Local intent:
|
||||||
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
|
# - Wires the app environment to point at the shared `db` service, which lives in
|
||||||
# - Wires the app environment to point at the local DB.
|
# docker-compose.infra.yml and starts only for lanes whose Compose profile names it.
|
||||||
|
# - Declares no depends_on: a depends_on aimed at a profiled service makes every lane that does
|
||||||
|
# not enable that profile fail to render at all, and ordering is the runtime-smoke wrapper's
|
||||||
|
# job — it knows which services a lane actually starts.
|
||||||
# - Keeps read-only filesystem and memory limits from the base compose.
|
# - Keeps read-only filesystem and memory limits from the base compose.
|
||||||
# - Does NOT expose the DB port publicly; app and db communicate on the
|
# - Publishes the DB on the loopback interface only, so a host-side run
|
||||||
# internal `caskeleton-local` network only.
|
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
|
||||||
|
# containerised app reaches over the internal `caskeleton-local` network.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
# Optional, because src/.env is operator input and a fresh clone does not have one. Before this
|
||||||
|
# was marked optional, untracking that file made `docker compose config` fail outright on a
|
||||||
|
# clone — the environment override that exists for convenience became a hard prerequisite for
|
||||||
|
# rendering the stack at all. The tracked contract is src/.env.example; copy it.
|
||||||
env_file:
|
env_file:
|
||||||
- ./src/.env
|
- path: ./src/.env
|
||||||
|
required: false
|
||||||
# Wire the app to the local Postgres service on the internal network.
|
# Wire the app to the local Postgres service on the internal network.
|
||||||
environment:
|
environment:
|
||||||
|
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
|
||||||
|
# environment the application believes it is in, and the two drifting is how a dev stack ends
|
||||||
|
# up running local's settings.
|
||||||
|
SPRING_PROFILES_ACTIVE: "local"
|
||||||
TZ: "UTC"
|
TZ: "UTC"
|
||||||
LANG: "C.UTF-8"
|
LANG: "C.UTF-8"
|
||||||
LC_ALL: "C.UTF-8"
|
LC_ALL: "C.UTF-8"
|
||||||
@@ -29,9 +42,6 @@ services:
|
|||||||
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
|
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
|
||||||
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- "CMD"
|
- "CMD"
|
||||||
@@ -45,39 +55,10 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
retries: 12
|
retries: 12
|
||||||
networks:
|
networks:
|
||||||
- caskeleton-local
|
- caskeleton-infra
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
|
|
||||||
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
|
||||||
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
|
||||||
TZ: "UTC"
|
|
||||||
# Persist data between restarts; remove the volume to start fresh.
|
|
||||||
volumes:
|
|
||||||
- type: volume
|
|
||||||
source: caskeleton-db-data
|
|
||||||
target: /var/lib/postgresql/data
|
|
||||||
# No host port: startup Flyway runs in the app container over the internal network.
|
|
||||||
networks:
|
|
||||||
- caskeleton-local
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
|
|
||||||
]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
caskeleton-local:
|
# Defined in docker-compose.infra.yml, where the services that share it live.
|
||||||
driver: bridge
|
caskeleton-infra:
|
||||||
|
external: false
|
||||||
|
|
||||||
volumes:
|
|
||||||
caskeleton-db-data:
|
|
||||||
driver: local
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# prod-smoke — a production-shaped runtime, for evidence, on a laptop.
|
||||||
|
#
|
||||||
|
# Not "production Compose". What it is for is proving that the prod profile's fail-closed validators
|
||||||
|
# can be satisfied at all: TLS on the JDBC URL, a schema Flyway owns, JSON logging, secret
|
||||||
|
# references rather than values. A prod lane that only ever gets as far as `config` proves the file
|
||||||
|
# parses, which was never the thing in doubt.
|
||||||
|
#
|
||||||
|
# The credentials here are generated per run by the lane wrapper. Nothing local is reused: a
|
||||||
|
# prod-smoke that borrows the local MinIO password is a prod-smoke that tests the local setup.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
# Explicit, not inherited. A Compose profile selects services and says nothing about which
|
||||||
|
# environment the application believes it is in.
|
||||||
|
SPRING_PROFILES_ACTIVE: "prod"
|
||||||
|
TZ: "UTC"
|
||||||
|
# verify-full, which is the point: PostgreSqlTransportSecurityValidator refuses anything less,
|
||||||
|
# and that refusal is the behaviour this lane exists to satisfy rather than bypass.
|
||||||
|
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}?sslmode=verify-full&sslrootcert=/run/secrets/postgres-ca"
|
||||||
|
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||||
|
# The password is deliberately absent here. An `environment:` entry beats `env_file:`, so
|
||||||
|
# declaring it as "${APP_DATASOURCE_PASSWORD:-}" read the host shell rather than the lane's
|
||||||
|
# generated file and injected an empty string — which the prod env validator then refused, for
|
||||||
|
# the right reason, about a value the lane had actually supplied.
|
||||||
|
APP_DATASOURCE_DDL_AUTO: "validate"
|
||||||
|
APP_LOG_JSON_ENABLED: "true"
|
||||||
|
APP_SECURITY_JWT_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||||
|
APP_SECURITY_JWT_AUDIENCE: "ca-skeleton-api"
|
||||||
|
networks:
|
||||||
|
- caskeleton-infra
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caskeleton-infra:
|
||||||
|
external: false
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Database transport security, for the lanes whose runtime requires it.
|
||||||
|
#
|
||||||
|
# The prod runtime connects with `sslmode=verify-full` and an explicit `sslrootcert`. That is not a
|
||||||
|
# lane setting to relax: a prod smoke test against a database with TLS disabled is a smoke test of a
|
||||||
|
# configuration production never runs, and the one failure mode it would hide — the certificate
|
||||||
|
# chain or the host name not checking out — is the one that only ever appears in production.
|
||||||
|
#
|
||||||
|
# So the lane brings a real certificate instead. The qualification wrapper generates a CA and a
|
||||||
|
# server certificate for the host name `db` per run, at mode 0600, and removes both on teardown; the
|
||||||
|
# realm-secret pattern, applied to a keypair. Nothing here is committed: infra/postgres/tls holds
|
||||||
|
# only a .gitignore.
|
||||||
|
#
|
||||||
|
# `verify-full` is deliberate rather than `verify-ca`. `verify-ca` proves the certificate was issued
|
||||||
|
# by the expected authority and says nothing about who presented it, so it does not detect a
|
||||||
|
# redirected connection — which is most of what transport security is for.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
# Runs as root just long enough to install the key where postgres can read it, then hands over
|
||||||
|
# to the official entrypoint. See infra/postgres/entrypoint.sh for why a bind mount cannot do it.
|
||||||
|
entrypoint: ["/bin/sh", "/opt/postgres-entrypoint/entrypoint.sh"]
|
||||||
|
command:
|
||||||
|
- "postgres"
|
||||||
|
- "-c"
|
||||||
|
- "ssl=on"
|
||||||
|
- "-c"
|
||||||
|
- "ssl_cert_file=/etc/postgresql-tls/server.crt"
|
||||||
|
- "-c"
|
||||||
|
- "ssl_key_file=/etc/postgresql-tls/server.key"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/postgres/entrypoint.sh
|
||||||
|
target: /opt/postgres-entrypoint/entrypoint.sh
|
||||||
|
read_only: true
|
||||||
|
- type: bind
|
||||||
|
source: ./infra/postgres/tls
|
||||||
|
target: /opt/postgres-tls
|
||||||
|
read_only: true
|
||||||
|
|
||||||
|
app:
|
||||||
|
# The certificate authority the JDBC URL names in `sslrootcert`. A public certificate, so it
|
||||||
|
# carries no mode problem — the private half never leaves the database container's filesystem.
|
||||||
|
secrets:
|
||||||
|
- postgres-ca
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
postgres-ca:
|
||||||
|
file: ./infra/postgres/tls/ca.crt
|
||||||
@@ -34,6 +34,14 @@ services:
|
|||||||
GIT_SHA: "${GIT_SHA:-0000000}"
|
GIT_SHA: "${GIT_SHA:-0000000}"
|
||||||
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
|
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
|
||||||
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
|
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
|
||||||
|
# Generated per run by scripts/run-compose-runtime-smoke.sh and removed on teardown. Seven values
|
||||||
|
# have no inline default on purpose — the datasource address and credential, the application
|
||||||
|
# name, and the JWT issuer and audience — so a lane has to supply them, and a lane that borrowed
|
||||||
|
# the developer's own src/.env would be reproducible only on that developer's machine. Optional,
|
||||||
|
# so an ordinary `docker compose up` is unaffected.
|
||||||
|
env_file:
|
||||||
|
- path: ./src/.env.lane
|
||||||
|
required: false
|
||||||
ports:
|
ports:
|
||||||
- "${APP_SERVER_PORT:-8080}:8080"
|
- "${APP_SERVER_PORT:-8080}:8080"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
@@ -53,6 +61,20 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:mode=1777,size=128m
|
- /tmp:mode=1777,size=128m
|
||||||
- /var/tmp/heap:mode=1777,size=512m
|
- /var/tmp/heap:mode=1777,size=512m
|
||||||
|
# ---- Fileserver storage volume ------------------------------------------
|
||||||
|
# A named volume, not a tmpfs and not the read-only root. The Fileserver platform's default
|
||||||
|
# storage root is /var/lib/backend/files, and with a read-only root and no mount there was
|
||||||
|
# nowhere on the image it could legally write: enabling the capability failed on its first
|
||||||
|
# upload rather than at startup. The volume is declared unconditionally because a volume
|
||||||
|
# nobody writes to costs nothing, while a missing one costs an outage.
|
||||||
|
#
|
||||||
|
# Ownership: the image runs as uid/gid 1000 (see src/Dockerfile). Docker initialises a fresh
|
||||||
|
# named volume from the image path's ownership, so the directory is created in the image with
|
||||||
|
# that owner; a pre-existing volume or a host bind mount must be chowned to 1000:1000 by the
|
||||||
|
# operator, or every write is refused with a permission error the application reports as
|
||||||
|
# STORAGE_UNAVAILABLE.
|
||||||
|
volumes:
|
||||||
|
- fileserver-data:/var/lib/backend/files
|
||||||
# ---- Memory limit (D4) --------------------------------------------------
|
# ---- Memory limit (D4) --------------------------------------------------
|
||||||
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
|
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
@@ -78,3 +100,8 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
retries: 3
|
retries: 3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Survives container replacement, which is the point: published content outlives the process
|
||||||
|
# that wrote it. Back this with real storage in any deployment that keeps files.
|
||||||
|
fileserver-data:
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# docs
|
||||||
|
|
||||||
|
저장소의 모든 문서는 이 디렉터리 아래에 있다. 어떤 문서를 어디에 두는지가 유일한 규칙이고,
|
||||||
|
파일 목록은 디렉터리를 직접 읽는다. 개수를 여기에 적으면 다음 문서가 추가되는 순간 틀린 글이 된다.
|
||||||
|
|
||||||
|
## 어댑터별 운영 문서
|
||||||
|
|
||||||
|
각 어댑터의 지원 범위, 설정, 보안, 운영, 마이그레이션 문서다. 코드와 함께 갱신되어야 하는 문서이고,
|
||||||
|
`docs/httpclient/` 는 `scripts/verify-httpclient-docs.py` 가 코드에서 뽑은 이름과 대조한다.
|
||||||
|
|
||||||
|
| 디렉터리 | 대상 |
|
||||||
|
| --- | --- |
|
||||||
|
| `fileserver/` | 파일 서버 어댑터 |
|
||||||
|
| `httpclient/` | HTTP 클라이언트 플랫폼 |
|
||||||
|
| `jpa/` | JPA·PostgreSQL 영속성 |
|
||||||
|
| `messaging/` | 메시징 어댑터 |
|
||||||
|
| `mongodb/` | MongoDB 문서 영속성 (`advanced/`, `runbooks/` 포함) |
|
||||||
|
| `notification/` | 알림 전달 플랫폼 (`adr/` 포함) |
|
||||||
|
| `redis/` | Redis 캐시·세션 |
|
||||||
|
|
||||||
|
## 횡단 문서
|
||||||
|
|
||||||
|
| 디렉터리 | 대상 |
|
||||||
|
| --- | --- |
|
||||||
|
| `adr/` | 아키텍처 결정 기록 |
|
||||||
|
| `architecture/` | 공개 API 표면 스냅숏 |
|
||||||
|
| `evidence/` | 작업 단계별 증거·체크포인트 |
|
||||||
|
| `registries/` | env 키·에러 코드·메트릭·헤더 등 레지스트리 SSOT |
|
||||||
|
| `reviews/` | 모듈 코드 리뷰 결과 |
|
||||||
|
| `runbooks/` | 장애 코드별 대응 런북 (`template.md` 기준) |
|
||||||
|
| `security/` | 공개 경로 스냅숏 |
|
||||||
|
|
||||||
|
## 설계와 계획
|
||||||
|
|
||||||
|
| 디렉터리 | 대상 |
|
||||||
|
| --- | --- |
|
||||||
|
| `superpowers/specs/` | 설계서. `YYYY-MM-DD-<주제>-design.md` |
|
||||||
|
| `superpowers/plans/` | 구현·확장 계획서. `YYYY-MM-DD-<주제>-plan.md` |
|
||||||
|
| `superpowers/packages/` | 외부에서 납품된 설계 패키지의 README와 정적 검증 결과 |
|
||||||
|
|
||||||
|
`superpowers/packages/<어댑터>/` 는 설계서가 처음 전달됐을 때의 안내와 `VALIDATION.md` 검증 이력을
|
||||||
|
남긴 기록 보관소다. 설계서·계획서 본문은 전부 `specs/` 와 `plans/` 에 있으므로 이 디렉터리에서
|
||||||
|
문서를 찾을 필요는 없다. 각 README 상단의 보존 안내가 무엇이 옮겨졌고 무엇이 제거됐는지 밝힌다.
|
||||||
|
|
||||||
|
계획서 본문에는 당시 계획한 경로와 명령이 그대로 남아 있다. 그중 일부는 실제 구현에서 다른 위치로
|
||||||
|
조정됐고, 저장소에 어떻게 대응시켰는지는 각 어댑터의 `repository-adaptation.md` 또는
|
||||||
|
`module-mapping.md` 가 기록한다. 계획서를 사후에 고치지 않는 이유는 그렇게 하면 계획의 기록이 아니라
|
||||||
|
결과를 계획처럼 보이게 만든 글이 되기 때문이다.
|
||||||
|
|
||||||
|
## 여기에 없는 것
|
||||||
|
|
||||||
|
- 실행되는 검증 스크립트는 문서가 아니다. `scripts/` 와 `.github/scripts/` 에 있다.
|
||||||
|
- 모듈 레지스트리·Gradle 정책은 `src/config/architecture/modules.json` 과 `src/build.gradle` 이 소유한다.
|
||||||
|
- 각 모듈의 지역 규칙은 해당 모듈의 `src/**/CLAUDE.md` 가 소유한다.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# ADR-BUILD-001: `java-test-fixtures` is the standard for shared test code
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-09-07
|
||||||
|
- Scope: every leaf that publishes or consumes shared test code
|
||||||
|
- Source: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` BOOT-015
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two conventions do the same job in this repository.
|
||||||
|
|
||||||
|
`ca.testkit-publisher` — a convention plugin — gives a leaf a `testkit` source set, wires its output
|
||||||
|
onto the lanes that leaf names, and optionally publishes it as a consumable configuration. Five
|
||||||
|
leaves use it: `persistence-jpa` (published as `jpaTestkit`), `web` (`webTestkit`), `websocket`
|
||||||
|
(`websocketTestkit`), `persistence-mongo` and `httpclient` (both unpublished).
|
||||||
|
|
||||||
|
`java-test-fixtures` — Gradle's own plugin — gives a leaf a `testFixtures` source set, puts it on
|
||||||
|
`test`'s classpath automatically, and always publishes it as a variant consumers reach with
|
||||||
|
`testFixtures(project(':x'))`. One leaf uses it: `graphql`, which additionally fails its build when a
|
||||||
|
fixture is written outside `src/testFixtures/java`.
|
||||||
|
|
||||||
|
Two conventions for one purpose is the defect. A contributor adding shared test code has to know
|
||||||
|
which leaf they are in before they know where the file goes, and the two answers are not
|
||||||
|
interchangeable: a consumer of the first writes `project(path: ':x', configuration: 'jpaTestkit')`
|
||||||
|
and has to know the configuration's name, while a consumer of the second writes
|
||||||
|
`testFixtures(project(':x'))` and does not.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**`java-test-fixtures` is the standard.** New shared test code goes in `src/testFixtures/java`, and a
|
||||||
|
consumer depends on it with `testFixtures(project(':x'))`.
|
||||||
|
|
||||||
|
Three reasons, in order of weight:
|
||||||
|
|
||||||
|
1. **The consumer side describes itself.** `testFixtures(project(':adapter:inbound:web'))` says what
|
||||||
|
it is. `project(path: ':adapter:inbound:web', configuration: 'webTestkit')` says where to look,
|
||||||
|
and only after the reader has learned that `webTestkit` is a testkit rather than a lane.
|
||||||
|
2. **The enforcement already exists and is copyable.** `graphql`'s build fails when a fixture is
|
||||||
|
declared in the wrong place. The same guard applies unchanged to any leaf that adopts the plugin.
|
||||||
|
3. **It is one fewer local concept.** A convention plugin that reimplements a Gradle plugin has to be
|
||||||
|
maintained against it.
|
||||||
|
|
||||||
|
## What the local plugin does better, and how it is replaced
|
||||||
|
|
||||||
|
This is worth writing down, because the review that prompted this ADR recommended the migration
|
||||||
|
before reading `ca.testkit-publisher`, and the plugin turns out to encode two deliberate decisions
|
||||||
|
rather than being an oversight.
|
||||||
|
|
||||||
|
**Publishing is opt-in.** `persistence-mongo` and `httpclient` have a testkit and publish nothing;
|
||||||
|
`persistence-jpa` publishes. The plugin's own comment names this as "a real difference in what each
|
||||||
|
leaf offers rather than an oversight to normalise away". `java-test-fixtures` always creates the
|
||||||
|
variant, so the distinction is lost — a leaf that never meant to offer its fixtures will offer them.
|
||||||
|
|
||||||
|
> Replacement: none at the build level. The distinction moves to review: the fixtures of a leaf that
|
||||||
|
> nobody consumes are simply unconsumed. This is a real, accepted loss.
|
||||||
|
|
||||||
|
**Lane consumption is declared.** `persistence-jpa` says `consumedBy 'test', 'postgresqlIntegrationTest'`.
|
||||||
|
`java-test-fixtures` puts fixtures on `test` only, so every other lane needs the output added
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
> Replacement: `strictTestLanes`' existing `compilesAgainst` expresses this unchanged — a lane
|
||||||
|
> declares `compilesAgainst 'main', 'testFixtures'`. The first draft of this ADR assumed the DSL
|
||||||
|
> would need a change, because `sourceSet(name)` creates what it is given and `testFixtures` already
|
||||||
|
> exists. The `persistence-mongo` migration showed otherwise: `compilesAgainst` only *looks a source
|
||||||
|
> set up*, so naming a plugin-created one works as-is. What the leaf drops is the
|
||||||
|
> `sourceSet('testkit')` declaration, not the lane's.
|
||||||
|
|
||||||
|
## Migration: done, and what it cost
|
||||||
|
|
||||||
|
Five leaves, eleven lanes, two published testkits, all migrated leaf by leaf with the suite run
|
||||||
|
between each. `ca.testkit-publisher` is deleted.
|
||||||
|
|
||||||
|
The order was chosen so a mistake would be cheap: unpublished leaves first, published ones last with
|
||||||
|
their consumer in the same step.
|
||||||
|
|
||||||
|
1. `persistence-mongo` — one leaf, two lanes, no cross-module consumer; the proof the path works.
|
||||||
|
What it took, per leaf:
|
||||||
|
- `apply plugin: 'java-test-fixtures'` at the top of the leaf build file;
|
||||||
|
- `git mv src/testkit src/testFixtures`;
|
||||||
|
- drop `sourceSet('testkit')` and the whole `testkitPublisher` block; keep every other lane's
|
||||||
|
`compilesAgainst`, renaming `'testkit'` to `'testFixtures'`;
|
||||||
|
- rename `testkitImplementation` to `testFixturesImplementation`, **and add what the old source
|
||||||
|
set was inheriting silently**. This is the one non-mechanical step: `testkit*` extended
|
||||||
|
`testImplementation`, so the fixtures saw every test library the leaf declared. Mongo's needed
|
||||||
|
four more lines (AssertJ, BSON, Spring Data commons, Toxiproxy) — none of which the leaf had
|
||||||
|
ever stated the fixtures depended on;
|
||||||
|
- regenerate the leaf's lock state.
|
||||||
|
2. `httpclient`, then `websocket` — unpublished as well, more lanes.
|
||||||
|
3. `web` and `persistence-jpa` with `app-bootstrap`'s two consumer declarations, which became
|
||||||
|
`testImplementation(testFixtures(project(':…')))`.
|
||||||
|
4. `ca.testkit-publisher` deleted, along with its `plugins {}` entry and its application in the root
|
||||||
|
build.
|
||||||
|
|
||||||
|
### Two things the migration broke, and what they taught
|
||||||
|
|
||||||
|
Both were caught by tests that exist to catch exactly this, which is the argument for having them.
|
||||||
|
|
||||||
|
**ArchUnit corpora went wrong in opposite directions.** `httpclient`'s boundary rules *excluded*
|
||||||
|
`build/classes/java/testkit`; after the move the fixtures arrived as a `…-test-fixtures.jar` on the
|
||||||
|
same classpath, so the exclusion missed them and 258 fixture-to-fixture calls were reported as
|
||||||
|
production depending on the testkit. `persistence-jpa`'s rules *included* only
|
||||||
|
`build/classes/java/main`; applying `java-test-fixtures` makes the module's own test classpath carry
|
||||||
|
the module as a **jar** rather than as a class directory, so its corpus became empty. The second is
|
||||||
|
the dangerous one — an empty corpus makes every `noClasses()` rule pass — and it surfaced only
|
||||||
|
because that suite asserts its corpus is non-empty before asserting anything about it.
|
||||||
|
|
||||||
|
**Fixtures had invisible dependencies.** `testkit*` configurations extended `testImplementation`, so
|
||||||
|
the fixtures compiled against every test library their leaf declared without ever naming one. Making
|
||||||
|
them explicit took roughly thirty `testFixturesImplementation` lines across the five leaves —
|
||||||
|
Micrometer, Spring Web, Netty, logback, Jackson, JUnit, AssertJ, Spring Data. None of them were
|
||||||
|
wrong; none of them were stated.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `docs/testing/TESTING_STRATEGY.md` §5 records the standard; this ADR records why and at what cost.
|
||||||
|
- Until step 5, two conventions remain visible. The strategy document says so explicitly, so a
|
||||||
|
contributor reading it is not left to infer which one is current.
|
||||||
|
- The opt-in-publishing distinction is given up. If it later proves load-bearing — a leaf whose
|
||||||
|
fixtures genuinely must not be reachable — the answer is a separate module, not a third convention.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# ADR-GQL-001 — GraphQL context stays inbound; object authorization moves to application-core; the persisted-operation store stays an inbound SPI
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-24
|
||||||
|
- Review: `docs/reviews/2026-08-14-graphql-module-code-review.md` GQL-026
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The GraphQL leaf's own documentation described three things crossing its boundary: a
|
||||||
|
`GraphQlRequestContext` with a deadline propagated into application, JPA, Mongo and the HTTP client;
|
||||||
|
object authorization decided inside the transport; and a persisted-operation registry implemented by
|
||||||
|
an external durable store.
|
||||||
|
|
||||||
|
Two of those invert the dependency direction. If `application-core` or an outbound adapter
|
||||||
|
implements a type that lives in `adapter:inbound:graphql`, the registry edge that says inbound
|
||||||
|
depends on application is satisfied while the real compile-time dependency runs the other way.
|
||||||
|
|
||||||
|
The third is a business rule in the wrong layer: whether an actor may see an object is a decision
|
||||||
|
about the domain, and GraphQL is one of four transports this skeleton ships.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Three different answers, because the three problems are not the same problem.
|
||||||
|
|
||||||
|
**GraphQL context stays inbound-local.** It is mapped explicitly onto application command fields —
|
||||||
|
actor, tenant, deadline — rather than travelling as a type. Nothing outside the leaf references
|
||||||
|
`GraphQlRequestContext`, and the boundary test is that grep returns nothing outside it.
|
||||||
|
|
||||||
|
**Object authorization moves to `application-core`.** `ObjectAccessPolicy`, `ObjectAccessRequest`
|
||||||
|
and `ObjectAccessDecision` are transport-neutral and live with the other application policies;
|
||||||
|
`ApplicationObjectAuthorization` in the GraphQL leaf is the bridge that calls them. This is the one
|
||||||
|
of the three that was a real layering defect, and it is fixed rather than documented.
|
||||||
|
|
||||||
|
**The persisted-operation store stays an inbound-owned SPI.** `GraphQlPersistedOperationRegistry`
|
||||||
|
remains in `advanced/persisted`, and no leaf outside GraphQL implements it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The third decision is the one that needs defending, because it leaves the reported risk in place
|
||||||
|
rather than removing it.
|
||||||
|
|
||||||
|
The risk is conditional: the direction inverts only when something outside the leaf implements the
|
||||||
|
interface. Nothing does. The template ships an in-memory registry and no durable one, because it
|
||||||
|
ships no persisted-operation store at all.
|
||||||
|
|
||||||
|
The alternative was to introduce a generic operational key-value store port owned by a neutral
|
||||||
|
contract holder, with the GraphQL adapter owning only the key and value mapping. That port would
|
||||||
|
have exactly one interface, zero implementations and one speculative consumer — a new abstraction
|
||||||
|
whose shape is guessed from a requirement nobody has stated. This repository has spent a full
|
||||||
|
remediation pass deleting controls that existed and were reached by nothing, and inventing a port
|
||||||
|
for a store that does not exist is how the next one of those gets written.
|
||||||
|
|
||||||
|
So the decision is to leave the SPI where it is and to move it when a durable store is actually
|
||||||
|
built. Moving it then is a rename across one leaf and one new adapter, which is cheaper than
|
||||||
|
carrying a wrong abstraction until then. What must not happen in the meantime is an outbound leaf
|
||||||
|
implementing the inbound interface, because that is the moment the direction actually inverts, and
|
||||||
|
it would happen in a commit whose diff looks like an implementation rather than a layering change.
|
||||||
|
|
||||||
|
The composition root wires these and owns no business or storage policy of its own.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`verifyCleanArchitectureDependencies` and `modules.json` hold the leaf's edges to
|
||||||
|
`domain-core`, `application-core` and `shared-contract`. `ObjectAccessPolicyTest` covers the
|
||||||
|
application-side policy and `ApplicationObjectAuthorizationTest` the bridge.
|
||||||
|
|
||||||
|
The condition this ADR turns on — that nothing outside the GraphQL leaf implements the
|
||||||
|
persisted-operation SPI — is a claim about the whole repository, so it is checked at the
|
||||||
|
composition root rather than inside the leaf, next to the other GraphQL boundary rules in
|
||||||
|
`app-bootstrap`'s architecture suite.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and
|
||||||
|
composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under
|
||||||
|
`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against
|
||||||
|
Spring Boot 4.1.
|
||||||
|
|
||||||
|
None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that
|
||||||
|
owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this:
|
||||||
|
their last Global Constraint says that when the repository structure differs, file paths are remapped
|
||||||
|
and the public contracts, invariants and test meanings are not changed.
|
||||||
|
|
||||||
|
Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as
|
||||||
|
packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as
|
||||||
|
a family the way `messaging:*` is registered.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under
|
||||||
|
`src/grpc-advanced/`.
|
||||||
|
|
||||||
|
The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already
|
||||||
|
describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters
|
||||||
|
and composition boundary, not a layer of this application", and the gRPC platform is the same shape
|
||||||
|
for the same reason — the application is meant to reach it the way it reaches a library, through an
|
||||||
|
application-owned port. The four platforms that became packages are all layers of this application;
|
||||||
|
this one is not.
|
||||||
|
|
||||||
|
The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan
|
||||||
|
requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle
|
||||||
|
path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note:
|
||||||
|
`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in
|
||||||
|
one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit
|
||||||
|
decision moves it.
|
||||||
|
|
||||||
|
**The advanced boundary is checked twice.** Once by the registry at build time, and once by
|
||||||
|
`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by
|
||||||
|
something the registry never sees.
|
||||||
|
|
||||||
|
**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot
|
||||||
|
be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose
|
||||||
|
lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps
|
||||||
|
the same rule inside the code, so a report cannot cite a contract run as transport evidence.
|
||||||
|
|
||||||
|
**Codegen is not wired.** See ADR-GRPC-002.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single
|
||||||
|
Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release.
|
||||||
|
|
||||||
|
Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And
|
||||||
|
`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes
|
||||||
|
spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror`
|
||||||
|
— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set
|
||||||
|
from five quality gates.
|
||||||
|
|
||||||
|
There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh`
|
||||||
|
disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality
|
||||||
|
baseline of a leaf, taken for one task, and outside what this work was asked to change.
|
||||||
|
|
||||||
|
`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md`
|
||||||
|
forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module
|
||||||
|
should own its schema.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no
|
||||||
|
protoc run and no Buf CLI invocation.
|
||||||
|
|
||||||
|
`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the
|
||||||
|
`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package
|
||||||
|
disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked
|
||||||
|
against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs
|
||||||
|
against the committed schema in its own test, so the shipped `.proto` files are live rather than
|
||||||
|
decorative.
|
||||||
|
|
||||||
|
`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages
|
||||||
|
a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal
|
||||||
|
generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher`
|
||||||
|
carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a
|
||||||
|
publish that breaks a consumer or republishes a released version with different bytes.
|
||||||
|
|
||||||
|
The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it
|
||||||
|
reaches the same verdict.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which
|
||||||
|
schema changes are refused, which consumer breaks block a release, who owns generation — is a
|
||||||
|
build-checkable rule here. What is missing is the protoc invocation and the Buf binary.
|
||||||
|
|
||||||
|
**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the
|
||||||
|
owner, the managed version source, the build-directory output paths and the disjoint package policy
|
||||||
|
that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin
|
||||||
|
block, not a redesign.
|
||||||
|
|
||||||
|
**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the
|
||||||
|
in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream
|
||||||
|
sequencing without generated stubs. Those contracts are properties of the platform and the transport,
|
||||||
|
not of any message shape, so the substitution costs nothing — and the lanes run today rather than
|
||||||
|
after codegen lands.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Scope: `:grpc:grpc-core-api`, `:grpc:grpc-policy`, `:grpc:grpc-testkit`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A failed RPC produces a status code, and a status code is not an answer to the question the caller
|
||||||
|
actually has. `DEADLINE_EXCEEDED` on a mutation does not say whether the mutation happened;
|
||||||
|
`UNAVAILABLE` after the request was sent does not say the server never saw it; response headers
|
||||||
|
arriving does not say a transaction committed.
|
||||||
|
|
||||||
|
Every one of those is a place where a plausible inference produces a duplicate write or a lost one,
|
||||||
|
and none of them is visible in a test that only exercises the happy path.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Model what happened as three independent axes, and refuse the inferences between them.
|
||||||
|
|
||||||
|
`GrpcTransportEvidence` records what the client observed on the wire, and distinguishes `NOT_SENT` —
|
||||||
|
the client watched its own send fail — from `UNOBSERVED`, which is every other case where nothing is
|
||||||
|
known. `GrpcBusinessEvidence` records what the application confirmed, with `COMMIT_UNKNOWN` as a real
|
||||||
|
state rather than a placeholder. `GrpcStreamEvidence` is a sealed hierarchy whose non-empty cases all
|
||||||
|
carry a position, because "partial" without a last sequence can be neither resumed nor reconciled.
|
||||||
|
|
||||||
|
`GrpcExecutionEvidence` holds all three and rejects combinations nobody could have observed: a unary
|
||||||
|
call with stream evidence, or a request the client watched fail to send that nonetheless carries
|
||||||
|
business evidence. Promoting response headers to a confirmed commit is possible only by editing
|
||||||
|
`withResponseHeadersSeen`, which is one method rather than a plausible line in an interceptor.
|
||||||
|
|
||||||
|
`GrpcCompletionOutcome.forMutation` derives what a caller may conclude, and defaults
|
||||||
|
`DEADLINE_EXCEEDED` and post-send `UNAVAILABLE` on a mutation to `COMPLETION_UNKNOWN`.
|
||||||
|
|
||||||
|
The same types are used by the failure model and by the observation convention, so an incident has
|
||||||
|
one account of a call rather than two.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**A whole class of retry bug becomes unrepresentable.** `GrpcRetryEligibility` reads all three axes
|
||||||
|
plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status
|
||||||
|
alone is not an input.
|
||||||
|
|
||||||
|
**The fault lane has something to check.** `GrpcTransportEvidenceClassifier` turns a client's
|
||||||
|
observations into evidence and refuses to infer `NOT_SENT` from an unobserved state — and the lane
|
||||||
|
exercises it against a real connection dropped mid-call, not against a mock.
|
||||||
|
|
||||||
|
**Callers must handle a third outcome.** `COMPLETION_UNKNOWN` is not a failure and not a success, and
|
||||||
|
a caller that treats it as either is wrong. `GrpcOperationStatusQuery` and `GrpcCompletionReconciler`
|
||||||
|
exist so that resolving it is a supported path rather than an exercise for the caller.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Three layers can retry a gRPC call: the application, the channel's service config, and a service
|
||||||
|
mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and
|
||||||
|
the load arrives exactly when the dependency is already failing.
|
||||||
|
|
||||||
|
Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a
|
||||||
|
retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the
|
||||||
|
two afterwards.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a
|
||||||
|
decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the
|
||||||
|
owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method
|
||||||
|
names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently,
|
||||||
|
and the method then runs with channel defaults.
|
||||||
|
|
||||||
|
**Retry eligibility reads the method, the evidence and the status together.**
|
||||||
|
`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream
|
||||||
|
delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into
|
||||||
|
"resolve the completion first" rather than a retry.
|
||||||
|
|
||||||
|
**A keyed mutation is retryable only with both a caller key and a durable ledger.**
|
||||||
|
`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable
|
||||||
|
idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read
|
||||||
|
against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation,
|
||||||
|
because a read-then-insert implementation has a window exactly as wide as the race it closes and
|
||||||
|
passes every test that does not run two attempts concurrently.
|
||||||
|
|
||||||
|
The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing:
|
||||||
|
without the caller, one tenant's key suppresses another's write; without the method, a key reused
|
||||||
|
across operations makes the second a replay of the first.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no
|
||||||
|
retries when everything is failing, which is the behaviour that lets a dependency recover.
|
||||||
|
|
||||||
|
**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no
|
||||||
|
transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and
|
||||||
|
reintroduce the window where the write is durable and the claim is not.
|
||||||
|
|
||||||
|
**A key reused for a different request is a caller error, not a duplicate.** The stored request
|
||||||
|
fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's
|
||||||
|
answer.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Scope: `:grpc:grpc-policy`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is
|
||||||
|
not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as
|
||||||
|
a valid one it should never have received.
|
||||||
|
|
||||||
|
Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer
|
||||||
|
that falls behind either terminates the stream or silently loses messages, and the second leaves a
|
||||||
|
client with a stream that appears fine and is missing changes. And a reconnect either continues from
|
||||||
|
a position the server can still replay, or skips whatever is no longer there.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any
|
||||||
|
thread and hands them to the transport only from `flush`, which is synchronized. `write` returning
|
||||||
|
`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon
|
||||||
|
as bytes are handed over, so no method here can honestly report delivery.
|
||||||
|
|
||||||
|
**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension.
|
||||||
|
`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies
|
||||||
|
only on its queue bound produces as fast as it can allocate.
|
||||||
|
|
||||||
|
**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for
|
||||||
|
feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect
|
||||||
|
dropped messages: the sequence numbers it sees are the ones it was sent.
|
||||||
|
|
||||||
|
**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token
|
||||||
|
whose caller and filter fingerprints match the current request, refuses one whose snapshot version
|
||||||
|
moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken`
|
||||||
|
carries a key id so the signing key can rotate without invalidating every outstanding token.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id,
|
||||||
|
generation, sequence, snapshot version and resume token, because resume, gap detection and drain all
|
||||||
|
need a position and a generation.
|
||||||
|
|
||||||
|
**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max
|
||||||
|
duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair
|
||||||
|
produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams.
|
||||||
|
|
||||||
|
**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and
|
||||||
|
not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse
|
||||||
|
it.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-31
|
||||||
|
- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and
|
||||||
|
it does not do what the dashboard says it does.
|
||||||
|
|
||||||
|
The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual
|
||||||
|
address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate
|
||||||
|
across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every
|
||||||
|
request from that client goes to the same pod for the life of the connection. Nothing fails. The
|
||||||
|
configuration says `round_robin`, the metrics show requests spread across clients rather than pods,
|
||||||
|
and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to
|
||||||
|
check.
|
||||||
|
|
||||||
|
The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out
|
||||||
|
of many.
|
||||||
|
|
||||||
|
Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without
|
||||||
|
removing its own retry policy has two retriers whose effects multiply.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and
|
||||||
|
`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd`
|
||||||
|
and `eureka` by name, with a message saying they are Advanced capabilities with their own control
|
||||||
|
plane and promotion gate rather than unknown schemes.
|
||||||
|
|
||||||
|
**The pairing is checked against the resolved address count, not against intent.**
|
||||||
|
`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers
|
||||||
|
whether the policy distributes anything over that many endpoints. A `round_robin` profile over one
|
||||||
|
address is a reported violation whose message says it describes spreading that is not happening.
|
||||||
|
|
||||||
|
**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the
|
||||||
|
retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and
|
||||||
|
`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process.
|
||||||
|
|
||||||
|
**A profile that carries long-lived streams must state a reconnect budget and a readiness drain
|
||||||
|
grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and
|
||||||
|
scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying
|
||||||
|
long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops
|
||||||
|
serving before its clients have finished reconnecting elsewhere.
|
||||||
|
|
||||||
|
**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS,
|
||||||
|
because a channel that resolved once at startup keeps sending to addresses that stopped existing an
|
||||||
|
hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout
|
||||||
|
finished.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything
|
||||||
|
over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape,
|
||||||
|
the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile
|
||||||
|
and still have put retries in two places, so merging them would let one answer hide the other.
|
||||||
|
|
||||||
|
**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can
|
||||||
|
be wrong. It is still better than the alternative, which is not comparing anything: a wrong
|
||||||
|
declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked.
|
||||||
|
`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the
|
||||||
|
check where they are not, rather than guessing and failing on a name that cannot be resolved yet.
|
||||||
|
|
||||||
|
**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its
|
||||||
|
capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()`
|
||||||
|
returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Scope: `:grpc-advanced:*`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring
|
||||||
|
with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage
|
||||||
|
modes, its own security boundary and its own version skew. Hedging duplicates production traffic.
|
||||||
|
|
||||||
|
Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the
|
||||||
|
most consequential.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One flag per capability**, under `ca-skeleton.grpc.advanced.<capability>.enabled`, all off by
|
||||||
|
default.
|
||||||
|
|
||||||
|
**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate
|
||||||
|
production approval, because the flag says somebody wanted the feature and the approval says somebody
|
||||||
|
accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED`
|
||||||
|
is withdrawn.
|
||||||
|
|
||||||
|
`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable,
|
||||||
|
production unapproved — because the remedy differs in each case.
|
||||||
|
|
||||||
|
**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per
|
||||||
|
capability, so no promotion can drag another along;
|
||||||
|
`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested
|
||||||
|
property rather than a claim. Two thresholds: seven days of soak plus complete evidence for
|
||||||
|
`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the
|
||||||
|
capability's dependencies and its failure modes.
|
||||||
|
|
||||||
|
**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that
|
||||||
|
gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin
|
||||||
|
needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which
|
||||||
|
is worse than not having one.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so
|
||||||
|
`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one
|
||||||
|
schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds,
|
||||||
|
platform evidence types preserved — are checkable and are checked; only the compile lane is missing.
|
||||||
|
|
||||||
|
**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not
|
||||||
|
conditional on the report, because letting a status record also authorise use means a schema moves
|
||||||
|
onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no
|
||||||
|
consumer migration and no ADR.
|
||||||
|
|
||||||
|
**xDS is not part of the Stable support statement.** It works, behind its flag and its approval;
|
||||||
|
`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen
|
||||||
|
quietly.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# ADR-JPA-001 — The domain owns the persistence model
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-11
|
||||||
|
- Design: §10.1, §23.3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A persistence platform can either own the repository abstraction — a `GenericRepository<T, ID>`
|
||||||
|
every aggregate inherits — or provide only the pieces domains assemble themselves.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The domain owns entities, embeddables, repositories, queries, index requirements, and lock,
|
||||||
|
soft-delete, and audit policy. The platform provides no generic CRUD repository and no base
|
||||||
|
repository. `JpaRepositoryFragmentSupport` exists, has no `save`, `findById`, `findAll`, or
|
||||||
|
`delete`, and is enforced not to acquire them.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A generic base repository has one property that looks like a benefit and is not: every aggregate
|
||||||
|
gets the same operations. That means each aggregate is offered operations that may be wrong for it —
|
||||||
|
a `delete` on an append-only ledger, a `findAll` on a table that will never be small — and, worse,
|
||||||
|
one aggregate's later requirement changes the shared base and therefore changes behaviour for
|
||||||
|
aggregates nobody reviewed.
|
||||||
|
|
||||||
|
Spring Data already implements CRUD. Re-implementing it adds a layer whose only function is to be
|
||||||
|
harder to opt out of.
|
||||||
|
|
||||||
|
The cost is a small amount of repetition: each domain declares the repository interface it needs.
|
||||||
|
That repetition is the thing that makes each aggregate's persistence surface reviewable.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`JpaArchitectureRules.noGenericRepository()`; `JpaRepositoryFragmentSupportTest`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ADR-JPA-002 — Retry re-runs the whole use case
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-11
|
||||||
|
- Design: §19.2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Optimistic conflicts, deadlocks, and serialization failures are recoverable. The question is what
|
||||||
|
unit gets retried: the failed statement, the transaction, or the use case.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The whole use case, in a new transaction with a new Persistence Context.
|
||||||
|
`FullTransactionRetryCoordinator` re-enters `JpaTransactionExecutor` for every attempt, and the
|
||||||
|
retry advice is ordered outside Spring's transaction advice so each attempt begins a new
|
||||||
|
transaction.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Statement-level retry is wrong for exactly the failures being retried. An optimistic conflict means
|
||||||
|
the state the attempt computed against is no longer the committed state; re-issuing the same
|
||||||
|
statement computes the same wrong answer against a version that has moved on. The domain rules have
|
||||||
|
to run again over reloaded data, which means the whole use case.
|
||||||
|
|
||||||
|
Reusing the Persistence Context would be equally wrong: the second attempt would read the first
|
||||||
|
attempt's stale entities out of the first-level cache. And with the advice ordering inverted, the
|
||||||
|
retry loop would run inside one transaction that has already been marked rollback-only, so the
|
||||||
|
second attempt fails immediately without executing anything.
|
||||||
|
|
||||||
|
The cost is that a retryable use case must be safe to run from scratch — no irreversible external
|
||||||
|
effect before the commit. `IrreversibleSideEffectContext` lets a use case declare when that does not
|
||||||
|
hold, and the policy then refuses to retry it whatever budget remains.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ADR-JPA-003 — Completion unknown is never retried
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-11
|
||||||
|
- Design: §17
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A connection can break while a commit is in flight. The server may have committed; the
|
||||||
|
acknowledgement may simply have been lost. The driver cannot tell the two apart.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`TransactionCompletionUnknownException` is never retried, automatically or otherwise. It is
|
||||||
|
produced only by a failure observed while the transaction phase is `COMMITTING`, and only for
|
||||||
|
SQLSTATE `40003`, a connection-class (`08*`) state, or a transport break. Recovery is
|
||||||
|
domain-specific reconciliation through `TransactionCompletionResolver`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Retrying a possibly-committed write is the most damaging thing this platform could do: a duplicate
|
||||||
|
payment, a duplicate order, a double decrement. There is no budget or backoff that makes it safe,
|
||||||
|
because the failure is epistemic rather than transient.
|
||||||
|
|
||||||
|
The invariant is enforced at the type level rather than by policy alone. `JpaFailureContext` refuses
|
||||||
|
to construct a retryable completion-unknown context, and the exception rebuilds its context through
|
||||||
|
the safe factory whatever it is handed. A future policy bug therefore cannot produce an unsafe
|
||||||
|
retry — the value it would need does not exist.
|
||||||
|
|
||||||
|
The rule is deliberately narrow in the other direction too. Classifying every connection failure as
|
||||||
|
completion-unknown would push ordinary pool exhaustion and server restarts into the reconciliation
|
||||||
|
queue, which trains operators to clear that queue without reading it — and then the one entry that
|
||||||
|
mattered gets cleared with the rest.
|
||||||
|
|
||||||
|
The cost is that the domain must supply the resolver. The platform cannot: only the domain knows
|
||||||
|
which idempotency record, business row, or outbox entry proves the write happened.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`JpaFailureContextTest`; `DefaultJpaRetryPolicyTest`; `CommitFailureClassifierTest`; release gate
|
||||||
|
`completion-unknown-no-retry`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ADR-JPA-004 — Flyway is the schema source of truth
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-11
|
||||||
|
- Design: §31
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Hibernate can create and alter schema from the entity mapping. Flyway can apply versioned scripts.
|
||||||
|
Both cannot own the schema.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Flyway owns every schema change. Hibernate validates and never mutates: `ddl-auto` is `validate` or
|
||||||
|
`none`, enforced at startup. The runtime database credential holds no DDL privilege, so the rule is
|
||||||
|
enforced by the server as well as by configuration.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
`ddl-auto=update` fails in a specific and expensive way: it adds but never drops or narrows, so the
|
||||||
|
result is a schema that is neither the previous one nor the one the mappings describe — produced
|
||||||
|
silently, by whichever instance started first, with no record of what it did.
|
||||||
|
|
||||||
|
Two credentials rather than one is what makes this more than a convention. A configuration rule can
|
||||||
|
be overridden by a property; a role without `CREATE` cannot be overridden by anything the
|
||||||
|
application does.
|
||||||
|
|
||||||
|
Validation fails closed and never repairs. `repair` rewrites the schema history to match the scripts
|
||||||
|
on disk, which resolves a checksum mismatch by deleting the evidence of which change is missing.
|
||||||
|
|
||||||
|
The cost is that a schema change requires a migration script and a deployment step. That is the
|
||||||
|
intended cost: it makes schema change reviewable and reversible.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`JpaDangerousConfigurationGuard`; `FlywaySchemaPolicy`; `FlywayValidationGate`;
|
||||||
|
`PostgreSqlRuntimeRoleVerifier`; release gates `flyway-validate` and `runtime-role-no-ddl`.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ADR-JPA-005 — Contracts run against real PostgreSQL
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-11
|
||||||
|
- Design: §40
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
An in-memory database makes tests fast and hermetic. A container makes them slow and requires
|
||||||
|
Docker.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Every persistence contract runs against real PostgreSQL 16, 17, and 18 in containers. H2 remains a
|
||||||
|
local-development convenience and never satisfies a contract. The lanes fail closed when Docker is
|
||||||
|
absent rather than skipping.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The behaviours these contracts verify either do not exist in H2 or differ there: SQLSTATE values for
|
||||||
|
the same violation, `FOR UPDATE SKIP LOCKED` semantics, JSONB operators, range types, concurrent
|
||||||
|
index builds, `search_path` privileges, and the generated SQL for a paged collection fetch. A green
|
||||||
|
H2 run is evidence that the code compiles and runs — not that any of the above holds.
|
||||||
|
|
||||||
|
Three versions rather than one because the platform claims three. A contract suite that ran only on
|
||||||
|
16 would make "Stable on 17 and 18" an assumption.
|
||||||
|
|
||||||
|
Skipping on missing Docker is the failure mode this decision most wants to avoid: a skipped contract
|
||||||
|
reports success, and CI eventually inherits that silence. `PostgreSqlContainerFactory.assertDockerAvailable()`
|
||||||
|
throws instead.
|
||||||
|
|
||||||
|
The cost is that the contract lanes need Docker and take minutes. The unit lane stays hermetic and
|
||||||
|
fast, and is where most tests live; the container lanes verify the things only a real server can
|
||||||
|
answer.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`PostgreSqlVersion.stable()`; `PostgreSqlContainerFactory`; release gate `postgresql-contract`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# ADR-JPA-006 — `audit` is the canonical technical audit model; `auditing` stays a frozen candidate
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-24
|
||||||
|
- Review: `docs/reviews/2026-08-14-jpa-module-code-review.md` JPA-022
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two complete technical-audit mechanisms live in this leaf and they disagree about the schema.
|
||||||
|
|
||||||
|
`audit/AuditableEntity` stamps `created_at`/`created_by`/`updated_at`/`updated_by` with an actor
|
||||||
|
column of length 256, captured through explicit `initializeAudit`/`applyModification` calls and an
|
||||||
|
`AuditContextPort`. `auditing/AuditMetadata` is a Spring Data embeddable that stamps
|
||||||
|
`created_*`/`modified_*` with an actor column of length 64, captured by `@CreatedDate` and friends
|
||||||
|
through an `AuditorAware`.
|
||||||
|
|
||||||
|
Only the first is real: it is what the sample entities extend and what the migrations were written
|
||||||
|
for. `JpaAuditingConfiguration` is not a Spring `@Configuration`, and nothing in production
|
||||||
|
constructs any of the three `auditing` types.
|
||||||
|
|
||||||
|
The review asked for one canonical model with a migration or activation decision. The failure mode
|
||||||
|
it was protecting against is specific: an author of a new entity picks whichever package they find
|
||||||
|
first, and column names, actor lengths and capture lifecycles then diverge per table.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`audit/AuditableEntity` is canonical. `auditing` stays in the tree as a candidate and is excluded
|
||||||
|
from the Stable capability report.
|
||||||
|
|
||||||
|
The candidate is not deleted and not promoted. Deleting it would discard a working Spring Data
|
||||||
|
integration that a deployment preferring declarative auditing would want. Promoting it would mean
|
||||||
|
either renaming `modified_*` to `updated_*` and widening the actor column — a schema migration of
|
||||||
|
every audited table to gain nothing a caller asked for — or moving the sample entities onto
|
||||||
|
`modified_*`, which is the same migration in the other direction.
|
||||||
|
|
||||||
|
Neither is worth doing now. What the divergence actually needed was not consolidation but a rule
|
||||||
|
that an entity cannot straddle the two, and that rule is cheaper than either migration.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Two audit mechanisms remain readable in one leaf, and a reader has to be told which one is live.
|
||||||
|
That cost is paid in this document, in the package javadoc and in a test whose name says so.
|
||||||
|
|
||||||
|
Two failure modes stay silent unless they are asserted, so both are:
|
||||||
|
|
||||||
|
- The candidate acquires a stereotype and starts stamping in every deployment that has this module
|
||||||
|
on the classpath, including the ones whose tables have no `modified_*` columns — where the result
|
||||||
|
is a failed startup rather than a feature.
|
||||||
|
- Somebody "harmonises" the two by editing one side's column names, at which point the schema a
|
||||||
|
deployed table was migrated for and the schema its entity expects diverge with no migration
|
||||||
|
between them.
|
||||||
|
|
||||||
|
If the candidate is ever promoted, it is promoted atomically: forward migration, sample conversion,
|
||||||
|
`AuditContextPort → AuditorAware` and `Clock → DateTimeProvider` bridges land together, and this
|
||||||
|
ADR is superseded rather than amended.
|
||||||
|
|
||||||
|
Bulk and native updates stamp nothing under either mechanism. That is a property of JPA, not of the
|
||||||
|
choice made here, so it is enforced separately rather than assumed away.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism` and
|
||||||
|
`bulkUpdatesOfAuditedEntitiesStampAudit`, run against the real production graph by
|
||||||
|
`JpaProductionArchitectureTest` at the composition root — not against fixtures, which is how the
|
||||||
|
earlier version of this rule pack passed while applying to nothing. `AuditingCandidateStatusTest`
|
||||||
|
asserts the candidate carries no composing stereotype and that the two column sets stay distinct.
|
||||||
|
`JpaAuditMechanismRuleTest` exercises the rules' own negative cases.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ADR-MONGO-001 — MongoDB platform boundary
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-13
|
||||||
|
- **Design source:** `docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two failure modes are common when a team wraps MongoDB.
|
||||||
|
|
||||||
|
The first is flattening: a shared `CommonMongoRepository<T, ID>` and a generic CRUD facade, which
|
||||||
|
forces every collection to share an id strategy, a consistency profile and a query surface. MongoDB's
|
||||||
|
single-document atomicity, aggregation model and change streams stop being reachable, and the first
|
||||||
|
collection that needs something different gets a cast or a leaky generic.
|
||||||
|
|
||||||
|
The second is unrestricted exposure: the driver and `runCommand` available everywhere. Then any
|
||||||
|
service can drop a collection, run an unbounded pipeline, or issue an admin command from a request
|
||||||
|
thread, and no review catches it because there is nothing structural to catch.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The domain owns its documents; the platform owns the cross-cutting decisions. Four exposure planes:
|
||||||
|
|
||||||
|
| Plane | Contents | Client |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 Standard document persistence | Spring Data repositories, typed queries, mapping manifest, atomic update primitives, optimistic revision | Stable API V1, `apiStrict=true` |
|
||||||
|
| D2 Advanced document operations | `MongoTemplate`, transactions/sessions, bulk, aggregation, keyset cursors, change streams | Stable API V1, `apiStrict=true` |
|
||||||
|
| D3 Explicit Mongo capability | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations | Separate capability client |
|
||||||
|
| D4 Admin plane | Collection, validator, index, migration, shard, repair | Separate admin client and credential |
|
||||||
|
|
||||||
|
Specifically:
|
||||||
|
|
||||||
|
1. **No `CommonMongoRepository<T, ID>`.** Each aggregate declares its own repository.
|
||||||
|
2. **D1/D2 run on Stable API V1 with `apiStrict=true`,** so a command outside the versioned API fails
|
||||||
|
at development time instead of on the next server upgrade.
|
||||||
|
3. **D3 is not a raw-client escape.** Every call passes a fixed admission order: capability registered
|
||||||
|
→ database profile → collection allowlist → operation name → timeout → consistency profile →
|
||||||
|
result limit → trace → redaction → command category → admin-command refusal → execute.
|
||||||
|
4. **D4 is a separate client with a separate credential.** No application-plane path reaches it;
|
||||||
|
`PolicyAwareMongoNativeGateway` refuses admin-category commands regardless of capability.
|
||||||
|
5. **Advanced and Experimental capabilities are opt-in modules**, never transitive dependencies of the
|
||||||
|
Stable surface.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.** MongoDB's semantics stay reachable. Misuse is refused structurally rather than reviewed
|
||||||
|
for. A server upgrade cannot silently change D1/D2 behaviour. Admin operations have their own audit
|
||||||
|
trail and credential.
|
||||||
|
|
||||||
|
**Negative.** Every operation needs a registered name and profile, so a new query is a small amount of
|
||||||
|
configuration rather than zero. A genuinely new capability requires a registration before it can be
|
||||||
|
used. Both are deliberate: the cost is paid once per operation, at review time.
|
||||||
|
|
||||||
|
**Rejected alternative — "expose the driver, rely on code review."** Review does not scale to every
|
||||||
|
query in every service, and the operations that matter (unbounded pipeline, `dropCollection`,
|
||||||
|
unanchored regex on user input) look unremarkable in a diff.
|
||||||
|
|
||||||
|
## Repository adaptation
|
||||||
|
|
||||||
|
The design assumes 19 Gradle modules under `modules/mongodb/`. This repository's fail-closed registry
|
||||||
|
declares exactly 19 leaf identities, so the modules became package boundaries inside
|
||||||
|
`:adapter:outbound:persistence-mongo`, enforced by ArchUnit. See
|
||||||
|
[docs/mongodb/repository-adaptation.md](../mongodb/repository-adaptation.md).
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-MONGO-002 — BSON representation is a pinned manifest
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-13
|
||||||
|
- **Design source:** design §10, decision D-06
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
How a Java value is represented in BSON is a data contract, but nothing in the default toolchain
|
||||||
|
treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have
|
||||||
|
changed across versions. A `BigDecimal` can land as a `Double`, a `String` or a `Decimal128`; a `UUID`
|
||||||
|
can land as `Binary` subtype 3 or subtype 4; an `Instant` can land as a `Date` or a `String`.
|
||||||
|
|
||||||
|
The consequences are asymmetric. A representation change is invisible in a value-equality test —
|
||||||
|
`12.30` looks like `12.30` whether it is a double or a `Decimal128` — but once a collection holds
|
||||||
|
production data, changing it is a full migration. And the UUID case is worse than a migration: legacy
|
||||||
|
Java representation byte-swaps two halves of the UUID, so a document written under one representation
|
||||||
|
and read under the other yields a *different, valid-looking* UUID. Nothing errors. You get the wrong
|
||||||
|
record.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`MongoTypeRepresentationManifest` pins the representation for every type the platform maps, and
|
||||||
|
`MongoMappingConfiguration` builds the Spring Data converters from it. Nothing relies on a library
|
||||||
|
default.
|
||||||
|
|
||||||
|
| Java | BSON | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| `UUID` | `Binary` subtype 4 (`STANDARD`) | Subtype 3 byte-swaps; cross-representation reads are silently wrong. |
|
||||||
|
| `BigDecimal` | `Decimal128` | A double cannot represent `12.30`; money compared as a double is eventually wrong by a cent. |
|
||||||
|
| `BigInteger` | `Decimal128`, or declared `String` when out of range | 34 significant digits; out of range fails on write instead of rounding. |
|
||||||
|
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | One instant, one representation. |
|
||||||
|
| `LocalDate` | declared per field | A calendar day is not an instant. |
|
||||||
|
| `LocalDateTime` | **refused** | No offset: the stored value depends on the writing JVM's default zone. |
|
||||||
|
| `enum` | `String` name | Ordinals renumber when someone inserts a constant. |
|
||||||
|
|
||||||
|
Type metadata follows `MongoTypeMetadataPolicy` — `NONE`, `ALIAS` or `CLASS_NAME`. A
|
||||||
|
`@LongLivedMongoDocument` type may not use `CLASS_NAME`: writing a FQCN into a million documents makes
|
||||||
|
a package rename a data migration.
|
||||||
|
|
||||||
|
The manifest is enforced by a golden gate. `MongoBsonSnapshot` canonicalises a stored document,
|
||||||
|
preserving BSON types and keeping missing distinct from null, and
|
||||||
|
`MongoBsonSnapshotAssert.hasTypeSignature(...)` fails on any representation change. The registry
|
||||||
|
pins `UuidCodec(STANDARD)` explicitly rather than inheriting a default, since inheriting the default
|
||||||
|
is the exact drift the gate exists to catch.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.** A library upgrade cannot move a representation without failing a test. Money is exact.
|
||||||
|
UUIDs read back as themselves. Class moves stay refactors.
|
||||||
|
|
||||||
|
**Negative.** Every representation-affecting change requires updating a snapshot *and* writing a
|
||||||
|
migration. A new mapped type needs a manifest entry before it can be used. This is the intended
|
||||||
|
friction: the alternative is discovering the change in production.
|
||||||
|
|
||||||
|
**Rejected alternative — "snapshot the JSON."** JSON destroys exactly the distinctions the gate
|
||||||
|
protects: `Decimal128` and `String` both render as text, `Binary` UUID and `ObjectId` both render as
|
||||||
|
hex, and missing and null both disappear.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-MONGO-003 — Transaction body retry and commit retry are separate loops
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-13
|
||||||
|
- **Design source:** design §14–§16, decisions D-07 through D-10
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
MongoDB reports two transaction failures that look similar and must be handled in opposite ways.
|
||||||
|
|
||||||
|
`TransientTransactionError` means the transaction definitively did not commit. The correct response is
|
||||||
|
to run the whole thing again.
|
||||||
|
|
||||||
|
`UnknownTransactionCommitResult` means the commit **may already have applied** — typically because the
|
||||||
|
primary changed while the commit was in flight. The correct response is to retry *the commit*, which
|
||||||
|
is a no-op if it already succeeded.
|
||||||
|
|
||||||
|
The common implementation wraps everything in one retry loop. That loop replays the body after an
|
||||||
|
unknown commit, and if the commit did apply, the body applies twice. In a payment or notification path
|
||||||
|
that is a duplicate charge or a duplicate message, produced by the error handler.
|
||||||
|
|
||||||
|
The related trap is session reuse: retrying on the same session after an abort carries the aborted
|
||||||
|
transaction's state into the retry.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`MongoTransactionRetryCoordinator` implements two loops with different scopes.
|
||||||
|
|
||||||
|
```
|
||||||
|
for each body attempt within the budget:
|
||||||
|
open a NEW session
|
||||||
|
run the body
|
||||||
|
TransientTransactionError -> abort, continue to next body attempt
|
||||||
|
commitWithRetry(session):
|
||||||
|
UnknownTransactionCommitResult -> retry the COMMIT ONLY, same session
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules that follow, all of them load-bearing:
|
||||||
|
|
||||||
|
1. **A new session per body attempt.** No aborted state leaks into a retry.
|
||||||
|
2. **The body is never replayed after a commit ambiguity.** `MongoRetryScope.COMMIT_ONLY` is a
|
||||||
|
distinct value from `BODY` precisely so this cannot be collapsed by accident.
|
||||||
|
3. **One budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with
|
||||||
|
jittered backoff, so a struggling primary is not retried into the ground by every instance at once.
|
||||||
|
4. **An exhausted commit retry surfaces `TRANSACTION_COMMIT_UNKNOWN`,** never a generic failure. An
|
||||||
|
ambiguous outcome reported as a failure invites the caller to retry — the one thing that must not
|
||||||
|
happen. See [docs/mongodb/runbooks/unknown-commit.md](../mongodb/runbooks/unknown-commit.md).
|
||||||
|
5. **Transaction bodies write a deterministic marker** so `MongoCommitReconciler` can establish what
|
||||||
|
actually happened. A transaction that cannot be reconciled has no recovery path.
|
||||||
|
6. **Classification reads labels before codes.** Server error labels are the authoritative statement
|
||||||
|
about retryability; error codes vary by version.
|
||||||
|
|
||||||
|
Surrounding decisions that reduce how often this path is reached at all: single-document atomic
|
||||||
|
operations are preferred over transactions (D-09), partial changes use update operators rather than
|
||||||
|
`save()` (D-07), and whole-document replacement requires an optimistic revision (D-08).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.** A commit ambiguity cannot become a duplicate effect. The ambiguity reaches the caller as
|
||||||
|
an ambiguity. The retry budget is bounded in both attempts and time.
|
||||||
|
|
||||||
|
**Negative.** Callers must handle a third outcome beyond success and failure. Transaction bodies must
|
||||||
|
write a marker they would not otherwise need. Both costs are small compared with reconciling
|
||||||
|
duplicated financial effects after the fact.
|
||||||
|
|
||||||
|
**Rejected alternative — "one retry loop, at-least-once everywhere."** It requires every transaction
|
||||||
|
body to be fully idempotent, which is a much stronger and much less checkable property than writing
|
||||||
|
one marker, and it is silently violated the first time someone adds a non-idempotent step.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ADR-MONGO-004 — Index and schema changes belong to the admin plane
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-13
|
||||||
|
- **Design source:** design §21–§25, decisions D-11, D-13
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Spring Data can create indexes automatically from annotations. On a laptop this is convenient. On a
|
||||||
|
collection with a hundred million documents, an index build is a capacity event: it consumes CPU, IO
|
||||||
|
and memory on the primary for minutes to hours, and it starts because a pod restarted.
|
||||||
|
|
||||||
|
Worse, it starts N times when N pods restart, and there is no approval step, no ordering relative to
|
||||||
|
the code that needs the index, and no record afterwards of what was created.
|
||||||
|
|
||||||
|
Schema validators have the same shape with a sharper edge: tightening a validator on a collection with
|
||||||
|
existing data rejects writes to documents that were legal when they were written.
|
||||||
|
|
||||||
|
TTL has a third shape. It looks like a scheduler and is not one: the TTL monitor runs about once a
|
||||||
|
minute and deletes in batches, so an expired document routinely remains readable for minutes or hours.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Indexes and validators are declared in a manifest and applied by the admin plane (D4).** Automatic
|
||||||
|
index creation in production is disabled.
|
||||||
|
|
||||||
|
1. `MongoManifestRegistry` holds the declared indexes (`MongoIndexManifest`) and validator
|
||||||
|
(`MongoSchemaManifest`) per collection. The manifest is the source of truth, reviewed in a pull
|
||||||
|
request.
|
||||||
|
2. `MongoIndexDiffEngine` compares manifest against observed state and reports missing, extra and
|
||||||
|
*changed* indexes. Changed ones are reported rather than re-issued: MongoDB will not silently
|
||||||
|
rebuild an index whose definition moved.
|
||||||
|
3. `MongoIndexApplyPolicy` sets what an environment may do — `APPLY` (local), `APPLY_WITH_DIFF`
|
||||||
|
(staging), `DIFF_WITH_APPROVED_APPLY` (production), `REPORT_ONLY` (audit).
|
||||||
|
4. **Ownership gates every drop.** `MongoMetadataOwnership` distinguishes `APPLICATION_MANAGED` from
|
||||||
|
`SEARCH_MANAGED`, `ENCRYPTION_MANAGED` and `EXTERNAL`. Only application-managed objects are
|
||||||
|
droppable on drift. A diff engine without ownership eventually proposes dropping
|
||||||
|
`enxcol_.customers.esc`, and "the drift tool cleaned it up" is a very bad incident summary.
|
||||||
|
5. **Retirement is staged.** `MongoIndexRetirementState` moves an index declared → hidden →
|
||||||
|
observed-unused → droppable, one deployment per transition. Hiding is instantly reversible;
|
||||||
|
dropping is a rebuild.
|
||||||
|
6. **Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
|
||||||
|
contract on 7.0 or 8.0 and is refused. Tightening goes `warn`+`MODERATE` → confirm zero warnings →
|
||||||
|
`error`+`STRICT`, in two deployments.
|
||||||
|
7. **TTL is physical cleanup only** (D-13). `MongoExpirationAccessPolicy` states the rule: a
|
||||||
|
document's presence is not authorization and its absence is not a deadline. Access control checks
|
||||||
|
the expiry field; scheduling uses a scheduler.
|
||||||
|
8. **Migrations are checksummed, locked, precondition-checked and resumable.**
|
||||||
|
`MongoMigrationRunner` fails hard when an applied id's checksum changed — two environments running
|
||||||
|
different code under one id is worse than a failed deploy.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.** Index builds are scheduled by people who know the capacity. Rollback is possible at
|
||||||
|
every step. Drift is visible without being dangerous. Nothing drops what it does not own.
|
||||||
|
|
||||||
|
**Negative.** Adding an index is a manifest change plus an apply, not an annotation. Local development
|
||||||
|
uses `APPLY` so the friction is confined to environments where it is warranted.
|
||||||
|
|
||||||
|
**Rejected alternative — "auto-create with a feature flag."** The flag is either on in production,
|
||||||
|
which is the problem, or off, in which case the manifest is the real mechanism and the annotation is a
|
||||||
|
second, divergent source of truth.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ADR-MONGO-ADV-001 — Advanced capability promotion
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-13
|
||||||
|
- **Design source:** design §2 (D-15), §3.2–§3.3; Advanced expansion plan Task 15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Sharding, time series, CSFLE, Queryable Encryption, search, vector search and multi-tenancy each work
|
||||||
|
in a demo within an afternoon. What they do not do is behave the same way in production, and the
|
||||||
|
differences are not discovered by functional tests:
|
||||||
|
|
||||||
|
- Sharding changes which queries are efficient. A query that misses the shard key becomes
|
||||||
|
scatter-gather, which passes every test on a one-shard cluster.
|
||||||
|
- Encryption's failure modes are KMS failure modes — wrong key, revoked permission, mid-rotation —
|
||||||
|
none of which occur against a local key provider.
|
||||||
|
- Search and vector search can be functionally correct and useless: the index returns results, and
|
||||||
|
the results are not relevant. Recall is not visible in a pass/fail assertion.
|
||||||
|
- Database-per-tenant works until the tenant count crosses what the connection and file-handle
|
||||||
|
budget supports, which is an operational property, not a code property.
|
||||||
|
|
||||||
|
The failure mode this ADR prevents is a capability marked "done" on the strength of a green test that
|
||||||
|
never touched the environment where it will run.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Every Advanced and Experimental capability is an **opt-in module behind its own flag**, and promotion
|
||||||
|
requires evidence, not confidence.
|
||||||
|
|
||||||
|
### Enablement
|
||||||
|
|
||||||
|
`MongoAdvancedCapabilityFlags` gates construction of every Advanced entry point. A disabled capability
|
||||||
|
does not produce a runtime warning — the type refuses to be constructed, naming the property that
|
||||||
|
enables it (`MongoAdvancedCapabilityFlags.propertyFor(capability)`). Being on the classpath is not
|
||||||
|
being enabled, and `stableNeverDependsOnAdvanced` (ArchUnit) keeps the Stable surface free of them.
|
||||||
|
|
||||||
|
### Promotion evidence
|
||||||
|
|
||||||
|
`MongoAdvancedPromotionGate.verify(evidence)` requires every category:
|
||||||
|
|
||||||
|
| Category | Means |
|
||||||
|
|---|---|
|
||||||
|
| `stable-platform` | The Stable release gate passed on the same revision. |
|
||||||
|
| `actual-topology` | The capability ran on the real topology — a real sharded cluster, the real KMS, the actual target deployment. Atlas Local is a pull-request convenience and explicitly not release evidence (`MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL`). |
|
||||||
|
| `security` | Privileges reviewed; the capability's admin role is separate from the application role. |
|
||||||
|
| `migration` | A documented path in and, where the capability is irreversible, an explicit statement that there is no path back. |
|
||||||
|
| `failure` | Negative cases fail closed: wrong key, missing permission, rotation, non-ready index, unrouted query. |
|
||||||
|
| `runbook` | A runbook exists for the capability's characteristic incident. |
|
||||||
|
|
||||||
|
### Additional per-capability requirements
|
||||||
|
|
||||||
|
- **Search / vector search:** relevance and performance evidence, not functional success alone.
|
||||||
|
`MongoVectorSearchBenchmarkGate` requires recall alongside latency and index size; a gate that
|
||||||
|
measures only latency certifies a fast wrong answer.
|
||||||
|
- **Database-per-tenant and reshard orchestration remain Experimental** until operational scale
|
||||||
|
evidence exists. Both are correct in the small and unbounded in the large.
|
||||||
|
- **Reshard requires an explicit `ReshardApproval`** — a named approver and a stated window. It
|
||||||
|
rewrites the collection.
|
||||||
|
|
||||||
|
### Promotion does not change the dependency boundary
|
||||||
|
|
||||||
|
A capability promoted to Stable **remains an opt-in module** unless a later starter ADR changes the
|
||||||
|
dependency boundary. Promotion is a statement about evidence, not an invitation to add a transitive
|
||||||
|
dependency to every service.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive.** No capability reaches production on the strength of a container-only test. The evidence
|
||||||
|
list is the same for every capability, so promotion is reviewable rather than negotiated.
|
||||||
|
|
||||||
|
**Negative.** Promotion requires access to real infrastructure — a sharded cluster, a real KMS, the
|
||||||
|
target deployment. That is the cost of the guarantee: the alternative is finding out in production,
|
||||||
|
where encryption and sharding are both expensive to reverse.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`scripts/verify-mongodb-advanced.sh` enforced this ADR until it was removed on 2026-08-15. The
|
||||||
|
promotion evidence categories this ADR requires are therefore no longer checked by any automated
|
||||||
|
gate; they are a review obligation until one is rebuilt. See `docs/mongodb/repository-adaptation.md`
|
||||||
|
§5 for the Gradle lanes the script wrapped.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Scope: `adapter:inbound:web` — `advanced.stream.**`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Advanced Tasks 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
|
||||||
|
|
||||||
|
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
|
||||||
|
change.** A stream that ends because a dependency failed and one that ends because it finished are
|
||||||
|
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
|
||||||
|
cut off mid-flight.
|
||||||
|
|
||||||
|
The second fact is that a resume path invites the web module to remember things. It must not: the
|
||||||
|
messaging platform already owns durable event history, and a second copy would have its own
|
||||||
|
retention, its own eviction and its own opinion about ordering.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
|
||||||
|
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
|
||||||
|
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
|
||||||
|
where a rising rate of mid-stream failures would otherwise hide.
|
||||||
|
|
||||||
|
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
|
||||||
|
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
|
||||||
|
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
|
||||||
|
which no client parses and every proxy caches as a success.
|
||||||
|
|
||||||
|
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
|
||||||
|
repeated or regressing position. A client deduplicating on position would silently drop the second
|
||||||
|
item.
|
||||||
|
|
||||||
|
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
|
||||||
|
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
|
||||||
|
consumer that reads slowly for an hour.
|
||||||
|
|
||||||
|
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
|
||||||
|
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
|
||||||
|
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
|
||||||
|
retries immediately, and if every socket is cut at once, every client retries at once.
|
||||||
|
|
||||||
|
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
|
||||||
|
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
|
||||||
|
the oldest retained position, because that delivers a stream with a hole the client cannot see.
|
||||||
|
|
||||||
|
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
|
||||||
|
visible in either half on its own.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
|
||||||
|
wrong, and no server change can fix that for it.
|
||||||
|
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
|
||||||
|
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
|
||||||
|
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
|
||||||
|
unparseable and the 200 is cached.
|
||||||
|
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
|
||||||
|
contiguous from where the replay started, so nothing in the data says events are missing.
|
||||||
|
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
|
||||||
|
invisibly.
|
||||||
|
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
|
||||||
|
server's heap.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Scope: `adapter:inbound:web` — `advanced.virtualthread`, `advanced.blockingbridge`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
|
||||||
|
for WebFlux.
|
||||||
|
|
||||||
|
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
|
||||||
|
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
|
||||||
|
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
|
||||||
|
side effect of the pool size.
|
||||||
|
|
||||||
|
Switching to virtual threads deletes that limit without deleting anything that depended on it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**An explicit admission limit is required when virtual threads are enabled.**
|
||||||
|
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
|
||||||
|
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
|
||||||
|
had it been refused. The load that used to be shed at the front door is shed at the back, after the
|
||||||
|
cost of accepting it.
|
||||||
|
|
||||||
|
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
|
||||||
|
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
|
||||||
|
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
|
||||||
|
|
||||||
|
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
|
||||||
|
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
|
||||||
|
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
|
||||||
|
all database-bound, and that should be a choice rather than an accident.
|
||||||
|
|
||||||
|
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
|
||||||
|
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
|
||||||
|
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
|
||||||
|
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
|
||||||
|
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
|
||||||
|
dependencies starve behind them.
|
||||||
|
|
||||||
|
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
|
||||||
|
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
|
||||||
|
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
|
||||||
|
a deadlock a thread dump does not obviously show.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
|
||||||
|
will not let it be one.
|
||||||
|
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
|
||||||
|
the client than the same request accepted and timed out thirty seconds later behind a full pool.
|
||||||
|
An operator seeing 503s climb should read them as the limit working.
|
||||||
|
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
|
||||||
|
It is invisible from throughput, which is why a load test that only measures throughput would pass
|
||||||
|
with the guard removed.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
|
||||||
|
sized to what the dependencies can serve, not to what the web tier can accept.
|
||||||
|
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
|
||||||
|
thread pool with extra steps.
|
||||||
|
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
|
||||||
|
until the pool is the thing consuming the heap.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Scope: `adapter:inbound:web` — `advanced.openapi`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot.
|
||||||
|
|
||||||
|
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
|
||||||
|
document looks fine. The value of an API description is entirely in what consumes it, and a document
|
||||||
|
in a version a client generator does not fully understand produces a client that compiles and is
|
||||||
|
wrong — which is worse than no document at all.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and
|
||||||
|
`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting.
|
||||||
|
|
||||||
|
**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a
|
||||||
|
contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped —
|
||||||
|
silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the
|
||||||
|
snapshot hash before and after and makes a difference a promotion blocker.
|
||||||
|
|
||||||
|
**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies
|
||||||
|
style rules and accepts documents a parser rejects; a generator produces client code, and this is
|
||||||
|
where an unsupported construct surfaces — not as an error but as a method with the wrong signature;
|
||||||
|
a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a
|
||||||
|
statement anybody can make. "This document is read correctly by these four tools at these versions"
|
||||||
|
is.
|
||||||
|
|
||||||
|
**Promotion requires an accepted ADR regardless of how green the matrix is.**
|
||||||
|
`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide
|
||||||
|
whether the consumer population is ready.
|
||||||
|
|
||||||
|
**Streaming description differences are reported separately.** They are the substantive difference
|
||||||
|
between the two versions for this application, and folding them into a pass/fail hides what
|
||||||
|
changed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The 3.2 document is published as an artifact of the experimental workflow, never of the release
|
||||||
|
workflow.
|
||||||
|
- A client generator that only understands 3.1 is unaffected, which is the point.
|
||||||
|
- Adopting 3.2 later is a documented decision with a named consumer matrix behind it.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the
|
||||||
|
constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences.
|
||||||
|
- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client
|
||||||
|
that compiles and misbehaves.
|
||||||
|
- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the
|
||||||
|
failure the others miss.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Scope: `:adapter:inbound:websocket`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The realtime connection platform design models itself as eighteen Gradle modules under
|
||||||
|
`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies.
|
||||||
|
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the
|
||||||
|
leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and
|
||||||
|
HARD-STOP #5 forbids doing it implicitly.
|
||||||
|
|
||||||
|
Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same
|
||||||
|
situation and resolved it the same way.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule`
|
||||||
|
declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||||||
|
scans the production tree and fails when the declaration and the tree disagree in either direction.
|
||||||
|
|
||||||
|
Three deviations from the design's module map were forced by the check and are recorded in
|
||||||
|
`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec
|
||||||
|
moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here;
|
||||||
|
and the `budget -> core` edge was inverted because `budget` imports nothing from `core`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The boundary is enforced, not documented.** Six violations were caught during implementation that
|
||||||
|
a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed
|
||||||
|
one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one
|
||||||
|
package, ownership depends on iteration order and one module's rules silently apply to nothing. A
|
||||||
|
guard against it is now part of the boundary test.
|
||||||
|
|
||||||
|
**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not
|
||||||
|
`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have
|
||||||
|
imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list.
|
||||||
|
|
||||||
|
**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so
|
||||||
|
splitting one out later is a registry edit rather than an archaeology exercise.
|
||||||
|
|
||||||
|
**The design's own rules were kept where they cost something.** `core` names no framework, so the
|
||||||
|
same decisions serve both runtimes and are testable without a server; no Java class name reaches the
|
||||||
|
wire; the payload is an encoded string rather than a map; and handlers are given no way to write,
|
||||||
|
which is what makes ordering and backpressure guarantees rather than conventions.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry
|
||||||
|
for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary
|
||||||
|
test provides the property the modules were for.
|
||||||
|
|
||||||
|
**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim.
|
||||||
|
The six violations found during implementation are the argument against it.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Scope: `adapter:inbound:websocket` — `advanced.resume`, `advanced.cluster`, `advanced.presence`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Advanced Tasks 2–8 add three things that all look like state and are not: a resume token that says
|
||||||
|
where a client got to, a cluster index that says which node holds a session, and a presence summary
|
||||||
|
derived from that index.
|
||||||
|
|
||||||
|
Each is a statement about the past. The resume token was minted before the disconnect; the index
|
||||||
|
entry was written by a node that may since have died; presence is a read of the index and inherits
|
||||||
|
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
|
||||||
|
fact, because each reads as one at the call site.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
|
||||||
|
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
|
||||||
|
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
|
||||||
|
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
|
||||||
|
resynchronise because the client believes it is complete.
|
||||||
|
|
||||||
|
**Cluster index entries carry an observation time and are checked against it on every read.**
|
||||||
|
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
|
||||||
|
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
|
||||||
|
the session.
|
||||||
|
|
||||||
|
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
|
||||||
|
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
|
||||||
|
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
|
||||||
|
`compute`, so two consumer threads cannot both deliver the same position.
|
||||||
|
|
||||||
|
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
|
||||||
|
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
|
||||||
|
that the index went dark and the connections are fine.
|
||||||
|
|
||||||
|
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
|
||||||
|
can move the platform's belief about who is present. Presence answers "show a green dot".
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
|
||||||
|
implement one; there is no mode in which the platform silently pretends.
|
||||||
|
- Every read of the cluster index needs a clock. This is deliberate friction.
|
||||||
|
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
|
||||||
|
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
|
||||||
|
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
|
||||||
|
object. A rolling deploy has two versions of the code reading the same envelope.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
|
||||||
|
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
|
||||||
|
that retries certainly does. Position is the property the ordering actually has.
|
||||||
|
- **A single `online` boolean.** Rejected for the partition case above.
|
||||||
|
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
|
||||||
|
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker
|
||||||
|
relay and cross-node user destinations.
|
||||||
|
|
||||||
|
This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on
|
||||||
|
`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context
|
||||||
|
do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the
|
||||||
|
broker that results is whichever ran last. Nothing errors and nothing logs.
|
||||||
|
|
||||||
|
STOMP also brings a destination model that is a free string from the client. Without a catalog, the
|
||||||
|
set of reachable destinations is whatever the broker accepts, which for the simple broker is every
|
||||||
|
string.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the
|
||||||
|
one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and
|
||||||
|
folding it into `advanced` would relax that module's purity for every capability in it.
|
||||||
|
|
||||||
|
**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the
|
||||||
|
relay opens a TCP connection to somebody else's broker and makes every delivery depend on it.
|
||||||
|
Different blast radius, different decision, different module.
|
||||||
|
|
||||||
|
**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation,
|
||||||
|
destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate
|
||||||
|
declarations, because reading a feed and publishing into it are different rights.
|
||||||
|
|
||||||
|
**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy`
|
||||||
|
returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule
|
||||||
|
reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for.
|
||||||
|
|
||||||
|
**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels
|
||||||
|
are enabled, when both brokers are, or when the adapter is enabled with no broker behind it.
|
||||||
|
|
||||||
|
**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and
|
||||||
|
`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the
|
||||||
|
protocol layer, which knows nothing about whether the work succeeded.
|
||||||
|
|
||||||
|
**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed
|
||||||
|
claiming cluster support or durable acks, and refuses activation outside local/test — in a
|
||||||
|
multi-node deployment it does not error, it delivers to whichever fraction of users is on the
|
||||||
|
publishing node.
|
||||||
|
|
||||||
|
**Unresolved user destinations are broadcast once and then dead-lettered.**
|
||||||
|
`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did
|
||||||
|
not. Without that, every node rebroadcasts every unresolvable message on receipt.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that
|
||||||
|
runs both; the exclusivity check makes that explicit at startup rather than at 3am.
|
||||||
|
- A deployment must write its own catalog. There is deliberately no default: an empty one refuses
|
||||||
|
every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose.
|
||||||
|
- The relay's cost is one broker connection per authenticated session plus one system connection.
|
||||||
|
`brokerConnectionsFor` exists so this is computed before the first outage.
|
||||||
|
- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user
|
||||||
|
destination contains a user identifier by construction.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable
|
||||||
|
module naming an Advanced one. Making the legacy channel profile-driven would have required that
|
||||||
|
edge.
|
||||||
|
- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational
|
||||||
|
decision and deserves to be refusable on its own.
|
||||||
|
- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the
|
||||||
|
default and the catalog becomes documentation.
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
# GraphQL leaf public API surface — every public top-level type in src/main/java.
|
||||||
|
# A public type in a single-jar leaf is reachable from every adopter's code, so
|
||||||
|
# additions are reviewed rather than discovered. `api` and `spi` are the intended
|
||||||
|
# external surface; the rest are candidates to become internal when this leaf is
|
||||||
|
# split into capability artifacts.
|
||||||
|
# Update only after review with:
|
||||||
|
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
|
||||||
|
# types: 408
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminDeniedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminPort
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminService
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAudit
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationBlockCommand
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationUsage
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityGrade
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedDependencyRules
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDataLoaderPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDispatchConfigurer
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedLoaderMetrics
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderCycleDetector
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyCycleException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyGraph
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlClientOperationGenerator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenBoundaryException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedCompatibilityGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedSourceBoundary
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlOperationValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlScalarMapping
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlTransportTypeGenerator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionResult
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationDeploymentOrder
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationLatencyBudget
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseEvidence
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationUsageReport
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlSubgraphContract
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationBatchResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationCapability
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityKey
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationProperties
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationRepresentationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationSchemaFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpDraftCompatibilityReport
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCachePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCsrfPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetOperationPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRequestParser
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCancellation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryCapability
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalPatch
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalTransportPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationConflictException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationInterceptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationLookup
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationNotFoundException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRecordMapping
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRequest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedPreparsedBridge
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.OperationalStoreGraphQlPersistedOperationRegistry
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorization
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorizationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayGapException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayHistoryLostException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayPosition
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplaySource
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSnapshotLiveHandoff
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSubscriptionCursor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlSubscriptionAuthorizationPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseReason
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseTermination
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSlowConsumerPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDispatcher
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainCoordinator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainPhase
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainingException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionEvent
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionLease
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionMetrics
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionOrderingProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionSource
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionState
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlAsyncReturnShape
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerContractException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerTransactionRule
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlInputTypePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlActivationEnvironmentPostProcessor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlDeploymentMode
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlOffAutoConfigurationImportFilter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformSettings
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRetiredSafetyAxis
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityImpact
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityReport
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlDeprecationGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalDecision
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalRequest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaChange
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.context.ActorRef
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.context.TenantContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlFieldCostDescriptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimitPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimits
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserOptionsFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserRejectedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResolverWeight
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseByteLimiter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseNodeCounter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchLoadException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchObservation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResult
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResultMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchValue
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderRequestRegistry
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlFailureBoundary
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlInternalErrorMasker
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlNullabilityContract
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlRequestErrorMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlSubscriptionExceptionResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlAnonymousOperationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlDeadlinePropagator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNameInterceptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNamePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheKey
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverBudget
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverCatalog
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverDescriptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlTimeoutPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileClassifier
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileName
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRegistry
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRule
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileValidationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionCoordinate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSetView
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSignature
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlUnmappedSelectionException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlAcceptHeader
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpOutcome
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponsePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpStatusMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestFormatException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestTooLargeException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModulePurity
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBatchMutationItemResult
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBusinessResult
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlCanonicalInput
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlExpectedVersion
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyConflictException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyKey
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationCoordinate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationFingerprint
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyInterceptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationPayload
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationResultMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationContractException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlProfilerAccessPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnection
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionAssembler
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionRequest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyRing
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyset
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorVersion
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlEdge
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlKeysetWindow
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlPageInfo
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.pagination.HmacGraphQlCursorCodec
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicyManifest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationCatalog
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlPolicyViolation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownClientProfileException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownOperationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.release.GraphQlStableCapabilityManifest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridge
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridgeFullException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionHandler
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionRequest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.BigDecimalScalar
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.DateScalar
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlDecimalBounds
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.InstantScalar
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.LongScalar
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.scalar.UuidScalar
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlContractVersion
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingIssue
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfInputValidator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfSchemaGate
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfViolationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssembler
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyResult
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaContract
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.ApplicationObjectAuthorization
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDecision
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDeniedException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlBatchContext
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlClientProfileResolver
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlObjectAuthorizationPort
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationException
|
||||||
|
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationPolicy
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# JPA persistence leaf public API surface — every public top-level type in src/main/java.
|
||||||
|
# A public type in a single-jar leaf is reachable from every adopter's code, so
|
||||||
|
# additions are reviewed rather than discovered. `api` is the intended external
|
||||||
|
# surface; the rest is implementation that has not been moved under an internal
|
||||||
|
# root yet.
|
||||||
|
# Update only after review with:
|
||||||
|
# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange
|
||||||
|
# types: 338
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.CheckConstraintViolationException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.ConnectionUnavailableException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.ForeignKeyViolationException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.JpaEntityNotFoundException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.NotNullConstraintViolationException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.QueryTimeoutException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionTimeoutException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.CursorCodec
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.CursorPayloadCodec
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.QueryName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.SignedJsonCursorCodec
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.query.SortDirection
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaTransactionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryEventListener
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.auditing.AuditMetadata
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditingConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditorProvider
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.cache.CacheConcurrencyStrategy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.cache.CacheRegionCatalog
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheGuard
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCachePolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheSettings
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.envers.EntityRevision
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.envers.EnversConfigurationGuard
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryReader
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.envers.EnversRevisionMetadata
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.envers.HibernateEnversHistoryReader
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceLifecycle
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantEntityManagerFactoryRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.next.CompatibilityLane
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.next.ExperimentalPromotionGate
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.next.HibernateCompatibilityPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionDecision
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionEvidence
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaRoutingDecision
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsAdminBypassToken
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsPolicyVerifier
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaMultiTenantConnectionProvider
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.schema.TenantMigrationStatus
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantAwareRepositoryGuard
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantEntityListenerGuard
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverSchemaActivation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.VerificationResultEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.h2.H2IdempotencyClaimRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.h2.H2LocalTimeoutConfigurer
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.h2.H2OutboxClaimRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.h2.H2SqlStateErrorMapping
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.JdbcBatchCounter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.NamedStatementInspector
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.BatchExecutionResult
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateBatchConfigurationGuard
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateJpaBatchExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfile
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfileRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.AffectedRowsExpectation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlResult
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkOperationName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.HibernateBulkDmlExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.HibernateStatelessSessionRunner
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessRowCapExceededException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessSessionRunner
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkResult
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyReaper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyRecordJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjectStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.liveevent.JpaLiveEventReplayAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.liveevent.LiveEventJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.lock.LockSettings
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.ConcurrentIndexMigrationInspector
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.FailedConcurrentIndexRecovery
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.MigrationResource
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.NonTransactionalMigrationPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.SchemaManagementMode
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.migration.SchemaVersionSnapshot
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaStream
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.configuration.NotificationJpaPersistenceFacade
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.crypto.DirectAeadNotificationPayloadCrypto
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCiphertext
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCryptoException
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationHmacDigester
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialHandle
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialProvider
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcNotificationServingState
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcReconciliationJobStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaAdminOperationStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaContactPointStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaDeliveryAttemptStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationRequestStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationSideEffectStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaPolicyStores
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaProviderEventLedger
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientDeliveryStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientLeaseStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaSuppressionStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaTemplateRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRecordMapper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientClaimSql
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.TenantBoundRepositoryGuard
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxCommitEventPublisher
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxOutboxRecordFactory
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.JpaNotificationInbox
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.observation.JpaMetricTags
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.observation.JpaRetryObservation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationStoreAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.array.PostgreSqlArraySupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintViolationTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.BoundedCopyInputStream
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyAdminCapability
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyFormat
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyLimits
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyOperationName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyResult
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.PostgreSqlCopyLoader
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredCopyStatement
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredPostgreSqlCopyLoader
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlState
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocument
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocumentCodec
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonPathName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.json.PostgreSqlJsonQuerySupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.LockWaitObservation
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockExceptionTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockOptions
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlWorkClaimExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaim
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaimExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueDefinition
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlPollingDeliveryAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRange
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeCodec
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeJdbcType
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PostgreSqlRangeQuerySupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.NativeWriteName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.PostgreSqlUpsertExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredPostgreSqlUpsertExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredUpsertStatement
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertConflictTarget
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertResult
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.postgresql.write.WriteDisposition
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.querydsl.PredicatePolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.querydsl.QueryPage
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.querydsl.QuerydslJpaSupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.security.DatabaseRolePolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.security.SearchPathPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.EntityGraphCatalog
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.EntityManagerAccess
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanApplier
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanName
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.JpaKeysetQuerySupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.JpaRepositoryFragmentSupport
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamScope
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetPredicateBuilder
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetSliceAssembler
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetTerm
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.RegisteredQuery
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortField
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortMapper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.ScrollPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.springdata.SpecificationPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.BackoffCalculator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecord
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecorder
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.DefaultJpaRetryPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.EvidenceAwareJpaTransactionManager
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionConfig
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionSettings
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.OptimisticConflictTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.PersistenceFailureTranslatorChain
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.RetryBudget
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.RetrySleeper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.ThreadRetrySleeper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionDefinitionMapper
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceContext
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceFrame
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceScope
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionProfileRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionStartBudget
|
||||||
|
dev.caskeleton.adapter.outbound.persistence.transaction.UnknownOperation
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
# MongoDB leaf public API surface — every public top-level type in src/main/java.
|
||||||
|
# A public type in a single-jar leaf is reachable from every adopter's code, so
|
||||||
|
# additions are reviewed rather than discovered. `api` is the intended external
|
||||||
|
# surface; the rest is implementation that has not been moved under an internal
|
||||||
|
# root yet.
|
||||||
|
# Update only after review with:
|
||||||
|
# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
|
||||||
|
# types: 346
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.MongoRootAutoConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedSettings
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeToIntegrationEventMapper
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventEnvelope
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventPublisher
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoPublishResult
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleClientFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleFieldPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleMode
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoDataKeyResolver
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptedFieldDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptionMetadataOwnership
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShape
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShapeSupport
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionCollectionManager
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionQueryType
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsCompatibilityReader
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationCheckpoint
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationJob
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsObjectReference
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexState
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchQuery
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchReadinessGate
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.MongoRoutingClassification
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardAwareQueryValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyPart
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardStrategy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.MongoShardingAdminGateway
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ReshardApproval
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyAnalyzer
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyReadinessReport
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantClientRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantDatabaseResolver
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantLifecyclePolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCheckpointStore
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCoordinator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantManifestValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantPredicateInjector
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.TenantScopedMongoOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapability
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapabilityValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesGranularity
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesSupport
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoEmbedding
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorIndexDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorQuery
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchBenchmarkGate
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationPlan
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationRisk
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationStageDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.aggregation.PolicyAwareMongoAggregationExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoServerVersion
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyGuarantee
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoBulkPartialFailureException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoConnectionException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoCursorException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDocumentTooLargeException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDuplicateKeyException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoEncryptionException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoReadConcernException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoResumeException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoServerSelectionException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoShardRoutingException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTimeoutException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoUnclassifiedFailureException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConcernException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConflictException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoBigIntegerRepresentation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoEnumRepresentation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoIdRepresentation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTemporalRepresentation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoClientPlane
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoRuntimeProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoStableApiProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopologyRequirement
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionRange
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGeneration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGenerationRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.ReactiveMongoChangeStreamConsumer
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.SpringReactiveChangeStreamSource
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeStreamRunner
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeHistoryLostException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.client.MongoClientSettingsFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassification
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureExtractor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailurePhase
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoDistance
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoPoint
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoQuery
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeospatialOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.geo.SpringMongoGeospatialOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionAccess
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCompletion
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoConsistencyBinder
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeCallback
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoOperationResult
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCallback
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCollectionAccess
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.MongoTemplateSupportContract
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.ScopedMongoOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemFailure
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemOutcome
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkMode
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkResult
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkWritePlan
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.SpringDataBulkFailureExtractor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoDocumentNotFoundException
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoOptimisticConflictTranslator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoRevision
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedMongoUpdater
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedUpdateCommand
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.BigDecimalToDecimal128Converter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.BigIntegerRepresentationConverters
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.Decimal128ToBigDecimalConverter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdReadConverter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdWriteConverter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.LocalDateTimeMappingGuard
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.MongoCustomConversionsFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.MongoMappingConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.MongoTypeMetadataConfigurer
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.type.LongLivedMongoDocument
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.mapping.type.PolicyAwareMongoTypeMapper
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLedger
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLock
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationChecksum
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationContext
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationHeartbeat
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationId
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLedger
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLock
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPostcondition
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPrecondition
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationResult
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationRunner
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockChangeUnitView
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLedgerAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLockAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMigrationConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMongoMigrationAdapter
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.nativecap.ApprovedMongoNativeOperation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCapabilityGateway
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCommandCategory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeOperationPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.nativecap.PolicyAwareMongoNativeGateway
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MicrometerMongoOperationObserver
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MongoCommandObservationListener
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationConvention
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationRedactor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MongoPoolObservationListener
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.observation.MongoSdamObservationListener
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.MongoFieldDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.MongoOperator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.MongoQueryPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.MongoRegexPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.MongoSortDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.PolicyAwareMongoQueryBuilder
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetPolicyRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursorCodec
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetPageRequest
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetQueryBuilder
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSlice
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSort
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoNullSortOrdering
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCallback
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCollectionAccess
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoContextKeys
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveScopedMongoOperations
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorGuard
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorLease
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorTermination
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoReactiveCursorPublisher
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoResultBudgetTracker
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexApplyPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDescriptorView
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiff
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiffEngine
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementPlan
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementState
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexDirection
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoManifestRegistry
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.EmbeddedCollectionDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoBinaryFieldDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelManifest
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentSizeBudget
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceLifecycle
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoExpirationAccessPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlIndexDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicyValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationLevel
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorApplyPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDescriptor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiff
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiffEngine
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialRotationPolicy
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfileValidator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminApproval
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditPhase
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditRecord
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuthorization
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminCommand
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminRuntimeGuard
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionProfile
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionScope
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSession
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSessionFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSession
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSessionFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionSessionFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionSessionFactory
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoCommitReconciler
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryBudget
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryDecision
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoTransactionRetryCoordinator
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionContext
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.session.ReactiveMongoCausalSessionExecutor
|
||||||
|
dev.caskeleton.adapter.outbound.mongo.transaction.session.SpringMongoCausalSessionExecutor
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# CI/CD 경계 — GitHub Actions는 CI, Argo CD는 CD
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
GitHub Actions는 **검증하고 아티팩트를 만든다**. Argo CD는 **배포한다**. 두 역할은 겹치지 않는다.
|
||||||
|
|
||||||
|
GitHub Actions 워크플로는 `kubectl apply`, `helm upgrade`, `argocd app sync` 중 어느 것도 하지
|
||||||
|
않는다. 그러므로 CI에는 클러스터 자격증명(kubeconfig, 서비스 계정 토큰)이 들어가지 않는다.
|
||||||
|
|
||||||
|
## 흐름
|
||||||
|
|
||||||
|
```text
|
||||||
|
git push / tag
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GitHub Actions ─────────────── CI ───────────────┐
|
||||||
|
• 테스트 · 정적분석 · 아키텍처 검증 │
|
||||||
|
• 컨테이너 이미지 빌드 │
|
||||||
|
• 취약점 스캔 (Trivy) │
|
||||||
|
• SBOM 생성 │
|
||||||
|
• 레지스트리에 이미지 push │
|
||||||
|
│ │
|
||||||
|
│ 이미지 태그(다이제스트)를 manifest에 기록 │
|
||||||
|
▼ │
|
||||||
|
GitOps 저장소 (배포 희망 상태) ──────────────────┘
|
||||||
|
│
|
||||||
|
│ Argo CD가 watch
|
||||||
|
▼
|
||||||
|
Argo CD ──────────────────── CD ───────────────
|
||||||
|
│ auto-sync
|
||||||
|
▼
|
||||||
|
Kubernetes
|
||||||
|
```
|
||||||
|
|
||||||
|
용어 한 줄 풀이:
|
||||||
|
|
||||||
|
- **GitOps 저장소** — 클러스터에 무엇이 떠 있어야 하는지를 적어 둔 Git 저장소. 애플리케이션 소스와
|
||||||
|
분리한다.
|
||||||
|
- **manifest** — Kubernetes에 넣을 YAML(Deployment, Service 등).
|
||||||
|
- **auto-sync** — Argo CD가 GitOps 저장소의 변경을 스스로 감지해 클러스터에 반영하는 모드. 이걸 쓰면
|
||||||
|
CI가 Argo CD API 서버에 접근할 필요가 없다.
|
||||||
|
|
||||||
|
## 왜 이렇게 나누나
|
||||||
|
|
||||||
|
1. **자격증명 반경.** CI가 배포하면 CI 러너가 프로덕션 클러스터에 대한 쓰기 권한을 갖는다. 포크된
|
||||||
|
PR, 서드파티 액션, 캐시 오염이 모두 그 권한에 닿는다. auto-sync를 쓰면 그 권한은 클러스터 안의
|
||||||
|
Argo CD에만 있고, CI는 Git에 커밋만 한다.
|
||||||
|
2. **현재 상태의 소유자가 하나.** 클러스터에 무엇이 떠 있는지는 GitOps 저장소가 답한다. CI가 직접
|
||||||
|
apply 하면 답이 두 개가 된다 — Git에 적힌 것과 실제로 떠 있는 것.
|
||||||
|
3. **롤백이 revert.** 배포를 되돌리는 것이 `git revert`가 된다.
|
||||||
|
|
||||||
|
## 이 저장소의 현재 위치
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
| --- | --- |
|
||||||
|
| 이미지 빌드/스캔/push | `release.yml`이 수행 |
|
||||||
|
| SBOM | `release.yml`이 생성 |
|
||||||
|
| 이미지 서명 · provenance attestation | **없음.** 추가 대상 |
|
||||||
|
| GitOps 저장소 | **없음.** 별도 저장소로 만들 예정 |
|
||||||
|
| Argo CD Application 정의 | **없음.** GitOps 저장소에 둘 예정 |
|
||||||
|
| CI에서의 클러스터 접근 | 없음 — 유일했던 `kubectl apply`는 제거됨 |
|
||||||
|
|
||||||
|
`fileserver-certification.yml`은 예외처럼 보이지만 아니다. PVC 매니페스트가 여전히 ReadWriteOnce를
|
||||||
|
선언하는지 **파일만** 확인하고, 클러스터에는 아무것도 적용하지 않는다. 실제 클러스터에서의 인증은
|
||||||
|
운영자가 `infra/fileserver/kubernetes/pvc-certification-job.yaml`을 직접 실행하고
|
||||||
|
`docs/fileserver/storage-certification.md`에 기록한다. 이름을 `fileserver-release.yml`에서 바꾼 이유가
|
||||||
|
이것이다 — 이 워크플로는 릴리스하지 않는다.
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
- 워크플로에 클러스터 자격증명 secret을 추가하지 않는다.
|
||||||
|
- 배포 대상이 바뀌면 GitOps 저장소의 manifest를 바꾼다. 워크플로를 바꾸지 않는다.
|
||||||
|
- CI가 만드는 것은 **불변 다이제스트로 지정된 이미지**다. `latest` 태그로 배포하지 않는다.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Template maintainer와 Template consumer의 검증은 다르다
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
이 저장소에는 성격이 다른 두 종류의 검증이 섞여 있다.
|
||||||
|
|
||||||
|
1. **스켈레톤을 만드는 사람**에게 필요한 검증 — sample 모듈이 정말 제거 가능한가, optional 모듈
|
||||||
|
조합이 모두 빌드되는가, 레지스트리가 확장 가능한가.
|
||||||
|
2. **스켈레톤을 가져다 서비스를 만드는 사람**에게 필요한 검증 — 내 애플리케이션의 테스트,
|
||||||
|
아키텍처 방향, 보안, 릴리스.
|
||||||
|
|
||||||
|
파생 프로젝트가 1번을 그대로 물려받으면, 자기 서비스와 아무 상관 없는 게이트를 평생 유지하게 된다.
|
||||||
|
이 문서는 어느 쪽이 어느 쪽인지 적어 둔다.
|
||||||
|
|
||||||
|
## Template 전용 (파생 프로젝트는 삭제해도 된다)
|
||||||
|
|
||||||
|
| 대상 | 무엇을 지키는가 |
|
||||||
|
| --- | --- |
|
||||||
|
| `:app-bootstrap:sampleOffTest`, `ci-quality-gates.yml`의 `sample-off` job | sample 픽스처를 지워도 애플리케이션이 빌드·부팅되는가 |
|
||||||
|
| `sample-portfolio` leaf 전체 | 참조 구현 |
|
||||||
|
| `Dockerfile.sample`, `docker-compose.*` 중 sample 관련 | 위와 동일 |
|
||||||
|
| `docs/superpowers/**` | 이 템플릿을 만든 과정의 설계/계획 기록 |
|
||||||
|
| `gradle/qualification/**` | 이 템플릿이 벤더링한 플랫폼(JPA, messaging)의 인증 체계 |
|
||||||
|
| `*-certification.yml`, `*-qualification.yml`, `jpa-next-*.yml` | 템플릿이 광고하는 지원 매트릭스의 근거 |
|
||||||
|
|
||||||
|
## Consumer 필수 (파생 프로젝트가 유지해야 한다)
|
||||||
|
|
||||||
|
| 대상 | 무엇을 지키는가 |
|
||||||
|
| --- | --- |
|
||||||
|
| `architectureCheck` | Clean Architecture 의존 방향. 이 템플릿의 존재 이유 |
|
||||||
|
| 각 leaf의 `check` | 컴파일 · 단위 테스트 · 포맷 · 스타일 · Error Prone |
|
||||||
|
| `qualityCheck` | SpotBugs / FindSecBugs |
|
||||||
|
| `configContractCheck` | 환경변수 계약 |
|
||||||
|
| `verifyDependencyLocks` | 재현 가능한 의존성 해석 |
|
||||||
|
| `dependency-vulnerability.yml` | dependency-review + Trivy |
|
||||||
|
| `ci-quality-gates.yml` | PR 게이트 |
|
||||||
|
| `release.yml` | 이미지 · SBOM 생산 |
|
||||||
|
| action의 full SHA 핀 | 공급망 |
|
||||||
|
|
||||||
|
## 파생 프로젝트가 할 일
|
||||||
|
|
||||||
|
1. Template 전용 표의 항목을 삭제한다. 삭제는 대부분 파일 삭제 + `config/architecture/modules.json`
|
||||||
|
에서 leaf 항목 제거로 끝난다 — 레지스트리가 leaf 목록의 SSOT이고, 개수를 따로 적어 둔 곳은 없다.
|
||||||
|
2. `docs/ci-cd/boundary.md`의 경계를 그대로 유지한 채 자기 GitOps 저장소를 연결한다.
|
||||||
|
3. `.trivyignore.yaml`과 CODEOWNERS는 그대로 쓴다.
|
||||||
|
|
||||||
|
## 아직 하지 않은 것
|
||||||
|
|
||||||
|
Template CI와 Generated Application CI를 **물리적으로** 분리하지는 않았다(생성기 없음). 지금은 이
|
||||||
|
문서가 그 경계다. 생성기를 만든다면, 위 표의 "Template 전용" 열이 생성기가 벗겨 내야 할 목록이다.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# gRPC advanced capability support matrix
|
||||||
|
|
||||||
|
Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it.
|
||||||
|
`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade`
|
||||||
|
carries the same values.
|
||||||
|
|
||||||
|
All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`.
|
||||||
|
|
||||||
|
## Grades
|
||||||
|
|
||||||
|
| Grade | May start | Production needs a separate approval |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ADVANCED_STABLE` | Yes | No |
|
||||||
|
| `EXPERIMENTAL` | Yes | Yes |
|
||||||
|
| `WATCH` | No | — |
|
||||||
|
| `DISABLED` | No | — |
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Capability | Flag | Grade | Real infrastructure its evidence needs |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — |
|
||||||
|
| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — |
|
||||||
|
| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — |
|
||||||
|
| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — |
|
||||||
|
| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — |
|
||||||
|
| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — |
|
||||||
|
| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — |
|
||||||
|
| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — |
|
||||||
|
| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane |
|
||||||
|
| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy |
|
||||||
|
| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container |
|
||||||
|
| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — |
|
||||||
|
| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — |
|
||||||
|
| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain |
|
||||||
|
| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — |
|
||||||
|
|
||||||
|
## What the grades mean here, concretely
|
||||||
|
|
||||||
|
**Grade is a statement about the contract, not about a deployment.** Every capability's contract is
|
||||||
|
implemented and tested in this repository. What no capability has is evidence from a real deployment:
|
||||||
|
`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted.
|
||||||
|
|
||||||
|
**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they
|
||||||
|
need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that
|
||||||
|
runs without its infrastructure passes and establishes nothing.
|
||||||
|
|
||||||
|
**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so
|
||||||
|
`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The
|
||||||
|
four contract requirements — one schema source shared with Java, coroutine cancellation propagated,
|
||||||
|
Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable
|
||||||
|
without a toolchain and are checked. The compile lane is not.
|
||||||
|
|
||||||
|
## Promotion thresholds
|
||||||
|
|
||||||
|
| To | Soak | Also required |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test |
|
||||||
|
| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review |
|
||||||
|
|
||||||
|
`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one
|
||||||
|
capability changes no other's grade.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# gRPC platform support matrix
|
||||||
|
|
||||||
|
What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and
|
||||||
|
what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement
|
||||||
|
anyone can act on.
|
||||||
|
|
||||||
|
`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and
|
||||||
|
`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one.
|
||||||
|
|
||||||
|
## Lanes
|
||||||
|
|
||||||
|
| Lane | Grade | Failure blocks a release |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes |
|
||||||
|
| proto3 with explicit `optional` | Certified | Yes |
|
||||||
|
| `grpc-netty-shaded` | Certified | Yes |
|
||||||
|
| `grpc-netty` (unshaded) | Compatibility | No |
|
||||||
|
| Upstream gRPC Java version override | Compatibility | No |
|
||||||
|
| Protobuf Edition 2024 | Watch | No |
|
||||||
|
| Protobuf Edition 2026 | Watch | No |
|
||||||
|
|
||||||
|
## Runtime baseline
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Java | 21 |
|
||||||
|
| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) |
|
||||||
|
| io.grpc | `ext.grpcVersion` in `src/build.gradle` |
|
||||||
|
| Protobuf | `ext.protobufVersion` in `src/build.gradle` |
|
||||||
|
| Stable transport | Netty (shaded) |
|
||||||
|
| Stable RPC shapes | Unary, Server Streaming |
|
||||||
|
| Stable resolvers | Static, DNS, Unix domain socket |
|
||||||
|
| Stable load balancing | `pick_first`, `round_robin` |
|
||||||
|
|
||||||
|
## Evidence grades
|
||||||
|
|
||||||
|
A capability may only be advertised on evidence of a grade that can establish it.
|
||||||
|
`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a
|
||||||
|
claim backed by the wrong lane.
|
||||||
|
|
||||||
|
| Grade | Lane | Establishes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation |
|
||||||
|
| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown |
|
||||||
|
| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier |
|
||||||
|
| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget |
|
||||||
|
|
||||||
|
In-process results are never transport evidence. The in-process transport does not negotiate TLS,
|
||||||
|
does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has
|
||||||
|
tested the adapter and not the transport.
|
||||||
|
|
||||||
|
## What is not supported
|
||||||
|
|
||||||
|
| | Where it lives |
|
||||||
|
| --- | --- |
|
||||||
|
| Client streaming, bidirectional streaming | `grpc-advanced-streaming` |
|
||||||
|
| Manual flow control | `grpc-advanced-streaming` |
|
||||||
|
| Hedging | `grpc-advanced-resilience` |
|
||||||
|
| Custom name resolver, custom load balancer | `grpc-advanced-resilience` |
|
||||||
|
| xDS | `grpc-advanced-resilience` |
|
||||||
|
| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` |
|
||||||
|
| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` |
|
||||||
|
|
||||||
|
## Current release status
|
||||||
|
|
||||||
|
Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the
|
||||||
|
platform is build-only: it compiles, its lanes run, and no deployed artifact carries it.
|
||||||
|
|
||||||
|
Two release gate inputs are outstanding and are the work between here and a release:
|
||||||
|
|
||||||
|
- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate
|
||||||
|
that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns
|
||||||
|
it into a regression gate.
|
||||||
|
- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and
|
||||||
|
the consumer-compile fixture are governed as policy rather than produced from a compiled schema.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Object Storage Batch A Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Claimed level: R0 application contract only
|
||||||
|
- Provider readiness advanced: no
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Characterized the legacy caller-key overwrite, whole-object materialization, locator exposure,
|
||||||
|
eager filesystem directory creation, optional S3 bucket provisioning, and Poster transaction/API
|
||||||
|
coupling without changing those behaviors.
|
||||||
|
- Added provider-neutral identities, opaque checked references/handles, bounded streaming
|
||||||
|
callbacks, content identity, digest/range values, requests, receipts, outcomes, and narrow ports
|
||||||
|
under `dev.caskeleton.application.objectstorage`.
|
||||||
|
- Required an `ObjectOperationKey` on mutation requests and separated normal publication,
|
||||||
|
scan-maintenance, purge-maintenance, direct, and staged privilege surfaces.
|
||||||
|
- Added recursive contract-purity tests and an ArchUnit freeze for the one existing sample legacy
|
||||||
|
import.
|
||||||
|
- Marked the legacy `ObjectStoragePort` and `StoredObject` as removal boundaries without adapting
|
||||||
|
new semantic calls back to raw keys.
|
||||||
|
|
||||||
|
No provider-neutral kernel, canonical namespace/control codec, local R1 provider, S3/MinIO
|
||||||
|
qualification, sample migration, or R2 readiness claim is included.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
The planned RED checks failed only for the intentionally missing types or removal annotations:
|
||||||
|
|
||||||
|
- `ObjectStorageIdentityContractTest`: missing identity types before Task 2 implementation.
|
||||||
|
- `ObjectContentContractTest` and `ObjectStorageValueContractTest`: missing content/value types
|
||||||
|
before Task 3 implementation.
|
||||||
|
- `ObjectStoragePortContractTest`: missing request/receipt/port family before Task 4 implementation.
|
||||||
|
- `ObjectStorageArchitectureContractTest`: missing legacy removal annotations before Task 5
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
An initial ArchUnit DSL compilation error was a test-authoring error, not accepted as a RED result;
|
||||||
|
the rule was corrected and rerun.
|
||||||
|
|
||||||
|
## GREEN verification
|
||||||
|
|
||||||
|
All commands ran from `src/` and completed with `BUILD SUCCESSFUL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :application-core:resolveAndLockAll --write-locks
|
||||||
|
./gradlew :application-core:verifyDependencyLocks --console=plain
|
||||||
|
./gradlew :application-core:test --tests '*ObjectStorageIdentityContractTest' --console=plain
|
||||||
|
./gradlew :application-core:test \
|
||||||
|
--tests '*ObjectContentContractTest' \
|
||||||
|
--tests '*ObjectStorageValueContractTest' --console=plain
|
||||||
|
./gradlew :application-core:test --tests '*ObjectStoragePortContractTest' --console=plain
|
||||||
|
./gradlew :application-core:test \
|
||||||
|
--tests '*ObjectStorageArchitectureContractTest' --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
./gradlew :application-core:check --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test :sample-portfolio:test --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The final combined legacy focused suites completed in 27 seconds. Deprecation-for-removal warnings
|
||||||
|
are expected evidence that legacy consumers remain visible; they are not suppressed.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault required by repository policy,
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` could not be created or updated.
|
||||||
|
No similarly named non-canonical clone was used. This exact access block is recorded in both the
|
||||||
|
plan and design headers and here at the Batch A boundary.
|
||||||
|
|
||||||
|
## Remaining gates and risks
|
||||||
|
|
||||||
|
- External broker and REST consumers and deployed legacy data were not inspected; Gate A remains
|
||||||
|
blocked for legacy removal or public API versioning.
|
||||||
|
- The new contracts have no provider implementation yet.
|
||||||
|
- The current legacy adapter retains whole-object and raw-locator behavior by design until the
|
||||||
|
later migration batch.
|
||||||
|
- No readiness registry row is promoted by this checkpoint.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Object Storage Batch B Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Evidence grade: repository-local non-skipping unit/contract/application-context tests
|
||||||
|
- Advanced cards: local managed single upload R1, local managed download R1
|
||||||
|
- R2 or production-provider readiness advanced: no
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Added deterministic data/control namespaces, opaque reference/handle codecs, canonical request
|
||||||
|
fingerprints, frozen binding/policy revisions, and bounded operation epochs.
|
||||||
|
- Added six strict canonical JSON control-record families with fixed field order, outer SHA-256
|
||||||
|
envelopes, schema/size checks, corruption rejection, and checked-in golden digests.
|
||||||
|
- Added provider-neutral publication, scan, reference, direct-session, multipart, and pending-effect
|
||||||
|
state transitions with same-operation replay and conflicting-intent rejection.
|
||||||
|
- Added a provider contract and `filesystem-local-dev` implementation with bounded streaming,
|
||||||
|
immutable exclusive create, SHA-256 verification, exact inspect/version, full/range transfer,
|
||||||
|
conditional retirement, create resolution, restrictive permissions, and path/symlink
|
||||||
|
confinement.
|
||||||
|
- Added single-process exact-version control CAS and restart/corruption/fault characterization.
|
||||||
|
Logical control keys use `.record` physical leaves locally so object-store-valid prefix/leaf key
|
||||||
|
pairs cannot collide as filesystem file/directory paths.
|
||||||
|
- Added constructor-bound `app.object-storage` settings and compile-before-construction
|
||||||
|
provider/destination/route/policy binding. The capability is disabled by default and
|
||||||
|
`filesystem-local-dev` is rejected for `prod`/`production`.
|
||||||
|
- Added disabled, unselected, invalid, selected-success, selected-construction-failure, close,
|
||||||
|
legacy-only, and namespace-separated dual-run composition tests.
|
||||||
|
- Added semantic routing evidence for publish, replay without producer invocation, inspect,
|
||||||
|
full transfer, absent reference, and exact retained route lookup.
|
||||||
|
- Added the exact nine-card readiness registry. Only local managed single upload/download are R1;
|
||||||
|
direct, multipart, quarantine, retention, and production reconciliation remain R0.
|
||||||
|
|
||||||
|
## TDD and defect evidence
|
||||||
|
|
||||||
|
Planned RED checks failed for the intentionally absent codec/kernel/provider/settings/readiness
|
||||||
|
types before each implementation. Additional tests found and drove these corrections:
|
||||||
|
|
||||||
|
- Local control keys may legally have both a leaf and a child in object storage, while a filesystem
|
||||||
|
cannot have both `reference` and `reference/lifecycle`; local physical `.record` mapping fixed the
|
||||||
|
collision without changing logical keys.
|
||||||
|
- `ObjectInspectionPort.inspect` initially threw for an absent known-route reference; it now
|
||||||
|
returns `Optional.empty()` while incomplete/corrupt evidence still fails closed.
|
||||||
|
- The application purity test initially scanned its own test output after a full `check`; it now
|
||||||
|
derives the production class root from a production contract type.
|
||||||
|
- The general B7 ArchUnit rule initially classified objectstorage provider-internal SPI/control
|
||||||
|
return values as public adapter responses. The existing negative fixture remains active, while a
|
||||||
|
dedicated non-empty rule now checks the actual objectstorage `*Adapter` semantic boundaries.
|
||||||
|
|
||||||
|
No skipped Docker or external-service test is used as Batch B readiness evidence.
|
||||||
|
|
||||||
|
## GREEN verification
|
||||||
|
|
||||||
|
All commands ran from `src/` unless noted and completed with `BUILD SUCCESSFUL` after the documented
|
||||||
|
RED/fix cycles:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectNamespaceCodecTest' \
|
||||||
|
--tests '*ObjectRequestFingerprintCodecTest' \
|
||||||
|
--tests '*ObjectOperationEpochTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectControlRecordCodecTest' \
|
||||||
|
--tests '*ObjectOperationStateMachineTest' \
|
||||||
|
--tests '*ObjectOperationKernelTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectStorageProviderContract' \
|
||||||
|
--tests '*LocalDevObjectStorageProviderTest' \
|
||||||
|
--tests '*LocalDevObjectStorageRecoveryTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectStorageBindingCompilerTest' \
|
||||||
|
--tests '*ObjectStorageCapabilityConfigTest' \
|
||||||
|
--tests '*RoutingObjectStorageAdapterTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*ObjectStorageReadinessRegistryTest' --console=plain
|
||||||
|
./gradlew :sample-portfolio:test --console=plain
|
||||||
|
./gradlew :application-core:check \
|
||||||
|
:adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The final combined application/objectstorage checkpoint completed in 23 seconds. The focused
|
||||||
|
Clean Architecture suite and dependency verification also passed.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault required by repository policy,
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/`, and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. Therefore the required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` and any derived raw documents could
|
||||||
|
not be created or updated. No similarly named non-canonical clone was used. This exact access block
|
||||||
|
is recorded in the plan/design status and at this Batch B boundary.
|
||||||
|
|
||||||
|
## Remaining gates and risks
|
||||||
|
|
||||||
|
- `filesystem-local-dev` has no multi-node linearizability or power-loss durability evidence and is
|
||||||
|
forbidden in production profiles.
|
||||||
|
- The canonical S3/MinIO provider contribution, async bounded transport, provider qualification,
|
||||||
|
response-loss fault tests, and protected AWS evidence are not implemented.
|
||||||
|
- Direct grants, multipart, quarantine/scan, retention/legal hold, privileged purge, reapers, and
|
||||||
|
production reconciliation remain R0.
|
||||||
|
- The sample Poster workflow still uses the deprecated whole-`byte[]` port and transaction-coupled
|
||||||
|
legacy choreography. It is explicitly activated only in sample local/test configuration.
|
||||||
|
- External API/broker consumers and deployed legacy data remain uninspected, so Gate A still blocks
|
||||||
|
destructive migration or legacy removal.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Object Storage Batch C Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Evidence grade: repository-local tests plus digest-pinned single-node MinIO/Toxiproxy tests
|
||||||
|
- AWS execution: not authorized; source set compiled only
|
||||||
|
- Production-provider readiness advanced: no
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Added exact AWS S3 and MinIO provider bindings, bounded evidence descriptors, qualifier/error
|
||||||
|
mapping, secret references, endpoint/owner/addressing validation, and selected-only lifecycle
|
||||||
|
construction.
|
||||||
|
- Added bounded async request/response bridges and the managed S3 put, inspect, full/range download,
|
||||||
|
checksum, exact-version, cancellation, and content-length paths.
|
||||||
|
- Added canonical conditional S3 control storage and operation response-loss resolution. Provider
|
||||||
|
ETags remain adapter-private and are never exposed as logical versions.
|
||||||
|
- Added low-level managed multipart planning, sharded immutable part ledgers, initiate-before-I/O
|
||||||
|
state, explicit create/upload/list/complete/abort calls, and exact completion verification.
|
||||||
|
- Added non-skipping MinIO contract/fault lanes, an AWS compile-only qualification lane, a protected
|
||||||
|
workflow, and gate-matrix coverage.
|
||||||
|
|
||||||
|
The exact MinIO image is
|
||||||
|
`minio/minio@sha256:4c4a4876193f030c81f57aabb22bcb9a73462010eb61fcab66908e03e5484af8`.
|
||||||
|
The exact Toxiproxy image is
|
||||||
|
`ghcr.io/shopify/toxiproxy@sha256:9378ed52a28bc50edc1350f936f518f31fa95f0d15917d6eb40b8e376d1a214e`.
|
||||||
|
|
||||||
|
## Exact MinIO finding
|
||||||
|
|
||||||
|
Real-provider tests proved an asymmetric conditional profile:
|
||||||
|
|
||||||
|
- `PutObject If-None-Match: *` was accepted but overwrote an existing object.
|
||||||
|
- stale `PutObject If-Match` was rejected with HTTP 412.
|
||||||
|
- `CompleteMultipartUpload If-None-Match: *` was accepted and overwrote an existing object.
|
||||||
|
- checksum, HEAD, and range behavior passed the exercised contract.
|
||||||
|
|
||||||
|
Because immutable create and create-if-absent control CAS cannot be proven, the exact MinIO managed
|
||||||
|
and direct mutation profiles remain `UNSUPPORTED`. The implementation does not emulate missing
|
||||||
|
atomicity with HEAD followed by an unconditional write and does not promote a readiness card.
|
||||||
|
|
||||||
|
## TDD and verification
|
||||||
|
|
||||||
|
The task-focused RED runs first failed on the planned absent binding, bridge, conditional store,
|
||||||
|
multipart, and qualification types. Provider qualification then found the real MinIO conditional
|
||||||
|
behavior above; the descriptor and negative contract were changed instead of weakening the
|
||||||
|
contract.
|
||||||
|
|
||||||
|
Commands completed with `BUILD SUCCESSFUL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*S3ProviderBindingTest' \
|
||||||
|
--tests '*S3ProviderQualifierTest' \
|
||||||
|
--tests '*S3ProviderCompositionTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
|
||||||
|
verifyCleanArchitectureDependencies --console=plain
|
||||||
|
bash ../.github/scripts/verify-gate-matrix.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate matrix reports 22 gates: 21 verified and the protected AWS qualification gate explicitly
|
||||||
|
`delegated-pending`.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
|
||||||
|
be created or updated. No similarly named non-canonical clone was used.
|
||||||
|
|
||||||
|
## Remaining risks
|
||||||
|
|
||||||
|
- No AWS request was executed, so there is no observed AWS provider claim.
|
||||||
|
- The pinned MinIO topology is a local single-node container and is not production TLS,
|
||||||
|
multi-node, durability, or linearizability evidence.
|
||||||
|
- The detailed managed multipart fault matrix is not exhaustive enough for R2.
|
||||||
|
- No sample migration, public API, scan/publication choreography, retention, purge, or reaper is
|
||||||
|
included in this checkpoint.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Object Storage Batch D Checkpoint
|
||||||
|
|
||||||
|
- Date: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Scope: direct-transfer provider/application primitives only
|
||||||
|
- Public endpoint: none
|
||||||
|
- Readiness advanced: no; all direct cards remain R0
|
||||||
|
|
||||||
|
## Implemented scope
|
||||||
|
|
||||||
|
- Added direct single-upload session policy, durable prepared/issued transitions, bearer
|
||||||
|
redaction, exact completion verification, published-version download resolution, and an
|
||||||
|
S3-presigner lifecycle owned by the selected provider.
|
||||||
|
- Added direct multipart durable session and part-grant families, opaque acknowledgement tokens,
|
||||||
|
sharded part records, admission-close/expiry fencing, exact ledger validation, completion/abort
|
||||||
|
states, response-loss resolution, and persisted terminal exact-version replay.
|
||||||
|
- Added direct S3 initiate/discovery, exact-part presign, `ListParts` acknowledgement, conditional
|
||||||
|
complete followed by exact HEAD verification, and abort resolution.
|
||||||
|
- Registered direct single and multipart delegates only when their exact compiled capability is
|
||||||
|
selected. One presigner is constructed and closed exactly once.
|
||||||
|
- Added golden canonical envelopes for the direct session, direct multipart session, and direct
|
||||||
|
multipart grant families.
|
||||||
|
- Fixed `MultipartCompleteRequest` null validation so valid immutable `List.of(...)` input no longer
|
||||||
|
throws from `contains(null)`.
|
||||||
|
|
||||||
|
## Qualification truth
|
||||||
|
|
||||||
|
The exact MinIO release cannot prove create-only PUT or create-only multipart completion, so both
|
||||||
|
direct profiles are explicitly `UNSUPPORTED`. The direct MinIO contract/fault lanes are negative
|
||||||
|
admission tests: they prove no bearer or multipart mutation enters an unsupported profile. No test
|
||||||
|
skip is used as positive evidence.
|
||||||
|
|
||||||
|
The AWS managed/direct source sets compile, but no AWS call was made and no AWS evidence row was
|
||||||
|
published. No inbound controller, authorization surface, CORS runtime configuration, or public
|
||||||
|
direct API exists.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands completed with `BUILD SUCCESSFUL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*DirectTransferCoordinatorTest' \
|
||||||
|
--tests '*PresignedGrantRedactionTest' \
|
||||||
|
--tests '*S3DirectTransferProviderTest' \
|
||||||
|
--tests '*ObjectControlRecordCodecTest' \
|
||||||
|
--tests '*S3ProviderCompositionTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*DirectMultipartCoordinatorTest' \
|
||||||
|
--tests '*DirectMultipartRaceTest' \
|
||||||
|
--tests '*S3DirectMultipartProviderTest' \
|
||||||
|
--tests '*ObjectControlRecordCodecTest' \
|
||||||
|
--tests '*S3ProviderCompositionTest' --console=plain
|
||||||
|
./gradlew \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioFaultTest \
|
||||||
|
--tests '*DirectTransfer*' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*DirectTransferCorsContractTest' --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTestClasses --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:verifyDependencyLocks \
|
||||||
|
verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioContractTest \
|
||||||
|
:adapter:outbound:objectstorage:objectStorageMinioFaultTest --console=plain
|
||||||
|
bash ../.github/scripts/verify-gate-matrix.sh
|
||||||
|
./gradlew test --console=plain
|
||||||
|
./gradlew check --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The module `check` includes unit tests, Checkstyle, Spotless, SpotBugs, architecture, configuration
|
||||||
|
processor, environment-key, and repository-wide policy checks. Existing test-only compiler
|
||||||
|
warnings remain non-failing. The final repository-wide test run completed 79 tasks and the final
|
||||||
|
repository-wide check completed 214 tasks.
|
||||||
|
|
||||||
|
## Deliberate limitations
|
||||||
|
|
||||||
|
- Issued bearer material is process-local. A restart fails closed instead of reconstructing or
|
||||||
|
reissuing an already-issued bearer.
|
||||||
|
- The signing clock/window is stored and bounded, but AWS SDK presigner query timing is not driven
|
||||||
|
by the injected application clock.
|
||||||
|
- The direct multipart recovery/race matrix covers its principal fences and completion response
|
||||||
|
loss but is not exhaustive enough for an R2 claim.
|
||||||
|
- Retention/Object Lock grant headers and a provider-enforced direct-single hard size ceiling are
|
||||||
|
not qualified.
|
||||||
|
- No public endpoint exists, so CORS evidence is a pure contract and no direct card may exceed R0
|
||||||
|
in the current registry.
|
||||||
|
|
||||||
|
## Approval Gate A
|
||||||
|
|
||||||
|
Tasks 20–24 remain blocked until the user explicitly approves scanner ownership, the sample's
|
||||||
|
first publication profile, the additive asynchronous API/status contract, and digest transport.
|
||||||
|
No scan/publication/sample endpoint implementation was started.
|
||||||
|
|
||||||
|
## LLM Wiki capture
|
||||||
|
|
||||||
|
The canonical vault `/home/donghyeon/workspace/ai-tool/llm-wiki-private/` and its parent
|
||||||
|
`/home/donghyeon/workspace/ai-tool/` do not exist in this environment. The required
|
||||||
|
`raw/branch-notes/codex-objectstorage-production-capability.md` and derived raw documents could not
|
||||||
|
be created or updated. No similarly named non-canonical clone was used.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Object Storage Batch E Pause Checkpoint
|
||||||
|
|
||||||
|
- Recorded: 2026-07-29 (Asia/Seoul)
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Worktree:
|
||||||
|
`/home/donghyeon/workspace/clean-architecture-backend-template-objectstorage`
|
||||||
|
- Status: implementation in progress; intentionally paused at the user's request
|
||||||
|
- Evidence grade: local unit/integration/architecture evidence only; no AWS R2 evidence
|
||||||
|
|
||||||
|
## Implemented at this checkpoint
|
||||||
|
|
||||||
|
- Staged integrity verification, fake-scanner routing, publication handoff fencing, and stable
|
||||||
|
replay receipts.
|
||||||
|
- Additive Poster V8 dual-read schema (renumbered from branch-local V7 during JPA integration),
|
||||||
|
upload/retirement intents, HMAC-sanitized idempotency scope,
|
||||||
|
PostgreSQL atomic claim SPI, and forward-only migration qualification lane.
|
||||||
|
- Short-transaction Poster image publication flow and additive locator-free `202` API under the
|
||||||
|
AIP-122-compatible `/posters/{id}/imagePublications` collection.
|
||||||
|
- Exact-reference/version logical retirement enqueue, lease/fence takeover, response-loss retry,
|
||||||
|
Poster deletion survival, and disabled-by-default worker composition.
|
||||||
|
- Isolated legacy migration contracts, report-only inspection, two-distinct-approver Ed25519
|
||||||
|
approval verification, nonce replay boundary, and explicit maintenance-only composition.
|
||||||
|
|
||||||
|
## Verification completed
|
||||||
|
|
||||||
|
The following focused command passed after the final architecture fixes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew \
|
||||||
|
:sample-portfolio:spotlessApply \
|
||||||
|
:sample-portfolio:test --tests '*PosterImagePublicationControllerWireTest' \
|
||||||
|
:app-bootstrap:test --tests '*CleanArchitectureTest' \
|
||||||
|
--console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The following focused suites also passed during this checkpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test \
|
||||||
|
--tests '*LegacyObjectAdoptionServiceTest' \
|
||||||
|
--tests '*LegacyAdoptionApprovalVerifierTest' \
|
||||||
|
--tests '*ObjectStorageLegacyMigrationConfigTest' --console=plain
|
||||||
|
|
||||||
|
./gradlew :sample-portfolio:test \
|
||||||
|
--tests '*DeletePosterImageRetirementTest' \
|
||||||
|
--tests '*PosterImageRetirementCrashMatrixTest' \
|
||||||
|
--tests '*PosterImageRetirementConfigTest' \
|
||||||
|
--tests '*LegacyPosterImageUploadCharacterizationTest' --console=plain
|
||||||
|
|
||||||
|
./gradlew :sample-portfolio:test \
|
||||||
|
--tests '*SampleApplicationContextTest' \
|
||||||
|
:sample-portfolio:posterImageMigrationTest --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration lane included
|
||||||
|
`PosterImageRetirementQualificationTest`, which proved that an exact retirement row survives
|
||||||
|
deletion of its Poster row.
|
||||||
|
|
||||||
|
## Failures found and resolved
|
||||||
|
|
||||||
|
- `spotlessJavaCheck` initially found formatting drift in newly changed application-core and
|
||||||
|
persistence files. The owner-module Spotless apply tasks fixed it.
|
||||||
|
- `SampleApplicationContextTest` initially failed because Spring's persistence exception advisor
|
||||||
|
could not CGLIB-proxy the final `PosterImageAttachmentCasRepository`. Removing `final` fixed the
|
||||||
|
context; the focused context suite then passed.
|
||||||
|
- `CleanArchitectureTest` initially rejected an application-core return type from the sample domain
|
||||||
|
and the kebab-case `image-publications` path. Conversion moved back to the application use case,
|
||||||
|
and the endpoint changed to the repository's AIP-122-compatible `imagePublications` segment. The
|
||||||
|
complete focused architecture suite then passed.
|
||||||
|
|
||||||
|
## Not yet re-run / not complete
|
||||||
|
|
||||||
|
- The combined Batch E checkpoint command stopped on the two architecture failures above before all
|
||||||
|
requested root tasks could complete. The focused failing suites passed after the fixes, but
|
||||||
|
`:sample-portfolio:check`, `verifyPublicPathSnapshot`, and the full combined Batch E command have
|
||||||
|
not been re-run after those final fixes.
|
||||||
|
- The complete repository `./gradlew test` and `./gradlew check` have not been re-run after the
|
||||||
|
Batch E additions.
|
||||||
|
- The legacy adoption runner/configuration is not yet wired to a production legacy inspector,
|
||||||
|
permission-checked trust-key loader, or durable control-record replay-store implementation.
|
||||||
|
- Tasks 25–30 (Batch F) have not started in this continuation.
|
||||||
|
- Actual AWS qualification is blocked by Approval Gate B: no approved account, bucket/namespaces,
|
||||||
|
workload roles, signed deployment attestation, or mutation/test authority was supplied.
|
||||||
|
- No readiness card was promoted. Local/MinIO ceilings and unsupported conditional behavior remain
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Wiki capture
|
||||||
|
|
||||||
|
At this isolated-branch checkpoint, the then-selected private vault
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/` was absent, so capture was blocked. The final
|
||||||
|
main integration was later captured in the user-designated public vault at
|
||||||
|
`raw/branch-notes/chore-main-worktree-capability-integration.md`, with the derived error note
|
||||||
|
`raw/errors/multi-worktree-contract-drift-2026-07-31.md`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Object Storage Phase 0 Inventory
|
||||||
|
|
||||||
|
- Captured: 2026-07-28
|
||||||
|
- Branch: `codex/objectstorage-production-capability`
|
||||||
|
- Scope: repository-local source, tests, configuration, migrations, and documentation
|
||||||
|
- Evidence grade: repository-local only; deployed data, broker subscribers, and external REST
|
||||||
|
consumers were not inspected
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n 'application\.storage|ObjectStoragePort|StoredObject|ca-skeleton\.objectstorage|file://|s3://' \
|
||||||
|
src docs
|
||||||
|
rg -n 'image_key|posters/.*/image' src/sample-portfolio
|
||||||
|
rg -n 'poster\.image-attached|StoredObjectResponse|PosterResponse|imageKey' \
|
||||||
|
src/sample-portfolio docs
|
||||||
|
```
|
||||||
|
|
||||||
|
The commands completed successfully in the isolated worktree. Results are classified below.
|
||||||
|
Documentation hits in the Object Storage design/plan describe the migration and are not runtime
|
||||||
|
consumers. The `s3://bucket/key-1` fixture in
|
||||||
|
`IdempotencyStoreAdapterTest` belongs to the generic idempotency response-reference test and is not
|
||||||
|
an Object Storage legacy-port consumer.
|
||||||
|
|
||||||
|
## Repository-local runtime inventory
|
||||||
|
|
||||||
|
| Contract/data | Producer | Repository-local consumers | Classification |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ObjectStoragePort` / `StoredObject` | `application-core/application/storage` | filesystem and S3 adapters, `UploadPosterImageUseCase`, `PosterController`/`PosterWebMapper` | legacy runtime contract |
|
||||||
|
| `ca-skeleton.objectstorage.*` | `ObjectStorageSettings` / `ObjectStorageConfig` | sample runtime through its objectstorage runtime dependency | legacy runtime configuration |
|
||||||
|
| `file://` receipt | `FilesystemObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
|
||||||
|
| `s3://bucket/key` receipt | `S3ObjectStorageAdapter` | `StoredObjectResponse.location` through `PosterWebMapper` | public legacy locator |
|
||||||
|
| `/posters/{id}/image` | `PosterController` | repository tests and the generated/public HTTP contract | legacy inbound API |
|
||||||
|
| `StoredObjectResponse` | `PosterController` / `PosterWebMapper` | HTTP caller, with `key`, `size`, `contentType`, and `location` | legacy response DTO |
|
||||||
|
| `PosterResponse.imageKey` | `PosterWebMapper` | list/get/create/update/publish/archive HTTP responses | legacy general response field |
|
||||||
|
| `poster.image-attached` | `PosterEventPublisher` | no subscriber found in this repository | versionless broker event; external consumers unknown |
|
||||||
|
| `poster.image-attached.imageKey` | `PosterImageAttached` and publisher JSON | no subscriber found in this repository | raw locator-shaped event field |
|
||||||
|
| `poster.image_key` | Flyway V6, `PosterEntity`, persistence mapper | `Poster` aggregate and repository adapter | stored-data schema |
|
||||||
|
| `posters/{id}/image` key | `UploadPosterImageUseCase` | aggregate `imageKey`, event payload, DB row, HTTP response | deterministic overwriteable legacy key |
|
||||||
|
|
||||||
|
## Executable characterization
|
||||||
|
|
||||||
|
The following tests pin the current behavior without approving it as the target design:
|
||||||
|
|
||||||
|
- `LegacyObjectStorageBehaviorTest`
|
||||||
|
- caller-selected keys overwrite;
|
||||||
|
- `get` returns `Optional<byte[]>` and materializes the whole object;
|
||||||
|
- receipts expose `file://` and `s3://` locators.
|
||||||
|
- `LegacyObjectStorageConfigTest`
|
||||||
|
- missing backend configuration selects filesystem;
|
||||||
|
- context creation creates the filesystem directory before the first write;
|
||||||
|
- `autoCreateBucket=true` probes and creates a missing bucket during S3 bean construction.
|
||||||
|
- `LegacyPosterImageUploadCharacterizationTest`
|
||||||
|
- remote storage is called while `TransactionPort.inWrite` is active;
|
||||||
|
- the controller calls `MultipartFile.getBytes`;
|
||||||
|
- the response exposes raw key and provider location;
|
||||||
|
- Poster deletion leaves the legacy object untouched.
|
||||||
|
|
||||||
|
## External inventory gap and Gate A
|
||||||
|
|
||||||
|
Repository search does not prove that the following have no deployed consumers:
|
||||||
|
|
||||||
|
- `POST /posters/{id}/image`;
|
||||||
|
- `StoredObjectResponse.key` and `.location`;
|
||||||
|
- `PosterResponse.imageKey`;
|
||||||
|
- broker event type `poster.image-attached` and its `imageKey` payload;
|
||||||
|
- rows already stored in `poster.image_key`;
|
||||||
|
- filesystem/S3 objects already written under `posters/{id}/image`.
|
||||||
|
|
||||||
|
No deployed database, object namespace, access log, API client catalog, broker consumer group, schema
|
||||||
|
registry, or owning team approval was inspected. Therefore removal, in-place field rename, event
|
||||||
|
payload replacement, or legacy-object deletion remains blocked. Approval Gate A must obtain owner
|
||||||
|
and consumer evidence and choose an additive/versioned migration contract.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Fileserver configuration
|
||||||
|
|
||||||
|
Every key below lives under `app.fileserver-platform` (environment form
|
||||||
|
`APP_FILESERVER_PLATFORM_*`). That namespace is the HTTP platform's alone: `app.fileserver.*`
|
||||||
|
belongs to the R2 tabular publication capability and `app.file-export.*` to the R1 CSV export, and
|
||||||
|
the three are deliberately separate so switching one on cannot switch on another.
|
||||||
|
|
||||||
|
While `app.fileserver-platform.enabled` is false none of these keys is bound at all — the
|
||||||
|
auto-configuration that binds them is not processed — so a malformed value in a block nobody
|
||||||
|
enabled cannot fail a startup. Once enabled, binding is strict: an unknown key under the prefix is
|
||||||
|
refused rather than ignored. The defaults are the conservative ones: the
|
||||||
|
capability is off, the admin plane is off, background reclamation is off, and there is no permissive
|
||||||
|
authorization fallback. Turning the capability on is a deliberate act, and so is every surface it
|
||||||
|
exposes.
|
||||||
|
|
||||||
|
## Minimum to start
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
enabled: true
|
||||||
|
instance-id: ${HOSTNAME} # writer-lease owner; must be unique per node
|
||||||
|
storage:
|
||||||
|
root: /var/lib/backend/files # absolute, outside any webroot or config dir
|
||||||
|
security:
|
||||||
|
access-policy: role-based # or supply your own FileAccessPolicy bean
|
||||||
|
observability:
|
||||||
|
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup fails, rather than degrading, when any of these is missing or unsafe:
|
||||||
|
|
||||||
|
| Condition | Why it is fatal |
|
||||||
|
| --- | --- |
|
||||||
|
| `security.access-policy` left at `required` with no `FileAccessPolicy` bean | a file capability that authorizes by default is worse than one that refuses to start |
|
||||||
|
| `observability.fingerprint-key` unset while metrics are on | an unkeyed digest of an enumerable identifier is reversible |
|
||||||
|
| the storage root fails a mandatory capability probe | a volume that cannot create atomically, keep staging and content on one FileStore, or refuse symlinks is unsafe, not degraded |
|
||||||
|
| `storage.publish-mode: atomic-move-required` on a volume where the probe could not prove an atomic move | the configured guarantee cannot be delivered |
|
||||||
|
| `security.access-policy: unenforced` under a `prod` profile | a value that was convenient in development must not survive promotion |
|
||||||
|
|
||||||
|
## Authorization — `security`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `access-policy` | `required` | `required` (supply your own bean), `role-based`, or `unenforced` |
|
||||||
|
| `read-roles` | `ROLE_FILE_READ` | grants metadata read and download |
|
||||||
|
| `write-roles` | `ROLE_FILE_WRITE` | grants create, append, finalize, delete, copy, move |
|
||||||
|
| `admin-roles` | `ROLE_FILE_ADMIN` | grants reverify and force-delete, and gates `/internal/fileserver/**` at the servlet chain |
|
||||||
|
|
||||||
|
There is no anonymous-read switch. Every Fileserver route is authenticated by the servlet chain
|
||||||
|
before any application policy is consulted, so such a setting could only ever have described a
|
||||||
|
permission the transport had already refused — a configuration that reads as if it grants access
|
||||||
|
and does not.
|
||||||
|
|
||||||
|
The three tiers do not inherit. An admin role cannot delete through the data plane, and a write role
|
||||||
|
cannot reach the management plane — a role model where "can delete" implied "can force-delete" would
|
||||||
|
make the audited plane reachable through the unaudited one.
|
||||||
|
|
||||||
|
`unenforced` authorizes everything and exists so a developer can exercise upload and download before
|
||||||
|
deciding on a role model. It is refused under a production profile.
|
||||||
|
|
||||||
|
## Storage — `storage`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `root` | `/var/lib/backend/files` | absolute path; the only place a path exists |
|
||||||
|
| `publish-mode` | `atomic-move-preferred` | `atomic-move-required`, `atomic-move-preferred`, `metadata-pointer` |
|
||||||
|
| `buffer-size` | `128KB` | bounds every transfer allocation; memory never scales with file size |
|
||||||
|
| `forbidden-root-ancestors` | `/app,/etc,/usr/share/nginx/html` | roots the storage root must not live under (webroot, config dirs) |
|
||||||
|
|
||||||
|
`root` must be absolute. A relative root resolves against the process working directory, which is
|
||||||
|
one path in a container and another in a test, so it is refused at binding time.
|
||||||
|
|
||||||
|
Three former keys are gone, pinned as constants instead: staging and content share one FileStore,
|
||||||
|
symbolic links are never followed, and the object and its directory are synced before READY. Each
|
||||||
|
is an invariant the atomic publish and the namespace boundary are built on — a deployment that
|
||||||
|
could switch one off would be running a different capability under the same name and the same
|
||||||
|
tests.
|
||||||
|
|
||||||
|
The storage provider has no selector either. There is exactly one implementation, and a `type` key
|
||||||
|
with one legal value is a promise of pluggability that nothing keeps.
|
||||||
|
|
||||||
|
## Upload, download, transfer
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `upload.max-file-size` | `100MB` | hard ceiling; also drives `spring.servlet.multipart.max-file-size` |
|
||||||
|
| `upload.max-request-size` | `110MB` | request envelope; must be at least `max-file-size` |
|
||||||
|
| `upload.initial-reservation` | `8MB` | quota reserved when the length is unknown |
|
||||||
|
| `upload.ttl` | `1h` | how long a resumable upload stays claimable |
|
||||||
|
| `upload.reservation-ttl` | `24h` | how long an unsettled quota reservation survives |
|
||||||
|
| `upload.lease-duration` | `30s` | writer lease; renewed at one third of this |
|
||||||
|
| `upload.max-parts` | `16` | multipart part ceiling |
|
||||||
|
| `upload.require-content-length` | `false` | refuse chunked raw uploads |
|
||||||
|
| `download.cache-control` | `private, no-store` | emitted on every content response |
|
||||||
|
| `download.inline-allowed` | `false` | scriptable content is always an attachment regardless |
|
||||||
|
| `download.max-ranges` | `1` | multi-range responses are opt-in |
|
||||||
|
| `download.max-range-bytes` | `100MB` | total bytes one ranged response may cover |
|
||||||
|
| `download.zero-copy-enabled` | `true` | hand large plaintext responses to the kernel |
|
||||||
|
| `download.zero-copy-minimum-bytes` | `16MB` | below this the syscall setup costs more than it saves |
|
||||||
|
| `transfer.core-size` / `max-size` / `queue-capacity` | `8` / `32` / `64` | blocking transfer pool bounds |
|
||||||
|
| `transfer.await-seconds` | `300` | how long a transfer may occupy a pool thread |
|
||||||
|
|
||||||
|
Zero copy changes no header and no status. When storage declines it — an unreadable region, an
|
||||||
|
unsupported backend — the response is streamed instead and is byte-identical.
|
||||||
|
|
||||||
|
## Verification — `verification`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `timeout` | `5s` | per-verifier ceiling |
|
||||||
|
| `require-media-type-verdict` | `false` | refuse a file whose type could not be determined |
|
||||||
|
| `inline-safe-profile` | `false` | accept scriptable content instead of quarantining it |
|
||||||
|
|
||||||
|
Set `inline-safe-profile: true` only when downloads are never served inline from a trusted origin.
|
||||||
|
|
||||||
|
## Quota and admission — `quota`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `instance-upload-permits` | `16` | concurrent uploads this node admits |
|
||||||
|
| `scope-upload-permits` | `4` | concurrent uploads one namespace admits |
|
||||||
|
| `direct-download-permits` | `64` | concurrent non-delegated downloads |
|
||||||
|
| `soft-high-water` | `0.70` | storage fraction at which pressure is reported |
|
||||||
|
| `hard-high-water` | `0.85` | storage fraction at which uploads are refused |
|
||||||
|
|
||||||
|
When the storage fraction cannot be read, admission treats it as unknown and does not apply the
|
||||||
|
high-water rule — a synthetic `0` would silently disable the guard, and a synthetic `1` would take
|
||||||
|
the capability down over a failed syscall.
|
||||||
|
|
||||||
|
## Background reclamation — `cleanup`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | run the cleanup worker on this node |
|
||||||
|
| `interval` | `60s` | fixed delay between batches, not fixed rate |
|
||||||
|
| `max-items` | `100` | items one batch may claim |
|
||||||
|
| `max-bytes` | `1GB` | bytes one batch may reclaim |
|
||||||
|
| `retry-backoff` | `5m` | how long a failed item waits before it is due again |
|
||||||
|
|
||||||
|
The worker deletes physical objects, so it is off until a deployment decides otherwise. A node
|
||||||
|
without it still queues cleanup items; another node or an operator reclaims them. An item that fails
|
||||||
|
eight times is abandoned rather than retried forever — it stays visible to an operator, parked
|
||||||
|
rather than discarded.
|
||||||
|
|
||||||
|
## Management plane — `admin`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | expose `/internal/fileserver/**` |
|
||||||
|
| `orphan-minimum-age` | `1h` | how long an unreferenced object must exist before a scan may name it |
|
||||||
|
|
||||||
|
Publishing content and committing its record are two steps. Anything younger than
|
||||||
|
`orphan-minimum-age` is assumed to be mid-commit rather than abandoned; shortening this makes
|
||||||
|
concurrent uploads look like orphans.
|
||||||
|
|
||||||
|
## Front-proxy delegation — `nginx`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | emit `X-Accel-Redirect` instead of a body |
|
||||||
|
| `internal-prefix` | `/__files/` | must be an `internal` location resolving to the content root |
|
||||||
|
| `object-suffix` | `.bin` | layout suffix the proxy appends |
|
||||||
|
| `minimum-size` | `16MB` | below this the application serves the transfer itself |
|
||||||
|
|
||||||
|
Delegation is decided only after authorization and the READY gate, so an internal redirect can only
|
||||||
|
ever name content the caller was already allowed to read.
|
||||||
|
|
||||||
|
## Protocols — `tus`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | expose the tus 1.0 endpoints |
|
||||||
|
|
||||||
|
The HTTPbis resumable-upload draft-12 surface is experimental and documented in
|
||||||
|
[support-matrix.md](support-matrix.md).
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Fileserver — deviations from the design
|
||||||
|
|
||||||
|
The design specification and the implementation plan are frozen documents. Where implementation
|
||||||
|
found them under-specified or self-contradicting, the resolution is recorded here rather than by
|
||||||
|
editing the specification, and every entry names the test that pins the decision.
|
||||||
|
|
||||||
|
## Resolved inconsistencies in the state machine and contracts
|
||||||
|
|
||||||
|
### 1. `CREATED → FAILED` has no edge in the transition table
|
||||||
|
|
||||||
|
A create that fails after the record exists must end in `FAILED`, but the table has no direct edge.
|
||||||
|
The record therefore walks `CREATED → UPLOADING → FAILED`, which is also the honest reading: the
|
||||||
|
upload had been admitted before it failed.
|
||||||
|
|
||||||
|
Pinned by `UploadApplicationServiceTest` (application-core).
|
||||||
|
|
||||||
|
### 2. `ContentKey`'s alphabet admits a leading separator
|
||||||
|
|
||||||
|
The design's key pattern `[a-z0-9/_-]{16,200}` matches `/etc/passwd/...`. Rejecting an absolute path
|
||||||
|
at the value type would change a design-fixed contract, so the stricter shape check lives in
|
||||||
|
`PhysicalPathResolver`, per §12.2 rule 1 — the only place that turns an identifier into a path.
|
||||||
|
|
||||||
|
Pinned by `PhysicalPathResolverTest`.
|
||||||
|
|
||||||
|
### 3. `VERIFYING → DELETING` has no edge
|
||||||
|
|
||||||
|
Deleting a file that is mid-verification has no legal transition. The lifecycle service refuses it
|
||||||
|
with `409 FILE_NOT_READY` rather than inventing an edge, which matches the allowed-state list the
|
||||||
|
JPA `markDeleting` statement already enforced.
|
||||||
|
|
||||||
|
Pinned by `FileLifecycleServiceTest`.
|
||||||
|
|
||||||
|
### 4. `If-Match` is specified as an ETag but the lifecycle was designed around the row version
|
||||||
|
|
||||||
|
The HTTP contract sends an entity tag; the metadata store guards on a numeric version. The service
|
||||||
|
takes `Optional<String> expectedEtag` and compares against the record's strong validator, so the
|
||||||
|
precondition a client sends is the precondition that is checked.
|
||||||
|
|
||||||
|
Pinned by `FileLifecycleServiceTest`.
|
||||||
|
|
||||||
|
### 5. The filename policy left `:` intact
|
||||||
|
|
||||||
|
`C:\Windows\system.ini` sanitized to `C:Windowssystem.ini` — a drive-qualified name surviving into
|
||||||
|
display text and headers. `:` joined the structural strip set.
|
||||||
|
|
||||||
|
Pinned by `FileserverHardeningContractTest` and `AmbiguousFilesystemOperationDetectorTest`.
|
||||||
|
|
||||||
|
## Additions the design implies but does not specify
|
||||||
|
|
||||||
|
### 6. `fs_recovery_item`
|
||||||
|
|
||||||
|
§10.2 lists five core tables and none of them can hold the recovery queue, yet §29.3 requires one:
|
||||||
|
reconciliation reports files whose bytes and metadata disagree, and holding that list in memory
|
||||||
|
would lose exactly the cases a restart interrupted. Added in
|
||||||
|
`V2__fileserver_recovery_and_staging_cleanup.sql` with one open item per file, so repeated sweeps
|
||||||
|
update a worklist rather than accumulating a log.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 7. `fs_cleanup_item.upload_id`
|
||||||
|
|
||||||
|
A staging object is addressed by upload, not by file. Without this column a queued staging cleanup
|
||||||
|
could name only already-published content, so a cancelled or expired upload left bytes nothing could
|
||||||
|
find. Added in the same migration, with a check constraint that an item names exactly one target.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 8. `ContentReferenceLedger` and `StagingUploadLocator`
|
||||||
|
|
||||||
|
The orphan scan must ask whether a record still claims a physical object, and reconciliation must
|
||||||
|
map a file back to the upload that last staged it. Neither question is answerable through the
|
||||||
|
design's `FileMetadataStore` or `UploadSessionStore` as written. Rather than widen those
|
||||||
|
design-fixed interfaces, both are narrow single-method ports.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest` and `LocalOrphanScanAdapterTest`.
|
||||||
|
|
||||||
|
## Interpretations
|
||||||
|
|
||||||
|
### 9. Quota settlement is FIFO within a scope
|
||||||
|
|
||||||
|
Nothing links a reservation row to the upload that took it, and the design deliberately reclaims
|
||||||
|
stragglers by TTL and the `STALE_QUOTA_RESERVATION` cleanup type rather than threading a reservation
|
||||||
|
id through the upload session. `QuotaCommitGateway` therefore settles the oldest live reservation in
|
||||||
|
the file's namespace.
|
||||||
|
|
||||||
|
Which row closes does not change any quota decision: enforcement sums reserved and committed bytes
|
||||||
|
per scope and never reads an individual row. Concurrent uploads of different sizes can leave the
|
||||||
|
reserved total transiently high or low, and it converges as each settles. Durable usage with no live
|
||||||
|
reservation behind it — an upload that outlived its TTL — is still recorded, because a ledger that
|
||||||
|
silently under-counts is worse than one that is briefly imprecise.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 10. Zero copy is a channel transfer, not a file handoff
|
||||||
|
|
||||||
|
Task 22 asks for zero copy on local files; §19 forbids a `Path` leaving the storage adapter, and §5
|
||||||
|
of the plan forbids adding `Path` to the content store. WebFlux's zero-copy API takes a `Path`, so
|
||||||
|
that route is closed.
|
||||||
|
|
||||||
|
The servlet path takes the other one: `ZeroCopyDownloadGateway` receives a `WritableByteChannel` from
|
||||||
|
the transport and the storage adapter performs `FileChannel.transferTo` into it. That is a genuine
|
||||||
|
kernel-level transfer with no filesystem concept leaving storage. The reactive path continues to
|
||||||
|
stream with bounded demand.
|
||||||
|
|
||||||
|
Zero copy is an optimization with no observable difference: when storage declines, the response is
|
||||||
|
streamed and is byte-identical.
|
||||||
|
|
||||||
|
Pinned by `LocalStorageGatewayContractTest` and `ZeroCopyEligibilityTest`.
|
||||||
|
|
||||||
|
### 11. The Fileserver JPA stores are gated on the capability switch
|
||||||
|
|
||||||
|
The metadata store, session store, quota service, queues, ledger, and staging locator carry
|
||||||
|
`@ConditionalOnProperty(app.fileserver-platform.enabled)` even though the rest of
|
||||||
|
`adapter:outbound:persistence-jpa` is unconditional.
|
||||||
|
|
||||||
|
Without the gate, every composition root that includes the persistence module built these beans —
|
||||||
|
including `sample-portfolio`, which has no Fileserver — and each of them needs collaborators only
|
||||||
|
the Fileserver configuration provides. That is the same rule the design states for the transport
|
||||||
|
surface, applied to persistence: no surface appears merely because the dependency is present.
|
||||||
|
|
||||||
|
`FileStateMachine` is bound alongside them, in `FileserverStorageConfiguration`. It had no
|
||||||
|
production binding at all before, which made the metadata store unconstructible in any
|
||||||
|
component-scanned context.
|
||||||
|
|
||||||
|
Pinned by `SampleApplicationContextTest` (the capability off) and
|
||||||
|
`FileserverRuntimeAssemblyTest` (the capability on).
|
||||||
|
|
||||||
|
### 12. Transaction boundaries are owned by the application services, and are deliberately narrow
|
||||||
|
|
||||||
|
The design does not say where a transaction begins. The repository does:
|
||||||
|
`adapter:outbound:persistence-jpa` forbids a repository adapter from owning a `@Transactional`
|
||||||
|
boundary, and `application-core` owns them through `TransactionPort`. The Fileserver follows that
|
||||||
|
rule — every `Jpa*` store here declares no `@Transactional` of its own.
|
||||||
|
|
||||||
|
What is specific to this capability is how narrow the boundaries are. A boundary covers a contiguous
|
||||||
|
run of metadata writes and **stops before every storage call**, because a filesystem operation
|
||||||
|
inside a database transaction would hold a connection for the length of a byte transfer. The upload
|
||||||
|
path therefore has three boundaries, not one: acquire the lease, transfer the bytes, commit the
|
||||||
|
offset.
|
||||||
|
|
||||||
|
Where several stores must agree, they share one boundary:
|
||||||
|
|
||||||
|
| Unit | Why it is one boundary |
|
||||||
|
| --- | --- |
|
||||||
|
| reserve quota + insert record + create session | a reservation that outlived a failed insert holds capacity for a file that never existed |
|
||||||
|
| READY transition + quota commit | a finished file whose reservation was never converted holds capacity until the reservation expires |
|
||||||
|
| `markDeleting` + enqueue cleanup | a file that stopped being reachable with nothing queued to reclaim it is never collected |
|
||||||
|
| content delete settlement: reclaim + retire record + close queue item | half of it leaves the item to be retried against content that no longer exists |
|
||||||
|
|
||||||
|
What this cannot make atomic is the storage/metadata seam itself — no database boundary could. That
|
||||||
|
seam is exactly what the ambiguous-completion path and the reconciler exist for, and the one
|
||||||
|
hand-written compensation that remains (staging creation failing after the records committed) is
|
||||||
|
there for the same reason.
|
||||||
|
|
||||||
|
Pinned by `FileserverRoundTripContractTest` against real PostgreSQL; the application tests use
|
||||||
|
`DirectTransactions`, which runs a boundary inline and counts it.
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
### `AsyncContentStore`, `CapacityAwareContentStore`, `CopyCapableContentStore`, `DelegatedDownloadStore`
|
||||||
|
|
||||||
|
Four optional content-store SPIs are declared in `application-core` with no implementation. Each is
|
||||||
|
an extension point for a backend this template does not ship:
|
||||||
|
|
||||||
|
- `AsyncContentStore` — for a backend whose native client is non-blocking. The local platform is
|
||||||
|
blocking, and the reactive transport bridges to it on a dedicated I/O scheduler.
|
||||||
|
- `CapacityAwareContentStore` — capacity is reported through `StorageHealthPort` and
|
||||||
|
`StorageUsageProbe`, which the local platform implements.
|
||||||
|
- `CopyCapableContentStore` — server-side copy is delivered by `CopyContentGateway`; the local
|
||||||
|
platform has no cheaper primitive than a streamed copy.
|
||||||
|
- `DelegatedDownloadStore` — delegation is delivered at the transport boundary by the nginx
|
||||||
|
`X-Accel-Redirect` strategy, which needs no store participation.
|
||||||
|
|
||||||
|
`ContentStoreCapabilities` reports what the running store actually supports, so no unimplemented SPI
|
||||||
|
is advertised as available.
|
||||||
|
|
||||||
|
## Known deviation from the repository's application-layer contract
|
||||||
|
|
||||||
|
### 5. Fileserver application services are not `CommandUseCase` / `QueryUseCase`
|
||||||
|
|
||||||
|
`src/application-core/CLAUDE.md` requires every inbound port implementation to extend
|
||||||
|
`CommandUseCase` or `QueryUseCase` and to carry `@UseCaseCapability`, which declares its transaction
|
||||||
|
mode, idempotency and repository access. The Fileserver instead exposes multi-method services —
|
||||||
|
`UploadApplicationService`, `DownloadApplicationService`, `FileLifecycleService`,
|
||||||
|
`FileserverAdminService` and their `Default*` implementations.
|
||||||
|
|
||||||
|
This is a real deviation, not an oversight, and it is unenforced: the ArchUnit rules
|
||||||
|
`inbound_port_implementations_end_with_use_case` and
|
||||||
|
`inbound_port_implementations_declare_capability` only match types that implement `UseCase`, so a
|
||||||
|
service that never does is silently exempt. The capability contract that every other feature in
|
||||||
|
this repository declares is therefore absent here.
|
||||||
|
|
||||||
|
Two things follow from it. The transaction mode of each operation is expressed only by which
|
||||||
|
`TransactionPort` method the body happens to call, rather than declared and checked. And the
|
||||||
|
application layer holds transport policy it would not hold if each operation were a use case with
|
||||||
|
its own command: HTTP status codes on `FileserverErrorCode`, `Range` and conditional-request
|
||||||
|
parsing in `api.transfer`, and `Content-Disposition` construction.
|
||||||
|
|
||||||
|
The status mapping in particular is a deliberate trade rather than an accident. It lives in
|
||||||
|
`application-core` so the servlet transport, the reactive transport and the Nginx delegation path
|
||||||
|
cannot answer the same failure with three different statuses. Moving it to the transport layer
|
||||||
|
resolves the layering complaint and reintroduces exactly that drift, which is why this is an
|
||||||
|
architecture decision rather than a cleanup.
|
||||||
|
|
||||||
|
**Status: open, deliberately unresolved in this change set.** Closing it means roughly thirty
|
||||||
|
command/query use cases, a decision about where the shared status vocabulary lives, and a change to
|
||||||
|
the ArchUnit rules so a service that bypasses the contract fails the build instead of being exempt
|
||||||
|
from it. That belongs in its own ADR with its own review, and doing it inside a correctness patch
|
||||||
|
would mix a large mechanical refactor into changes that need to be readable.
|
||||||
|
|
||||||
|
Nothing here is pinned by a test, because the deviation is the absence of a constraint. The next
|
||||||
|
step is the ADR, not another test.
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Fileserver HTTP contract
|
||||||
|
|
||||||
|
Every public endpoint is listed here. `FileserverDocumentationCoverageTest` scans the controllers
|
||||||
|
and fails if one is missing, so this file cannot silently fall behind the code.
|
||||||
|
|
||||||
|
## Public endpoints
|
||||||
|
|
||||||
|
| Method | Path | Success | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/v1/files` | `201` READY, `202` VERIFYING | multipart single upload |
|
||||||
|
| POST | `/v1/files:raw` | `201`, `202` | the whole request body is the file |
|
||||||
|
| POST | `/v1/files:batch` | `200` | ordered per-part results; explicitly non-atomic |
|
||||||
|
| GET | `/v1/files/{fileId}` | `200` | public metadata; never a content key or path |
|
||||||
|
| GET | `/v1/files/{fileId}/content` | `200`, `206`, `304` | download |
|
||||||
|
| HEAD | `/v1/files/{fileId}/content` | `200`, `304` | identical headers, no body |
|
||||||
|
| DELETE | `/v1/files/{fileId}` | `202`, `204` | logical delete first |
|
||||||
|
| POST | `/v1/files/{fileId}:copy` | `202` | create-only target |
|
||||||
|
| POST | `/v1/files/{fileId}:move` | `200` | logical namespace change only |
|
||||||
|
| OPTIONS | `/v1/uploads` | `204` | tus capability discovery |
|
||||||
|
| POST | `/v1/uploads` | `201` | tus creation |
|
||||||
|
| HEAD | `/v1/uploads/{uploadId}` | `204` | tus offset |
|
||||||
|
| PATCH | `/v1/uploads/{uploadId}` | `204` | tus append |
|
||||||
|
| DELETE | `/v1/uploads/{uploadId}` | `204` | tus termination |
|
||||||
|
| POST | `/v1/experimental/draft12/uploads` | `201` | Experimental; off by default |
|
||||||
|
| PATCH | `/v1/experimental/draft12/uploads/{uploadId}` | `204` | Experimental; off by default |
|
||||||
|
|
||||||
|
## Management endpoints
|
||||||
|
|
||||||
|
Reachable only where both `app.fileserver-platform.enabled=true` and
|
||||||
|
`app.fileserver-platform.admin.enabled=true`, gated at the servlet chain on
|
||||||
|
`app.fileserver-platform.security.admin-roles`, and intended for a management
|
||||||
|
port rather than the public one.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| GET | `/internal/fileserver/storage-health` |
|
||||||
|
| GET | `/internal/fileserver/capabilities` |
|
||||||
|
| GET | `/internal/fileserver/orphans` |
|
||||||
|
| POST | `/internal/fileserver/orphans:reconcile` |
|
||||||
|
| POST | `/internal/fileserver/files/{fileId}:reverify` |
|
||||||
|
| POST | `/internal/fileserver/files/{fileId}:force-delete` |
|
||||||
|
| GET | `/internal/fileserver/uploads/incomplete` |
|
||||||
|
| POST | `/internal/fileserver/uploads:cleanup` |
|
||||||
|
|
||||||
|
## Status codes
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|---:|---|
|
||||||
|
| `200` | metadata, full GET, batch result, move |
|
||||||
|
| `201` | file or upload created |
|
||||||
|
| `202` | verification or physical cleanup deferred |
|
||||||
|
| `204` | append, cancel, bodyless update |
|
||||||
|
| `206` | satisfiable Range |
|
||||||
|
| `304` | validator matched on GET or HEAD |
|
||||||
|
| `400` | malformed header or header combination |
|
||||||
|
| `401` | unauthenticated |
|
||||||
|
| `403` / `404` | denied, or hidden under the existence-hiding profile |
|
||||||
|
| `409` | state, offset, or lease conflict |
|
||||||
|
| `410` | expired upload resource |
|
||||||
|
| `411` | `require-content-length` profile with no length |
|
||||||
|
| `412` | precondition failed |
|
||||||
|
| `413` | size or quota policy violation |
|
||||||
|
| `415` | upload media type not accepted |
|
||||||
|
| `416` | unsatisfiable Range; carries the real length |
|
||||||
|
| `422` | digest, signature, or scanner rejection |
|
||||||
|
| `429` | transfer admission or rate limit |
|
||||||
|
| `503` | storage or scanner unavailable |
|
||||||
|
| `504` | downstream timeout |
|
||||||
|
| `507` | out of storage capacity |
|
||||||
|
|
||||||
|
## Failure body
|
||||||
|
|
||||||
|
Every failure answers `application/problem+json` with a stable code and its URN:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "urn:fileserver:problem:upload-offset-mismatch",
|
||||||
|
"title": "Upload offset mismatch",
|
||||||
|
"status": 409,
|
||||||
|
"code": "UPLOAD_OFFSET_MISMATCH",
|
||||||
|
"retryable": true,
|
||||||
|
"ambiguous": false,
|
||||||
|
"reconciliationRequired": false,
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server-side exception message never appears. `ambiguous` is the field a client must read before
|
||||||
|
retrying: an ambiguous failure may already have taken effect.
|
||||||
|
|
||||||
|
## Header contract
|
||||||
|
|
||||||
|
| Header | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `Content-Type` | client value is a claim; the verified type is stored separately |
|
||||||
|
| `Content-Disposition` | `attachment` by default; scriptable types are never inline |
|
||||||
|
| `Accept-Ranges` | `bytes` |
|
||||||
|
| `Range` | single range by default; multi-range only under an explicit budget |
|
||||||
|
| `Content-Range` | actual range on `206`; the unsatisfied form on `416` |
|
||||||
|
| `ETag` | strong validator derived from the SHA-256 |
|
||||||
|
| `Last-Modified` | metadata publication instant, never a filesystem timestamp |
|
||||||
|
| `Cache-Control` | `private, no-store` by default |
|
||||||
|
| `X-Content-Type-Options` | always `nosniff` on a download |
|
||||||
|
| `Retry-After` | on retryable `409`, `429`, `503`, and `504` |
|
||||||
|
| `X-Accel-Redirect` | internal only; never forwarded to a client |
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Fileserver runbooks
|
||||||
|
|
||||||
|
Each runbook names the exact metric that fires it and the exact command that resolves it. A runbook
|
||||||
|
whose trigger is "someone noticed" is not actionable, so every one below starts from a signal.
|
||||||
|
|
||||||
|
## Storage full
|
||||||
|
|
||||||
|
**Signal** — `fileserver.quota{result="rejected"}` rising, or `507` responses appearing.
|
||||||
|
|
||||||
|
Storage capacity is exhausted or the high-water guard tripped. Uploads are rejected before any bytes
|
||||||
|
are written, so nothing is corrupt; the system is refusing work it cannot complete.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s $ADMIN/internal/fileserver/storage-health | jq '.usedFraction, .usableBytes'
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||||
|
curl -s "$ADMIN/internal/fileserver/orphans?limit=200" | jq '[.[].sizeBytes] | add'
|
||||||
|
```
|
||||||
|
|
||||||
|
Drain the cleanup backlog first — it reclaims space the system already knows is dead. Only then
|
||||||
|
consider an orphan reconcile, and start with a dry run.
|
||||||
|
|
||||||
|
## Orphan growth
|
||||||
|
|
||||||
|
**Signal** — `fileserver.cleanup{result="skipped"}` climbing, or the orphan scan returning more
|
||||||
|
objects each run.
|
||||||
|
|
||||||
|
Physical objects exist with no metadata record pointing at them. This is not immediately dangerous —
|
||||||
|
nothing serves them — but it consumes capacity indefinitely.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always look first. A reconcile without dryRun=false is a plan, not an action.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||||
|
-H 'content-type: application/json' -d '{"limit":100}' | jq '.candidates'
|
||||||
|
|
||||||
|
# Apply only the fingerprints you were just shown.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dryRun":false,"limit":100,"maxBytes":1073741824,
|
||||||
|
"expectedFingerprints":["<from the dry run>"],"reasonCode":"ORPHAN_GROWTH_RUNBOOK"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Echoing the fingerprints is the safety property: an object that changed between the scan and the
|
||||||
|
apply is skipped rather than deleted.
|
||||||
|
|
||||||
|
## Verification backlog
|
||||||
|
|
||||||
|
**Signal** — `fileserver.verification.queue{age_bucket="old"}` non-zero, or files sitting in
|
||||||
|
VERIFYING.
|
||||||
|
|
||||||
|
A verifier is slow or unavailable. Files stay non-public, which is the correct failure direction: a
|
||||||
|
`RETRY` verdict never becomes an `ACCEPT`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s $ADMIN/internal/fileserver/capabilities | jq '.storageType'
|
||||||
|
# Once the verifier is healthy, quarantined files can be re-examined individually.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/files/$FILE_ID:reverify"
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not clear the backlog by disabling verification. A file that reached READY without an accepting
|
||||||
|
verdict cannot be distinguished later from one that was verified.
|
||||||
|
|
||||||
|
## NFS ambiguity
|
||||||
|
|
||||||
|
**Signal** — problem documents carrying `"ambiguous": true`, or
|
||||||
|
`fileserver.transfer.interruption{reason="stale_handle"}`.
|
||||||
|
|
||||||
|
An operation's outcome could not be determined: the response was lost after the write or rename may
|
||||||
|
have landed. These are never retried automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The recovery queue holds the files awaiting a decision.
|
||||||
|
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconciliation compares the physical size and digest against the record and only confirms READY when
|
||||||
|
all four of key, size, digest, and version agree. Anything short of that is reported, never guessed.
|
||||||
|
|
||||||
|
## PVC remount
|
||||||
|
|
||||||
|
**Signal** — startup failure naming "atomic move", "same file store", or "not writable".
|
||||||
|
|
||||||
|
The volume was remounted somewhere the probe can no longer prove a required capability. The
|
||||||
|
application refuses traffic rather than serving from storage it cannot publish to atomically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the printed tuple with the certified one in `docs/fileserver/storage-certification.md`. A
|
||||||
|
mismatch in CSI driver, StorageClass, access mode, or mount options is the cause; the certification
|
||||||
|
does not carry across it.
|
||||||
|
|
||||||
|
## Nginx delegation failure
|
||||||
|
|
||||||
|
**Signal** — `fileserver.download.delegation{delegated="true"}` with client-visible `404`s.
|
||||||
|
|
||||||
|
The internal location is misconfigured, so the proxy cannot resolve the redirect it was handed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The internal prefix must resolve to the content root and must be marked `internal`.
|
||||||
|
grep -A5 '__files' infra/fileserver/nginx/nginx.conf
|
||||||
|
curl -s $ADMIN/internal/fileserver/capabilities | jq '.capabilities.delegatedDownload'
|
||||||
|
```
|
||||||
|
|
||||||
|
Turning delegation off is a safe immediate mitigation: the application serves the transfer itself,
|
||||||
|
slower but correct.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
app.fileserver-platform.nginx.enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup backlog
|
||||||
|
|
||||||
|
**Signal** — `fileserver.cleanup{result="deferred"}` rising, or reclaimed bytes flat while deletes
|
||||||
|
continue.
|
||||||
|
|
||||||
|
Items are being deferred faster than they drain. The usual cause is an active writer lease still
|
||||||
|
holding staging objects, which is correct behaviour, not a fault.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" \
|
||||||
|
| jq '[.[] | select(.leaseUntil != null)] | length'
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the deferrals are all `ACTIVE_WRITER_LEASE`, the backlog resolves itself as those uploads expire.
|
||||||
|
Never delete staging content to clear a backlog: an upload that is mid-flight will corrupt.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Fileserver security model
|
||||||
|
|
||||||
|
## The rule everything else follows
|
||||||
|
|
||||||
|
Uploaded content is attacker-controlled. Every guard below exists because some part of the request
|
||||||
|
— the filename, the declared media type, the range, the offset — is a value the caller chose.
|
||||||
|
|
||||||
|
## Path safety
|
||||||
|
|
||||||
|
A client value never becomes a path. The physical key is server-generated, and `ContentKey`'s
|
||||||
|
character class excludes `.` entirely, so no traversal or extension-shaped segment survives
|
||||||
|
validation. `DefaultPhysicalPathResolver` is the only place an identifier becomes a `Path`, and it
|
||||||
|
normalizes and re-checks containment after construction rather than trusting the input.
|
||||||
|
|
||||||
|
Symlink refusal happens at open time, not only at construction. A parent directory can be replaced
|
||||||
|
between the two, so a check that ran only at path-building time would be a race, not a guard.
|
||||||
|
|
||||||
|
## Filename handling
|
||||||
|
|
||||||
|
`OriginalFilenamePolicy` strips path separators, NUL, quoting characters, and the colon — the last
|
||||||
|
because on Windows it opens both a drive reference and an NTFS alternate data stream, so a name that
|
||||||
|
keeps it is still path-shaped after the slashes are gone. Control characters and bidirectional
|
||||||
|
overrides are removed, dot runs collapsed, reserved device names guarded, and the result is bounded
|
||||||
|
in UTF-8 bytes.
|
||||||
|
|
||||||
|
The sanitized name is display data. It is never used to build a key, and it reaches a header only
|
||||||
|
through `ContentDispositionFactory`, which restricts the ASCII form and percent-encodes the UTF-8
|
||||||
|
form.
|
||||||
|
|
||||||
|
## Content type
|
||||||
|
|
||||||
|
The client's `Content-Type` is stored as a claim. The verified type comes from the verification
|
||||||
|
pipeline, and only the verified type is served. A claimed type that contradicts the content is
|
||||||
|
quarantined rather than corrected.
|
||||||
|
|
||||||
|
Scriptable types are never served inline, whatever the caller asked for: serving stored HTML or SVG
|
||||||
|
inline from an upload origin is a stored cross-site scripting primitive. Every download also carries
|
||||||
|
`X-Content-Type-Options: nosniff`.
|
||||||
|
|
||||||
|
## Verification precedence
|
||||||
|
|
||||||
|
`REJECT > QUARANTINE > RETRY > ACCEPT`. A verifier that times out or throws is `RETRY`, never a
|
||||||
|
silent pass, and an empty verifier chain answers `RETRY` rather than accepting. A file becomes
|
||||||
|
publicly readable only after an `ACCEPT`.
|
||||||
|
|
||||||
|
## Range safety
|
||||||
|
|
||||||
|
The range budget is enforced before content is opened, so a request naming many ranges is rejected
|
||||||
|
without amplifying into storage work. An unsatisfiable range answers `416` with the real length and
|
||||||
|
opens nothing.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
Every public operation calls the injected `FileAccessPolicy` before any quota reservation or storage
|
||||||
|
mutation, so a denial leaves no record, no reservation, and no staging object. Startup refuses to
|
||||||
|
run a production profile with an allow-all policy.
|
||||||
|
|
||||||
|
## Delegation
|
||||||
|
|
||||||
|
`X-Accel-Redirect` is emitted only after authorization and the READY gate, and only for a full,
|
||||||
|
unconditional response. The internal prefix must be an `internal` Nginx location; the front proxy
|
||||||
|
also strips any client-supplied delegation header so a caller cannot name an internal object.
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
No metric label, span attribute, or audit record carries a file id, upload id, filename, path, or
|
||||||
|
user id. Where correlation is needed the value is a keyed HMAC fingerprint — keyed because the
|
||||||
|
identifier space is enumerable and an unkeyed digest of it is reversible by brute force.
|
||||||
|
|
||||||
|
## Ambiguous failures
|
||||||
|
|
||||||
|
A failure whose operation may already have taken effect is reported as ambiguous and is never
|
||||||
|
retryable. On a network filesystem a lost response is indistinguishable from a rejection at the
|
||||||
|
socket level, so anything not provably safe is treated as ambiguous and sent to reconciliation.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Storage certification
|
||||||
|
|
||||||
|
## Why a certification is per-volume
|
||||||
|
|
||||||
|
Atomic rename, same-file-store guarantees, and symlink refusal are properties of a specific
|
||||||
|
filesystem behind a specific mount — not of "Kubernetes" or "a PVC". Change the CSI driver, the
|
||||||
|
StorageClass, the access mode, the backend, or the mount options and any of them can differ. A
|
||||||
|
certification that does not name all five is not transferable.
|
||||||
|
|
||||||
|
## What is certified
|
||||||
|
|
||||||
|
| Property | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| Same file store for staging and content | A rename across stores is a copy, so publication stops being atomic. |
|
||||||
|
| Atomic rename | The publish path's default strategy. |
|
||||||
|
| Atomic create (`O_EXCL`) | Makes a publish create-only rather than a silent overwrite. |
|
||||||
|
| Symlink refusal | Stops a replaced parent from redirecting a write outside the root. |
|
||||||
|
| Ranged read | The download contract depends on it. |
|
||||||
|
|
||||||
|
## Running the certification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
The job writes a machine-readable result to the claim itself, carrying the full tuple:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kubernetesVersion": "...",
|
||||||
|
"csiDriver": "...",
|
||||||
|
"storageClass": "...",
|
||||||
|
"accessMode": "ReadWriteOnce",
|
||||||
|
"backend": "ext2/ext3",
|
||||||
|
"mountOptions": "rw,relatime",
|
||||||
|
"atomicMove": true,
|
||||||
|
"sameFileStore": true,
|
||||||
|
"atomicCreate": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The job fails closed: a volume whose staging and content areas are on different stores is not
|
||||||
|
certified, because its publish would silently degrade to a copy.
|
||||||
|
|
||||||
|
## Network filesystems
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/fileserver/nfs/compose.yml up -d
|
||||||
|
FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
|
||||||
|
```
|
||||||
|
|
||||||
|
The mount is `hard`, deliberately. A `soft` mount converts a slow server into a short write, which
|
||||||
|
is exactly the corruption this design refuses to accept.
|
||||||
|
|
||||||
|
## Startup enforcement
|
||||||
|
|
||||||
|
`FileserverStartupValidator` re-runs the probe at boot and refuses to accept traffic when a required
|
||||||
|
capability is missing — `ATOMIC_MOVE_REQUIRED` on a filesystem that cannot prove an atomic move
|
||||||
|
fails closed rather than degrading silently.
|
||||||
|
|
||||||
|
## Adding a new store
|
||||||
|
|
||||||
|
Extend `ContentStoreContract` and pass it. A prose claim of compatibility is not accepted; the
|
||||||
|
contract is executable precisely so a future object-storage adapter has to demonstrate the same
|
||||||
|
offset, digest, and create-only behaviour the local store does.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Fileserver support matrix
|
||||||
|
|
||||||
|
A support level here is a claim about evidence, not about intent. Every row names the CI job that
|
||||||
|
produces that evidence; `FileserverDocumentationCoverageTest` fails the build if a row names a job
|
||||||
|
that does not exist, so a level can never outlive the test that justified it.
|
||||||
|
|
||||||
|
## Levels
|
||||||
|
|
||||||
|
| Level | What it means |
|
||||||
|
|---|---|
|
||||||
|
| Stable | Certified on every pull request. Contract changes are breaking changes. |
|
||||||
|
| Beta | Certified nightly. The contract may still change with a deprecation notice. |
|
||||||
|
| Limited | Certified on the release gate only, under stated constraints. |
|
||||||
|
| Compatibility | Accepted but not optimized; known caveats are listed inline. |
|
||||||
|
| Experimental | Off by default, unratified upstream, may change without notice. |
|
||||||
|
|
||||||
|
## Runtime profiles
|
||||||
|
|
||||||
|
| Profile | Level | CI job |
|
||||||
|
|---|---|---|
|
||||||
|
| Local filesystem (ext4) content store | Stable | `fileserver-local-ext4-contract` |
|
||||||
|
| Spring MVC transport (raw, multipart, batch, download) | Stable | `fileserver-http-contract` |
|
||||||
|
| Spring WebFlux transport | Experimental | `fileserver-http-contract` |
|
||||||
|
| Path, filename, range, and problem-detail hardening | Stable | `fileserver-security-suite` |
|
||||||
|
| Bounded-memory transfer | Stable | `fileserver-bounded-memory` |
|
||||||
|
| Application and architecture invariants | Stable | `fileserver-unit-and-architecture` |
|
||||||
|
| Runtime assembly (the capability starts with the flag on) | Stable | `fileserver-unit-and-architecture` |
|
||||||
|
| tus 1.0 resumable uploads | Stable | `fileserver-http-contract` |
|
||||||
|
| Crash-recovery matrix | Beta | `fileserver-process-kill-matrix` |
|
||||||
|
| NFSv4 ambiguity handling | Beta | `fileserver-nfs-ambiguity` |
|
||||||
|
| Large-file and slow-client performance | Beta | `fileserver-large-file-performance` |
|
||||||
|
| Multi-instance writer lease | Beta | `fileserver-multi-instance-lease` |
|
||||||
|
| Kubernetes ReadWriteOnce PVC | Limited | `fileserver-pvc-certification` (manifest checks in CI; cluster run is operator-driven) |
|
||||||
|
| Nginx `X-Accel-Redirect` delegation | Limited | `fileserver-http-contract` |
|
||||||
|
| Telemetry sensitive-data suppression | Stable | `fileserver-sensitive-telemetry-scan` |
|
||||||
|
| Documentation and support-claim coverage | Stable | `fileserver-documentation-gate` |
|
||||||
|
| Full release verification | Stable | `fileserver-full-verification` |
|
||||||
|
| HTTP resumable uploads draft-12 | Experimental | `fileserver-http-contract` |
|
||||||
|
|
||||||
|
### Why WebFlux is Experimental, not Stable
|
||||||
|
|
||||||
|
The reactive router, handlers and readers are now wired: `FileserverReactiveConfiguration`
|
||||||
|
contributes the scheduler, the handlers and a `RouterFunction` bean under
|
||||||
|
`@ConditionalOnWebApplication(type = REACTIVE)` plus the platform master switch. Previously nothing
|
||||||
|
built them at all, so "Stable" described the source tree rather than a running server.
|
||||||
|
|
||||||
|
It stays `Experimental` because the shipped composition cannot select it. `adapter:inbound:web`
|
||||||
|
also puts `DispatcherServlet` on the classpath — deliberately, so adding `spring-webflux` does not
|
||||||
|
drag a second embedded server onto the runtime — and Boot's application-type deduction therefore
|
||||||
|
resolves SERVLET. A fork that removes the servlet stack and adds a reactive server gets working
|
||||||
|
routes without editing any Fileserver code; the shipped template does not exercise that path.
|
||||||
|
|
||||||
|
Raising it to Stable requires a contract job that drives the routes over a running reactive server
|
||||||
|
rather than through direct construction.
|
||||||
|
|
||||||
|
## Explicitly not claimed
|
||||||
|
|
||||||
|
These have no job, and therefore no claim:
|
||||||
|
|
||||||
|
- An automated Kubernetes cluster result. `fileserver-pvc-certification` validates the manifest on
|
||||||
|
every release and applies it only when a release cluster is configured; without one it warns and
|
||||||
|
records that nothing was certified. The cluster tuple is produced by an operator and read from
|
||||||
|
[storage-certification.md](storage-certification.md).
|
||||||
|
|
||||||
|
- Kubernetes ReadWriteMany PVC. Concurrent writers across nodes are not certified.
|
||||||
|
- Windows NTFS as a production storage root. The filename policy strips the characters NTFS
|
||||||
|
reserves, but no job certifies the publish path there.
|
||||||
|
- Object storage as a content store. The contract exists (`ContentStoreContract`) but no adapter
|
||||||
|
implements it yet.
|
||||||
|
- Server-side malware scanning. The verification pipeline has the port and the verdict precedence;
|
||||||
|
no scanner is shipped.
|
||||||
|
|
||||||
|
## Where the rest is written down
|
||||||
|
|
||||||
|
- [configuration.md](configuration.md) — every `app.fileserver-platform.*` key, its default, and the
|
||||||
|
conditions that fail startup rather than degrade.
|
||||||
|
- [design-deviations.md](design-deviations.md) — where the implementation departs from the frozen
|
||||||
|
design, why, and the test that pins each decision.
|
||||||
|
- [http-contract.md](http-contract.md) — the wire contract.
|
||||||
|
- [security.md](security.md) — the threat model and what enforces each control.
|
||||||
|
- [operations.md](operations.md) — runbooks, each starting from a metric.
|
||||||
|
- [storage-certification.md](storage-certification.md) — how a volume is certified.
|
||||||
|
- [upgrade-guide.md](upgrade-guide.md) — what changes between versions.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Fileserver upgrade guide
|
||||||
|
|
||||||
|
## Enabling the capability
|
||||||
|
|
||||||
|
The Fileserver ships off. Nothing is registered — no endpoint, no thread pool, no metric — until it
|
||||||
|
is enabled explicitly.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
enabled: true
|
||||||
|
instance-id: ${HOSTNAME}
|
||||||
|
default-namespace: default
|
||||||
|
observability:
|
||||||
|
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
`instance-id` must be unique per instance: it is the writer-lease owner, and two nodes sharing one
|
||||||
|
would both believe they hold the same lease.
|
||||||
|
|
||||||
|
`fingerprint-key` is required and has no default. Startup fails without it rather than falling back
|
||||||
|
to an unkeyed digest, which would be reversible for an enumerable identifier space.
|
||||||
|
|
||||||
|
## Optional surfaces
|
||||||
|
|
||||||
|
Each is a separate switch, and each defaults to off:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
admin:
|
||||||
|
enabled: false # management plane; intended for a management port
|
||||||
|
tus:
|
||||||
|
enabled: false # tus 1.0 Stable
|
||||||
|
httpbis-draft12:
|
||||||
|
enabled: false # Experimental; unratified, may change without notice
|
||||||
|
nginx:
|
||||||
|
enabled: false # front-proxy delegation; needs a validated internal location
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
The metadata schema is installed as a capability migration and starts inactive:
|
||||||
|
|
||||||
|
```
|
||||||
|
V1__create_fileserver_metadata.sql → capability_schema_registry: jpa-fileserver-metadata-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate it deliberately. Enabling the capability without an activated schema fails at startup
|
||||||
|
rather than at the first upload.
|
||||||
|
|
||||||
|
## Choosing a publish mode
|
||||||
|
|
||||||
|
| Mode | When |
|
||||||
|
|---|---|
|
||||||
|
| `atomic-move-preferred` | Default. Uses an atomic rename when the probe proves one, else a metadata pointer. |
|
||||||
|
| `atomic-move-required` | Fail closed. Refuses to start on storage that cannot prove an atomic move. |
|
||||||
|
| `metadata-pointer` | For storage without atomic rename; publication is the metadata commit. |
|
||||||
|
|
||||||
|
Pick `atomic-move-required` when the storage is certified and you want a misconfiguration to surface
|
||||||
|
at boot rather than at publish time.
|
||||||
|
|
||||||
|
## Behaviour that will surprise you
|
||||||
|
|
||||||
|
- **A delete answers `202`, not `204`, when content still exists.** The file is already unreadable;
|
||||||
|
the physical reclaim is deferred. Treating `202` as a failure will produce spurious retries.
|
||||||
|
- **A batch upload answers `200` even when parts failed.** The batch is explicitly non-atomic, and a
|
||||||
|
single status could not report a partial outcome honestly. Read `results[].problem`.
|
||||||
|
- **An ambiguous failure must not be retried.** Check `"ambiguous": true` in the problem document.
|
||||||
|
- **`If-Match` takes the strong ETag, not a version number.** A client can only assert about the
|
||||||
|
representation it was actually served.
|
||||||
|
- **Inline rendering is refused for scriptable types** even when the caller asks for it.
|
||||||
|
|
||||||
|
## Verifying an upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :application-core:check :adapter:inbound:web:check \
|
||||||
|
:adapter:outbound:fileserver:check --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*Fileserver*' --console=plain
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
44ba9931722364a53fcb3b5f31a1d539eabcaf42db775f5a33fb558f558c7504 README.md
|
||||||
|
d064f0ac6c3be0e5c76ef22454db2a97e1d78ed287bd22f4c125f19aba3ad8e3 VALIDATION.md
|
||||||
|
1ef15812f33dc998a6332b87523ed5942ba46d79d984a0ca776b05bb9247a06a docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md
|
||||||
|
5ae70b53e22cdb852b2bb0df171dec868bfe99b15bb8e71fb2b0b3431cd7e2cd docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md
|
||||||
|
8d0203203f6bfe4b2e18625eff23bb308ba6454703a4ca4cd3236dab31ecafc3 docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md
|
||||||
|
8048fe6a536de67d2cf5b0df05d35128f2c68ba8f0dd615831b40430fc76277b validate_graphql_docs.py
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# GraphQL Superpowers 설계 패키지
|
||||||
|
|
||||||
|
이 패키지는 `GraphQL API 실행 플랫폼 심층 리서치`를 구현 기준선으로 변환한 설계서와 실행 계획서다.
|
||||||
|
|
||||||
|
## 문서
|
||||||
|
|
||||||
|
- `docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md`
|
||||||
|
- Stable·Advanced 전체 아키텍처, 공개 계약, 경계, 실패 의미론, 테스트와 지원 등급
|
||||||
|
- 입력 심층 리서치 원문을 추적 부록으로 포함
|
||||||
|
- `docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md`
|
||||||
|
- Stable 구현 Task 1–48
|
||||||
|
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
|
||||||
|
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 1–19
|
||||||
|
- `VALIDATION.md`
|
||||||
|
- 정적 검증 결과와 검증 범위
|
||||||
|
- `validate_graphql_docs.py`
|
||||||
|
- 패키지 내부 문서 재검증 스크립트
|
||||||
|
- `MANIFEST.sha256`
|
||||||
|
- 패키지 파일 무결성 목록
|
||||||
|
|
||||||
|
## 구현 순서
|
||||||
|
|
||||||
|
```text
|
||||||
|
Stable Task 1–48
|
||||||
|
→ Stable Release Gate
|
||||||
|
→ Advanced Task 1–19
|
||||||
|
→ Capability별 Promotion Gate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 명시적 전제
|
||||||
|
|
||||||
|
```text
|
||||||
|
Java 21
|
||||||
|
Gradle Kotlin DSL
|
||||||
|
Spring Boot 4.1 BOM
|
||||||
|
Spring for GraphQL 2.0
|
||||||
|
Boot-managed GraphQL Java v25 계열
|
||||||
|
Stable module root: modules/graphql
|
||||||
|
Advanced module root: modules/graphql-advanced
|
||||||
|
Root package: io.backend.skeleton.graphql
|
||||||
|
```
|
||||||
|
|
||||||
|
실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# GraphQL Superpowers 문서 정적 검증 결과
|
||||||
|
|
||||||
|
- **검증 시각 기준:** 2026-08-12
|
||||||
|
- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개
|
||||||
|
- **검증 명령:** `python3 validate_graphql_docs.py`
|
||||||
|
- **결과:** **PASS**
|
||||||
|
- **실행 검사:** 1,475
|
||||||
|
- **통과:** 1,475
|
||||||
|
- **실패:** 0
|
||||||
|
|
||||||
|
## 문서 규모
|
||||||
|
|
||||||
|
| 문서 | 행 수 | 크기 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes |
|
||||||
|
| Stable 구현 계획서 | 4,560 | 209,041 bytes |
|
||||||
|
| Advanced 확장 계획서 | 1,976 | 105,717 bytes |
|
||||||
|
|
||||||
|
## 계획 구조
|
||||||
|
|
||||||
|
| 항목 | Stable | Advanced |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Task 수 | 48 | 19 |
|
||||||
|
| Create 경로 수 | 227 | 113 |
|
||||||
|
| Task 번호 연속성 | PASS | PASS |
|
||||||
|
| 모든 Task의 `Files`·`Interfaces` | PASS | PASS |
|
||||||
|
| 모든 Task의 Implementation Requirements | PASS | PASS |
|
||||||
|
| 모든 Task의 Step 1–5 | PASS | PASS |
|
||||||
|
| 실패·통과 예상 결과 | PASS | PASS |
|
||||||
|
| Task별 Git commit 명령 | PASS | PASS |
|
||||||
|
| Create 경로 중복 | 없음 | 없음 |
|
||||||
|
| Stable·Advanced 경로 충돌 | 없음 | 없음 |
|
||||||
|
|
||||||
|
## 핵심 계약 검증
|
||||||
|
|
||||||
|
```text
|
||||||
|
SDL-first external contract
|
||||||
|
Single Executable Schema Stable default
|
||||||
|
HTTP POST Stable profile
|
||||||
|
application/graphql-response+json preferred
|
||||||
|
Validation 이후 Field Error·Partial Data는 HTTP 200
|
||||||
|
Draft 294는 Stable에서 제외
|
||||||
|
JPA Entity·MongoDB Document 직접 노출 금지
|
||||||
|
GraphQL Multipart Upload 미지원·Fileserver 사용
|
||||||
|
request-wide database transaction 금지
|
||||||
|
DataLoader request scope
|
||||||
|
Finite Fetch Profile
|
||||||
|
HMAC-signed cursor
|
||||||
|
Mutation idempotency·expected version 분리
|
||||||
|
Parser·shape·complexity·runtime response budget
|
||||||
|
Actor·Field·Object·Tenant authorization
|
||||||
|
Low-cardinality observability
|
||||||
|
Stable/Advanced dependency isolation
|
||||||
|
Persisted Operation·WebSocket·SSE·Federation 분리
|
||||||
|
RSocket·HTTP GET·Incremental Delivery Experimental
|
||||||
|
```
|
||||||
|
|
||||||
|
위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다.
|
||||||
|
|
||||||
|
## 입력 리서치 추적성
|
||||||
|
|
||||||
|
- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다.
|
||||||
|
- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다.
|
||||||
|
- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다.
|
||||||
|
|
||||||
|
## 패키지 검증 항목
|
||||||
|
|
||||||
|
```text
|
||||||
|
문서 파일 존재
|
||||||
|
Markdown code fence 균형
|
||||||
|
Task 1–48 / 1–19 연속성
|
||||||
|
Task별 테스트·명령·commit
|
||||||
|
정확한 Create 경로
|
||||||
|
Placeholder 금지
|
||||||
|
Stable module에 WebSocket·Federation·Persisted Operation 경로 부재
|
||||||
|
Advanced module에 feature flag와 capability 경로 존재
|
||||||
|
금지 API pattern 부재
|
||||||
|
문서 SHA-256 계산
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증 범위의 한계
|
||||||
|
|
||||||
|
현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Gradle configuration·compile
|
||||||
|
Spring Boot ApplicationContext 기동
|
||||||
|
SchemaMappingInspector 실제 결과
|
||||||
|
GraphQlTester HTTP·WebFlux contract
|
||||||
|
JPA·MongoDB statement/query-count integration
|
||||||
|
query bomb·complexity load test
|
||||||
|
Virtual Thread·event-loop blocking test
|
||||||
|
WebSocket·SSE soak test
|
||||||
|
Federation composition·router integration
|
||||||
|
actual Git commit
|
||||||
|
```
|
||||||
|
|
||||||
|
실제 구현에서는 Stable Task 1–48을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 1–19를 시작해야 합니다.
|
||||||
+1976
File diff suppressed because it is too large
Load Diff
+4560
File diff suppressed because it is too large
Load Diff
+2553
File diff suppressed because it is too large
Load Diff
+249
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md"
|
||||||
|
STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md"
|
||||||
|
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md"
|
||||||
|
|
||||||
|
checks: list[tuple[str, bool, str]] = []
|
||||||
|
|
||||||
|
def check(name: str, condition: bool, detail: str = "") -> None:
|
||||||
|
checks.append((name, bool(condition), detail))
|
||||||
|
|
||||||
|
def read(path: Path) -> str:
|
||||||
|
check(f"file exists: {path.name}", path.exists(), str(path))
|
||||||
|
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
|
||||||
|
design = read(DESIGN)
|
||||||
|
stable = read(STABLE)
|
||||||
|
advanced = read(ADVANCED)
|
||||||
|
|
||||||
|
# Basic document integrity
|
||||||
|
check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines())))
|
||||||
|
check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines())))
|
||||||
|
check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines())))
|
||||||
|
for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]:
|
||||||
|
check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```")))
|
||||||
|
for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
|
||||||
|
check(f"{label} no placeholder {marker}", marker.lower() not in text.lower())
|
||||||
|
|
||||||
|
# Design required sections and source traceability
|
||||||
|
required_design_terms = [
|
||||||
|
"# GraphQL API 실행 플랫폼 설계서",
|
||||||
|
"GraphQL Platform owns",
|
||||||
|
"Domain/Application owns",
|
||||||
|
"G1 Standard GraphQL API",
|
||||||
|
"G2 Advanced Execution",
|
||||||
|
"G3 GraphQL Extension",
|
||||||
|
"G4 Admin Plane",
|
||||||
|
"SDL",
|
||||||
|
"September 2025",
|
||||||
|
"application/graphql-response+json",
|
||||||
|
"HTTP `200`",
|
||||||
|
"GraphQlRequestContext",
|
||||||
|
"DataLoader",
|
||||||
|
"GraphQlFetchProfile",
|
||||||
|
"HMAC",
|
||||||
|
"Idempotency",
|
||||||
|
"Partial Data",
|
||||||
|
"Persisted Operation",
|
||||||
|
"Subscription",
|
||||||
|
"Federation",
|
||||||
|
"GraphQL Multipart Upload",
|
||||||
|
"Fileserver",
|
||||||
|
"부록 B. 입력 심층 리서치 원문",
|
||||||
|
"# GraphQL API 실행 플랫폼 심층 리서치",
|
||||||
|
]
|
||||||
|
for term in required_design_terms:
|
||||||
|
check(f"design contains {term}", term in design)
|
||||||
|
|
||||||
|
# Critical design invariants
|
||||||
|
critical_pairs = [
|
||||||
|
("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design),
|
||||||
|
("no draft 294 stable", "294" in design and "Stable" in design),
|
||||||
|
("dataloader request scope", "request" in design.lower() and "DataLoader" in design),
|
||||||
|
("cursor HMAC", "Cursor" in design and "HMAC" in design),
|
||||||
|
("no multipart upload", "Multipart Upload" in design and "Fileserver" in design),
|
||||||
|
("single schema default", "Single Executable Schema" in design),
|
||||||
|
("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()),
|
||||||
|
("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design),
|
||||||
|
]
|
||||||
|
for name, condition in critical_pairs:
|
||||||
|
check(name, condition)
|
||||||
|
|
||||||
|
# Plan headers and global constraints
|
||||||
|
stable_header_terms = [
|
||||||
|
"# GraphQL API 실행 플랫폼 Implementation Plan",
|
||||||
|
"REQUIRED SUB-SKILL",
|
||||||
|
"**Goal:**",
|
||||||
|
"**Architecture:**",
|
||||||
|
"**Tech Stack:**",
|
||||||
|
"## Global Constraints",
|
||||||
|
"Stable Task",
|
||||||
|
]
|
||||||
|
advanced_header_terms = [
|
||||||
|
"# GraphQL Advanced Capability Expansion Implementation Plan",
|
||||||
|
"REQUIRED SUB-SKILL",
|
||||||
|
"backend.graphql.advanced.*",
|
||||||
|
"Stable 구현 계획 Task `1–48`",
|
||||||
|
]
|
||||||
|
for term in stable_header_terms:
|
||||||
|
check(f"stable header contains {term}", term in stable)
|
||||||
|
for term in advanced_header_terms:
|
||||||
|
check(f"advanced header contains {term}", term in advanced)
|
||||||
|
|
||||||
|
# Task sequence and per-task structure
|
||||||
|
def task_sections(text: str) -> list[tuple[int, str]]:
|
||||||
|
matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE))
|
||||||
|
result = []
|
||||||
|
for i, match in enumerate(matches):
|
||||||
|
start = match.start()
|
||||||
|
end = matches[i+1].start() if i+1 < len(matches) else len(text)
|
||||||
|
result.append((int(match.group(1)), text[start:end]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
stable_tasks = task_sections(stable)
|
||||||
|
advanced_tasks = task_sections(advanced)
|
||||||
|
check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks)))
|
||||||
|
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
|
||||||
|
check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49)))
|
||||||
|
check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20)))
|
||||||
|
|
||||||
|
def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None:
|
||||||
|
required = [
|
||||||
|
"**Files:**",
|
||||||
|
"**Interfaces:**",
|
||||||
|
"**Implementation requirements:**",
|
||||||
|
"**Step 1: Write the failing test**",
|
||||||
|
"**Step 2: Run the focused test and verify the failure**",
|
||||||
|
"**Step 3: Implement the smallest complete production contract**",
|
||||||
|
"**Step 4: Run the focused test and the owning suite**",
|
||||||
|
"**Step 5: Commit the independently reviewable change**",
|
||||||
|
"Expected: FAIL",
|
||||||
|
"Expected: PASS",
|
||||||
|
"git commit -m",
|
||||||
|
]
|
||||||
|
for number, section in tasks:
|
||||||
|
for token in required:
|
||||||
|
check(f"{label} task {number} contains {token}", token in section)
|
||||||
|
check(f"{label} task {number} has test path", "- Test: `" in section)
|
||||||
|
check(f"{label} task {number} has production file", "- Create: `" in section)
|
||||||
|
check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0)
|
||||||
|
check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section)
|
||||||
|
|
||||||
|
validate_tasks("stable", stable_tasks)
|
||||||
|
validate_tasks("advanced", advanced_tasks)
|
||||||
|
|
||||||
|
# Create paths
|
||||||
|
def create_paths(text: str) -> list[str]:
|
||||||
|
return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE)
|
||||||
|
|
||||||
|
stable_paths = create_paths(stable)
|
||||||
|
advanced_paths = create_paths(advanced)
|
||||||
|
check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths)))
|
||||||
|
check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths)))
|
||||||
|
check("stable create paths unique", len(stable_paths) == len(set(stable_paths)))
|
||||||
|
check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths)))
|
||||||
|
check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths))
|
||||||
|
for index, path in enumerate(stable_paths, 1):
|
||||||
|
check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/")))
|
||||||
|
for index, path in enumerate(advanced_paths, 1):
|
||||||
|
check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/"))
|
||||||
|
|
||||||
|
# Stable/Advanced separation
|
||||||
|
for forbidden in [
|
||||||
|
"modules/graphql/graphql-websocket/",
|
||||||
|
"modules/graphql/graphql-federation/",
|
||||||
|
"modules/graphql/graphql-persisted-operation/",
|
||||||
|
"modules/graphql/graphql-rsocket/",
|
||||||
|
]:
|
||||||
|
check(f"stable excludes {forbidden}", forbidden not in stable)
|
||||||
|
|
||||||
|
for required in [
|
||||||
|
"modules/graphql-advanced/graphql-persisted-operation/",
|
||||||
|
"modules/graphql-advanced/graphql-websocket/",
|
||||||
|
"modules/graphql-advanced/graphql-subscription/",
|
||||||
|
"modules/graphql-advanced/graphql-federation/",
|
||||||
|
"modules/graphql-advanced/graphql-rsocket/",
|
||||||
|
]:
|
||||||
|
check(f"advanced includes {required}", required in advanced)
|
||||||
|
|
||||||
|
# Stable coverage
|
||||||
|
stable_required_terms = [
|
||||||
|
"GraphQlRequestContext",
|
||||||
|
"GraphQlClientPolicy",
|
||||||
|
"GraphQlSchemaContract",
|
||||||
|
"SchemaMappingInspector",
|
||||||
|
"@oneOf",
|
||||||
|
"GraphQlHttpProfile",
|
||||||
|
"application/graphql-response+json",
|
||||||
|
"GraphQlExecutionProfile",
|
||||||
|
"GraphQlWireError",
|
||||||
|
"GraphQlTenantIsolationPolicy",
|
||||||
|
"GraphQlParserLimits",
|
||||||
|
"GraphQlComplexityCalculator",
|
||||||
|
"GraphQlRuntimeBudget",
|
||||||
|
"GraphQlPreparsedCacheKey",
|
||||||
|
"GraphQlBatchPolicy",
|
||||||
|
"GraphQlFetchProfile",
|
||||||
|
"HmacGraphQlCursorCodec",
|
||||||
|
"GraphQlConnection",
|
||||||
|
"GraphQlMutationIdempotencyContext",
|
||||||
|
"GraphQlMetricCardinalityPolicy",
|
||||||
|
"GraphQlPlatformStartupValidator",
|
||||||
|
"GraphQlReleaseGate",
|
||||||
|
]
|
||||||
|
for term in stable_required_terms:
|
||||||
|
check(f"stable coverage {term}", term in stable)
|
||||||
|
|
||||||
|
advanced_required_terms = [
|
||||||
|
"GraphQlPersistedOperation",
|
||||||
|
"GraphQlWebSocketProtocol",
|
||||||
|
"GraphQlSubscriptionBufferPolicy",
|
||||||
|
"GraphQlSubscriptionOrderingProfile",
|
||||||
|
"GraphQlSseConnectionPolicy",
|
||||||
|
"GraphQlReplayPosition",
|
||||||
|
"GraphQlDataLoaderDependencyGraph",
|
||||||
|
"GraphQlFederationEntityKey",
|
||||||
|
"GraphQlFederationCompositionGate",
|
||||||
|
"GraphQlGeneratedSourceBoundary",
|
||||||
|
"GraphQlRepositoryAllowlist",
|
||||||
|
"GraphQlRSocketRoutePolicy",
|
||||||
|
"GraphQlHttpGetOperationPolicy",
|
||||||
|
"GraphQlIncrementalCompatibilityGate",
|
||||||
|
"GraphQlAdvancedReleaseGate",
|
||||||
|
]
|
||||||
|
for term in advanced_required_terms:
|
||||||
|
check(f"advanced coverage {term}", term in advanced)
|
||||||
|
|
||||||
|
# Prohibited API patterns
|
||||||
|
prohibited_patterns = [
|
||||||
|
(r"interface\s+GenericGraphQlRepository", "no generic graphql repository"),
|
||||||
|
(r"public\s+.*\bEntityManager\b", "no public entity manager"),
|
||||||
|
(r"public\s+.*\bMongoTemplate\b", "no public mongo template"),
|
||||||
|
(r"scalar\s+Upload\b", "no upload scalar declaration"),
|
||||||
|
(r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"),
|
||||||
|
]
|
||||||
|
for pattern, name in prohibited_patterns:
|
||||||
|
check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None)
|
||||||
|
|
||||||
|
# File hashes can be printed for package evidence
|
||||||
|
for path in [DESIGN, STABLE, ADVANCED]:
|
||||||
|
if path.exists():
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
check(f"sha256 computed: {path.name}", len(digest) == 64, digest)
|
||||||
|
|
||||||
|
failed = [(n, d) for n, ok, d in checks if not ok]
|
||||||
|
print(f"CHECKS={len(checks)}")
|
||||||
|
print(f"PASSED={len(checks)-len(failed)}")
|
||||||
|
print(f"FAILED={len(failed)}")
|
||||||
|
for name, detail in failed:
|
||||||
|
print(f"FAIL: {name}" + (f" :: {detail}" if detail else ""))
|
||||||
|
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# HTTP Client Platform — Configuration Reference
|
||||||
|
|
||||||
|
Every outbound call resolves exactly one **Named Client Profile**. The whole capability lives under
|
||||||
|
the `app.httpclient` prefix: profiles under `app.httpclient.clients[N]`, Dynamic Target policies
|
||||||
|
under `app.httpclient.dynamic-targets[N]`.
|
||||||
|
|
||||||
|
Design §30.1 forbids a production profile from inheriting large framework defaults. Anything a
|
||||||
|
production deployment must decide has either no default or an unusable one, and
|
||||||
|
`HttpClientStartupValidator` fails the context rather than guessing.
|
||||||
|
|
||||||
|
## The master switch
|
||||||
|
|
||||||
|
| Property | Type | Default | Environment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app.httpclient.enabled` | boolean | `false` | `APP_HTTPCLIENT_ENABLED` |
|
||||||
|
|
||||||
|
Off is the shipped state and it is a structural one. `HttpClientPlatformAutoConfiguration` lives in
|
||||||
|
a package the composition root's component scan excludes, so while the switch is absent or false the
|
||||||
|
class is never processed and neither is anything it imports: no property is bound, and no transport
|
||||||
|
provider, connection pool, TLS context, credential, thread, gateway or actuator endpoint exists. A
|
||||||
|
malformed HTTP client setting cannot fail the startup of a deployment that never wanted outbound
|
||||||
|
HTTP.
|
||||||
|
|
||||||
|
Anything that is not exactly `true` — `yes`, `1`, blank — leaves the platform off. Turning it on
|
||||||
|
with no client declared is a startup failure carrying `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`: a
|
||||||
|
platform with nothing to call still holds transport providers and gateways no caller can reach.
|
||||||
|
|
||||||
|
## Declaring clients from the environment
|
||||||
|
|
||||||
|
Clients are an indexed list carrying their own `name`, not a map keyed by name. A map key becomes a
|
||||||
|
segment of the environment variable and the relaxed binder normalises it, so `payment-api` and
|
||||||
|
`payment_api` would arrive as one entry with nothing said about the one that was lost. Both a
|
||||||
|
duplicate name and a name that collides once normalised fail startup.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
APP_HTTPCLIENT_ENABLED=true
|
||||||
|
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_NAME=payment
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_BASE_URL=https://payment.example
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0=payment.example
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0=443
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES=1048576
|
||||||
|
APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID=payment
|
||||||
|
|
||||||
|
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME=webhook
|
||||||
|
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0=https
|
||||||
|
```
|
||||||
|
|
||||||
|
`docs/httpclient/env-fields.yaml` is the registry of accepted variable names. It is
|
||||||
|
derived from the settings record and held to it in both directions, and the platform refuses to
|
||||||
|
start on an `APP_HTTPCLIENT_` variable that is not in it — so
|
||||||
|
`APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL` fails startup instead of silently leaving the client
|
||||||
|
on its default budget. Unknown keys supplied through a configuration file rather than the
|
||||||
|
environment are refused by strict binding for the same reason.
|
||||||
|
|
||||||
|
Only `APP_HTTPCLIENT_ENABLED` appears in `src/.env` and `docs/registries/env-keys.yaml`. It is the
|
||||||
|
one key with a deployment-independent value; templating an indexed client in `application.yml` would
|
||||||
|
materialise a nameless client in every deployment, which the aggregate validation refuses.
|
||||||
|
|
||||||
|
## `app.httpclient.clients[N]`
|
||||||
|
|
||||||
|
| Property | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | string | — | Required, unique, and distinct from every other name once normalised for the environment |
|
||||||
|
| `mode` | `TRUSTED` \| `DYNAMIC` | `TRUSTED` | A dynamic profile may not carry a default credential |
|
||||||
|
| `base-url` | URI | — | Required for a trusted profile; no userinfo, no query |
|
||||||
|
| `allowed-hosts` | list | empty | Required in production |
|
||||||
|
| `allowed-ports` | list | empty | Compared against the effective port |
|
||||||
|
| `api` | `REST_CLIENT` \| `WEB_CLIENT` | `REST_CLIENT` | Decides blocking or reactive runtime |
|
||||||
|
| `transport` | `APACHE` \| `JDK` \| `REACTOR_NETTY` \| `JETTY` \| `SIMPLE` | `APACHE` | `SIMPLE` is rejected in production |
|
||||||
|
| `protocols` | list | `HTTP_1_1` | The default transport is Apache, whose classic client is HTTP/1.1 only; a profile that wants HTTP/2 declares it together with a transport that can deliver it. `HTTP_3` requires the experimental acknowledgement |
|
||||||
|
| `experimental-acknowledgement` | string | — | Must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||||
|
|
||||||
|
### `pool`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `max-total-connections` | `50` | Socket ceiling for the runtime |
|
||||||
|
| `max-connections-per-route` | `25` | Per-upstream ceiling |
|
||||||
|
| `max-pending-acquires` | `100` | Waiting-request memory ceiling |
|
||||||
|
| `pending-acquire-timeout` | `200ms` | Pool or stream wait ceiling |
|
||||||
|
| `max-idle-time` | `30s` | Idle eviction |
|
||||||
|
| `max-life-time` | `5m` | Picks up DNS, load-balancer, and certificate changes |
|
||||||
|
| `validate-after-inactivity` | `5s` | Stale and half-open detection |
|
||||||
|
| `eviction-interval` | `15s` | Background cleanup |
|
||||||
|
| `shutdown-timeout` | `5s` | Drain deadline before forced close |
|
||||||
|
| `requires-route-pool` | `false` | Set when route-scoped limits are mandatory; the JDK transport then refuses the profile |
|
||||||
|
| `requires-bounded-pending-queue` | `false` | Same, for a bounded pending queue |
|
||||||
|
|
||||||
|
### `timeout`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `dns` | `300ms` | Hostname resolution |
|
||||||
|
| `connect` | `500ms` | Socket connect |
|
||||||
|
| `tls-handshake` | `1s` | TLS and ALPN |
|
||||||
|
| `proxy-connect` | `500ms` | Proxy socket or CONNECT |
|
||||||
|
| `request-write-idle` | `1s` | No progress writing the request |
|
||||||
|
| `response-header` | `2s` | Until final response headers |
|
||||||
|
| `read-idle` | `3s` | Between response chunks |
|
||||||
|
| `total-call` | `4s` | The whole logical call, including retry backoff |
|
||||||
|
| `streaming-idle` | `30s` | Silence on a long-lived stream |
|
||||||
|
|
||||||
|
`total-call` must not be shorter than `connect` or `response-header`; the validator emits
|
||||||
|
`INVALID_TIMEOUT_BUDGET` otherwise.
|
||||||
|
|
||||||
|
### `redirect`, `request`, `response`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `redirect.enabled` | `false` | Engine redirect handling is always off; the platform follows hops itself |
|
||||||
|
| `redirect.max-hops` | `0` | Enabling redirects with zero hops is a configuration error |
|
||||||
|
| `redirect.allow-cross-origin` | `false` | When enabled, credentials are stripped on the hop |
|
||||||
|
| `request.max-body-bytes` | `0` | Required in production |
|
||||||
|
| `request.compression` | `false` | |
|
||||||
|
| `response.max-wire-bytes` | `5242880` | Bytes on the wire |
|
||||||
|
| `response.max-decoded-bytes` | `10485760` | Bytes after decoding; hard maximum is 64 MiB |
|
||||||
|
| `response.allowed-content-types` | JSON + problem+json | Empty means "any" |
|
||||||
|
|
||||||
|
### `authentication`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` | `NONE` | One of the design §20.1 methods |
|
||||||
|
| `registration-id` | — | Required for OAuth2 |
|
||||||
|
| `scopes` | empty | Part of the token cache key |
|
||||||
|
| `audience` | — | Part of the token cache key |
|
||||||
|
| `header-name` | — | Required for `API_KEY_HEADER`; must be on the allowlist |
|
||||||
|
| `secret-reference` | — | Resolved by the deployment's secret loader, never a literal |
|
||||||
|
|
||||||
|
### `retry`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `policy` | `none` | Named policy for reporting |
|
||||||
|
| `max-attempts` | `1` | Attempts, not retries |
|
||||||
|
| `base-backoff` | `50ms` | |
|
||||||
|
| `max-backoff` | `200ms` | |
|
||||||
|
| `jitter` | `FULL` | `NONE` \| `FULL` \| `DECORRELATED` |
|
||||||
|
| `retry-after` | `HONOR` | `HONOR` \| `IGNORE` \| `CAP` |
|
||||||
|
| `budget` | — | Shared token bucket name |
|
||||||
|
|
||||||
|
### `tls`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `profile-id` | — | Required in production; the only TLS identifier the actuator exposes |
|
||||||
|
| `protocols` | `TLSv1.3, TLSv1.2` | Anything else is rejected |
|
||||||
|
| `hostname-verification` | `true` | Setting it false fails startup |
|
||||||
|
| `trust-all` | `false` | Exists only so the unsafe intent is rejectable; nothing acts on `true` |
|
||||||
|
| `allow-plain-http` | `false` | Plaintext fallback fails startup in production |
|
||||||
|
| `trust-material-reference` | — | Custom CA, resolved by the secret loader |
|
||||||
|
| `key-material-reference` | — | Client certificate for mTLS |
|
||||||
|
|
||||||
|
### `proxy` and `observability`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `proxy.enabled` | `false` | |
|
||||||
|
| `proxy.host` / `proxy.port` / `proxy.type` | — / `0` / `HTTP` | |
|
||||||
|
| `proxy.credential-provider` | — | Proxy authentication is separate from target authentication |
|
||||||
|
| `proxy.connect-timeout` | `500ms` | Recorded as its own metric |
|
||||||
|
| `proxy.import-ambient-no-proxy` | `false` | Ambient `NO_PROXY` never widens a validated profile |
|
||||||
|
| `observability.operation-name-required` | `true` | |
|
||||||
|
| `observability.full-url-recording` | `false` | |
|
||||||
|
| `observability.body-logging` | `false` | |
|
||||||
|
|
||||||
|
## `app.httpclient.dynamic-targets[N]`
|
||||||
|
|
||||||
|
| Property | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | — | Required, unique, and subject to the same normalisation rule as a client name |
|
||||||
|
| `allowed-schemes` | `https` | |
|
||||||
|
| `allowed-ports` | `443` | |
|
||||||
|
| `allowed-host-suffixes` | empty | |
|
||||||
|
| `allowed-hosts` | empty | Empty means "any host that survives address validation" |
|
||||||
|
| `max-redirect-hops` | `0` | Each hop repeats the full validation flow |
|
||||||
|
| `trace-propagation` | `false` | Off by default for dynamic targets |
|
||||||
|
| `blocked-cidrs` | empty | Organisation-defined internal ranges |
|
||||||
|
|
||||||
|
## Startup violation codes
|
||||||
|
|
||||||
|
`TRUSTED_BASE_URL_REQUIRED`, `BASE_URL_USERINFO_FORBIDDEN`, `BASE_URL_QUERY_FORBIDDEN`,
|
||||||
|
`PLAINTEXT_PRODUCTION_TARGET`, `ALLOWED_HOST_MISMATCH`, `ALLOWED_PORT_MISMATCH`,
|
||||||
|
`REDIRECT_POLICY_INVALID`, `REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED`,
|
||||||
|
`INVALID_TIMEOUT_BUDGET`, `RESPONSE_HARD_MAXIMUM_EXCEEDED`, `PRODUCTION_SIMPLE_FACTORY_FORBIDDEN`,
|
||||||
|
`JDK_FINE_GRAINED_POOL_UNSUPPORTED`, `HTTP3_STABLE_FORBIDDEN`,
|
||||||
|
`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`, `DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN`,
|
||||||
|
`OAUTH2_REGISTRATION_REQUIRED`, `API_KEY_HEADER_NAME_REQUIRED`, `TRUST_ALL_FORBIDDEN`,
|
||||||
|
`HOSTNAME_VERIFICATION_REQUIRED`, `PLAINTEXT_FALLBACK_FORBIDDEN`, `TLS_PROTOCOL_FORBIDDEN`,
|
||||||
|
`RETRY_BACKOFF_REQUIRED`, `MISSING_PRODUCTION_SETTING`, `DUPLICATE_CLIENT_NAME`,
|
||||||
|
`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, `DYNAMIC_BASE_URL_REQUIRED`,
|
||||||
|
`DYNAMIC_TARGET_PROXY_UNSUPPORTED`, `REACTIVE_AUTHENTICATION_UNSUPPORTED`,
|
||||||
|
`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`, `POOL_ROUTE_EXCEEDS_TOTAL`,
|
||||||
|
`TLS_PROTOCOL_SET_REQUIRED`, `REACTIVE_REDIRECT_UNSUPPORTED`,
|
||||||
|
`RETRY_POLICY_CONTRADICTS_ATTEMPTS`, `FULL_URL_RECORDING_FORBIDDEN`, `BODY_LOGGING_FORBIDDEN`,
|
||||||
|
`DNS_TIMEOUT_UNSUPPORTED`, `PROXY_CREDENTIAL_UNSUPPORTED`, `PROXY_AMBIENT_NO_PROXY_UNSUPPORTED`.
|
||||||
|
|
||||||
|
The last three name settings the platform binds but cannot yet honour. Neither the Apache classic
|
||||||
|
client nor the JDK client exposes a DNS-resolution timeout, and no proxy-credential path exists, so
|
||||||
|
a non-default value is refused rather than accepted and ignored. Leaving the defaults alone is
|
||||||
|
unaffected — only a deliberate, unmet request fails.
|
||||||
|
|
||||||
|
Three of these are about a guarantee that used to be silently unmet rather than refused:
|
||||||
|
|
||||||
|
- `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` — declaring `protocols: [HTTP_2]` alone states that HTTP/2
|
||||||
|
is required. Only `REACTOR_NETTY` can be configured to offer H2 and nothing else; the JDK client
|
||||||
|
treats it as a preference and negotiates HTTP/1.1, and Apache's classic client is HTTP/1.1 only.
|
||||||
|
- `POOL_ROUTE_EXCEEDS_TOTAL` — a per-route ceiling above the total is incoherent, and on Reactor,
|
||||||
|
where the per-route knob is the only one that exists, it silently becomes the effective limit.
|
||||||
|
- `TLS_PROTOCOL_SET_REQUIRED` — an empty `tls.protocols` used to pass and then let the JVM choose,
|
||||||
|
so emptying the list to "tighten" a profile loosened it.
|
||||||
|
- `REACTIVE_REDIRECT_UNSUPPORTED` — engine redirect following is disabled on every transport and
|
||||||
|
only the blocking stack has a coordinator that follows hops with per-hop re-validation. A
|
||||||
|
`WEB_CLIENT` profile with `redirect.enabled=true` did not follow redirects; the caller received the
|
||||||
|
3xx as an ordinary response. Refused until the reactive coordinator exists.
|
||||||
|
- `RETRY_POLICY_CONTRADICTS_ATTEMPTS` — `retry.policy` was read by nothing on the execution path, so
|
||||||
|
the actuator could report `none` for a profile retrying three times. The two settings must now
|
||||||
|
agree: `policy: none` requires `max-attempts: 1`, and any other policy requires more than one.
|
||||||
|
- `FULL_URL_RECORDING_FORBIDDEN` / `BODY_LOGGING_FORBIDDEN` — both settings were bindable and inert.
|
||||||
|
Recording an expanded URL puts path identifiers and query strings into unbounded metric tags;
|
||||||
|
recording bodies puts someone else's data into logs. Representable so the intent is rejectable,
|
||||||
|
refused under a production profile.
|
||||||
|
|
||||||
|
`DYNAMIC_TARGET_PROXY_UNSUPPORTED` is worth spelling out: a forward proxy resolves the hostname on
|
||||||
|
its own side, so the addresses this platform validated and pinned are not the addresses the
|
||||||
|
connection reaches. The SSRF defence would be present, correct, and bypassed — so the combination is
|
||||||
|
refused rather than served with a guarantee it cannot keep.
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# HTTP Client platform — Java field path to environment variable template.
|
||||||
|
#
|
||||||
|
# The SSOT is HttpClientPlatformSettings. HttpClientEnvironmentKeys derives this list from the
|
||||||
|
# record tree at runtime, HttpClientPlatformEnvManifestTest fails when the two disagree in either
|
||||||
|
# direction, and the platform refuses to start on an APP_HTTPCLIENT_ variable that is not here. So a
|
||||||
|
# field added with no entry, an entry whose field was renamed, and a misspelled variable in a
|
||||||
|
# deployment are all failures rather than silence.
|
||||||
|
#
|
||||||
|
# `N` and `M` are list indices, not literals: `N` for the outermost list, `M` for a list inside it.
|
||||||
|
# `app.httpclient.clients[N].base-url` is set as APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first
|
||||||
|
# client, and `clients[N].allowed-hosts[M]` as APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0.
|
||||||
|
#
|
||||||
|
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
|
||||||
|
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
|
||||||
|
# three-way :app-bootstrap:verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||||
|
# in the environment — templating an indexed client in application.yml would materialise a nameless
|
||||||
|
# client in every deployment, which the settings' aggregate validation refuses.
|
||||||
|
#
|
||||||
|
# This file lives beside the HTTP Client documentation rather than in docs/registries, which is a
|
||||||
|
# fail-closed catalog of exactly eight contract registries with a fixed row schema
|
||||||
|
# (owner_branch/compatibility_impact/required_test per row). A field-to-variable mapping does not
|
||||||
|
# have that shape, and admitting it would have meant loosening a gate rather than satisfying one.
|
||||||
|
#
|
||||||
|
# Secrets are referenced, never carried: authentication.secret-reference, tls.*-material-reference
|
||||||
|
# and proxy.credential-provider name material that a secret backend resolves. Putting the material
|
||||||
|
# itself in one of these variables defeats the indirection they exist for.
|
||||||
|
fields:
|
||||||
|
- field: enabled
|
||||||
|
env: APP_HTTPCLIENT_ENABLED
|
||||||
|
- field: clients[N].name
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_NAME
|
||||||
|
- field: clients[N].mode
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_MODE
|
||||||
|
- field: clients[N].base-url
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL
|
||||||
|
- field: clients[N].allowed-hosts[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_HOSTS_M
|
||||||
|
- field: clients[N].allowed-ports[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_PORTS_M
|
||||||
|
- field: clients[N].api
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_API
|
||||||
|
- field: clients[N].transport
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TRANSPORT
|
||||||
|
- field: clients[N].protocols[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROTOCOLS_M
|
||||||
|
- field: clients[N].pool.max-total-connections
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_TOTAL_CONNECTIONS
|
||||||
|
- field: clients[N].pool.max-connections-per-route
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_CONNECTIONS_PER_ROUTE
|
||||||
|
- field: clients[N].pool.max-pending-acquires
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_PENDING_ACQUIRES
|
||||||
|
- field: clients[N].pool.pending-acquire-timeout
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_PENDING_ACQUIRE_TIMEOUT
|
||||||
|
- field: clients[N].pool.max-idle-time
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_IDLE_TIME
|
||||||
|
- field: clients[N].pool.max-life-time
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_LIFE_TIME
|
||||||
|
- field: clients[N].pool.validate-after-inactivity
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_VALIDATE_AFTER_INACTIVITY
|
||||||
|
- field: clients[N].pool.eviction-interval
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_EVICTION_INTERVAL
|
||||||
|
- field: clients[N].pool.shutdown-timeout
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_SHUTDOWN_TIMEOUT
|
||||||
|
- field: clients[N].pool.requires-route-pool
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_ROUTE_POOL
|
||||||
|
- field: clients[N].pool.requires-bounded-pending-queue
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_BOUNDED_PENDING_QUEUE
|
||||||
|
- field: clients[N].timeout.dns
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_DNS
|
||||||
|
- field: clients[N].timeout.connect
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_CONNECT
|
||||||
|
- field: clients[N].timeout.tls-handshake
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TLS_HANDSHAKE
|
||||||
|
- field: clients[N].timeout.proxy-connect
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_PROXY_CONNECT
|
||||||
|
- field: clients[N].timeout.request-write-idle
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_REQUEST_WRITE_IDLE
|
||||||
|
- field: clients[N].timeout.response-header
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_RESPONSE_HEADER
|
||||||
|
- field: clients[N].timeout.read-idle
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_READ_IDLE
|
||||||
|
- field: clients[N].timeout.total-call
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TOTAL_CALL
|
||||||
|
- field: clients[N].timeout.streaming-idle
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_STREAMING_IDLE
|
||||||
|
- field: clients[N].redirect.enabled
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ENABLED
|
||||||
|
- field: clients[N].redirect.max-hops
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_MAX_HOPS
|
||||||
|
- field: clients[N].redirect.allow-cross-origin
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ALLOW_CROSS_ORIGIN
|
||||||
|
- field: clients[N].request.max-body-bytes
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_MAX_BODY_BYTES
|
||||||
|
- field: clients[N].request.compression
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_COMPRESSION
|
||||||
|
- field: clients[N].response.max-wire-bytes
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_WIRE_BYTES
|
||||||
|
- field: clients[N].response.max-decoded-bytes
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_DECODED_BYTES
|
||||||
|
- field: clients[N].response.allowed-content-types[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_ALLOWED_CONTENT_TYPES_M
|
||||||
|
- field: clients[N].authentication.type
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_TYPE
|
||||||
|
- field: clients[N].authentication.registration-id
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_REGISTRATION_ID
|
||||||
|
- field: clients[N].authentication.scopes[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SCOPES_M
|
||||||
|
- field: clients[N].authentication.audience
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_AUDIENCE
|
||||||
|
- field: clients[N].authentication.header-name
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_HEADER_NAME
|
||||||
|
- field: clients[N].authentication.secret-reference
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SECRET_REFERENCE
|
||||||
|
- field: clients[N].retry.policy
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_POLICY
|
||||||
|
- field: clients[N].retry.max-attempts
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_ATTEMPTS
|
||||||
|
- field: clients[N].retry.base-backoff
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BASE_BACKOFF
|
||||||
|
- field: clients[N].retry.max-backoff
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_BACKOFF
|
||||||
|
- field: clients[N].retry.jitter
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_JITTER
|
||||||
|
- field: clients[N].retry.retry-after
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_RETRY_AFTER
|
||||||
|
- field: clients[N].retry.budget
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BUDGET
|
||||||
|
- field: clients[N].observability.operation-name-required
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_OPERATION_NAME_REQUIRED
|
||||||
|
- field: clients[N].observability.full-url-recording
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_FULL_URL_RECORDING
|
||||||
|
- field: clients[N].observability.body-logging
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_BODY_LOGGING
|
||||||
|
- field: clients[N].tls.profile-id
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROFILE_ID
|
||||||
|
- field: clients[N].tls.protocols[M]
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROTOCOLS_M
|
||||||
|
- field: clients[N].tls.hostname-verification
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_HOSTNAME_VERIFICATION
|
||||||
|
- field: clients[N].tls.trust-all
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_ALL
|
||||||
|
- field: clients[N].tls.allow-plain-http
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_ALLOW_PLAIN_HTTP
|
||||||
|
- field: clients[N].tls.trust-material-reference
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_MATERIAL_REFERENCE
|
||||||
|
- field: clients[N].tls.key-material-reference
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_TLS_KEY_MATERIAL_REFERENCE
|
||||||
|
- field: clients[N].proxy.enabled
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_ENABLED
|
||||||
|
- field: clients[N].proxy.host
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_HOST
|
||||||
|
- field: clients[N].proxy.port
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_PORT
|
||||||
|
- field: clients[N].proxy.type
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_TYPE
|
||||||
|
- field: clients[N].proxy.credential-provider
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CREDENTIAL_PROVIDER
|
||||||
|
- field: clients[N].proxy.connect-timeout
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CONNECT_TIMEOUT
|
||||||
|
- field: clients[N].proxy.import-ambient-no-proxy
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_IMPORT_AMBIENT_NO_PROXY
|
||||||
|
- field: clients[N].experimental-acknowledgement
|
||||||
|
env: APP_HTTPCLIENT_CLIENTS_N_EXPERIMENTAL_ACKNOWLEDGEMENT
|
||||||
|
- field: dynamic-targets[N].name
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_NAME
|
||||||
|
- field: dynamic-targets[N].allowed-schemes[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_SCHEMES_M
|
||||||
|
- field: dynamic-targets[N].allowed-ports[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_PORTS_M
|
||||||
|
- field: dynamic-targets[N].allowed-host-suffixes[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOST_SUFFIXES_M
|
||||||
|
- field: dynamic-targets[N].allowed-hosts[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOSTS_M
|
||||||
|
- field: dynamic-targets[N].max-redirect-hops
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_MAX_REDIRECT_HOPS
|
||||||
|
- field: dynamic-targets[N].trace-propagation
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_TRACE_PROPAGATION
|
||||||
|
- field: dynamic-targets[N].blocked-cidrs[M]
|
||||||
|
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_BLOCKED_CIDRS_M
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Migrating from `RestTemplate`
|
||||||
|
|
||||||
|
`RestTemplate` is permitted only inside `…httpclient.migration`; `RestTemplateBoundaryTest` enforces
|
||||||
|
that. New retry, Dynamic Target, and HTTP/3 capabilities are deliberately unreachable from the
|
||||||
|
migration path — a caller that wants them moves to a Named Client Profile.
|
||||||
|
|
||||||
|
## 1. Audit before changing anything
|
||||||
|
|
||||||
|
```java
|
||||||
|
RestTemplateInventory inventory = new RestTemplateInventoryScanner().scan(existingTemplate);
|
||||||
|
```
|
||||||
|
|
||||||
|
The inventory reports the request factory, message converters, interceptors, error handler, and URI
|
||||||
|
template handler, plus findings:
|
||||||
|
|
||||||
|
| Code | Severity | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `SIMPLE_REQUEST_FACTORY` | blocking | no connection pool; unsupported in production |
|
||||||
|
| `NO_MESSAGE_CONVERTERS` | blocking | the template cannot encode or decode a body |
|
||||||
|
| `NO_INTERCEPTORS` | warning | confirm where correlation and timeouts are applied |
|
||||||
|
| `TIMEOUTS_NOT_INTROSPECTABLE` | informational | declare timeouts explicitly on the target profile |
|
||||||
|
|
||||||
|
## 2. Bridge without changing behaviour
|
||||||
|
|
||||||
|
```java
|
||||||
|
RestClient client = new RestTemplateToRestClientAdapter().adaptChecked(existingTemplate);
|
||||||
|
```
|
||||||
|
|
||||||
|
`adaptChecked` refuses to migrate a template with a blocking finding. The bridge carries the
|
||||||
|
existing converters, interceptors, error handler, and URI handler across, so this step changes the
|
||||||
|
API and nothing else.
|
||||||
|
|
||||||
|
## 3. Move to a Named Client Profile
|
||||||
|
|
||||||
|
Turn the platform on with `APP_HTTPCLIENT_ENABLED=true` — it ships off, and while it is off none of
|
||||||
|
the settings below are bound — then declare the upstream as `app.httpclient.clients[N]` with its
|
||||||
|
`name` and an explicit base URL, transport, timeouts, pool, body limits, authentication, retry
|
||||||
|
policy, redirect policy, and TLS profile. Startup validation will tell you exactly which of those is
|
||||||
|
missing. See `docs/httpclient/configuration-reference.md` for the environment form.
|
||||||
|
|
||||||
|
## 4. Move to a typed client
|
||||||
|
|
||||||
|
```java
|
||||||
|
@HttpClientProfile("payment")
|
||||||
|
@HttpExchange("/payments")
|
||||||
|
public interface PaymentClient {
|
||||||
|
|
||||||
|
@PostExchange
|
||||||
|
@HttpOperationPolicy(
|
||||||
|
name = "create-payment",
|
||||||
|
idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED,
|
||||||
|
retryPolicy = "payment-write")
|
||||||
|
PaymentResponse create(
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The interface fails startup validation unless it declares a profile, gives every method a stable
|
||||||
|
operation name and an explicit idempotency, supplies a key parameter when the operation requires
|
||||||
|
one, keeps a single execution model, and does not enable retry on a non-idempotent write.
|
||||||
|
|
||||||
|
## 5. Retire the template
|
||||||
|
|
||||||
|
Once no production package references `RestTemplate`, `RestTemplateBoundaryTest` keeps it that way.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# HTTP Client Platform — Operations Runbook
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `http.client.requests` | Physical attempt timer (Spring standard name, kept deliberately) |
|
||||||
|
| `http.client.logical.calls` | User-visible logical call timer |
|
||||||
|
| `http.client.attempts` | Attempt counter |
|
||||||
|
| `http.client.retry.count` | Retries by reason |
|
||||||
|
| `http.client.retry.exhausted` | Retry budget exhausted |
|
||||||
|
| `http.client.ambiguous` | Ambiguous outcomes |
|
||||||
|
| `http.client.timeout` | Timeouts by stage |
|
||||||
|
| `http.client.request.bytes` | Request wire bytes |
|
||||||
|
| `http.client.response.bytes` | Response bytes |
|
||||||
|
| `http.client.active` | In-flight attempts |
|
||||||
|
| `http.client.pool.connections` | Leased and available connections |
|
||||||
|
| `http.client.pool.pending` | Pool waiters |
|
||||||
|
| `http.client.pool.acquire.duration` | Pool wait time |
|
||||||
|
| `http.client.dns.duration` | DNS time |
|
||||||
|
| `http.client.connect.duration` | Connect time |
|
||||||
|
| `http.client.tls.duration` | TLS time |
|
||||||
|
| `http.client.circuit.state` | Circuit state |
|
||||||
|
| `http.client.bulkhead.rejected` | Bulkhead rejections |
|
||||||
|
| `http.client.rate_limit.rejected` | Local rate-limit rejections |
|
||||||
|
| `http.client.oauth.refresh` | Token refresh outcomes |
|
||||||
|
| `http.client.ssrf.rejected` | Dynamic target rejections |
|
||||||
|
|
||||||
|
`http.client.requests` counts attempts and `http.client.logical.calls` counts user calls. When they
|
||||||
|
diverge, retries are absorbing failures — which is the first thing to look at during an incident.
|
||||||
|
|
||||||
|
## Reading an incident
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| logical calls fine, attempts spiking | upstream degraded, retries absorbing it | `http.client.retry.count` by reason |
|
||||||
|
| `http.client.ambiguous` non-zero | non-idempotent writes reaching `SENT_NO_RESPONSE` | reconcile with the upstream; consider an idempotency key |
|
||||||
|
| pool pending climbing | pool too small or upstream slow | `http.client.pool.acquire.duration`, `pool.connections` |
|
||||||
|
| circuit open | sustained upstream failure | `http.client.circuit.state`; local rejections do not open it |
|
||||||
|
| `http.client.ssrf.rejected` non-zero | a caller is submitting internal URLs | Dynamic Target policy and audit trail |
|
||||||
|
|
||||||
|
## Actuator
|
||||||
|
|
||||||
|
`GET /actuator/httpclients` reports profile name, runtime generation, state, transport, API,
|
||||||
|
protocols, active leases, pool ceiling, credential type, TLS profile id, redirect flag, retry policy,
|
||||||
|
and capability warnings. Base URL, credentials, trust store paths, and resolved IPs are deliberately
|
||||||
|
absent: an actuator endpoint is reachable by more people than a secret store is.
|
||||||
|
|
||||||
|
## Rotation
|
||||||
|
|
||||||
|
Certificates and secrets rotate by building a new runtime generation and swapping the registry
|
||||||
|
pointer, never by mutating a live client. A connection pool holds sockets established under the
|
||||||
|
previous identity, so replacing material without replacing the pool leaves live connections
|
||||||
|
authenticated by a certificate that is meant to be gone.
|
||||||
|
|
||||||
|
```text
|
||||||
|
build new generation → validate → atomic swap → new calls use it
|
||||||
|
old generation → DRAINING → in-flight calls finish → no new retries → forced close at the drain deadline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shutdown
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUNNING → DRAINING
|
||||||
|
new logical calls refused or routed to the new generation
|
||||||
|
in-flight attempts complete
|
||||||
|
new retries refused
|
||||||
|
shutdown timeout
|
||||||
|
remaining calls cancelled
|
||||||
|
pool closed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry ownership
|
||||||
|
|
||||||
|
Exactly one of the application client, an external SDK, or the service mesh may own retries.
|
||||||
|
Two owners multiply traffic during an incident. Record the owner per upstream and check it whenever
|
||||||
|
a mesh retry policy changes.
|
||||||
|
|
||||||
|
## Error model
|
||||||
|
|
||||||
|
Every outbound failure is one of these stable types. The type is derived from the classified failure
|
||||||
|
category, not from whatever the engine happened to throw, so it means the same thing on Apache, JDK,
|
||||||
|
and Reactor Netty. Each carries `HttpFailureMetadata`: client, operation, method, URI **template**,
|
||||||
|
evidence, replayability, stage, retryability, attempt, elapsed, remaining deadline, status, trace id
|
||||||
|
— and nothing else.
|
||||||
|
|
||||||
|
| Exception | Raised when | Retryable |
|
||||||
|
|---|---|---|
|
||||||
|
| `HttpConfigurationException` | profile, operation, or capability configuration is invalid | never |
|
||||||
|
| `HttpTargetRejectedException` | target URI, host, port, header, or address policy refused the request | never |
|
||||||
|
| `HttpDnsException` | hostname resolution failed or timed out | yes, inside budget |
|
||||||
|
| `HttpPoolAcquireTimeoutException` | no connection or stream within the pending-acquire budget | yes, inside budget |
|
||||||
|
| `HttpConnectException` | socket connect failed | yes, inside budget |
|
||||||
|
| `HttpProxyException` | proxy connect, CONNECT tunnel, or proxy auth failed | yes, inside budget |
|
||||||
|
| `HttpTlsException` | TLS handshake failed | only a transient handshake timeout |
|
||||||
|
| `HttpRequestWriteException` | request headers or body could not be fully written | only when safely idempotent |
|
||||||
|
| `HttpResponseTimeoutException` | final headers or a body chunk did not arrive in time | only when safely idempotent |
|
||||||
|
| `HttpResponseTruncatedException` | the response ended before the body was complete | only when safely idempotent and undelivered |
|
||||||
|
| `HttpRemoteErrorException` | non-success status without a problem document | per the status rules |
|
||||||
|
| `HttpProblemDetailException` | non-success status with a bounded RFC 9457 document | per the status rules |
|
||||||
|
| `HttpRedirectRejectedException` | a hop violated hop count, origin, method, or replay policy | never |
|
||||||
|
| `HttpAuthenticationException` | credential materialization or refresh failed | never |
|
||||||
|
| `HttpSerializationException` | request encoding or response decoding failed | never |
|
||||||
|
| `HttpResponseTooLargeException` | wire or decoded bytes exceeded the profile limit | never |
|
||||||
|
| `HttpDeadlineExceededException` | the effective deadline was reached | never |
|
||||||
|
| `HttpCircuitOpenException` | the upstream circuit is open | never |
|
||||||
|
| `HttpBulkheadRejectedException` | no attempt or logical admission permit was available | never |
|
||||||
|
| `HttpRateLimitRejectedException` | the local attempt rate limit or retry budget rejected the attempt | never |
|
||||||
|
| `HttpAmbiguousExecutionException` | a non-idempotent request was sent and the outcome is unknown | never — reconcile instead |
|
||||||
|
|
||||||
|
## Traces
|
||||||
|
|
||||||
|
```text
|
||||||
|
http.client.operation logical internal span
|
||||||
|
└─ http.client.request attempt 1 CLIENT span
|
||||||
|
└─ http.client.request attempt 2 CLIENT span
|
||||||
|
```
|
||||||
|
|
||||||
|
W3C Trace Context is propagated with a Baggage allowlist. Dynamic Targets do not propagate trace
|
||||||
|
context by default. Retry reason and evidence are recorded as span events; credentials and remote
|
||||||
|
error bodies are never recorded as attributes.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# HTTP Client Platform — Performance Baseline
|
||||||
|
|
||||||
|
The certification lane asserts **resource bounds**, not throughput targets. Its purpose is to prove
|
||||||
|
that a failing upstream, a large body, or a rotation cannot consume unbounded memory, connections,
|
||||||
|
threads, or upstream traffic. Nothing here becomes a runtime adaptive default: every bound comes
|
||||||
|
from an explicit profile setting.
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# structural bounds only (default; still executes every test)
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --console=plain
|
||||||
|
|
||||||
|
# full certification, including machine-dependent bounds
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||||
|
-Pperformance.assertions.enabled=true --console=plain
|
||||||
|
|
||||||
|
# JMH benchmarks
|
||||||
|
./gradlew :adapter:outbound:httpclient:jmh --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Machine-dependent assertions are reported as explicitly skipped when the flag is absent — the lane
|
||||||
|
never silently degrades into a pass.
|
||||||
|
|
||||||
|
## Certified bounds
|
||||||
|
|
||||||
|
| Test | Bound | Kind |
|
||||||
|
|---|---|---|
|
||||||
|
| `RetryStormBudgetTest` | 10 000 logical calls against a failing upstream produce at most 11 000 physical attempts at a 10 % budget | structural |
|
||||||
|
| `LargeBodyResourceTest` | a 32 MiB streaming download consumes every byte without buffering the payload on the heap | structural + machine-dependent heap bound |
|
||||||
|
| `PoolSaturationPerformanceTest` | 24 concurrent calls against a 4-connection pool all reach a terminal outcome; none hang | structural |
|
||||||
|
| `Http2StreamSaturationTest` | 32 concurrent reactive streams share a 2-connection pool and complete | structural |
|
||||||
|
| `OAuthRefreshContentionTest` | 100 genuinely concurrent callers produce exactly one token request | structural |
|
||||||
|
| `RuntimeRotationDrainTest` | 50 rotations close all 50 retired generations and leave no drain thread | structural |
|
||||||
|
|
||||||
|
## Recording a baseline
|
||||||
|
|
||||||
|
When certifying a deployment, record alongside the numbers: the exact command, the commit, hardware,
|
||||||
|
JVM flags, the profile YAML under test, p50/p95/p99/max, peak heap, peak direct memory, thread count,
|
||||||
|
connection count, physical attempt count, and error count. A latency figure without its profile and
|
||||||
|
hardware is not a baseline; it is an anecdote.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Command | _fill in at certification time_ |
|
||||||
|
| Commit | _fill in_ |
|
||||||
|
| Hardware / JVM | _fill in_ |
|
||||||
|
| Profile under test | _fill in_ |
|
||||||
|
| p50 / p95 / p99 / max | _fill in_ |
|
||||||
|
| Peak heap / direct memory | _fill in_ |
|
||||||
|
| Threads / connections | _fill in_ |
|
||||||
|
| Physical attempts / errors | _fill in_ |
|
||||||
|
|
||||||
|
The table is intentionally left unfilled in the repository: publishing numbers measured on a build
|
||||||
|
agent as if they were a certified baseline would be worse than having none.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# HTTP Client Platform — Release Checklist
|
||||||
|
|
||||||
|
A release is complete when each item below is demonstrated by a command, not by review.
|
||||||
|
|
||||||
|
## Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:httpclient:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:spring62CompatibilityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --console=plain # needs Docker
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||||
|
-Pperformance.assertions.enabled=true --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
python3 ../scripts/verify-httpclient-docs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion criteria (design §33)
|
||||||
|
|
||||||
|
- [ ] Typed clients are the default entry point; H2 and H3 are separately authorised.
|
||||||
|
- [ ] H1–H4 cannot bypass timeout, host, TLS, auth, size, or observation policy.
|
||||||
|
- [ ] Apache, JDK, and Reactor produce identical result and exception metadata.
|
||||||
|
- [ ] Pool, DNS, connect, TLS, and retry backoff all fit inside the effective deadline.
|
||||||
|
- [ ] Every extra attempt is explained by idempotency, replayability, evidence, deadline, and budget.
|
||||||
|
- [ ] Non-idempotent `SENT_NO_RESPONSE` surfaces as `HttpAmbiguousExecutionException`.
|
||||||
|
- [ ] Pool and buffers are reclaimed after unread bodies, decode errors, cancels, and size rejections.
|
||||||
|
- [ ] OAuth2 refresh is single-flight and 401 replay happens at most once.
|
||||||
|
- [ ] Trust-all and hostname-verification bypass fail at startup.
|
||||||
|
- [ ] Canonicalisation, DNS/IP validation, redirect revalidation, and egress control all pass.
|
||||||
|
- [ ] No transparent retry occurs after the first delivered byte.
|
||||||
|
- [ ] No platform code blocks a Reactor event loop, proven by a BlockHound self-check.
|
||||||
|
- [ ] The negotiated wire protocol matches what the support matrix claims per transport.
|
||||||
|
- [ ] Logical calls and attempts are separate metrics with no forbidden label.
|
||||||
|
- [ ] DNS, pool, TLS, reset, partial response, and HTTP/2 GOAWAY are reproducible.
|
||||||
|
- [ ] Thread, heap, direct memory, pool, and retry budget bounds hold.
|
||||||
|
- [ ] The support matrix, configuration reference, security guide, runbook, and migration guide match the code.
|
||||||
|
|
||||||
|
## Experimental
|
||||||
|
|
||||||
|
Jetty HTTP/3 stays Experimental until `Http3CapabilityReport` reports QUIC and TLS 1.3 and the
|
||||||
|
contract subset it declares passes in a dedicated environment. It is never auto-configured by the
|
||||||
|
Stable starter.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# HTTP Client Platform — Repository Adaptation Contract
|
||||||
|
|
||||||
|
**Design source:** `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||||
|
**Plan source:** `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||||
|
|
||||||
|
The design package states its own adaptation rule:
|
||||||
|
|
||||||
|
> 실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적
|
||||||
|
> 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과
|
||||||
|
> 정책 의미론은 유지한다.
|
||||||
|
|
||||||
|
This file is the single record of *how* the design's assumed layout was mapped onto this repository.
|
||||||
|
Only paths, build DSL, and composition-root ownership changed. Public contracts, policy order, and
|
||||||
|
error semantics are implemented exactly as specified.
|
||||||
|
|
||||||
|
## 1. Why the module layout differs
|
||||||
|
|
||||||
|
The design assumes a greenfield library with 19 Gradle projects under `modules/httpclient/`.
|
||||||
|
This repository is a Clean Architecture template whose **fail-closed registry**
|
||||||
|
(`src/config/architecture/modules.json`, enforced by `src/settings.gradle` and
|
||||||
|
`verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 19 more
|
||||||
|
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||||
|
|
||||||
|
Therefore the design's 19 library modules become **package boundaries inside the registered leaf**
|
||||||
|
`:adapter:outbound:httpclient`, with two exceptions driven by this repository's own rules:
|
||||||
|
|
||||||
|
| Design module | Repository home | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. |
|
||||||
|
| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/testkit/java/**/testkit` | The design forbids production modules depending on the testkit; a source set whose dependencies are declared only on the test configurations gives the same guarantee without a new Gradle project. It is its own source set rather than part of `test` because three lanes consume it — `test`, `httpClientPerformanceTest` and `jmh` — and reaching into `sourceSets.test.output` from `jmh` compiled under Gradle but could not be modelled by an IDE, which classifies a source set as test source only when a `Test` task runs its output and forbids main source from reading test source. `PlatformClasses` excludes the source set's output so the boundary rules keep meaning production classes. |
|
||||||
|
|
||||||
|
The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`,
|
||||||
|
`HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table.
|
||||||
|
|
||||||
|
## 2. Package mapping
|
||||||
|
|
||||||
|
Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbound.httpclient`.
|
||||||
|
|
||||||
|
| Design module | Design package | Repository package |
|
||||||
|
|---|---|---|
|
||||||
|
| `httpclient-core-api` | `…httpclient.api` (+ `.body`, `.error`, `.operation`, `.result`) | `dev.caskeleton.adapter.outbound.httpclient.api` (+ same subpackages) |
|
||||||
|
| `httpclient-profile` | `…httpclient.profile` | `…outbound.httpclient.profile` |
|
||||||
|
| `httpclient-transport-spi` | `…httpclient.transport` | `…outbound.httpclient.transport` |
|
||||||
|
| `httpclient-transport-apache` | `…httpclient.apache` | `…outbound.httpclient.apache` |
|
||||||
|
| `httpclient-transport-jdk` | `…httpclient.jdk` | `…outbound.httpclient.jdk` |
|
||||||
|
| `httpclient-restclient` | `…httpclient.restclient` | `…outbound.httpclient.restclient` |
|
||||||
|
| `httpclient-resilience` | `…httpclient.resilience` | `…outbound.httpclient.resilience` |
|
||||||
|
| `httpclient-auth` | `…httpclient.auth` | `…outbound.httpclient.auth` |
|
||||||
|
| `httpclient-security` | `…httpclient.security` | `…outbound.httpclient.security` |
|
||||||
|
| `httpclient-observability` | `…httpclient.observation` | `…outbound.httpclient.observation` |
|
||||||
|
| `httpclient-transport-reactor-netty` | `…httpclient.reactor` | `…outbound.httpclient.reactor` |
|
||||||
|
| `httpclient-webclient` | `…httpclient.webclient` | `…outbound.httpclient.webclient` |
|
||||||
|
| `httpclient-service-client` | `…httpclient.service` | `…outbound.httpclient.service` |
|
||||||
|
| `httpclient-dynamic-target` | `…httpclient.dynamic` | `…outbound.httpclient.dynamic` |
|
||||||
|
| `httpclient-resttemplate-migration` | `…httpclient.migration` | `…outbound.httpclient.migration` |
|
||||||
|
| `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` |
|
||||||
|
| `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` |
|
||||||
|
| `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` |
|
||||||
|
| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (`testkit` source set) |
|
||||||
|
|
||||||
|
## 3. Other deliberate substitutions
|
||||||
|
|
||||||
|
| Design assumption | Repository reality | Adaptation |
|
||||||
|
|---|---|---|
|
||||||
|
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||||
|
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||||
|
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
|
||||||
|
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
|
||||||
|
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
|
||||||
|
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||||
|
| `docs/httpclient/**`, `.github/workflows/httpclient-*.yml`, `scripts/verify-httpclient-docs.py` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||||
|
|
||||||
|
## 4. What is unchanged from the design
|
||||||
|
|
||||||
|
- H1 / H2 / H3 / H4 exposure rules and the forbidden native-engine signatures.
|
||||||
|
- `ExecutionEvidence`, `BodyReplayability`, `OperationIdempotency`, `AttemptStage`, `FailureCategory`.
|
||||||
|
- `HttpOperation`, `HttpCallResult`, `BodySource`, `ResponseType`, `BlockingStreamingResponse`.
|
||||||
|
- The complete stable exception hierarchy and `HttpFailureMetadata` redaction rules.
|
||||||
|
- Named Client Profile schema, startup validation codes, and operation override direction.
|
||||||
|
- Effective deadline formula, attempt budget, and streaming setup/idle split.
|
||||||
|
- Retry eligibility inputs, the ordered decision table, retry budget, and backoff rules.
|
||||||
|
- Circuit → Rate Limiter → Bulkhead attempt order and logical admission placement.
|
||||||
|
- OAuth2 cache key, single-flight refresh, and the 401 replay-at-most-once rule.
|
||||||
|
- TLS allow/forbid lists and permanent-failure classification.
|
||||||
|
- Dynamic Target canonicalization → all-answer DNS validation → pinning → redirect revalidation.
|
||||||
|
- Low-cardinality tag allowlist, forbidden labels, trace and logging rules.
|
||||||
|
- Runtime generation swap and drain semantics.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Retry and Ambiguity
|
||||||
|
|
||||||
|
The platform never decides a retry from the HTTP method alone (design D-09). A second attempt
|
||||||
|
happens only when idempotency, body replayability, execution evidence, deadline, and retry budget
|
||||||
|
all permit it.
|
||||||
|
|
||||||
|
## Execution evidence
|
||||||
|
|
||||||
|
| Evidence | Meaning | Typical cause |
|
||||||
|
|---|---|---|
|
||||||
|
| `NOT_SENT` | Proven that the server never received the request | profile rejection, pool timeout, DNS failure, connect failure, pre-request TLS failure, HTTP/2 `REFUSED_STREAM` |
|
||||||
|
| `SENT_NO_RESPONSE` | Some or all of the request was written, no final header arrived | partial write, response-header timeout, connection reset |
|
||||||
|
| `RESPONSE_RECEIVED` | Final headers arrived, whatever the status | 2xx, 4xx, 5xx, redirect |
|
||||||
|
| `PARTIAL_RESPONSE` | Headers and part of the body arrived | reset during decode, interrupted stream |
|
||||||
|
|
||||||
|
`NOT_SENT` is only produced by a stage failure that proves it. A generic engine I/O error is never
|
||||||
|
upgraded to `NOT_SENT`, because that is exactly how a timeout becomes a duplicate payment.
|
||||||
|
|
||||||
|
## Body replayability
|
||||||
|
|
||||||
|
| Body | Replayability |
|
||||||
|
|---|---|
|
||||||
|
| immutable `byte[]` | `REPLAYABLE` |
|
||||||
|
| DTO plus a deterministic codec | `REPLAYABLE` |
|
||||||
|
| reopenable file or resource supplier | `REOPENABLE` |
|
||||||
|
| a single `InputStream` instance | `ONE_SHOT` |
|
||||||
|
| publisher factory | as declared |
|
||||||
|
| publisher instance | `ONE_SHOT` |
|
||||||
|
| multipart | the weakest part |
|
||||||
|
|
||||||
|
## Decision order
|
||||||
|
|
||||||
|
`DefaultRetryEligibilityEngine` evaluates in this order, and a later rule can never re-enable
|
||||||
|
something an earlier one forbade:
|
||||||
|
|
||||||
|
1. attempts exhausted → `RetryDenied.maxAttempts()`
|
||||||
|
2. retry budget empty → `RetryDenied.budgetExhausted()`
|
||||||
|
3. body not replayable → `RetryDenied.bodyNotReplayable()`
|
||||||
|
4. first byte already delivered → `RetryDenied.responseAlreadyDelivered()`
|
||||||
|
5. runtime draining → `RetryDenied.runtimeDraining()`
|
||||||
|
6. remaining deadline below the minimum attempt budget → `RetryDenied.deadline()`
|
||||||
|
7. permanent failure category → `RetryDenied.permanentFailure(...)`
|
||||||
|
8. `SENT_NO_RESPONSE` on an operation that is not safely idempotent → `AmbiguousFailure`
|
||||||
|
9. status- and failure-specific rules
|
||||||
|
|
||||||
|
## Status rules
|
||||||
|
|
||||||
|
| Status | Decision |
|
||||||
|
|---|---|
|
||||||
|
| 408 | retry inside deadline and budget |
|
||||||
|
| 425 | at most one retry, first attempt only |
|
||||||
|
| 429 | retry inside `Retry-After`, deadline, and budget |
|
||||||
|
| 401 | one refresh-and-replay, safe replayable operations only |
|
||||||
|
| 500 | denied unless the upstream registered it as transient **and** the operation is safely idempotent |
|
||||||
|
| 502, 503, 504 | retry for safely idempotent operations; ambiguous otherwise |
|
||||||
|
| other 4xx | denied |
|
||||||
|
|
||||||
|
## Ambiguity
|
||||||
|
|
||||||
|
A non-idempotent request that reached `SENT_NO_RESPONSE` raises
|
||||||
|
`HttpAmbiguousExecutionException`. It is a third answer on purpose: retrying may duplicate a side
|
||||||
|
effect, and reporting a plain failure would tell the caller the request did not happen, which may
|
||||||
|
be false. The caller reconciles, usually by querying the upstream or replaying with an idempotency
|
||||||
|
key.
|
||||||
|
|
||||||
|
## Budget and backoff
|
||||||
|
|
||||||
|
Retry tokens come from a per-upstream token bucket sized as a fraction of real traffic, so a failing
|
||||||
|
upstream cannot be flooded by retries from a healthy fleet. Backoff is exponential with full or
|
||||||
|
decorrelated jitter, bounded by `max-backoff`, by `Retry-After`, and by the remaining deadline. No
|
||||||
|
connection and no bulkhead permit is held while a backoff is waiting.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# HTTP Client Platform — Security Guide
|
||||||
|
|
||||||
|
## What the platform owns
|
||||||
|
|
||||||
|
`Authorization`, `Proxy-Authorization`, `Host`, `Content-Length`, `Transfer-Encoding`,
|
||||||
|
`Traceparent`, `Tracestate`, `Baggage`, and (unless a profile opts in) `Cookie` are platform-owned.
|
||||||
|
A caller cannot set them. `Idempotency-Key` is accepted only when the operation declares it. Any
|
||||||
|
header name or value containing CR or LF is rejected before the request is built.
|
||||||
|
|
||||||
|
## Target policy
|
||||||
|
|
||||||
|
A trusted profile accepts only a profile-relative URI template. An absolute URI is rejected rather
|
||||||
|
than sanitised: varying the destination is what H3 is for, and H3 has its own policy, credentials,
|
||||||
|
and address validation. Template variables are encoded per component, so a value containing `/`,
|
||||||
|
`?`, or `#` cannot change the shape of the request.
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Allowed: TLS 1.2 and 1.3, hostname verification, the JVM trust store, a per-profile custom CA, a
|
||||||
|
per-profile client certificate, mTLS, SNI and ALPN, and certificate rotation through a new runtime
|
||||||
|
generation.
|
||||||
|
|
||||||
|
Forbidden and unrepresentable: a trust-all trust manager, disabled hostname verification, ignoring
|
||||||
|
certificate errors, automatically trusting a production self-signed certificate, falling back to
|
||||||
|
plaintext after an HTTPS failure, and writing key material into configuration or logs.
|
||||||
|
|
||||||
|
Unknown CA, hostname mismatch, expired certificate, revoked certificate, protocol mismatch, and a
|
||||||
|
missing client certificate are permanent. Only a transient handshake timeout may be retried, inside
|
||||||
|
the deadline.
|
||||||
|
|
||||||
|
## Dynamic Target (SSRF)
|
||||||
|
|
||||||
|
Every hop — the first one included — runs the whole flow:
|
||||||
|
|
||||||
|
1. strict URI parse
|
||||||
|
2. scheme allowlist
|
||||||
|
3. reject userinfo and invalid ports
|
||||||
|
4. IDNA-canonicalise the host
|
||||||
|
5. host allowlist or suffix policy
|
||||||
|
6. resolve **every** A and AAAA answer
|
||||||
|
7. normalise each address, including IPv4-mapped IPv6
|
||||||
|
8. reject loopback, link-local, RFC1918, ULA, carrier-grade NAT, unspecified, multicast, cloud
|
||||||
|
metadata, and organisation-defined ranges
|
||||||
|
9. pin the connection to the approved addresses through the same validated resolver
|
||||||
|
10. apply response size and content policy
|
||||||
|
11. repeat for each redirect
|
||||||
|
|
||||||
|
Any forbidden address in the answer set rejects the whole target. Validating only the first answer
|
||||||
|
would let a host that resolves to one public and one private address through.
|
||||||
|
|
||||||
|
Dynamic profiles inherit no API key, OAuth token, Cookie, or default header, and no Cookie jar is
|
||||||
|
created. A specific host may be granted a credential only through an explicitly registered
|
||||||
|
`DynamicCredentialBinding`.
|
||||||
|
|
||||||
|
Application-level validation is not sufficient on its own. A network control — Kubernetes
|
||||||
|
NetworkPolicy, service-mesh egress policy, firewall, or proxy ACL — is an operational completion
|
||||||
|
requirement.
|
||||||
|
|
||||||
|
## Redirects
|
||||||
|
|
||||||
|
Disabled by default. Engine redirect handling is off in every transport so the platform can
|
||||||
|
re-validate each hop. 307 and 308 preserve method and body and are therefore allowed only for a
|
||||||
|
replayable body. Cross-origin hops are refused unless the profile opts in, and when they are
|
||||||
|
allowed `Authorization`, `Proxy-Authorization`, `Cookie`, and API-key headers are stripped.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Allowed tags: `clientName`, `operationName`, `method`, `uriTemplate`, `status`, `outcome`,
|
||||||
|
`transport`, `protocol`, `timeoutType`, `retryReason`, `evidence`, `circuitState`.
|
||||||
|
|
||||||
|
Rejected outright: full URL, query parameters, path variable values, user ID, raw tenant ID,
|
||||||
|
resolved IP, API key, token, Cookie, idempotency key, request or response body, exception message.
|
||||||
|
|
||||||
|
Failures are logged once, structured, at the end of a logical call. Retry attempts are DEBUG or span
|
||||||
|
events. URLs appear only as templates.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Streaming and Large Bodies
|
||||||
|
|
||||||
|
## Response lifecycle
|
||||||
|
|
||||||
|
A blocking streaming download returns `BlockingStreamingResponse`, never a bare `InputStream`.
|
||||||
|
Closing is idempotent and always releases the connection — after a full read, a partial read, a
|
||||||
|
decode failure, or a size rejection. The status is validated before any body byte is delivered, so a
|
||||||
|
failed download never becomes a half-consumed stream the caller has to reason about.
|
||||||
|
|
||||||
|
A reactive download emits bounded `DataBuffer` values. Buffers are released on completion, error,
|
||||||
|
and cancellation; a dropped buffer is direct memory nobody returns.
|
||||||
|
|
||||||
|
Wire bytes and decoded bytes are bounded independently, because a compressed payload passes a wire
|
||||||
|
check and then expands. Limits are enforced while reading, not after buffering.
|
||||||
|
|
||||||
|
## The first-byte boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
response headers received
|
||||||
|
→ nothing delivered yet
|
||||||
|
→ a read-only operation may still be retried
|
||||||
|
→ first InputStream read or first Flux onNext
|
||||||
|
→ transparent retry is permanently disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
`FirstByteDeliveryGuard` latches once and never resets. Retrying after delivery would replay a
|
||||||
|
stream the caller has already partly consumed, producing duplicated or reordered data that no
|
||||||
|
downstream code can detect.
|
||||||
|
|
||||||
|
## Request bodies
|
||||||
|
|
||||||
|
A reopenable body is opened once per attempt, which is what makes it replayable; reusing the
|
||||||
|
previous stream would silently send an empty body on the retry. A one-shot stream or publisher
|
||||||
|
instance is never retried. `ReactiveBodySource` takes a publisher *factory* rather than a publisher
|
||||||
|
so a reactive body can honestly declare itself replayable.
|
||||||
|
|
||||||
|
A multipart body is exactly as replayable as its weakest part.
|
||||||
|
|
||||||
|
## Server-sent events
|
||||||
|
|
||||||
|
Three budgets stay separate:
|
||||||
|
|
||||||
|
- `setupDeadline` — establishing the stream
|
||||||
|
- `streamingIdleTimeout` — silence once it is open
|
||||||
|
- `maxStreamDuration` — optional total lifetime
|
||||||
|
|
||||||
|
Applying the request-shaped `total-call` timeout to an SSE subscription would terminate a perfectly
|
||||||
|
healthy stream on schedule, so it is not applied.
|
||||||
|
|
||||||
|
`Last-Event-ID` is opt-in. Replaying from an id is only correct when the producer guarantees it;
|
||||||
|
sending it blindly can skip or duplicate events. Reconnects consume the retry budget like any other
|
||||||
|
physical attempt, and cancelling the subscription stops both the stream and any pending reconnect.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# HTTP Client Platform — Support Matrix
|
||||||
|
|
||||||
|
Grades follow design §6 and §29. A row is **Stable** only when the cross-transport contract suite
|
||||||
|
proves it; anything the suite cannot prove is **Experimental** and says so.
|
||||||
|
|
||||||
|
## Spring API
|
||||||
|
|
||||||
|
| API | Grade | Role | Constraint |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RestClient` | Stable | Blocking execution | Bounded concurrency and an effective deadline are mandatory |
|
||||||
|
| `WebClient` | Stable | Reactive, streaming, SSE | No blocking work on the event loop |
|
||||||
|
| HTTP Service Client (`@HttpExchange`) | Default | Declarative typed client | Operation metadata is mandatory |
|
||||||
|
| `RestTemplate` | Migration only | Moving existing calls | No new profile or feature |
|
||||||
|
| Generic Exchange (H2) | Restricted | Dynamic method, path, body | Base URL and policy are immutable |
|
||||||
|
| Dynamic Target (H3) | Restricted | User-supplied URL | Separate SSRF policy; inherits no credential |
|
||||||
|
| Native engine | Internal | Engine-specific configuration | Never an application-facing API |
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
| Transport | Blocking | Reactive | HTTP/1.1 | HTTP/2 | HTTP/3 | Grade | Verified by |
|
||||||
|
|---|---:|---:|---:|---:|---:|---|---|
|
||||||
|
| Apache HttpClient 5 (classic) | yes | no | yes | **no** | no | Stable (blocking default) | `httpClientStableContractTest`, `NegotiatedProtocolContractTest` |
|
||||||
|
| JDK HttpClient | yes | `sendAsync` | yes | yes (TLS/ALPN) | no | Stable (lightweight, blocking HTTP/2) | `NegotiatedProtocolContractTest` |
|
||||||
|
| Reactor Netty | limited | yes | yes | yes | experimental | Stable (reactive default) | `NegotiatedProtocolContractTest` |
|
||||||
|
| Jetty | facade | yes | yes | yes | yes | **Experimental** | `Http3OptInTest` only |
|
||||||
|
| Simple request factory | yes | no | limited | no | no | Local test only | rejected in production by `ClientProfileValidator` |
|
||||||
|
|
||||||
|
### Apache is HTTP/1.1 here, and why
|
||||||
|
|
||||||
|
Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable, and the library is — in its **async**
|
||||||
|
client. Spring's `HttpComponentsClientHttpRequestFactory` drives the **classic** client, which
|
||||||
|
speaks HTTP/1.1 only. `NegotiatedProtocolContractTest` measures this rather than assuming it: the
|
||||||
|
classic client fails outright against a prior-knowledge h2c server.
|
||||||
|
|
||||||
|
So `ApacheBlockingTransportProvider.capabilities()` declares HTTP/1.1, and a profile that pairs
|
||||||
|
Apache with `HTTP_2` is rejected at startup instead of quietly running HTTP/1.1 while this table
|
||||||
|
claims otherwise. **Blocking HTTP/2 is served by the JDK transport**; reactive HTTP/2 by Reactor
|
||||||
|
Netty. Both are measured from the client after a real TLS handshake, not read from configuration.
|
||||||
|
|
||||||
|
The JDK transport declares `routeScopedPool=false`, `boundedPendingAcquireQueue=false`, and
|
||||||
|
`dynamicTargetStable=false`. A profile that needs any of those is rejected at startup rather than
|
||||||
|
served with weaker guarantees. Choosing between Apache and JDK is therefore a real trade: Apache
|
||||||
|
gives route-scoped pooling and Dynamic Target pinning, JDK gives HTTP/2.
|
||||||
|
|
||||||
|
## Capability gates
|
||||||
|
|
||||||
|
| Capability | Gate |
|
||||||
|
|---|---|
|
||||||
|
| Dynamic Target (H3) | Apache and Reactor Netty only; JDK and Jetty are rejected |
|
||||||
|
| HTTP/3 | `experimentalAcknowledgement` must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||||
|
| Cross-origin redirect | opt-in per profile; credentials are stripped on the hop |
|
||||||
|
| Retry | evidence-based; never enabled by HTTP method alone |
|
||||||
|
|
||||||
|
## CI matrix
|
||||||
|
|
||||||
|
| Profile | Frequency | Release gate | Task |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Spring Framework 7.0 (repository baseline) | every PR | required | `spring70CompatibilityTest` |
|
||||||
|
| Spring Framework 6.2 API surface | every PR | required | `spring62CompatibilityTest` |
|
||||||
|
| Apache HC5 + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=apache` |
|
||||||
|
| JDK HttpClient + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=jdk` |
|
||||||
|
| Reactor Netty + WebClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=reactor` |
|
||||||
|
| SSRF / cardinality suite | every PR | required | `httpClientSecurityTest` |
|
||||||
|
| Toxiproxy fault suite | nightly, release | required | `httpClientFailureInjectionTest` |
|
||||||
|
| Event-loop blocking (BlockHound) | every PR | required | `httpClientBlockHoundTest` |
|
||||||
|
| Performance certification | nightly, release | required | `httpClientPerformanceTest -Pperformance.assertions.enabled=true` |
|
||||||
|
| Jetty HTTP/3 | nightly | Experimental, non-blocking | `test -Phttp3.tests.enabled=true` |
|
||||||
|
|
||||||
|
### Known limitation of the Spring 6.2 lane
|
||||||
|
|
||||||
|
This repository's Spring Boot 4.0 baseline pins Spring Framework 7, so a real 6.2 runtime cannot be
|
||||||
|
resolved here. `spring62CompatibilityTest` therefore verifies the **API surface**: the common
|
||||||
|
packages must not reference any Spring 7-only type, and `org.springframework.web.service.registry`
|
||||||
|
is confined to `…httpclient.spring7`. Executing the suite against an actual 6.2 distribution
|
||||||
|
requires a host project on that line. This limitation is stated rather than hidden behind a passing
|
||||||
|
check.
|
||||||
|
|
||||||
|
|
||||||
|
## What the suites do not prove
|
||||||
|
|
||||||
|
Stated so the matrix is read as a measurement rather than an aspiration.
|
||||||
|
|
||||||
|
| Gap | Why | What is proven instead |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP/2 frame injection (`REFUSED_STREAM`, arbitrary `GOAWAY`) | The fixture server exposes no frame-level control, and a purpose-built h2 server is a larger dependency than the guarantee is worth here | `Http2EvidenceMapperTest` proves the frame → evidence mapping, and `NegotiatedProtocolContractTest` proves h2 is really negotiated |
|
||||||
|
| Netty buffer-leak detection | Netty reports a leak when an unreferenced buffer is collected, which the suite does not force | `NettyLeakDetectionExtension` asserts the PARANOID detector is live and reports nothing; explicit release assertions in the streaming suites are the primary guarantee |
|
||||||
|
| Spring 6.2 runtime | This repository's Boot 4.0 baseline pins Spring 7 | `spring62CompatibilityTest` confines the common packages to the 6.2 API surface |
|
||||||
|
| Performance latency baseline | Numbers measured on a build agent are not a certification | `httpClientPerformanceTest` asserts structural bounds unconditionally; latency and heap bounds run under `-Pperformance.assertions.enabled=true` |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user