Compare commits
34
Commits
1a3b560678
...
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 |
@@ -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,194 +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: redis-standalone
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: redis-standalone
|
||||
workflow: ci-quality-gates.yml
|
||||
job: redis-standalone
|
||||
execution: job
|
||||
- id: jpa-candidate-evidence
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: jpa-candidate-evidence
|
||||
workflow: ci-quality-gates.yml
|
||||
job: jpa-candidate-evidence
|
||||
execution: job
|
||||
- id: jpa-r2-evidence
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: jpa-r2-evidence
|
||||
workflow: jpa-r2-evidence.yml
|
||||
job: jpa-r2-evidence
|
||||
execution: job
|
||||
- id: quality-release-gate
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: release-gate
|
||||
workflow: ci-quality-gates.yml
|
||||
job: release-gate
|
||||
execution: job
|
||||
- id: flaky-quarantine
|
||||
release_blocking: false
|
||||
mechanism: workflow-job
|
||||
ref: quarantine
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quarantine
|
||||
execution: job
|
||||
- id: dependency-review
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: dependency-review
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: dependency-review
|
||||
execution: job
|
||||
- id: dependency-submission
|
||||
release_blocking: false
|
||||
mechanism: workflow-job
|
||||
ref: dependency-submission
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: dependency-submission
|
||||
execution: job
|
||||
- id: filesystem-vulnerability-scan
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: trivy-fs
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: trivy-fs
|
||||
execution: job
|
||||
- id: documentation-links
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: lychee
|
||||
workflow: link-check.yml
|
||||
job: lychee
|
||||
execution: job
|
||||
- id: object-storage-minio-managed-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioContractTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-contract
|
||||
execution: explicit
|
||||
- id: poster-image-v7-migration
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: posterImageMigrationTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: poster-image-v7-migration
|
||||
execution: explicit
|
||||
- id: object-storage-minio-managed-fault
|
||||
release_blocking: conditional
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioFaultTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-fault
|
||||
execution: explicit
|
||||
- id: object-storage-aws-protected-qualification
|
||||
release_blocking: conditional
|
||||
mechanism: delegated-pending
|
||||
ref: approval-gate-b
|
||||
workflow: object-storage-qualification.yml
|
||||
job: aws-managed-common-subset
|
||||
execution: job
|
||||
@@ -3,7 +3,7 @@
|
||||
This policy is enforced by
|
||||
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
||||
[`dependency-review-config.yml`](dependency-review-config.yml),
|
||||
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and
|
||||
[`../.trivyignore.yaml`](../.trivyignore.yaml), CODEOWNERS, and
|
||||
[`../renovate.json`](../renovate.json).
|
||||
|
||||
## 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
|
||||
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.
|
||||
Neither control substitutes for the other.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] I ran the focused test for each changed leaf.
|
||||
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`.
|
||||
- [ ] I ran focused `:<changed-leaf>:check` tasks for the modules I changed.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -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=26
|
||||
|
||||
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
|
||||
exit 1
|
||||
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
|
||||
|
||||
# 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:
|
||||
pull_request:
|
||||
push:
|
||||
@@ -33,90 +42,97 @@ jobs:
|
||||
echo "::error::${snapshot} exists locally but is not committed."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Check quality, public paths, and dependency locks
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
# `ci`, not `check`. A leaf's `check` is that leaf's — compile, its tests, Spotless, Checkstyle
|
||||
# and Error Prone — and the repository-wide gates are named tasks of their own:
|
||||
# ci = every leaf check + architectureCheck + qualityCheck + configContractCheck + qualificationCheck
|
||||
# so CI runs strictly more than it used to while `./gradlew :domain-core:check` runs strictly
|
||||
# less.
|
||||
- name: Run the pull-request gate
|
||||
working-directory: src
|
||||
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --stacktrace
|
||||
|
||||
sample-off:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify the application without the sample fixture
|
||||
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 :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --stacktrace
|
||||
- name: Qualify opt-in inbound transports without skips
|
||||
working-directory: src
|
||||
run: ./gradlew :conditionalTransportQualification --stacktrace
|
||||
|
||||
gate-matrix-lint:
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Verify the gate matrix against the repository
|
||||
run: bash .github/scripts/verify-gate-matrix.sh
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Test the build-logic convention plugins
|
||||
working-directory: src
|
||||
run: ./gradlew -p build-logic test --stacktrace
|
||||
|
||||
redis-standalone:
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify standalone Redis policy, provider, and composition contracts
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Verify the optional gRPC platform build
|
||||
working-directory: src
|
||||
run: ./gradlew -p optional-platforms ci --stacktrace
|
||||
|
||||
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
|
||||
:application-core:redisPolicyContractTest
|
||||
:shared-contract:edgeRateLimitContractTest
|
||||
:adapter:outbound:cache-redis:check
|
||||
:app-bootstrap:redisCompositionTest
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyEnvKeys
|
||||
verifyPublicPathSnapshot
|
||||
verifyConfigurationPropertiesProcessor
|
||||
--no-daemon --stacktrace
|
||||
: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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Produce zero-skip JPA candidate manifests
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
- name: Retain content-addressed JPA candidate manifests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||
@@ -126,50 +142,46 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
|
||||
# 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:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run quarantined tests as an advisory signal
|
||||
working-directory: src
|
||||
run: ./gradlew quarantineTest --no-daemon
|
||||
uses: ./.github/workflows/_reusable-gradle.yml
|
||||
with:
|
||||
tasks: ":quarantineTest"
|
||||
gradle-args: "--stacktrace"
|
||||
continue-on-error: true
|
||||
|
||||
release-gate:
|
||||
needs:
|
||||
- quality-gates
|
||||
- sample-off
|
||||
- gate-matrix-lint
|
||||
- redis-standalone
|
||||
- build-logic
|
||||
- redis-sdk
|
||||
- jpa-candidate-evidence
|
||||
- optional-platforms
|
||||
- configuration-cache
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require every current blocking job to succeed
|
||||
env:
|
||||
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
||||
REDIS_RESULT: ${{ needs.redis-standalone.result }}
|
||||
BUILD_LOGIC_RESULT: ${{ needs.build-logic.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: |
|
||||
set -euo pipefail
|
||||
for result in \
|
||||
"${QUALITY_RESULT}" \
|
||||
"${SAMPLE_OFF_RESULT}" \
|
||||
"${MATRIX_RESULT}" \
|
||||
"${BUILD_LOGIC_RESULT}" \
|
||||
"${REDIS_RESULT}" \
|
||||
"${JPA_CANDIDATE_RESULT}"; do
|
||||
"${JPA_CANDIDATE_RESULT}" \
|
||||
"${OPTIONAL_PLATFORMS_RESULT}" \
|
||||
"${CONFIGURATION_CACHE_RESULT}"; do
|
||||
if [[ "${result}" != "success" ]]; then
|
||||
echo "::error::release-gate: required job result was ${result}"
|
||||
exit 1
|
||||
|
||||
@@ -35,15 +35,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Submit the resolved Gradle dependency graph
|
||||
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
|
||||
with:
|
||||
@@ -176,7 +168,10 @@ jobs:
|
||||
trivy-kev.json | sort -u > found-cves.txt
|
||||
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
|
||||
kev.json | sort -u > kev-cves.txt
|
||||
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
|
||||
# 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
|
||||
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
|
||||
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
|
||||
@@ -26,22 +26,14 @@ jobs:
|
||||
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
- name: Retain JPA R2 attempt manifests
|
||||
if: always()
|
||||
|
||||
@@ -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:
|
||||
- "README.md"
|
||||
- "src/README.md"
|
||||
- "src/**/README.md"
|
||||
- "src/**/CLAUDE.md"
|
||||
- "docs/**/*.md"
|
||||
- ".github/**/*.md"
|
||||
- ".github/workflows/link-check.yml"
|
||||
@@ -13,6 +15,8 @@ on:
|
||||
paths:
|
||||
- "README.md"
|
||||
- "src/README.md"
|
||||
- "src/**/README.md"
|
||||
- "src/**/CLAUDE.md"
|
||||
- "docs/**/*.md"
|
||||
- ".github/**/*.md"
|
||||
- ".github/workflows/link-check.yml"
|
||||
@@ -38,6 +42,8 @@ jobs:
|
||||
--root-dir .
|
||||
README.md
|
||||
src/README.md
|
||||
'src/**/README.md'
|
||||
'src/**/CLAUDE.md'
|
||||
'docs/**/*.md'
|
||||
'.github/**/*.md'
|
||||
fail: true
|
||||
|
||||
@@ -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.
|
||||
@@ -11,6 +11,17 @@ on:
|
||||
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
|
||||
@@ -19,78 +30,41 @@ env:
|
||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||
|
||||
jobs:
|
||||
poster-image-v7-migration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run non-skipping Poster image V7 migration qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
||||
|
||||
minio-managed-contract:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run exact-release MinIO managed contract
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
|
||||
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --stacktrace
|
||||
|
||||
aws-managed-common-subset:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
|
||||
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run protected AWS common-subset qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
|
||||
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
|
||||
@@ -1,375 +0,0 @@
|
||||
name: redis-production-readiness
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 18 * * *"
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*-rc.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve-redis-readiness:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
selected: ${{ steps.resolve.outputs.selected }}
|
||||
candidates: ${{ steps.resolve.outputs.candidates }}
|
||||
selected_count: ${{ steps.resolve.outputs.selected_count }}
|
||||
sentinel_required: ${{ steps.resolve.outputs.sentinel_required }}
|
||||
cluster_required: ${{ steps.resolve.outputs.cluster_required }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- name: Generate the strict checked-in readiness control artifact
|
||||
working-directory: src
|
||||
run: ./gradlew writeRedisCiMatrix --no-daemon --stacktrace
|
||||
- id: resolve
|
||||
name: Transport the generated matrix to job outputs
|
||||
shell: python
|
||||
run: |
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
matrix_path = Path(
|
||||
"src/build/redis-evidence/control/redis-readiness-matrix.json"
|
||||
)
|
||||
matrix = json.loads(matrix_path.read_text(encoding="utf-8"))
|
||||
if matrix["releaseQualification"] != "NOT_CLAIMED":
|
||||
raise SystemExit("resolver control artifact must not claim release qualification")
|
||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
|
||||
stream.write(
|
||||
"selected="
|
||||
+ json.dumps(matrix["selected"], separators=(",", ":"))
|
||||
+ "\n"
|
||||
)
|
||||
stream.write(
|
||||
"candidates="
|
||||
+ json.dumps(
|
||||
matrix["implementedCandidates"], separators=(",", ":")
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
stream.write(f"selected_count={matrix['selectedCount']}\n")
|
||||
stream.write(
|
||||
"sentinel_required="
|
||||
+ str(matrix["topologyJobs"]["sentinel"]).lower()
|
||||
+ "\n"
|
||||
)
|
||||
stream.write(
|
||||
"cluster_required="
|
||||
+ str(matrix["topologyJobs"]["cluster"]).lower()
|
||||
+ "\n"
|
||||
)
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-readiness-control
|
||||
path: src/build/redis-evidence/control
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
redis-security:
|
||||
needs: resolve-redis-readiness
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:redisSecurityTest --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-security-evidence
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
redis-sentinel:
|
||||
needs: resolve-redis-readiness
|
||||
if: ${{ needs.resolve-redis-readiness.outputs.sentinel_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:redisSentinelTest --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-sentinel-evidence
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
redis-cluster:
|
||||
needs: resolve-redis-readiness
|
||||
if: ${{ needs.resolve-redis-readiness.outputs.cluster_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:redisClusterTest --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-cluster-evidence
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
redis-fault:
|
||||
needs: resolve-redis-readiness
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:redisFaultTest --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-fault-evidence
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
redis-compatibility:
|
||||
needs: resolve-redis-readiness
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:redisCompatibilityTest --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-compatibility-evidence
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
selected-card-readiness:
|
||||
needs: resolve-redis-readiness
|
||||
if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.resolve-redis-readiness.outputs.selected) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew ${{ matrix.readinessTask }} --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-selected-${{ matrix.cardId }}
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
redis-all-candidates:
|
||||
needs: resolve-redis-readiness
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- id: redis-tests
|
||||
working-directory: src
|
||||
run: ./gradlew redisAllImplementedCandidates --no-daemon --stacktrace
|
||||
- id: redis-evidence-sanitizer
|
||||
if: always()
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
||||
with:
|
||||
name: redis-all-candidates-evidence
|
||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
redis-production-readiness:
|
||||
needs:
|
||||
- resolve-redis-readiness
|
||||
- selected-card-readiness
|
||||
if: ${{ always() && needs.resolve-redis-readiness.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require the exact selected matrix result
|
||||
shell: python
|
||||
env:
|
||||
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
|
||||
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
|
||||
run: |
|
||||
import os
|
||||
|
||||
selected_count_text = os.environ["SELECTED_COUNT"]
|
||||
selected_job_result = os.environ["SELECTED_JOB_RESULT"]
|
||||
if not selected_count_text.isdecimal():
|
||||
raise SystemExit("selected_count must be a non-negative integer")
|
||||
selected_count = int(selected_count_text)
|
||||
expected_result = "skipped" if selected_count == 0 else "success"
|
||||
if selected_job_result != expected_result:
|
||||
raise SystemExit(
|
||||
"selected-card-readiness result mismatch: "
|
||||
f"count={selected_count}, expected={expected_result}, "
|
||||
f"actual={selected_job_result}"
|
||||
)
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
|
||||
with:
|
||||
name: redis-readiness-control
|
||||
path: ${{ runner.temp }}/redis-readiness/control
|
||||
- if: >-
|
||||
${{
|
||||
needs.resolve-redis-readiness.outputs.selected_count != '0'
|
||||
&& needs.selected-card-readiness.result == 'success'
|
||||
}}
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
|
||||
with:
|
||||
pattern: redis-selected-*
|
||||
path: ${{ runner.temp }}/redis-readiness/selected
|
||||
- name: Record the downloaded selected artifact inventory
|
||||
shell: python
|
||||
env:
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
SELECTED_JSON: ${{ needs.resolve-redis-readiness.outputs.selected }}
|
||||
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
|
||||
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
|
||||
REDIS_SELECTED_DIRECTORY: ${{ runner.temp }}/redis-readiness/selected
|
||||
REDIS_CI_RESULT_FILE: ${{ runner.temp }}/redis-readiness/redis-ci-result.json
|
||||
run: |
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
selected = json.loads(os.environ["SELECTED_JSON"])
|
||||
if not isinstance(selected, list):
|
||||
raise SystemExit("selected matrix must be a JSON array")
|
||||
expected_names = sorted(
|
||||
"redis-selected-" + entry["cardId"] for entry in selected
|
||||
)
|
||||
if len(expected_names) != int(os.environ["SELECTED_COUNT"]):
|
||||
raise SystemExit("selected_count does not match the selected matrix")
|
||||
if len(expected_names) != len(set(expected_names)):
|
||||
raise SystemExit("selected matrix contains duplicate artifact names")
|
||||
|
||||
selected_directory = Path(os.environ["REDIS_SELECTED_DIRECTORY"])
|
||||
actual_names = (
|
||||
sorted(path.name for path in selected_directory.iterdir() if path.is_dir())
|
||||
if selected_directory.is_dir()
|
||||
else []
|
||||
)
|
||||
if actual_names != expected_names:
|
||||
raise SystemExit(
|
||||
"downloaded selected artifact inventory mismatch: "
|
||||
f"expected={expected_names}, actual={actual_names}"
|
||||
)
|
||||
|
||||
result = {
|
||||
"schemaVersion": 1,
|
||||
"runId": os.environ["GITHUB_RUN_ID"],
|
||||
"selectedCount": len(expected_names),
|
||||
"selectedJobResult": os.environ["SELECTED_JOB_RESULT"],
|
||||
"selectedArtifactNames": actual_names,
|
||||
}
|
||||
result_path = Path(os.environ["REDIS_CI_RESULT_FILE"])
|
||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result_path.write_text(
|
||||
json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count == '0' }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
|
||||
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
|
||||
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
|
||||
--no-daemon --stacktrace
|
||||
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
|
||||
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
|
||||
-PredisEvidenceDirectory=${{ runner.temp }}/redis-readiness/selected
|
||||
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
|
||||
--no-daemon --stacktrace
|
||||
@@ -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
|
||||
+10
@@ -1,2 +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.
|
||||
#
|
||||
# This repository-root file is the only CI suppression source. Every future entry must include:
|
||||
# id: advisory, license, misconfiguration, or secret identifier
|
||||
# statement: non-empty accepted-risk or false-positive rationale
|
||||
# expired_at: future YYYY-MM-DD no more than 90 days from review
|
||||
# This repository-root file is the only CI suppression source. Every Trivy invocation must name it
|
||||
# with `--ignorefile .trivyignore.yaml`; ad-hoc ignore files and inline bypasses are not allowed.
|
||||
#
|
||||
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate
|
||||
# reviewer control. Every Trivy invocation must also name this file with
|
||||
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
|
||||
# Every entry must carry:
|
||||
# id: advisory, license, misconfiguration, or secret identifier
|
||||
# statement: non-empty accepted-risk or false-positive rationale
|
||||
# expired_at: future YYYY-MM-DD, no more than 90 days from review
|
||||
#
|
||||
# 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: []
|
||||
licenses: []
|
||||
|
||||
@@ -49,11 +49,22 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
|
||||
|
||||
## Gradle 정책 권위
|
||||
|
||||
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
||||
Gradle path, 허용 production project dependency edge
|
||||
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
||||
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
||||
architecture-wide verification task
|
||||
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
|
||||
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
|
||||
산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 이걸 강제하는 태스크는 없다:
|
||||
`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`를
|
||||
함께 읽는다. 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`에서
|
||||
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
||||
`src/**/CLAUDE.md`를 함께 읽는다.
|
||||
@@ -172,16 +183,19 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew <owner-gradle-path>:test --console=plain
|
||||
./gradlew test
|
||||
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
||||
./gradlew verifyCleanArchitectureDependencies
|
||||
./gradlew <owner-gradle-path>:check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne
|
||||
./gradlew check # 모든 leaf의 check
|
||||
./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성
|
||||
./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다)
|
||||
./gradlew ci # PR 게이트 = 위 셋 + configContractCheck
|
||||
./gradlew verifyPublicPathSnapshot
|
||||
./gradlew verifyEnvKeys
|
||||
./gradlew :app-bootstrap:verifyEnvKeys
|
||||
```
|
||||
|
||||
leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다.
|
||||
|
||||
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
||||
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
|
||||
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
|
||||
|
||||
## 설정과 런타임
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -21,8 +21,10 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
|
||||
|
||||
## Gradle policy authorities
|
||||
|
||||
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
|
||||
paths, Gradle paths, and allowed production project dependency edges.
|
||||
- `src/config/architecture/modules.json`: every registered leaf identity, repository-relative source
|
||||
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/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
||||
verification tasks.
|
||||
@@ -42,8 +44,10 @@ count.
|
||||
|
||||
## Module families
|
||||
|
||||
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
|
||||
families; the nearest `src/**/CLAUDE.md` owns local rules.
|
||||
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
@@ -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:*` | 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 |
|
||||
| `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 |
|
||||
| `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.
|
||||
Read its `gradle_path` and `allowed_dependencies` from
|
||||
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
|
||||
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||
|
||||
## Layer workflow
|
||||
@@ -91,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from
|
||||
Architecture-wide commands:
|
||||
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew architectureCheck --console=plain
|
||||
./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --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
|
||||
the controller's workflow authorization.
|
||||
A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone.
|
||||
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
|
||||
|
||||
|
||||
@@ -56,6 +56,23 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||
|
||||
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
|
||||
|
||||
### 프로파일별 데이터스토어
|
||||
|
||||
`bootstrap`은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 `local` 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :app-bootstrap:bootRun
|
||||
```
|
||||
|
||||
| 프로파일 | 데이터스토어 | 스키마 소유자 |
|
||||
| --- | --- | --- |
|
||||
| `local` (bootRun 기본) | H2 in-memory | Hibernate `create-drop` |
|
||||
| `dev` | PostgreSQL | Flyway |
|
||||
| `prod` | PostgreSQL | Flyway |
|
||||
|
||||
`local`은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 [src/app-bootstrap/src/main/resources/](src/app-bootstrap/src/main/resources/)의 `application-{local,dev,prod}.yml`이, 상세 설명은 [src/README.md](src/README.md)가 소유합니다.
|
||||
|
||||
## 새 프로젝트로 시작하기
|
||||
|
||||
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
|
||||
@@ -73,14 +90,20 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
||||
4. 모듈 이름과 경계는 그대로 유지합니다.
|
||||
|
||||
검증은 sample-on과 sample-off를 모두 통과시킵니다.
|
||||
검증은 먼저 composition root의 빠른 테스트와 sample-off 계약을 확인합니다. 루트에서
|
||||
`./gradlew test`를 호출하면 등록된 모든 하위 프로젝트의 `test`를 실행하므로 일상적인 로컬
|
||||
피드백 명령으로 사용하지 않습니다. 저장소 전체 qualification은 CI 또는 명시적인 `ci` task가
|
||||
담당합니다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew test
|
||||
./gradlew :app-bootstrap:test
|
||||
./gradlew :app-bootstrap:sampleOffTest
|
||||
./gradlew architectureCheck
|
||||
```
|
||||
|
||||
병합 전 저장소 전체 검증이 필요하면 `./gradlew ci`를 실행합니다.
|
||||
|
||||
`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에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
|
||||
|
||||
## 수동 전용 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)
|
||||
|
||||
+41
-1
@@ -18,11 +18,30 @@ services:
|
||||
app:
|
||||
# Relax read-only constraint for local development.
|
||||
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.
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
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"
|
||||
LANG: "C.UTF-8"
|
||||
LC_ALL: "C.UTF-8"
|
||||
@@ -53,9 +72,30 @@ services:
|
||||
# Do not restart automatically so crash loops stay visible.
|
||||
restart: "no"
|
||||
# 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:
|
||||
- type: bind
|
||||
source: ./tmp/heap-dumps
|
||||
target: /var/tmp/heap
|
||||
bind:
|
||||
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
|
||||
#
|
||||
# Local intent:
|
||||
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
|
||||
# - Wires the app environment to point at the local DB.
|
||||
# - Wires the app environment to point at the shared `db` service, which lives in
|
||||
# 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.
|
||||
# - Does NOT expose the DB port publicly; app and db communicate on the
|
||||
# internal `caskeleton-local` network only.
|
||||
# - Publishes the DB on the loopback interface only, so a host-side run
|
||||
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
|
||||
# containerised app reaches over the internal `caskeleton-local` network.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
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:
|
||||
- ./src/.env
|
||||
- path: ./src/.env
|
||||
required: false
|
||||
# Wire the app to the local Postgres service on the internal network.
|
||||
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"
|
||||
LANG: "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_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
@@ -45,39 +55,10 @@ services:
|
||||
start_period: 20s
|
||||
retries: 12
|
||||
networks:
|
||||
- caskeleton-local
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
|
||||
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
TZ: "UTC"
|
||||
# Persist data between restarts; remove the volume to start fresh.
|
||||
volumes:
|
||||
- type: volume
|
||||
source: caskeleton-db-data
|
||||
target: /var/lib/postgresql/data
|
||||
# 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
|
||||
- caskeleton-infra
|
||||
|
||||
networks:
|
||||
caskeleton-local:
|
||||
driver: bridge
|
||||
# Defined in docker-compose.infra.yml, where the services that share it live.
|
||||
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}"
|
||||
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
|
||||
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:
|
||||
- "${APP_SERVER_PORT:-8080}:8080"
|
||||
- "9001:9001"
|
||||
@@ -53,6 +61,20 @@ services:
|
||||
tmpfs:
|
||||
- /tmp:mode=1777,size=128m
|
||||
- /var/tmp/heap:mode=1777,size=512m
|
||||
# ---- Fileserver storage volume ------------------------------------------
|
||||
# A named volume, not a tmpfs and not the read-only root. The Fileserver platform's default
|
||||
# storage root is /var/lib/backend/files, and with a read-only root and no mount there was
|
||||
# nowhere on the image it could legally write: enabling the capability failed on its first
|
||||
# upload rather than at startup. The volume is declared unconditionally because a volume
|
||||
# nobody writes to costs nothing, while a missing one costs an outage.
|
||||
#
|
||||
# Ownership: the image runs as uid/gid 1000 (see src/Dockerfile). Docker initialises a fresh
|
||||
# named volume from the image path's ownership, so the directory is created in the image with
|
||||
# that owner; a pre-existing volume or a host bind mount must be chowned to 1000:1000 by the
|
||||
# operator, or every write is refused with a permission error the application reports as
|
||||
# STORAGE_UNAVAILABLE.
|
||||
volumes:
|
||||
- fileserver-data:/var/lib/backend/files
|
||||
# ---- Memory limit (D4) --------------------------------------------------
|
||||
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
|
||||
mem_limit: 512m
|
||||
@@ -78,3 +100,8 @@ services:
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Survives container replacement, which is the point: published content outlives the process
|
||||
# that wrote it. Back this with real storage in any deployment that keeps files.
|
||||
fileserver-data:
|
||||
|
||||
@@ -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,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` |
|
||||
@@ -0,0 +1,64 @@
|
||||
# Entity Mapping Guide
|
||||
|
||||
Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review
|
||||
and expensive in production.
|
||||
|
||||
## The domain owns the model
|
||||
|
||||
The platform defines no business entity. Table names, column semantics, keys, unique and check
|
||||
requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the
|
||||
domain module. There is no `GenericRepository<T, ID>` and no platform base repository, because a
|
||||
single generic API forces every aggregate through the same operations — and one aggregate's later
|
||||
requirement then changes behaviour for all of them.
|
||||
|
||||
## Entities must be proxyable
|
||||
|
||||
- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be
|
||||
subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors.
|
||||
- A non-private no-arg constructor. The provider instantiates entities reflectively before
|
||||
populating fields.
|
||||
|
||||
`EntityMappingCondition` in the testkit enforces both.
|
||||
|
||||
## Identifiers
|
||||
|
||||
Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When
|
||||
they disagree, the provider hands out identifiers the sequence has not reserved and the collision
|
||||
surfaces later as a primary-key violation under load.
|
||||
|
||||
`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider
|
||||
must execute each insert immediately to learn it, which disables JDBC insert batching entirely.
|
||||
`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than
|
||||
letting the import silently run an order of magnitude slower.
|
||||
|
||||
UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary
|
||||
key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index
|
||||
never stays in cache and write amplification grows with the table.
|
||||
|
||||
## Values
|
||||
|
||||
- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the
|
||||
constant's position, so inserting a new constant anywhere but the end silently reinterprets every
|
||||
existing row.
|
||||
- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums
|
||||
drift and reconciliation disagrees with the ledger.
|
||||
- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and
|
||||
compares wrongly in SQL.
|
||||
- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the
|
||||
other.
|
||||
|
||||
## Associations
|
||||
|
||||
- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a
|
||||
child also queries for its parent — the most common accidental N+1 in a JPA application.
|
||||
- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row
|
||||
unlinked, so aggregates expose an association helper that sets both sides.
|
||||
- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns.
|
||||
Between independent aggregates it deletes rows another part of the system still owns.
|
||||
|
||||
## Entities never leave the transaction
|
||||
|
||||
A controller must not return an entity, or a collection or `Optional` of one. Response serialisation
|
||||
happens after the transaction closes, so a lazy association touched by the serialiser either throws
|
||||
or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element.
|
||||
`EntityExposureCondition` checks generic type arguments, not just the erased return type.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Promotion Checklist
|
||||
|
||||
`ExperimentalPromotionGate` evaluates this checklist. Every technical item, then the ADR.
|
||||
|
||||
## Technical evidence
|
||||
|
||||
- [ ] **Compatibility** — the Stable contract suite passes on the experimental target, twice, on two
|
||||
supported patch releases. One passing run is a coincidence.
|
||||
- [ ] **Security** — for tenancy features, cross-tenant read *and* write are both proven impossible,
|
||||
including through native SQL, bulk DML, `getReference`, and the second-level cache. A filter
|
||||
that covers only entity queries covers none of those.
|
||||
- [ ] **Failure** — connection reuse does not leak tenant context; a failover does not silently route
|
||||
a read-after-write to a stale replica; the commit-ambiguity scenarios still behave.
|
||||
- [ ] **Migration** — per-tenant migration is resumable after a partial failure, and rate-limited.
|
||||
With one schema per tenant, a run is N independent migrations and "it failed" is not an answer.
|
||||
- [ ] **Performance** — pool capacity, replica lag under load, and per-tenant memory are measured,
|
||||
not estimated. Database-per-tenant fails as a sum, not as an individual pool.
|
||||
|
||||
## Decision
|
||||
|
||||
- [ ] **Reviewed ADR** — recording what is being promised, the operational burden it carries, and
|
||||
what would cause it to be withdrawn.
|
||||
|
||||
The ADR is not a formality. The technical suites establish that something works; the ADR records
|
||||
that the platform should promise it, which is a different question with a different cost.
|
||||
|
||||
## What does not count as evidence
|
||||
|
||||
- The version being generally available.
|
||||
- The feature working in one environment.
|
||||
- A passing suite that skipped because Docker was unavailable.
|
||||
- A green lane whose assertions were relaxed to make it pass.
|
||||
|
||||
## Outcomes
|
||||
|
||||
| Decision | Meaning |
|
||||
|---|---|
|
||||
| `BLOCKED_TECHNICAL` | at least one suite has not passed |
|
||||
| `BLOCKED_MISSING_ADR` | evidence is complete; no reviewed decision exists |
|
||||
| `ELIGIBLE_FOR_STABLE_REVIEW` | both; Stable review may begin |
|
||||
|
||||
The two blocked states are distinct because they need different work: one needs evidence, the other
|
||||
needs a decision.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Support Matrix
|
||||
|
||||
Everything here is off unless its `backend.jpa.experimental.*` flag is explicitly true, and none of
|
||||
it is part of the Stable composition.
|
||||
|
||||
| Feature | Flag | State |
|
||||
|---|---|---|
|
||||
| Shared-schema multi-tenancy (column) | `backend.jpa.experimental.multitenancy-column` | Experimental |
|
||||
| PostgreSQL RLS multi-tenancy | `backend.jpa.experimental.multitenancy-rls` | Experimental |
|
||||
| Schema-per-tenant | `backend.jpa.experimental.multitenancy-schema` | Experimental |
|
||||
| Database-per-tenant | `backend.jpa.experimental.multitenancy-database` | Experimental |
|
||||
| Consistency-aware read replica | `backend.jpa.experimental.read-replica` | Experimental |
|
||||
| Jakarta Persistence 4.0 lane | `backend.jpa.experimental.jakarta-persistence-4` | Experimental |
|
||||
| Hibernate ORM 8 lane | `backend.jpa.experimental.hibernate-8` | Experimental |
|
||||
| PostgreSQL 19 lane | `backend.jpa.experimental.postgresql-19` | Experimental |
|
||||
|
||||
Presence on the classpath is not consent. `ExperimentalFeatureGate` fails startup when a module is
|
||||
present and its flag is not set, because an experimental module can arrive transitively and a
|
||||
tenant-isolation feature that switched itself on would be the worst possible default.
|
||||
|
||||
## Known constraints
|
||||
|
||||
- Tenant context is fail-closed. An unbound tenant in a shared-schema deployment means a query with
|
||||
no tenant predicate, which returns every tenant's rows.
|
||||
- A Hibernate filter is not the security boundary. It does not apply to native SQL, bulk DML,
|
||||
`getReference`, or the second-level cache.
|
||||
- RLS requires all three of: `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` (the owner is
|
||||
otherwise exempt from its own policies), and a runtime role without `BYPASSRLS`.
|
||||
- Tenant bindings are transaction-local. A session-local setting survives the connection's return to
|
||||
the pool.
|
||||
- `readOnly=true` never routes to a replica on its own. Read-after-write uses a consistency token or
|
||||
the primary.
|
||||
- Unavailable replica lag evidence means the primary. Absence of evidence is not evidence of
|
||||
freshness.
|
||||
- Per-tenant pools are bounded globally. Fifty tenants with a modest pool each is five hundred
|
||||
connections against a server that permits a hundred.
|
||||
- Tenant ids never become metric tags. Tenant cardinality is unbounded by definition.
|
||||
|
||||
## Lanes never change Stable
|
||||
|
||||
A compatibility lane publishes nothing and changes no Stable contract. If Hibernate 8 generates
|
||||
different SQL for the fetch-pagination gate, that is a finding about Hibernate 8 — the 7.x gate keeps
|
||||
asserting what 7.x must do, because that is what deployments run.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Migration Guide
|
||||
|
||||
Design §31-§32. Flyway owns the schema; Hibernate only validates.
|
||||
|
||||
## Who may change the schema
|
||||
|
||||
| Environment | Mode |
|
||||
|---|---|
|
||||
| local, test, dev | migrate at startup with the migration credential |
|
||||
| staging, prod | deployment-owned migration; the application validates only |
|
||||
|
||||
Migrating from inside the application in production means every instance of a rolling deploy races
|
||||
to apply the same script, and the loser's failure is indistinguishable from a real one.
|
||||
|
||||
`ddl-auto` is `validate` or `none`. Never `update`: it never drops or narrows anything, so it
|
||||
produces a schema that is neither the old one nor the one the migrations describe — silently, on
|
||||
whichever instance started first.
|
||||
|
||||
## Validation fails closed and never repairs
|
||||
|
||||
`FlywayValidationGate` throws `SchemaMismatchException` on a checksum mismatch, a missing migration,
|
||||
or a schema Hibernate disagrees with. It never calls `repair`.
|
||||
|
||||
Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the
|
||||
symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the
|
||||
applied one, and the interesting question is which change is missing from this database. Repair
|
||||
makes that question unaskable. It exists only as an explicit admin operation with an operator, a
|
||||
reason, and an approval (design §8.4).
|
||||
|
||||
Only Flyway's structured error codes reach the exception. Its messages embed the script path and
|
||||
part of the failing statement.
|
||||
|
||||
## Concurrent index builds
|
||||
|
||||
`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and Flyway wraps migrations in
|
||||
one by default. The migration therefore needs a companion configuration:
|
||||
|
||||
```conf
|
||||
# V42__order_index.sql.conf
|
||||
executeInTransaction=false
|
||||
```
|
||||
|
||||
`ConcurrentIndexMigrationInspector` fails validation without it, and additionally requires the
|
||||
migration to contain nothing else. A failed concurrent build leaves an invalid index behind;
|
||||
recovering is a single `DROP INDEX` when the migration did nothing else, and a manual reconstruction
|
||||
of partial state when it did.
|
||||
|
||||
An invalid index is not merely useless — the planner ignores it while every write still maintains
|
||||
it. `FailedConcurrentIndexRecovery` reports them with the statement to run, and deliberately does
|
||||
not drop them: an invalid index can also mean a build is still running, and the two are
|
||||
indistinguishable from the catalog alone.
|
||||
|
||||
## Upgrade scenarios
|
||||
|
||||
Three, each catching something the others do not:
|
||||
|
||||
| Scenario | Catches |
|
||||
|---|---|
|
||||
| `empty` | an early migration edited to match a later one, no longer applying to a fresh database |
|
||||
| `previous-release` | the actual deployment path; the only one exercising this release's migrations |
|
||||
| `oldest-supported` | a migration that silently assumes state only recent databases have |
|
||||
|
||||
Each asserts a data invariant, not just the schema version. A migration that renames a column and
|
||||
loses its contents leaves the version correct and the data gone.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Observability
|
||||
|
||||
Design §37. What is measured, and what must never appear in a measurement.
|
||||
|
||||
## Bounded tags, always
|
||||
|
||||
Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure
|
||||
category. All five are registered identifiers, validated by `LowCardinality` at construction rather
|
||||
than at the registry — so an unbounded value fails where it was introduced instead of surviving
|
||||
until a dashboard stops loading.
|
||||
|
||||
Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so
|
||||
each creates a time series per row or per failure; several are also the data the platform keeps out
|
||||
of logs, which a metrics backend would store just as durably and export just as widely.
|
||||
|
||||
## Transaction metrics
|
||||
|
||||
| Meter | Why it exists |
|
||||
|---|---|
|
||||
| `jpa.transaction.duration` | the baseline |
|
||||
| `jpa.transaction.rollback` | rollback rate by failure category |
|
||||
| `jpa.transaction.timeout` | timeouts, distinct from other rollbacks |
|
||||
| `jpa.transaction.completion.unknown` | its own counter, deliberately |
|
||||
|
||||
Completion-unknown gets a separate counter rather than being folded into failures. It is the one
|
||||
outcome that means a human has to look: every other failure is a transaction that definitely did not
|
||||
happen, while this one is a transaction that may have.
|
||||
|
||||
## Query metrics
|
||||
|
||||
`jpa.query.duration` and `jpa.query.rows`. Rows are measured as well as duration because a query
|
||||
that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic
|
||||
per request — a duration metric alone reports it as merely slow.
|
||||
|
||||
## Retry metrics
|
||||
|
||||
Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected
|
||||
cost of concurrency; logging each at WARN pages someone for a system working as designed, after
|
||||
which the retry log gets filtered out and takes the genuinely interesting entries with it.
|
||||
|
||||
`jpa.retry.attempt`, `jpa.retry.attempts` (distribution per operation), `jpa.retry.exhausted`.
|
||||
|
||||
## Query names in SQL
|
||||
|
||||
`NamedStatementInspector` prefixes each statement with its registered query name as a SQL comment,
|
||||
which travels into `pg_stat_activity`, `auto_explain`, and the slow-query log. Without it, "which
|
||||
endpoint issues this query" is answered by grepping the codebase for fragments of SQL.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`SqlDiagnosticRedactor` removes string literals, numbers, and anything email-shaped before SQL
|
||||
reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing
|
||||
which columns hold personal data.
|
||||
|
||||
## The actuator endpoint
|
||||
|
||||
`jpaplatform` reports database major version, provider version, schema version, OSIV state, runtime
|
||||
role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity
|
||||
catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of
|
||||
those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a
|
||||
migration or a repair would be an admin capability exposed over HTTP.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user