Compare commits
31
Commits
0a6dd0e419
...
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 |
@@ -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,289 +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: conditional-transport-qualification
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: conditionalTransportQualification
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: explicit
|
||||
- id: clean-architecture-dependencies
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyCleanArchitectureDependencies
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: environment-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyEnvKeys
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: one-type-per-file
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyOneTypePerFile
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: readme-command-drift
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyReadmeCommands
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: trivy-suppression-governance
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyTrivyignore
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: quarantine-sunset
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyQuarantineSunset
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: public-path-snapshot
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyPublicPathSnapshot
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: explicit
|
||||
- id: dependency-locks
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyDependencyLocks
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: explicit
|
||||
- id: architecture-contract-test
|
||||
release_blocking: true
|
||||
mechanism: contract-test
|
||||
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: sample-off
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: sampleOffTest
|
||||
workflow: ci-quality-gates.yml
|
||||
job: sample-off
|
||||
execution: explicit
|
||||
- id: gate-matrix-lint
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: gate-matrix-lint
|
||||
workflow: ci-quality-gates.yml
|
||||
job: gate-matrix-lint
|
||||
execution: job
|
||||
- id: redis-sdk
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: redis-sdk
|
||||
workflow: ci-quality-gates.yml
|
||||
job: redis-sdk
|
||||
execution: job
|
||||
- id: jpa-candidate-evidence
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: jpa-candidate-evidence
|
||||
workflow: ci-quality-gates.yml
|
||||
job: jpa-candidate-evidence
|
||||
execution: job
|
||||
- id: jpa-r2-evidence
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: jpa-r2-evidence
|
||||
workflow: jpa-r2-evidence.yml
|
||||
job: jpa-r2-evidence
|
||||
execution: job
|
||||
- id: quality-release-gate
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: release-gate
|
||||
workflow: ci-quality-gates.yml
|
||||
job: release-gate
|
||||
execution: job
|
||||
- id: flaky-quarantine
|
||||
release_blocking: false
|
||||
mechanism: workflow-job
|
||||
ref: quarantine
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quarantine
|
||||
execution: job
|
||||
- id: dependency-review
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: dependency-review
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: dependency-review
|
||||
execution: job
|
||||
- id: dependency-submission
|
||||
release_blocking: false
|
||||
mechanism: workflow-job
|
||||
ref: dependency-submission
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: dependency-submission
|
||||
execution: job
|
||||
- id: filesystem-vulnerability-scan
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: trivy-fs
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: trivy-fs
|
||||
execution: job
|
||||
- id: documentation-links
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: lychee
|
||||
workflow: link-check.yml
|
||||
job: lychee
|
||||
execution: job
|
||||
- id: object-storage-minio-managed-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioContractTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-contract
|
||||
execution: explicit
|
||||
- id: poster-image-migration
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: posterImageMigrationTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: poster-image-v7-migration
|
||||
execution: explicit
|
||||
- id: object-storage-minio-managed-fault
|
||||
release_blocking: conditional
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioFaultTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-fault
|
||||
execution: explicit
|
||||
- id: object-storage-aws-protected-qualification
|
||||
release_blocking: conditional
|
||||
mechanism: delegated-pending
|
||||
ref: approval-gate-b
|
||||
workflow: object-storage-qualification.yml
|
||||
job: aws-managed-common-subset
|
||||
execution: job
|
||||
- id: redis-sdk-support-matrix
|
||||
release_blocking: true
|
||||
mechanism: contract-test
|
||||
ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
# Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that
|
||||
# touches the Redis leaf runs the standalone lane, and the full supported-version x topology
|
||||
# matrix runs nightly and on a release candidate. While it was dispatch-only, a release could
|
||||
# claim topology evidence that nobody had produced for that commit.
|
||||
- id: redis-sdk-topology-evidence
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: topology-evidence
|
||||
workflow: redis-sdk-topology.yml
|
||||
job: topology-evidence
|
||||
execution: job
|
||||
- id: httpclient-stable-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientStableContractTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-security-suite
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientSecurityTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-fault-injection
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientFailureInjectionTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-performance-certification
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientPerformanceTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-spring62-api-surface
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: spring62ApiSurfaceScan
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
# The 6.2 API-surface scan above proves the common packages compile against the older surface. It
|
||||
# does not prove they run on it, and the two were being conflated: a lane called
|
||||
# "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to
|
||||
# say what it does, and the runtime claim is registered here as its own delegated-pending control
|
||||
# so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring
|
||||
# Framework 6.2 distribution resolved into a separate test runtime, which this repository's
|
||||
# Boot 4.0 baseline does not carry.
|
||||
- id: httpclient-spring62-runtime
|
||||
release_blocking: conditional
|
||||
mechanism: delegated-pending
|
||||
ref: spring62-runtime-lane
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: job
|
||||
- id: httpclient-spring70-compatibility
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: spring70CompatibilityTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-documentation-drift
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: httpclient-documentation
|
||||
workflow: httpclient-release.yml
|
||||
job: httpclient-documentation
|
||||
execution: job
|
||||
- id: httpclient-event-loop-blocking
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientBlockHoundTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
@@ -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,365 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
if (( $# > 1 )); then
|
||||
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if (( $# == 1 )); then
|
||||
if [[ ! -d "$1" ]]; then
|
||||
printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2
|
||||
exit 2
|
||||
fi
|
||||
REPO_ROOT="$(cd -- "$1" && pwd -P)"
|
||||
else
|
||||
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
||||
EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
||||
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
||||
printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
readonly REPO_ROOT
|
||||
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
||||
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
|
||||
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
|
||||
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
|
||||
# claim, distinct from the API-surface scan that was standing in for it.
|
||||
readonly EXPECTED_GATE_COUNT=38
|
||||
|
||||
if [[ ! -f "${MATRIX}" ]]; then
|
||||
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
records="$(
|
||||
awk '
|
||||
function flush() {
|
||||
if (id != "") {
|
||||
printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, blocking, mechanism, ref, workflow, job, execution
|
||||
}
|
||||
}
|
||||
/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/ {
|
||||
flush()
|
||||
id=$0
|
||||
sub(/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/, "", id)
|
||||
blocking=mechanism=ref=workflow=job=execution=""
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+release_blocking:[[:space:]]*/ {
|
||||
blocking=$0
|
||||
sub(/^[[:space:]]+release_blocking:[[:space:]]*/, "", blocking)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+mechanism:[[:space:]]*/ {
|
||||
mechanism=$0
|
||||
sub(/^[[:space:]]+mechanism:[[:space:]]*/, "", mechanism)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+ref:[[:space:]]*/ {
|
||||
ref=$0
|
||||
sub(/^[[:space:]]+ref:[[:space:]]*/, "", ref)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+workflow:[[:space:]]*/ {
|
||||
workflow=$0
|
||||
sub(/^[[:space:]]+workflow:[[:space:]]*/, "", workflow)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+job:[[:space:]]*/ {
|
||||
job=$0
|
||||
sub(/^[[:space:]]+job:[[:space:]]*/, "", job)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+execution:[[:space:]]*/ {
|
||||
execution=$0
|
||||
sub(/^[[:space:]]+execution:[[:space:]]*/, "", execution)
|
||||
next
|
||||
}
|
||||
END { flush() }
|
||||
' "${MATRIX}"
|
||||
)"
|
||||
|
||||
declare -A seen_ids=()
|
||||
declare -a failures=()
|
||||
total=0
|
||||
verified=0
|
||||
delegated=0
|
||||
|
||||
job_body() {
|
||||
local workflow_file="$1"
|
||||
local job_id="$2"
|
||||
awk -v target="${job_id}" '
|
||||
$0 ~ "^ " target ":[[:space:]]*$" { inside=1; print; next }
|
||||
inside && $0 ~ "^ [A-Za-z0-9_-]+:[[:space:]]*$" { exit }
|
||||
inside { print }
|
||||
' "${workflow_file}"
|
||||
}
|
||||
|
||||
gradle_command_has_safe_literal_grammar() {
|
||||
local command="$1"
|
||||
[[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]]
|
||||
}
|
||||
|
||||
gradle_token_suppresses_execution() {
|
||||
local token="$1"
|
||||
case "${token}" in
|
||||
'--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
gradle_token_is_allowed_gate_argument() {
|
||||
local token="$1"
|
||||
case "${token}" in
|
||||
'--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;;
|
||||
esac
|
||||
[[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]]
|
||||
}
|
||||
|
||||
gradle_plugin_is_applied() {
|
||||
local plugin_id="$1"
|
||||
grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \
|
||||
|| grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \
|
||||
|| grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \
|
||||
|| grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src"
|
||||
}
|
||||
|
||||
gradle_custom_task_is_registered_in_build_file() {
|
||||
local task_name="$1"
|
||||
local build_file="$2"
|
||||
if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
awk -v required_task="${task_name}" '
|
||||
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
|
||||
inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
|
||||
candidate=$0
|
||||
sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate)
|
||||
quote=substr(candidate, 1, 1)
|
||||
if (quote != "\"" && quote != sprintf("%c", 39)) {
|
||||
next
|
||||
}
|
||||
candidate=substr(candidate, 2)
|
||||
closing_quote=index(candidate, quote)
|
||||
if (closing_quote == 0) {
|
||||
next
|
||||
}
|
||||
candidate=substr(candidate, 1, closing_quote - 1)
|
||||
if (candidate == required_task) {
|
||||
found=1
|
||||
}
|
||||
}
|
||||
inside_registration && /\)[[:space:]]*$/ { inside_registration=0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' "${build_file}"
|
||||
}
|
||||
|
||||
gradle_custom_task_is_registered() {
|
||||
local task_name="$1"
|
||||
local build_file
|
||||
while IFS= read -r -d '' build_file; do
|
||||
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
|
||||
return 0
|
||||
fi
|
||||
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0)
|
||||
return 1
|
||||
}
|
||||
|
||||
gradle_token_matches_registered_task() {
|
||||
local token="$1"
|
||||
local required_task="$2"
|
||||
local project_path build_file
|
||||
if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
project_path="${token%:"${required_task}"}"
|
||||
project_path="${project_path#:}"
|
||||
project_path="${project_path%:}"
|
||||
build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle"
|
||||
[[ -f "${build_file}" ]] \
|
||||
&& gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}"
|
||||
}
|
||||
|
||||
job_runs_gradle_task() {
|
||||
local workflow_file="$1"
|
||||
local job_id="$2"
|
||||
local required_task="$3"
|
||||
local command token
|
||||
local found_task suppressed
|
||||
local -a tokens=()
|
||||
|
||||
while IFS= read -r command; do
|
||||
if ! gradle_command_has_safe_literal_grammar "${command}"; then
|
||||
continue
|
||||
fi
|
||||
read -r -a tokens <<< "${command}"
|
||||
if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then
|
||||
continue
|
||||
fi
|
||||
found_task=0
|
||||
suppressed=0
|
||||
for token in "${tokens[@]:1}"; do
|
||||
case "${token}" in
|
||||
'&&'|'||'|';'|'|'|'#'*) break ;;
|
||||
esac
|
||||
if gradle_token_suppresses_execution "${token}"; then
|
||||
suppressed=1
|
||||
break
|
||||
fi
|
||||
if ! gradle_token_is_allowed_gate_argument "${token}"; then
|
||||
suppressed=1
|
||||
break
|
||||
fi
|
||||
if gradle_token_matches_registered_task "${token}" "${required_task}"; then
|
||||
found_task=1
|
||||
fi
|
||||
done
|
||||
if (( found_task == 1 && suppressed == 0 )); then
|
||||
return 0
|
||||
fi
|
||||
done < <(
|
||||
job_body "${workflow_file}" "${job_id}" | awk '
|
||||
/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ {
|
||||
command=$0
|
||||
sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command)
|
||||
if (command !~ /^(\||>)/) {
|
||||
print command
|
||||
}
|
||||
}
|
||||
'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
||||
[[ -z "${id}" ]] && continue
|
||||
total=$((total + 1))
|
||||
|
||||
if [[ -n "${seen_ids[${id}]:-}" ]]; then
|
||||
failures+=("duplicate gate id '${id}'")
|
||||
fi
|
||||
seen_ids["${id}"]=1
|
||||
|
||||
if [[ -z "${blocking}" || -z "${mechanism}" || -z "${ref}" || -z "${workflow}" \
|
||||
|| -z "${job}" || -z "${execution}" ]]; then
|
||||
failures+=("gate '${id}' has an empty required field")
|
||||
continue
|
||||
fi
|
||||
if [[ ! "${blocking}" =~ ^(true|false|conditional)$ ]]; then
|
||||
failures+=("gate '${id}' has invalid release_blocking '${blocking}'")
|
||||
fi
|
||||
if [[ ! "${workflow}" =~ ^[A-Za-z0-9._-]+\.ya?ml$ || ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
failures+=("gate '${id}' has an unsafe workflow or job identifier")
|
||||
continue
|
||||
fi
|
||||
|
||||
workflow_file="${REPO_ROOT}/.github/workflows/${workflow}"
|
||||
if [[ ! -f "${workflow_file}" ]]; then
|
||||
failures+=("gate '${id}' references missing workflow '.github/workflows/${workflow}'")
|
||||
continue
|
||||
fi
|
||||
if ! grep -Eqs -- "^[[:space:]]{2}${job}:[[:space:]]*$" "${workflow_file}"; then
|
||||
failures+=("gate '${id}' references missing job '${job}' in '${workflow}'")
|
||||
continue
|
||||
fi
|
||||
|
||||
case "${mechanism}" in
|
||||
gradle-custom-task)
|
||||
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||
failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'")
|
||||
continue
|
||||
fi
|
||||
if ! gradle_custom_task_is_registered "${ref}"; then
|
||||
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
gradle-plugin-task)
|
||||
plugin="${ref%@*}"
|
||||
task="${ref#*@}"
|
||||
if [[ "${plugin}" == "${ref}" \
|
||||
|| ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \
|
||||
|| ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||
failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'")
|
||||
continue
|
||||
fi
|
||||
if ! gradle_plugin_is_applied "${plugin}"; then
|
||||
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
contract-test)
|
||||
if [[ "${ref}" == /* || "${ref}" == *".."* || ! -f "${REPO_ROOT}/src/${ref}" ]]; then
|
||||
failures+=("gate '${id}' references missing or unsafe contract test 'src/${ref}'")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
workflow-job)
|
||||
if [[ "${ref}" != "${job}" ]]; then
|
||||
failures+=("gate '${id}' workflow-job ref '${ref}' must equal job '${job}'")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
delegated-pending)
|
||||
delegated=$((delegated + 1))
|
||||
printf "gate '%s': explicitly delegated-pending\n" "${id}"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
failures+=("gate '${id}' has unknown mechanism '${mechanism}'")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
case "${execution}" in
|
||||
check)
|
||||
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
|
||||
failures+=("gate '${id}' expects Gradle check in job '${job}'")
|
||||
continue
|
||||
fi
|
||||
if [[ "${mechanism}" == "gradle-custom-task" ]] \
|
||||
&& ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \
|
||||
--include='build.gradle'; then
|
||||
failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
explicit)
|
||||
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
|
||||
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
job)
|
||||
;;
|
||||
*)
|
||||
failures+=("gate '${id}' has unknown execution '${execution}'")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
verified=$((verified + 1))
|
||||
done <<< "${records}"
|
||||
|
||||
if (( total != EXPECTED_GATE_COUNT )); then
|
||||
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
|
||||
fi
|
||||
|
||||
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
|
||||
"${total}" "${verified}" "${delegated}"
|
||||
if (( ${#failures[@]} > 0 )); then
|
||||
printf '::error::gate-matrix-lint: %d drift(s) found\n' "${#failures[@]}" >&2
|
||||
for failure in "${failures[@]}"; do
|
||||
printf ' - %s\n' "${failure}" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
printf 'gate-matrix-lint: OK\n'
|
||||
@@ -1,740 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
|
||||
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
|
||||
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
|
||||
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
|
||||
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
|
||||
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
|
||||
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
|
||||
# find .github/workflows -mindepth 1 -maxdepth 1 \
|
||||
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
|
||||
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
|
||||
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
||||
# | LC_ALL=C sort -z | xargs -0 sha256sum
|
||||
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
|
||||
# merely to make this verifier pass.
|
||||
readonly EXPECTED_WORKFLOW_LOCK=(
|
||||
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
|
||||
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
|
||||
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
|
||||
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
|
||||
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
|
||||
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
|
||||
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
|
||||
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
||||
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
||||
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
||||
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
||||
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
|
||||
)
|
||||
readonly EXPECTED_WRAPPER_PROPERTIES=(
|
||||
'distributionBase=GRADLE_USER_HOME'
|
||||
'distributionPath=wrapper/dists'
|
||||
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
|
||||
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
|
||||
'networkTimeout=10000'
|
||||
'validateDistributionUrl=true'
|
||||
'zipStoreBase=GRADLE_USER_HOME'
|
||||
'zipStorePath=wrapper/dists'
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
fail 'expected exactly one repository-root argument'
|
||||
fi
|
||||
|
||||
readonly REPOSITORY_ROOT=$1
|
||||
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
|
||||
|
||||
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
|
||||
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
|
||||
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
|
||||
|
||||
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
|
||||
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
|
||||
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
|
||||
|
||||
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
|
||||
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
|
||||
fi
|
||||
|
||||
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
|
||||
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|
||||
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
|
||||
|
||||
workflow_lock_valid=1
|
||||
actual_workflow_lock=()
|
||||
while IFS= read -r -d '' locked_workflow; do
|
||||
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
|
||||
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
|
||||
locked_workflow_sha256='<invalid-file-type>'
|
||||
else
|
||||
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
|
||||
fi
|
||||
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
|
||||
done < <(
|
||||
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
|
||||
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
||||
| LC_ALL=C sort -z
|
||||
)
|
||||
|
||||
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
|
||||
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
|
||||
workflow_lock_entry_count=${#actual_workflow_lock[@]}
|
||||
fi
|
||||
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
|
||||
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
|
||||
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
|
||||
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
|
||||
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
|
||||
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
|
||||
workflow_lock_valid=0
|
||||
fi
|
||||
done
|
||||
|
||||
workflow_count=0
|
||||
gradle_job_count=0
|
||||
while IFS= read -r -d '' workflow; do
|
||||
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
|
||||
function reset_step(known_field) {
|
||||
step_active = 0
|
||||
run_block = 0
|
||||
for (known_field in step_fields) {
|
||||
delete step_fields[known_field]
|
||||
}
|
||||
}
|
||||
|
||||
function reset_job() {
|
||||
job = ""
|
||||
in_steps = 0
|
||||
steps_count = 0
|
||||
reset_step()
|
||||
}
|
||||
|
||||
function indentation(line, first_non_space) {
|
||||
if (line ~ /^ *$/) {
|
||||
return length(line)
|
||||
}
|
||||
first_non_space = match(line, /[^ ]/)
|
||||
return first_non_space - 1
|
||||
}
|
||||
|
||||
function trim(value) {
|
||||
sub(/^[[:space:]]+/, "", value)
|
||||
sub(/[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
|
||||
function grammar_error(message) {
|
||||
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
|
||||
invalid = 1
|
||||
}
|
||||
|
||||
function workflow_grammar_error(message) {
|
||||
printf "%s: %s\n", workflow, message > "/dev/stderr"
|
||||
invalid = 1
|
||||
}
|
||||
|
||||
function validate_job_shape() {
|
||||
if (job != "" && steps_count != 1) {
|
||||
grammar_error("must contain exactly one canonical steps block")
|
||||
}
|
||||
}
|
||||
|
||||
function is_allowed_step_field(field) {
|
||||
return field == "name" \
|
||||
|| field == "id" \
|
||||
|| field == "uses" \
|
||||
|| field == "run" \
|
||||
|| field == "if" \
|
||||
|| field == "shell" \
|
||||
|| field == "with" \
|
||||
|| field == "env" \
|
||||
|| field == "working-directory" \
|
||||
|| field == "continue-on-error" \
|
||||
|| field == "timeout-minutes"
|
||||
}
|
||||
|
||||
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
|
||||
value = trim(value)
|
||||
if (value == "" || index(value, "\\") != 0) {
|
||||
grammar_error("has unsupported uses scalar")
|
||||
return
|
||||
}
|
||||
|
||||
first = substr(value, 1, 1)
|
||||
single_quote = sprintf("%c", 39)
|
||||
if (first == "\"" || first == single_quote) {
|
||||
quote = first
|
||||
closing = 0
|
||||
for (index_value = 2; index_value <= length(value); index_value++) {
|
||||
if (substr(value, index_value, 1) == quote) {
|
||||
closing = index_value
|
||||
break
|
||||
}
|
||||
}
|
||||
if (closing == 0) {
|
||||
grammar_error("has unsupported uses scalar")
|
||||
return
|
||||
}
|
||||
suffix = substr(value, closing + 1)
|
||||
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
|
||||
grammar_error("has unsupported uses scalar")
|
||||
return
|
||||
}
|
||||
action = substr(value, 2, closing - 2)
|
||||
if (index(action, quote) != 0) {
|
||||
grammar_error("has unsupported uses scalar")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
action = value
|
||||
sub(/[[:space:]]+#.*$/, "", action)
|
||||
action = trim(action)
|
||||
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
|
||||
grammar_error("has unsupported uses scalar")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
|
||||
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
|
||||
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
|
||||
grammar_error("has unsupported uses scalar")
|
||||
}
|
||||
}
|
||||
|
||||
function validate_run_scalar(value, first) {
|
||||
value = trim(value)
|
||||
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
|
||||
run_block = 1
|
||||
return
|
||||
}
|
||||
first = substr(value, 1, 1)
|
||||
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|
||||
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
|
||||
grammar_error("has unsupported run scalar")
|
||||
}
|
||||
}
|
||||
|
||||
function validate_step_field(content, field, value, separator) {
|
||||
content = trim(content)
|
||||
if (content ~ /^[{[]/) {
|
||||
grammar_error("contains unsupported flow-style step syntax")
|
||||
return
|
||||
}
|
||||
if (content ~ /^<</) {
|
||||
grammar_error("contains a forbidden step merge key")
|
||||
return
|
||||
}
|
||||
if (content ~ /^[*&!]/) {
|
||||
grammar_error("contains unsupported step anchor, alias, or tag syntax")
|
||||
return
|
||||
}
|
||||
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
|
||||
grammar_error("contains unsupported step field syntax")
|
||||
return
|
||||
}
|
||||
|
||||
separator = index(content, ":")
|
||||
field = substr(content, 1, separator - 1)
|
||||
value = substr(content, separator + 1)
|
||||
sub(/^[[:space:]]*/, "", value)
|
||||
if (!is_allowed_step_field(field)) {
|
||||
grammar_error("contains unsupported step field: " field)
|
||||
return
|
||||
}
|
||||
if (field in step_fields) {
|
||||
grammar_error("contains duplicate step field: " field)
|
||||
return
|
||||
}
|
||||
step_fields[field] = 1
|
||||
|
||||
if (field == "uses") {
|
||||
validate_uses_scalar(value)
|
||||
} else if (field == "run") {
|
||||
validate_run_scalar(value)
|
||||
}
|
||||
}
|
||||
|
||||
BEGIN {
|
||||
in_jobs = 0
|
||||
invalid = 0
|
||||
jobs_count = 0
|
||||
single_quote = sprintf("%c", 39)
|
||||
reset_job()
|
||||
}
|
||||
|
||||
/^jobs:/ {
|
||||
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
|
||||
workflow_grammar_error("jobs container must use a canonical block mapping")
|
||||
next
|
||||
}
|
||||
jobs_count++
|
||||
if (jobs_count != 1) {
|
||||
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
|
||||
}
|
||||
in_jobs = 1
|
||||
next
|
||||
}
|
||||
|
||||
/^"jobs":/ {
|
||||
workflow_grammar_error("jobs container must use a canonical block mapping")
|
||||
next
|
||||
}
|
||||
|
||||
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
|
||||
workflow_grammar_error("jobs container must use a canonical block mapping")
|
||||
next
|
||||
}
|
||||
|
||||
run_block == 0 && /^<<:/ {
|
||||
workflow_grammar_error("workflow contains a forbidden merge key")
|
||||
next
|
||||
}
|
||||
|
||||
in_jobs && /^[^[:space:]#]/ {
|
||||
validate_job_shape()
|
||||
reset_job()
|
||||
in_jobs = 0
|
||||
}
|
||||
|
||||
in_jobs && /^ [^[:space:]#]/ {
|
||||
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
|
||||
grammar_error("job declaration must use a canonical block mapping")
|
||||
next
|
||||
}
|
||||
validate_job_shape()
|
||||
reset_job()
|
||||
job = $0
|
||||
sub(/^ /, "", job)
|
||||
sub(/:.*/, "", job)
|
||||
next
|
||||
}
|
||||
|
||||
in_jobs && job != "" {
|
||||
raw = $0
|
||||
line_indent = indentation(raw)
|
||||
|
||||
if (run_block != 0) {
|
||||
if (raw ~ /^ *$/ || line_indent > 8) {
|
||||
next
|
||||
}
|
||||
run_block = 0
|
||||
}
|
||||
|
||||
if (raw ~ /^ *#/) {
|
||||
next
|
||||
}
|
||||
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|
||||
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
|
||||
if (raw != " steps:") {
|
||||
grammar_error("steps container must use a canonical block sequence")
|
||||
next
|
||||
}
|
||||
steps_count++
|
||||
if (steps_count != 1) {
|
||||
grammar_error("must contain exactly one canonical steps block")
|
||||
}
|
||||
in_steps = 1
|
||||
reset_step()
|
||||
next
|
||||
}
|
||||
if (in_steps != 0 && line_indent == 4) {
|
||||
in_steps = 0
|
||||
reset_step()
|
||||
}
|
||||
|
||||
if (raw ~ /^ *<<:/) {
|
||||
grammar_error("contains a forbidden merge key")
|
||||
next
|
||||
}
|
||||
|
||||
if (in_steps != 0 && raw ~ /^ - /) {
|
||||
reset_step()
|
||||
step_active = 1
|
||||
content = substr(raw, 9)
|
||||
validate_step_field(content)
|
||||
next
|
||||
}
|
||||
|
||||
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
|
||||
grammar_error("contains unsupported empty step syntax")
|
||||
next
|
||||
}
|
||||
|
||||
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
|
||||
content = substr(raw, 9)
|
||||
validate_step_field(content)
|
||||
next
|
||||
}
|
||||
|
||||
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
|
||||
grammar_error("contains unsupported step-list syntax")
|
||||
}
|
||||
}
|
||||
|
||||
END {
|
||||
validate_job_shape()
|
||||
if (jobs_count != 1) {
|
||||
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
|
||||
}
|
||||
if (invalid) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "${workflow}"; then
|
||||
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- './gradlew' "${workflow}" \
|
||||
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
|
||||
continue
|
||||
fi
|
||||
((workflow_count += 1))
|
||||
|
||||
if ! jobs_in_workflow=$(
|
||||
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
|
||||
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
|
||||
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
|
||||
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
|
||||
function reset_step(known_field) {
|
||||
step_active = 0
|
||||
run_block = 0
|
||||
step_kind = ""
|
||||
step_name = ""
|
||||
step_id = ""
|
||||
step_uses = ""
|
||||
step_uses_action = ""
|
||||
step_if = ""
|
||||
step_if_present = 0
|
||||
step_continue_on_error = 0
|
||||
step_gradle = 0
|
||||
step_gradle_line = 0
|
||||
step_unsupported_gradle = 0
|
||||
step_field_count = 0
|
||||
step_name_line = 0
|
||||
step_id_line = 0
|
||||
step_uses_line = 0
|
||||
step_extra_field = ""
|
||||
for (known_field in step_fields) {
|
||||
delete step_fields[known_field]
|
||||
delete step_field_raw[known_field]
|
||||
}
|
||||
}
|
||||
|
||||
function reset_job() {
|
||||
job = ""
|
||||
checkout_line = 0
|
||||
validation_line = 0
|
||||
gradle_line = 0
|
||||
in_steps = 0
|
||||
unsupported_gradle = 0
|
||||
reset_step()
|
||||
}
|
||||
|
||||
function indentation(line, first_non_space) {
|
||||
if (line ~ /^ *$/) {
|
||||
return length(line)
|
||||
}
|
||||
first_non_space = match(line, /[^ ]/)
|
||||
return first_non_space - 1
|
||||
}
|
||||
|
||||
function has_gradle_reference(line) {
|
||||
return index(line, "./gradlew") != 0 \
|
||||
|| index(line, "gradle/actions/dependency-submission@") != 0
|
||||
}
|
||||
|
||||
function trim(value) {
|
||||
sub(/^[[:space:]]+/, "", value)
|
||||
sub(/[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
|
||||
function normalize_action(value, scalar, first, quote, closing, index_value) {
|
||||
scalar = trim(value)
|
||||
first = substr(scalar, 1, 1)
|
||||
if (first == "\"" || first == single_quote) {
|
||||
quote = first
|
||||
closing = index(substr(scalar, 2), quote)
|
||||
if (closing == 0) {
|
||||
return ""
|
||||
}
|
||||
return substr(scalar, 2, closing - 1)
|
||||
}
|
||||
sub(/[[:space:]]+#.*$/, "", scalar)
|
||||
return trim(scalar)
|
||||
}
|
||||
|
||||
function record_gradle(line_number) {
|
||||
step_gradle = 1
|
||||
if (step_gradle_line == 0) {
|
||||
step_gradle_line = line_number
|
||||
}
|
||||
if (gradle_line == 0) {
|
||||
gradle_line = line_number
|
||||
}
|
||||
}
|
||||
|
||||
function record_uses(value, line_number, action) {
|
||||
if (step_kind == "run") {
|
||||
if (index(value, "gradle/actions/dependency-submission@") != 0) {
|
||||
step_unsupported_gradle = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
step_kind = "uses"
|
||||
action = normalize_action(value)
|
||||
step_uses = trim(value)
|
||||
step_uses_action = action
|
||||
step_uses_line = line_number
|
||||
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
|
||||
checkout_line = line_number
|
||||
}
|
||||
if (action == dependency_action) {
|
||||
record_gradle(line_number)
|
||||
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
|
||||
record_gradle(line_number)
|
||||
step_unsupported_gradle = 1
|
||||
}
|
||||
}
|
||||
|
||||
function record_run(value, line_number) {
|
||||
if (step_kind == "uses") {
|
||||
if (index(value, "./gradlew") != 0) {
|
||||
step_unsupported_gradle = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
step_kind = "run"
|
||||
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
|
||||
run_block = 1
|
||||
} else if (index(value, "./gradlew") != 0) {
|
||||
record_gradle(line_number)
|
||||
}
|
||||
}
|
||||
|
||||
function record_step_field(content, line_number, separator, field, value) {
|
||||
separator = index(content, ":")
|
||||
field = substr(content, 1, separator - 1)
|
||||
value = substr(content, separator + 1)
|
||||
sub(/^[[:space:]]*/, "", value)
|
||||
step_fields[field] = 1
|
||||
step_field_raw[field] = trim(content)
|
||||
step_field_count++
|
||||
|
||||
if (field == "name") {
|
||||
step_name = trim(value)
|
||||
step_name_line = line_number
|
||||
} else if (field == "id") {
|
||||
step_id = trim(value)
|
||||
step_id_line = line_number
|
||||
} else if (field == "uses") {
|
||||
record_uses(value, line_number)
|
||||
} else if (field == "run") {
|
||||
record_run(trim(value), line_number)
|
||||
} else if (field == "if") {
|
||||
step_if_present = 1
|
||||
step_if = trim(value)
|
||||
} else if (field == "continue-on-error") {
|
||||
step_continue_on_error = 1
|
||||
}
|
||||
|
||||
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
|
||||
step_extra_field = step_field_raw[field]
|
||||
}
|
||||
}
|
||||
|
||||
function validate_wrapper_step() {
|
||||
if (step_uses_action != validation_reference) {
|
||||
return
|
||||
}
|
||||
if (step_extra_field != "") {
|
||||
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
|
||||
invalid = 1
|
||||
return
|
||||
}
|
||||
if (step_field_count != 3 \
|
||||
|| step_name != "Validate Gradle wrapper" \
|
||||
|| step_id != "gradle-wrapper-validation" \
|
||||
|| step_uses != validation_action \
|
||||
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
|
||||
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
|
||||
invalid = 1
|
||||
return
|
||||
}
|
||||
if (validation_line == 0) {
|
||||
validation_line = step_uses_line
|
||||
}
|
||||
}
|
||||
|
||||
function validate_gradle_step() {
|
||||
if (step_gradle == 0 && step_unsupported_gradle == 0) {
|
||||
return
|
||||
}
|
||||
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
|
||||
unsupported_gradle = 1
|
||||
}
|
||||
if (step_if_present != 0 && step_if != guarded_gradle_if) {
|
||||
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
|
||||
invalid = 1
|
||||
}
|
||||
if (step_continue_on_error != 0) {
|
||||
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
|
||||
invalid = 1
|
||||
}
|
||||
}
|
||||
|
||||
function finalize_step() {
|
||||
if (step_active == 0) {
|
||||
return
|
||||
}
|
||||
validate_wrapper_step()
|
||||
validate_gradle_step()
|
||||
}
|
||||
|
||||
function start_step() {
|
||||
finalize_step()
|
||||
reset_step()
|
||||
step_active = 1
|
||||
}
|
||||
|
||||
function validate_job() {
|
||||
finalize_step()
|
||||
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
|
||||
return
|
||||
}
|
||||
gradle_jobs++
|
||||
if (unsupported_gradle != 0) {
|
||||
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
|
||||
invalid = 1
|
||||
}
|
||||
if (gradle_line == 0) {
|
||||
return
|
||||
} else if (checkout_line == 0) {
|
||||
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
|
||||
invalid = 1
|
||||
} else if (validation_line == 0) {
|
||||
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
|
||||
invalid = 1
|
||||
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
|
||||
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
|
||||
invalid = 1
|
||||
}
|
||||
}
|
||||
|
||||
BEGIN {
|
||||
in_jobs = 0
|
||||
invalid = 0
|
||||
gradle_jobs = 0
|
||||
single_quote = sprintf("%c", 39)
|
||||
validation_reference = validation_action
|
||||
sub(/[[:space:]]+#.*$/, "", validation_reference)
|
||||
reset_job()
|
||||
}
|
||||
|
||||
/^jobs:[[:space:]]*(#.*)?$/ {
|
||||
in_jobs = 1
|
||||
next
|
||||
}
|
||||
|
||||
in_jobs && /^[^[:space:]#]/ {
|
||||
validate_job()
|
||||
reset_job()
|
||||
in_jobs = 0
|
||||
}
|
||||
|
||||
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
|
||||
validate_job()
|
||||
reset_job()
|
||||
job = $0
|
||||
sub(/^ /, "", job)
|
||||
sub(/:.*/, "", job)
|
||||
next
|
||||
}
|
||||
|
||||
in_jobs && job != "" {
|
||||
raw = $0
|
||||
line_indent = indentation(raw)
|
||||
|
||||
if (run_block != 0) {
|
||||
if (raw ~ /^ *$/) {
|
||||
next
|
||||
}
|
||||
if (line_indent > 8) {
|
||||
if (index(raw, "./gradlew") != 0) {
|
||||
record_gradle(NR)
|
||||
}
|
||||
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
|
||||
step_unsupported_gradle = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
run_block = 0
|
||||
}
|
||||
|
||||
if (raw ~ /^ *#/) {
|
||||
next
|
||||
}
|
||||
|
||||
if (raw == " steps:") {
|
||||
in_steps = 1
|
||||
reset_step()
|
||||
next
|
||||
}
|
||||
|
||||
if (in_steps != 0 && line_indent == 4) {
|
||||
finalize_step()
|
||||
in_steps = 0
|
||||
reset_step()
|
||||
}
|
||||
|
||||
if (in_steps != 0 && raw ~ /^ - /) {
|
||||
start_step()
|
||||
content = substr(raw, 9)
|
||||
record_step_field(content, NR)
|
||||
next
|
||||
}
|
||||
|
||||
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
|
||||
content = substr(raw, 9)
|
||||
record_step_field(content, NR)
|
||||
next
|
||||
}
|
||||
|
||||
if (has_gradle_reference(raw)) {
|
||||
unsupported_gradle = 1
|
||||
}
|
||||
}
|
||||
|
||||
END {
|
||||
validate_job()
|
||||
print gradle_jobs
|
||||
if (invalid) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "${workflow}"
|
||||
); then
|
||||
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||
fi
|
||||
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|
||||
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||
((jobs_in_workflow > 0)) \
|
||||
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
|
||||
((gradle_job_count += jobs_in_workflow))
|
||||
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
|
||||
|
||||
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
|
||||
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
|
||||
((workflow_lock_valid != 0)) \
|
||||
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
|
||||
|
||||
printf 'gradle-wrapper-contract: PASS\n'
|
||||
@@ -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:
|
||||
@@ -21,9 +30,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- name: Require the committed public-path security baseline
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -36,103 +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 --warning-mode=fail --no-daemon --stacktrace
|
||||
run: ./gradlew :ci :verifyPublicPathSnapshot :verifyDependencyLocks --warning-mode=fail --stacktrace
|
||||
# Named as its own step because nothing else runs it: `check` does not depend on
|
||||
# graphqlStableTest, so the lane's required-class guard — the check that its module-boundary
|
||||
# suite has not silently stopped being discovered — would protect nothing in CI.
|
||||
- name: Qualify the GraphQL Stable lane
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --stacktrace
|
||||
- name: Qualify opt-in inbound transports without skips
|
||||
working-directory: src
|
||||
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
|
||||
run: ./gradlew :conditionalTransportQualification --stacktrace
|
||||
|
||||
sample-off:
|
||||
build-logic:
|
||||
# Included-build tests are independent of the main project task graph. Running them as a
|
||||
# separate blocking job keeps plugin TestKit work off the quality-gates critical path.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify the application without the sample fixture
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Test the build-logic convention plugins
|
||||
working-directory: src
|
||||
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||
|
||||
gate-matrix-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Verify the gate matrix against the repository
|
||||
run: bash .github/scripts/verify-gate-matrix.sh
|
||||
run: ./gradlew -p build-logic test --stacktrace
|
||||
|
||||
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
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
|
||||
# permit provenance, connection isolation, and the executor guard. There is no real-server
|
||||
# lane yet — Tasks 10-17 add the contract suites that need one.
|
||||
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
|
||||
- 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
|
||||
:shared-contract:edgeRateLimitContractTest
|
||||
:adapter:outbound:cache-redis:check
|
||||
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
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
@@ -142,53 +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
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run quarantined tests as an advisory signal
|
||||
working-directory: src
|
||||
run: ./gradlew quarantineTest --no-daemon
|
||||
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
|
||||
- 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 }}
|
||||
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,18 +35,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
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:
|
||||
@@ -179,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
|
||||
@@ -24,18 +24,7 @@ jobs:
|
||||
FILESERVER_NFS_TESTS: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
@@ -43,7 +32,7 @@ jobs:
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
- name: Tear down the NFS environment
|
||||
if: always()
|
||||
@@ -54,18 +43,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the crash matrix and reconciliation suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
@@ -73,7 +51,7 @@ jobs:
|
||||
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
|
||||
:application-core:test --tests '*FileReconciliationServiceTest'
|
||||
--rerun-tasks
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-large-file-performance:
|
||||
@@ -81,18 +59,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the large-file and slow-client suites under a constrained heap
|
||||
working-directory: src
|
||||
env:
|
||||
@@ -102,7 +69,7 @@ jobs:
|
||||
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
|
||||
--rerun-tasks
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-multi-instance-lease:
|
||||
@@ -110,23 +77,12 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
@@ -23,9 +23,13 @@ on:
|
||||
- 'docs/registries/env-keys.yaml'
|
||||
- 'src/Dockerfile'
|
||||
- 'docker-compose.yml'
|
||||
- 'infra/nginx/**'
|
||||
- 'infra/k8s/**'
|
||||
- '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
|
||||
@@ -40,25 +44,14 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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 '*CleanArchitectureTest' --tests '*Fileserver*'
|
||||
--no-daemon
|
||||
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --tests '*Fileserver*'
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-local-ext4-contract:
|
||||
@@ -66,24 +59,13 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-http-contract:
|
||||
@@ -91,24 +73,13 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the servlet and reactive transport contracts
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:test
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-security-suite:
|
||||
@@ -116,25 +87,14 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
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 the path, filename, range, and problem-detail hardening suite
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
|
||||
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-bounded-memory:
|
||||
@@ -142,23 +102,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
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: Prove transfer cost does not scale with file size
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
name: fileserver-release
|
||||
|
||||
# The gate a release must clear. Its job list is deliberately the same shape as the support matrix:
|
||||
# nothing may be advertised at a support level whose evidence job is absent here.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
fileserver-full-verification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the architecture-wide dependency and module verification
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
verifyCleanArchitectureDependencies
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
- name: Run the complete fileserver suite across every leaf
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:check
|
||||
:adapter:inbound:web:check
|
||||
:adapter:outbound:fileserver:check
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
fileserver-documentation-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
fileserver-pvc-certification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
|
||||
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
|
||||
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
|
||||
# when nothing had been applied anywhere.
|
||||
- name: Check the certification manifest still says what the claim depends on
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
test -f "$manifest"
|
||||
grep -q 'kind: PersistentVolumeClaim' "$manifest"
|
||||
grep -q 'kind: Job' "$manifest"
|
||||
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
|
||||
# mode would certify a topology the support matrix says is uncertified.
|
||||
grep -q 'ReadWriteOnce' "$manifest"
|
||||
! grep -q 'ReadWriteMany' "$manifest"
|
||||
- name: Certify the ReadWriteOnce claim on the release cluster
|
||||
id: pvc-cluster-run
|
||||
env:
|
||||
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
|
||||
echo "::warning::no release cluster configured; PVC certification was NOT run."
|
||||
echo "The support matrix records this profile as Limited for exactly this reason:"
|
||||
echo "the cluster result is produced by an operator against a real cluster and read"
|
||||
echo "from docs/fileserver/storage-certification.md, not by this job."
|
||||
echo "certified=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
|
||||
export KUBECONFIG=/tmp/kubeconfig
|
||||
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
|
||||
kubectl logs job/fileserver-pvc-certification
|
||||
echo "certified=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
fileserver-sensitive-telemetry-scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:test --tests '*FileserverObservabilityTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -1,132 +0,0 @@
|
||||
name: httpclient-contract
|
||||
|
||||
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
|
||||
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
|
||||
# disappearing into an aggregate run.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/adapter/outbound/httpclient/**'
|
||||
- 'src/app-bootstrap/src/**/httpclient/**'
|
||||
- 'docs/httpclient/**'
|
||||
- 'scripts/verify-httpclient-docs.py'
|
||||
- '.github/workflows/httpclient-contract.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
httpclient-unit-and-boundaries:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the focused module suite and the architecture gate
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:test
|
||||
verifyCleanArchitectureDependencies
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-stable-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
transport: [apache, jdk, reactor]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify one transport against the shared contract
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||
-Phttpclient.contract.transports=${{ matrix.transport }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-security-and-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the SSRF, cardinality, and Spring compatibility lanes
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientSecurityTest
|
||||
:adapter:outbound:httpclient:httpClientBlockHoundTest
|
||||
:adapter:outbound:httpclient:spring62ApiSurfaceScan
|
||||
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-composition:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify composition and architecture in the bootstrap module
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -1,94 +0,0 @@
|
||||
name: httpclient-nightly
|
||||
|
||||
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
|
||||
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
|
||||
# fails, it does not skip.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
httpclient-fault-injection:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Inject TCP faults against a real upstream
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-performance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify pool, streaming, retry, and rotation bounds
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-http3-experimental:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Experimental by design (D-08): the result is reported, never used to block a merge.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Exercise the experimental HTTP/3 opt-in
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:test
|
||||
-Phttp3.tests.enabled=true
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -1,70 +0,0 @@
|
||||
name: httpclient-release
|
||||
|
||||
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
|
||||
# single-line `./gradlew <task>` step, because .github/scripts/verify-gate-matrix.sh reads these
|
||||
# commands to prove the gate is actually executed — a folded or flag-laden command would make the
|
||||
# declaration in .github/ci-gate-matrix.yml unverifiable.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src
|
||||
env:
|
||||
# A project property rather than a command-line flag, so each run command stays a plain,
|
||||
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Focused module tests
|
||||
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
|
||||
- name: Spring 6.2 API surface lane
|
||||
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
|
||||
- name: Spring 7.0 compatibility lane
|
||||
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
|
||||
- name: Stable cross-transport contract suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
|
||||
- name: SSRF and cardinality suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
|
||||
- name: Event-loop blocking suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
|
||||
- name: Toxiproxy fault-injection suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
|
||||
- name: Resource-bound performance certification
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
|
||||
- name: Architecture dependency gate
|
||||
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||
|
||||
httpclient-documentation:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify documentation matches the code
|
||||
run: python3 scripts/verify-httpclient-docs.py
|
||||
@@ -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,25 +26,14 @@ jobs:
|
||||
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
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
|
||||
@@ -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,90 +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
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run non-skipping Poster image migration qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
||||
|
||||
minio-managed-contract:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- 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
|
||||
@@ -34,6 +34,9 @@ on:
|
||||
- "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 * * *"
|
||||
@@ -113,18 +116,7 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Start the topology
|
||||
env:
|
||||
REDIS_VERSION: ${{ matrix.redis_version }}
|
||||
@@ -135,8 +127,19 @@ jobs:
|
||||
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 }}" 2>/dev/null || echo 'unresolved')"
|
||||
"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
|
||||
|
||||
@@ -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,12 +49,22 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
|
||||
|
||||
## Gradle 정책 권위
|
||||
|
||||
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
||||
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
|
||||
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||
membership
|
||||
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
||||
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
||||
architecture-wide verification task
|
||||
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`에서
|
||||
@@ -102,7 +112,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
|
||||
|
||||
## 모듈 책임
|
||||
|
||||
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
|
||||
모든 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
|
||||
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
||||
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
||||
`src/**/CLAUDE.md`를 함께 읽는다.
|
||||
@@ -173,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,9 +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
|
||||
- `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.
|
||||
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.
|
||||
@@ -43,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 |
|
||||
| --- | --- | --- |
|
||||
@@ -54,9 +57,20 @@ 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`, `allowed_dependencies`, and `runtime_memberships` from
|
||||
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||
@@ -92,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
|
||||
|
||||
|
||||
@@ -90,14 +90,20 @@ cd src
|
||||
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 테스트를 통과시킨 뒤 정리합니다.
|
||||
|
||||
## 아키텍처 규칙과 검증
|
||||
@@ -122,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
|
||||
+19
-49
@@ -5,8 +5,11 @@
|
||||
# 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.
|
||||
# - Publishes the DB on the loopback interface only, so a host-side run
|
||||
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
|
||||
@@ -15,10 +18,19 @@
|
||||
|
||||
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"
|
||||
@@ -30,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"
|
||||
@@ -46,49 +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
|
||||
# The containerised app reaches this over the internal network and needs no host port. A
|
||||
# host-side run does: src/.env is the dotenv source bootRun reads, and its committed
|
||||
# APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
|
||||
# that default named an address nothing in the repository provisioned, so every bootRun died in
|
||||
# the startup migration phase with a connection refusal.
|
||||
#
|
||||
# Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
|
||||
# nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
|
||||
# host keeps its conventional port.
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
networks:
|
||||
- caskeleton-local
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
- 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"
|
||||
|
||||
@@ -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,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)
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
|
||||
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
|
||||
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||
# 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.
|
||||
#
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HTTP Client Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||
**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:
|
||||
|
||||
@@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| 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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# PostgreSQL Extensions
|
||||
|
||||
Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by.
|
||||
|
||||
Everything here is core PostgreSQL. No server extension is required.
|
||||
|
||||
## Locking
|
||||
|
||||
`SELECT ... FOR UPDATE` with a finite bound, always. `PostgreSqlLockOptions` refuses an unbounded
|
||||
lock request because it waits as long as the holder holds it, turning one slow transaction into a
|
||||
pile-up of blocked connections.
|
||||
|
||||
`NOWAIT` and a wait timeout are separate requests, not two spellings of one — modelling them as a
|
||||
single field with a magic zero is how "no wait" becomes "wait forever".
|
||||
|
||||
`55P03` (lock not available) and `40P01` (deadlock) drive opposite recovery and are never collapsed:
|
||||
the first leaves the transaction alive and the caller in control; the second has already been rolled
|
||||
back by the server.
|
||||
|
||||
## Work claims
|
||||
|
||||
`FOR UPDATE SKIP LOCKED` is reachable only through a registered `WorkQueueName`, never as a
|
||||
repository flag. It deliberately returns an incomplete view of the table: correct for handing
|
||||
disjoint work to competing workers, silently wrong for anything that needs to see every matching
|
||||
row. A registered claim statement must skip locked rows and impose a deterministic `ORDER BY`.
|
||||
|
||||
## Upserts
|
||||
|
||||
`INSERT ... ON CONFLICT ... RETURNING` under a registered `NativeWriteName` with a fixed conflict
|
||||
target and update column set. The conflict target cannot be a bound parameter, so accepting one from
|
||||
a caller would mean building SQL from input.
|
||||
|
||||
An upsert is the correct answer to a create race precisely because the database decides.
|
||||
Read-then-write cannot be made correct: another transaction can commit between the read and the
|
||||
write. `(xmax = 0) AS inserted` in the `RETURNING` list is what lets the platform report
|
||||
insert-versus-update without a second query.
|
||||
|
||||
The executor flushes before and clears after: a native write is invisible to the Persistence
|
||||
Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded
|
||||
beforehand would keep serving pre-upsert values.
|
||||
|
||||
## JSONB
|
||||
|
||||
`JsonDocument` carries a schema name and version alongside the payload. A JSONB column is schemaless
|
||||
at the database level, so without an envelope the only record of what a stored document means is the
|
||||
code that wrote it — and a document written two releases ago is indistinguishable from a current one.
|
||||
|
||||
The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization
|
||||
gadget: whoever can write a row chooses the class the reader instantiates.
|
||||
|
||||
Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a
|
||||
request field into one is concatenating untrusted input into a statement. Values are always bound.
|
||||
|
||||
## Arrays and ranges
|
||||
|
||||
Arrays are built with `Connection.createArrayOf`, never by formatting a literal — hand-formatting is
|
||||
where quoting bugs live, and a tag containing a comma changes the array's shape rather than its
|
||||
content.
|
||||
|
||||
`PgRange` models both endpoints as independently optional and independently inclusive, because that
|
||||
is what a PostgreSQL range is. Whether `[09:00, 10:00)` and `[10:00, 11:00)` overlap depends on the
|
||||
bracket, not the values, and a pair of `timestamptz` columns cannot express it.
|
||||
|
||||
## COPY (J4 admin)
|
||||
|
||||
`COPY` bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That
|
||||
is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a
|
||||
row and byte cap, a finite server-side `statement_timeout`, and a named operator.
|
||||
|
||||
The registry accepts only `COPY ... FROM STDIN`. `COPY ... FROM '/path'` reads a file on the
|
||||
*database server* as the server's OS user; it is superuser-only for exactly that reason and does not
|
||||
belong behind an application API.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Query and Fetch Guide
|
||||
|
||||
Design §23-§28. How queries are chosen, bounded, and proven.
|
||||
|
||||
## Named queries
|
||||
|
||||
Every registered query carries a `QueryName`. It becomes the metric tag, the trace attribute, and
|
||||
the SQL comment that appears in `pg_stat_activity` and the slow-query log — which is the only thing
|
||||
that connects a statement on the server back to the use case that issued it. The format rejects raw
|
||||
SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built
|
||||
from a parameterised value leaks row data into telemetry.
|
||||
|
||||
## Fetch plans, not eager mappings
|
||||
|
||||
N+1 is solved per use case with a registered entity graph, not by making an association `EAGER` in
|
||||
the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every
|
||||
other query against that entity, including the ones that only wanted the id.
|
||||
|
||||
`fetchgraph` and `loadgraph` are different: a fetch graph is exhaustive (attributes outside it are
|
||||
lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either
|
||||
missing data or the amplification the graph was meant to avoid.
|
||||
|
||||
## Measuring, not guessing
|
||||
|
||||
`QueryMeasurement` records statements, hydrated entities, rows, fetches, and elapsed time. Statement
|
||||
count alone cannot distinguish the two failures that matter:
|
||||
|
||||
- **N+1** — many statements, few rows.
|
||||
- **Cartesian fetch** — one statement, an enormous number of rows.
|
||||
|
||||
A suite asserting only on statement count passes the second one every time.
|
||||
|
||||
## Pagination
|
||||
|
||||
Offset pagination makes the database walk and discard `n` rows before returning any. Keyset
|
||||
pagination replaces it:
|
||||
|
||||
- The predicate is lexicographic. For an ordering of `(createdAt, id)`, "after `(t, x)`" is
|
||||
`createdAt < t OR (createdAt = t AND id < x)` — **not** `createdAt <= t AND id < x`, which reads
|
||||
plausibly and silently drops rows from the middle of the result set.
|
||||
- The ordering must end in a unique column. Without one, a page boundary inside a run of equal
|
||||
values duplicates and skips rows.
|
||||
- `size + 1` rows are fetched and `size` returned. That extra row answers `hasNext` without a count
|
||||
query, which would be a second full scan whose answer is stale on arrival.
|
||||
|
||||
Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a
|
||||
caller seek to arbitrary keys.
|
||||
|
||||
## Sorting
|
||||
|
||||
Client sort parameters are mapped through `SafeSortRegistry`, never passed through. A sort field
|
||||
reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the
|
||||
client's string means the client writes part of the statement. `JpaSort.unsafe` has no call site in
|
||||
this platform.
|
||||
|
||||
The registry's tie-breaker is always appended, because a sort that does not end in a unique column
|
||||
has no total order and paging over a non-total order duplicates and skips rows.
|
||||
|
||||
## Streaming
|
||||
|
||||
A JPA `Stream` is a live cursor holding a `ResultSet`, a statement, and a connection. `JpaStreamExecutor`
|
||||
consumes it inside a try-with-resources and never returns it, because a stream returned past the
|
||||
transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only
|
||||
transaction is required: streaming inside a write transaction pins a write connection for the whole
|
||||
traversal.
|
||||
|
||||
## Batching
|
||||
|
||||
Configuring `hibernate.jdbc.batch_size` proves nothing. `BatchExecutionResult.jdbcBatches` comes from
|
||||
counting real `executeBatch()` calls at the JDBC layer, because an IDENTITY generator, an interleaved
|
||||
select, or a mid-loop flush disables batching while the configuration still says it is on.
|
||||
|
||||
Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity
|
||||
in the Persistence Context — the classic bulk-import out-of-memory.
|
||||
@@ -0,0 +1,164 @@
|
||||
# JPA Relational Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
|
||||
**Stable plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
|
||||
**Experimental plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
|
||||
|
||||
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
|
||||
structure are explicit implementation *assumptions* made because the real Backend Skeleton
|
||||
repository was not supplied. Before implementing, paths are adjusted to the repository's existing
|
||||
conventions and root package while the public contracts and policy semantics are preserved.
|
||||
|
||||
This file is the single record of *how* that mapping was performed. Only paths, build DSL, and
|
||||
composition-root ownership changed. Public contracts, policy order, retry semantics, and error
|
||||
semantics are implemented as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7
|
||||
Experimental projects under `modules/jpa-experimental/`. 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**, and `src/settings.gradle` throws when the registry does not contain exactly 19
|
||||
modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the plan's library modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules.
|
||||
This is the same adaptation already applied to the HTTP client platform
|
||||
(`docs/httpclient/repository-adaptation.md`).
|
||||
|
||||
| Plan module | Repository home | Reason |
|
||||
|---|---|---|
|
||||
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
|
||||
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
|
||||
|
||||
The package boundary is enforced by `JpaModuleBoundaryTest`. It holds a closed catalog of the
|
||||
production root's direct child packages, compares that catalog against the tree for exact equality,
|
||||
checks every observed top-level edge against the declared ones, and rejects cycles.
|
||||
|
||||
This used to be a stronger claim than the test. The catalog listed thirteen packages while the tree
|
||||
held twenty-two, so nine — `audit`, `config`, `failure`, `fileserver`, `h2`, `idempotency`, `lock`,
|
||||
`notification`, `outbox` — were governed by nothing, and a `transaction → postgresql` /
|
||||
`postgresql → transaction` cycle passed. Both are closed now, and the catalog's exact-equality check
|
||||
is what keeps a new package from being green by omission.
|
||||
|
||||
**Known gap.** The catalog governs top-level packages. Sub-package edges inside one top-level
|
||||
package are not checked, and the target tree in the review's JPA-023 (a `capability/*` layout) is
|
||||
not implemented — the notification configuration facade is the first step toward it.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.jpa` → `dev.caskeleton.adapter.outbound.persistence`.
|
||||
|
||||
| Plan module | Plan package | Repository package |
|
||||
|---|---|---|
|
||||
| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) |
|
||||
| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` |
|
||||
| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` |
|
||||
| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` |
|
||||
| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) |
|
||||
| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) |
|
||||
| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` |
|
||||
| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` |
|
||||
| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` |
|
||||
| `jpa-envers` | `…jpa.envers` | `…persistence.envers` |
|
||||
| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` |
|
||||
| `jpa-observability` | `…jpa.observation` | `…persistence.observation` |
|
||||
| `jpa-security` | `…jpa.security` | `…persistence.security` |
|
||||
| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` |
|
||||
| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set |
|
||||
| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) |
|
||||
|
||||
The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this
|
||||
leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are
|
||||
**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an
|
||||
existing name.
|
||||
|
||||
## 3. Test-suite mapping
|
||||
|
||||
The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`,
|
||||
`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a
|
||||
Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered
|
||||
in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|
||||
|
||||
| Plan suite | Repository lane |
|
||||
|---|---|
|
||||
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
|
||||
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
|
||||
| `performanceTest` | `src/jpaPlatformPerformanceTest` — pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. |
|
||||
|
||||
Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
|
||||
|
||||
## 4. Other deliberate substitutions
|
||||
|
||||
| Plan assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
|
||||
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
|
||||
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
|
||||
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
| release blocking aggregate | `.github/workflows/jpa-release.yml` | CI names the blocking JPA lanes directly; Gradle only defines how each lane runs. |
|
||||
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
|
||||
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
|
||||
| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. |
|
||||
| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. |
|
||||
| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. |
|
||||
|
||||
### Types relocated to keep the dependency direction legal
|
||||
|
||||
The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three
|
||||
value-only types the design places in a downstream module are consumed by a core contract, so they
|
||||
live in the core here instead. Each is a pure value with no framework dependency, so the relocation
|
||||
costs nothing and the alternative — a core contract importing an adapter package — would break the
|
||||
boundary the module map exists to hold.
|
||||
|
||||
| Type | Plan module | Repository package | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) |
|
||||
| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) |
|
||||
| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator |
|
||||
|
||||
The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`)
|
||||
is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit
|
||||
is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime
|
||||
classpath to serve code that only ever runs in a test.
|
||||
|
||||
|
||||
### Findings the contracts produced against a real server
|
||||
|
||||
Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what
|
||||
showed it. Both are recorded here because the design text still reads the old way.
|
||||
|
||||
- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend
|
||||
with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the
|
||||
commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats
|
||||
`57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks.
|
||||
`CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow
|
||||
again.
|
||||
- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s
|
||||
target version is empty for a tenant that was already current, so recording it reported migrated
|
||||
tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the
|
||||
applied version from the tenant's schema history.
|
||||
|
||||
## 5. What is unchanged from the design
|
||||
|
||||
- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit
|
||||
policy. No `GenericRepository<T, ID>` and no Spring Data CRUD re-implementation exists.
|
||||
- Application Service owns the transaction boundary; OSIV is false in every runtime profile.
|
||||
- `TransactionCompletionUnknownException` always reports `completionUnknown=true`,
|
||||
`retryable=false`, and is never automatically retried — reconciliation handles it.
|
||||
- Retry re-executes the whole use case in a new transaction and a new Persistence Context.
|
||||
- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`,
|
||||
`55P03`) and never parses localized message text.
|
||||
- Flyway is the source of truth for production schema change; Hibernate only validates;
|
||||
`ddl-auto` never mutates a deployed schema.
|
||||
- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker.
|
||||
- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
|
||||
- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4,
|
||||
Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the
|
||||
Stable composition.
|
||||
@@ -0,0 +1,85 @@
|
||||
# JPA Platform Runbooks
|
||||
|
||||
Operator procedures for the failures this platform is designed to surface rather than hide.
|
||||
|
||||
## A transaction reported completion unknown
|
||||
|
||||
**Signal:** `jpa.transaction.completion.unknown` incremented; a `CompletionUnknownRecord` in the
|
||||
reconciliation channel.
|
||||
|
||||
**What it means:** the commit may or may not have happened. It is not a rollback.
|
||||
|
||||
**Do not** re-run the use case. That is what the platform refused to do automatically, for the same
|
||||
reason.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. Take the `transactionKey` from the record.
|
||||
2. Check the idempotency record for that key.
|
||||
3. Check the business row the use case would have written.
|
||||
4. Check the outbox for a corresponding event.
|
||||
5. If all three agree the write happened, mark the record `COMMITTED` and stop.
|
||||
6. If all three agree it did not, the use case may be re-run.
|
||||
7. If they disagree or are inconclusive, leave it `STILL_UNKNOWN` and escalate. An inconclusive
|
||||
answer is a legitimate outcome; guessing is not.
|
||||
|
||||
A record with no `transactionKey` cannot be resolved automatically — use the operation name and
|
||||
timestamp.
|
||||
|
||||
## Deadlock or serialization rate rising
|
||||
|
||||
**Signal:** `jpa.retry.attempt` rising; `jpa.retry.exhausted` non-zero.
|
||||
|
||||
Retries are expected. Exhaustion is not.
|
||||
|
||||
1. Group `jpa.retry.attempt` by operation. A single operation dominating means a hot row or an
|
||||
inconsistent lock order.
|
||||
2. For deadlocks, check whether two operations take the same rows in opposite orders — that is a
|
||||
code fix, not a tuning one.
|
||||
3. For serialization failures under `SERIALIZABLE`, confirm the isolation is actually required.
|
||||
4. Only then consider raising `maxAttempts`. A larger budget on a hot row converts a fast failure
|
||||
into a slow one.
|
||||
|
||||
## Pool exhaustion
|
||||
|
||||
**Signal:** connection acquisition timeouts; `PoolMeasurement.pending` non-zero.
|
||||
|
||||
1. Check `REQUIRES_NEW` usage. It takes a second connection while pinning the first, so the pool
|
||||
must satisfy `(threads x (1 + depth)) + 1`.
|
||||
2. Check for streaming outside a bounded scope — a `Stream` returned past the transaction holds its
|
||||
connection until the pool notices.
|
||||
3. Check for external calls inside a DB transaction. The design forbids them precisely because an
|
||||
HTTP timeout then holds a connection for its whole duration.
|
||||
|
||||
## Flyway validation failed at startup
|
||||
|
||||
The deployment is running against a schema it was not built for. It failed closed, which is correct.
|
||||
|
||||
1. Read the reported error codes (the messages are deliberately not propagated).
|
||||
2. `CHECKSUM_MISMATCH` — an applied migration was edited afterwards. Find which change is missing
|
||||
from this database. **Do not run `repair`**: it rewrites history to match the scripts, which
|
||||
resolves the symptom by deleting the evidence.
|
||||
3. `MISSING_SCRIPT` — a migration applied here is not in this build. Usually a rollback to an older
|
||||
artifact.
|
||||
|
||||
## An invalid index exists
|
||||
|
||||
**Signal:** `FailedConcurrentIndexRecovery.invalidIndexes()` is non-empty.
|
||||
|
||||
A concurrent build failed. The index is ignored by the planner and maintained by every write.
|
||||
|
||||
1. Confirm no build is currently running. An in-progress build looks identical in the catalog.
|
||||
2. Run the reported `DROP INDEX CONCURRENTLY` outside a migration.
|
||||
3. Re-apply the index migration.
|
||||
|
||||
The platform does not drop these automatically: on a rolling deploy every instance would race to
|
||||
drop an index another instance was about to finish building.
|
||||
|
||||
## The runtime role failed verification
|
||||
|
||||
Startup refused because the runtime credential holds `CREATE`, or `search_path` contains an
|
||||
unapproved schema.
|
||||
|
||||
This is not a false positive to be worked around. Re-provision from
|
||||
`infra/jpa/roles/runtime-roles.sql`; the application's credential having DDL is the condition that
|
||||
makes every other schema guarantee unenforceable.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Security
|
||||
|
||||
Design §36. Credential separation, privilege verification, and what never leaves the process.
|
||||
|
||||
## Three credentials
|
||||
|
||||
| Role | May |
|
||||
|---|---|
|
||||
| `app_migration` | own the schema, apply migrations (DDL) |
|
||||
| `app_runtime` | select, insert, update, delete (DML only) |
|
||||
| `app_admin` | J4 operations — COPY, backfill, maintenance |
|
||||
|
||||
The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If
|
||||
the application's own credential cannot execute DDL, then no code path, no library, and no injected
|
||||
statement can alter the schema at runtime, regardless of what the application intended.
|
||||
|
||||
`infra/jpa/roles/runtime-roles.sql` provisions them.
|
||||
|
||||
## Startup verification
|
||||
|
||||
`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do:
|
||||
|
||||
```sql
|
||||
select current_user,
|
||||
current_setting('search_path'),
|
||||
has_schema_privilege(current_user, current_schema(), 'CREATE'),
|
||||
has_database_privilege(current_user, current_database(), 'CREATE')
|
||||
```
|
||||
|
||||
Configuration cannot answer this. Effective privileges come from direct grants, inherited role
|
||||
memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest
|
||||
reconstructs that combination reliably.
|
||||
|
||||
Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the
|
||||
database.
|
||||
|
||||
## search_path
|
||||
|
||||
`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves
|
||||
to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly
|
||||
before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the
|
||||
application executes it without noticing. `$user` is exempt: only the connected role owns it.
|
||||
|
||||
Refusing the runtime role `CREATE` closes the same route from the other side.
|
||||
|
||||
## What never leaves the process
|
||||
|
||||
- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric
|
||||
tags, not in logs. `JpaFailureContext` composes messages from bounded values only.
|
||||
- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical
|
||||
name maps to a bounded unknown code rather than being passed through.
|
||||
- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state.
|
||||
- The actuator report carries no JDBC URL, username, or SQL.
|
||||
|
||||
## Injection surfaces, and how each is closed
|
||||
|
||||
| Surface | Why it cannot be a parameter | Closed by |
|
||||
|---|---|---|
|
||||
| sort field | part of ORDER BY | `SafeSortRegistry` allowlist |
|
||||
| JSON path | part of the statement | registered `JsonPathName` |
|
||||
| schema name | an identifier | registered `SchemaTenantRegistry` |
|
||||
| upsert conflict target | an identifier list | registered `UpsertConflictTarget` |
|
||||
| COPY table | an identifier | registered `RegisteredCopyStatement` |
|
||||
| queue claim SQL | a whole statement | registered `WorkQueueDefinition` |
|
||||
|
||||
Values are always bound. Identifiers are always registered.
|
||||
@@ -0,0 +1,144 @@
|
||||
# JPA Persistence Platform — Support Matrix
|
||||
|
||||
**This document is a rendering. The machine-readable source is
|
||||
[`src/config/jpa/release-registry.json`](../../src/config/jpa/release-registry.json).**
|
||||
|
||||
`JpaReleaseManifest` used to parse this file with regular expressions: every `PostgreSQL NN` it
|
||||
mentioned became a supported version, whatever table or sentence produced the match. An Experimental
|
||||
major joined the Stable list, a version named once in prose counted as supported, and demoting a
|
||||
major changed nothing so long as the string survived somewhere in the document. Now the registry
|
||||
declares a support level per major as a field, each gate names the Gradle task that produces its
|
||||
evidence, and this document describes what the registry says.
|
||||
|
||||
Being a rendering used to be a claim rather than a mechanism: the tables below were still typed by
|
||||
hand, so a major demoted in the registry stayed Stable here and kept its full release job.
|
||||
`JpaReleaseRenderingTest` now compares the database table, the gate table and `jpa-release.yml`'s
|
||||
matrix and promotion lists to the registry, and `verifyJpaReleaseGateTasks` resolves every gate's
|
||||
task against the real Gradle task graph. Edit the registry; these tables follow, or the build fails.
|
||||
|
||||
Two renderings stayed outside that comparison until they were added to it. `jpa-nightly.yml` runs
|
||||
its own matrix and nothing checked it, so a demotion corrected the release lane and left the nightly
|
||||
lane certifying the major. And an Experimental major's "compatibility lane only" named no file: the
|
||||
lane existed, but the registry, this document and the release workflow could each be read end to end
|
||||
without establishing that, so a reader looking for it concluded there was none. An Experimental major
|
||||
now has to be recorded as the target of a lane in `.github/workflows`, and a Stable lane may not run
|
||||
it.
|
||||
|
||||
## Database
|
||||
|
||||
| Database | Support | Evidence |
|
||||
|---|---|---|
|
||||
| PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) |
|
||||
| PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) |
|
||||
| PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) |
|
||||
| PostgreSQL 19 | Experimental | [`jpa-next-postgresql19.yml`](../../.github/workflows/jpa-next-postgresql19.yml) — `NOT_EXECUTABLE`: no `postgres:19-alpine` is published, so no container of that major has been started; promotion requires an ADR |
|
||||
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
|
||||
|
||||
Each major gets its **own release job**, because for a while it did not. The release lane passed
|
||||
`-Pjpa.matrix.versions=16,17,18` to a `JpaPlatformContractSupport.start()` that used
|
||||
`selectedVersions().get(0)`, so the whole integration suite ran against PostgreSQL 16 and this table
|
||||
recorded 17 and 18 as fully covered on the strength of a three-assertion smoke test. `start()` now
|
||||
refuses a multi-version selection outright, `jpa-release.yml` fans out to one job per major, and a
|
||||
promotion job requires all three majors' evidence to carry the same commit SHA — so a removed major
|
||||
removes the release, not the evidence for it.
|
||||
|
||||
**Provider baseline.** The gates run against the Hibernate version the Spring Boot BOM resolves —
|
||||
**7.1.8.Final** — which the registry records as `stable-tested-baseline`. This document previously
|
||||
called 7.4 the Stable baseline and the pagination gate was named `hibernate-7.4-fetch-pagination`,
|
||||
so every run of that gate produced evidence labelled with a provider it had never executed against.
|
||||
7.4 is recorded as `compatibility-target`; it becomes the baseline when a full lane has actually run
|
||||
on it.
|
||||
|
||||
H2 is not a second production target. It reports different SQLSTATEs for the same violation, no JSONB
|
||||
operators, no range types, and no concurrent index builds. A green H2 run is evidence that the code
|
||||
compiles and runs, and nothing more.
|
||||
|
||||
`SKIP LOCKED` needs its own sentence, because two documents said different things about it. The
|
||||
module's `CLAUDE.md` records a measurement: H2 2.4.240 accepts `FOR UPDATE SKIP LOCKED` and does
|
||||
genuinely skip locked rows, which is why the outbox claim SQL is identical on both vendors. This
|
||||
document previously said H2 has no such guarantee. Both are right about different questions, and
|
||||
the distinction is the point: **observed behaviour in the version we measured is not a production
|
||||
guarantee, and it is never PostgreSQL contract evidence.** The measurement is why the claim SQL
|
||||
needs no vendor branch; the absence of a guarantee is why every concurrency contract still runs
|
||||
against a real PostgreSQL.
|
||||
|
||||
## Specification and provider
|
||||
|
||||
| Component | Stable | Experimental |
|
||||
|---|---|---|
|
||||
| Jakarta Persistence | 3.2 | 4.0 (lane) |
|
||||
| Hibernate ORM | 7.4 declared baseline | 8 (lane) |
|
||||
| Spring Boot | repository BOM | — |
|
||||
|
||||
The Hibernate row needs a note. The design declares 7.4 as the Stable provider; this repository's
|
||||
Spring Boot BOM resolves 7.1.x. `HibernateProviderPolicy` holds both — the declared baseline as a
|
||||
constant, the resolved version read from Hibernate itself — and `driftsFromDeclaredBaseline()` makes
|
||||
the difference visible instead of asserting a constant against itself. See
|
||||
[repository-adaptation.md](repository-adaptation.md) §4.
|
||||
|
||||
## Capability support levels
|
||||
|
||||
| Capability | Level |
|
||||
|---|---|
|
||||
| Full-transaction retry | Stable |
|
||||
| Commit completion evidence | Stable |
|
||||
| Keyset pagination | Stable |
|
||||
| JDBC batch | Stable |
|
||||
| Flyway schema gate | Stable |
|
||||
| Runtime role verification | Stable |
|
||||
| Observability | Stable |
|
||||
| PostgreSQL native write (`ON CONFLICT`/`RETURNING`) | Advanced |
|
||||
| PostgreSQL work claim (`SKIP LOCKED`) | Advanced |
|
||||
| PostgreSQL JSONB | Advanced |
|
||||
| PostgreSQL array and range | Advanced |
|
||||
| Bulk DML | Advanced |
|
||||
| Hibernate `StatelessSession` | Advanced |
|
||||
| PostgreSQL `COPY` | Admin (J4) |
|
||||
| Hibernate second-level cache | Advanced |
|
||||
| Hibernate Envers | Advanced |
|
||||
| Technical auditing — `audit/AuditableEntity` | Stable (canonical) |
|
||||
| Technical auditing — `auditing/AuditMetadata` | Candidate, not composed |
|
||||
| Multi-tenancy (column, RLS, schema, database) | Experimental |
|
||||
| Consistency-aware read replica | Experimental |
|
||||
|
||||
## Release gates
|
||||
|
||||
Each row is a way the platform could pass its tests and still be wrong in production.
|
||||
|
||||
| Gate | Kind | What it prevents |
|
||||
|---|---|---|
|
||||
| `postgresql-contract` | gate | a release whose only database evidence came from H2 |
|
||||
| `completion-unknown-no-retry` | gate | automatically re-running a write that may already have committed |
|
||||
| `osiv-disabled` | gate | lazy loading from the view layer, one query per rendered row |
|
||||
| `flyway-validate` | gate | Hibernate mutating a deployed schema, or running against one it was not built for |
|
||||
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
|
||||
| `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
|
||||
|
||||
### The two audit mechanisms
|
||||
|
||||
`audit/AuditableEntity` is the canonical one: `created_*`/`updated_*`, a 256-character actor,
|
||||
stamped explicitly by the repository adapter. It is what the sample entities extend and what the
|
||||
migrations were written for.
|
||||
|
||||
`auditing/AuditMetadata` is a second, complete mechanism with different column names
|
||||
(`modified_*`), a different actor length (64) and a different capture lifecycle (Spring Data
|
||||
listeners). Nothing embeds it and nothing composes `JpaAuditingConfiguration`, which is why it is
|
||||
listed as a candidate rather than as a capability: promoting it means choosing between reshaping it
|
||||
to the canonical columns and writing a forward migration for the new ones, and that choice has not
|
||||
been made. Until it is, an entity picks one mechanism or none — enforced on the production graph by
|
||||
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`.
|
||||
|
||||
Neither mechanism reaches a bulk or native update. Both stamp on an ordinary save — one in the
|
||||
adapter, one on a managed entity's lifecycle — so a statement that goes straight to the database
|
||||
leaves the audit columns showing the previous save. A bulk update of an audited entity must
|
||||
therefore set the audit column in the statement, which
|
||||
`JpaAuditMechanismRule.bulkUpdatesOfAuditedEntitiesStampAudit` checks over the production graph.
|
||||
|
||||
## Explicitly unsupported
|
||||
|
||||
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
|
||||
onto an event loop rather than removing it.
|
||||
- Hibernate as the production schema writer. `ddl-auto` never mutates a deployed schema.
|
||||
- A platform-owned generic CRUD repository. Domains own their repositories (design §10.1).
|
||||
- Automatic reconciliation of a completion-unknown transaction. The platform records; the domain
|
||||
resolves.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Transaction Guide
|
||||
|
||||
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
|
||||
|
||||
## The application service owns the boundary
|
||||
|
||||
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
|
||||
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
|
||||
knows where it starts and ends.
|
||||
|
||||
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
|
||||
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
|
||||
|
||||
## Profiles
|
||||
|
||||
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
|
||||
write profile must carry a positive finite timeout — the type refuses to represent one without —
|
||||
because an unbounded write transaction holds a connection, its locks, and its row versions for as
|
||||
long as one stuck statement takes.
|
||||
|
||||
`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a
|
||||
profile using it must be paired with the pool-pressure evidence in design §38:
|
||||
|
||||
```text
|
||||
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
|
||||
```
|
||||
|
||||
## Retry is per use case, never per statement
|
||||
|
||||
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
|
||||
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
|
||||
means the state the attempt computed against is no longer the committed state, so re-issuing the
|
||||
same statement would compute the same wrong answer. The domain rules have to run again against
|
||||
reloaded data.
|
||||
|
||||
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
|
||||
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
|
||||
|
||||
Two additional refusals, independent of budget:
|
||||
|
||||
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
|
||||
Rollback reverses database work only; an email or a card charge has already changed the world.
|
||||
- Anything completion-unknown.
|
||||
|
||||
## Completion unknown
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
|
||||
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
|
||||
rebuilds its context through the safe factory whatever it is handed.
|
||||
|
||||
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
|
||||
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
|
||||
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
|
||||
mistaken for a rollback.
|
||||
|
||||
Recovery is reconciliation, not retry:
|
||||
|
||||
```text
|
||||
record the transaction key -> check the idempotency record
|
||||
-> check the business row
|
||||
-> check the outbox
|
||||
-> still undetermined? reconciliation queue
|
||||
```
|
||||
|
||||
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
|
||||
Writing it through the same connection would make the audit trail share the failure it documents.
|
||||
@@ -0,0 +1,246 @@
|
||||
# 설정 레퍼런스
|
||||
|
||||
> **Prefix.** Every property below binds under `app.messaging`, which is the prefix the deployed
|
||||
> runtime and the `APP_MESSAGING_*` environment variables already use. Earlier revisions of this
|
||||
> page documented a bare `messaging` prefix and the starter bound `backend.messaging`; neither
|
||||
> bound what this page describes, so a deployment configured from it changed nothing. A key under
|
||||
> either of the old prefixes now fails startup with a message naming the key — see
|
||||
> `MessagingPrefixMigrationValidator`.
|
||||
|
||||
> **이 페이지는 실행된다.** 아래 YAML 블록은 `MessagingConfigurationBindingTest`가 이 파일에서 직접
|
||||
> 읽어 컨텍스트에 올린다. 문서가 설명하는 모양이 곧 바인딩되는 모양이라는 뜻이고, 문서를 고치면서
|
||||
> 코드를 고치지 않으면 테스트가 깨진다. 이전 판은 destination·broker·security 세 섹션을 설명했지만
|
||||
> 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지
|
||||
> 못했다 (MSG-008).
|
||||
|
||||
## Application publish bridge identity
|
||||
|
||||
Application의 canonical integration event를 platform publish pipeline으로 보낼 때는
|
||||
`app.messaging.producer-id`를 명시한다. 같은 값의 환경변수 이름은
|
||||
`APP_MESSAGING_PRODUCER_ID`다. 이 값은 host/pod 이름이 아니라 배포와 무관하게 유지되는 논리적
|
||||
producing-service identity다.
|
||||
|
||||
값이 없으면 `IntegrationEventPublishPort` bridge 자체를 만들지 않는다. `spring.application.name`이나
|
||||
현재 process 이름으로 추론하지 않는다. 기존 legacy `OutboxEvent`/realtime 경로는 별도 cutover가
|
||||
끝날 때까지 `app.messaging.broker` 경로를 유지한다.
|
||||
|
||||
## Outbox canonical transport-only cutover
|
||||
|
||||
`APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED` / `ca-skeleton.outbox.canonical-transport-enabled`은
|
||||
기존 `outbox_event` writer/claim/status authority를 유지한 채 canonical row의 **transport만** platform
|
||||
publish path로 보내는 compatibility gate다. 기본값은 `false`이며 `POLLING_V2`를 활성화하지 않는다.
|
||||
|
||||
`true`일 때는 `OutboxAppendPort`가 `ValidatedIntegrationEvent`의 exact envelope bytes와 canonical
|
||||
metadata를 기존 outbox row에 저장하고, claim된 canonical row는 `IntegrationEventPublishPort`로 간다.
|
||||
legacy row는 계속 `MessageBroker`를 사용한다. 따라서 mixed-row compatibility 기간에는 relay가 켜져
|
||||
있다면 `app.messaging.broker`도 계속 필요하며, canonical path를 위해 `IntegrationEventPublishPort`도
|
||||
추가로 필요하다. legacy backlog가 0이라는 별도 증거 없이 broker 요구를 제거하지 않는다.
|
||||
|
||||
## Destination profile
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
destinations:
|
||||
order-events:
|
||||
broker: kafka-primary
|
||||
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
|
||||
# | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY
|
||||
tier: M1 # M1 | M2 | M3
|
||||
physical:
|
||||
topic: order.events.v1
|
||||
schema:
|
||||
codec: application/json
|
||||
compatibility: BACKWARD_TRANSITIVE
|
||||
message-types: [order.created]
|
||||
guarantees:
|
||||
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
|
||||
ordering: KEY # NONE | DESTINATION | PARTITION | KEY
|
||||
external-side-effect: INBOX_TRANSACTIONAL
|
||||
producer:
|
||||
confirmation: REPLICATION_OR_PERSISTENCE_ACK
|
||||
timeout: 5s
|
||||
mandatory-routing: true
|
||||
idempotent: true
|
||||
consumer:
|
||||
group: order-projection
|
||||
concurrency: 1 # DESTINATION 순서를 요구하면 1이어야 한다
|
||||
max-in-flight-per-ordering-unit: 1
|
||||
prefetch: 16
|
||||
handler-timeout: 30s
|
||||
manual-settlement: false
|
||||
retry:
|
||||
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
|
||||
# | RETRY_DESTINATION | BROKER_DELAYED
|
||||
max-attempts: 3
|
||||
initial-delay: 200ms
|
||||
max-delay: 2s
|
||||
multiplier: 2.0
|
||||
jitter: true
|
||||
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
|
||||
dlq:
|
||||
destination: order-events-dlq
|
||||
max-redrive-count: 1
|
||||
payload:
|
||||
max-bytes: 1048576
|
||||
claim-check-threshold-bytes: 1048576
|
||||
key-resolver-configured: true
|
||||
production: false
|
||||
topology-auto-create: false
|
||||
order-events-dlq:
|
||||
broker: kafka-primary
|
||||
kind: WORK_QUEUE
|
||||
physical:
|
||||
topic: order.events.v1.dlt
|
||||
schema:
|
||||
message-types: [order.created]
|
||||
```
|
||||
|
||||
`dlq.destination`이 가리키는 destination도 선언되어야 한다. 선언되지 않은 이름은 부팅 실패이며,
|
||||
메시지가 갈 곳 없는 DLQ 설정이 조용히 통과하지 않는다. `retry.destination`과 `dlq.destination`이
|
||||
섞여 만드는 순환(A의 retry가 B로, B의 dlq가 A로)도 하나의 그래프로 검사되어 경로와 함께 거절된다.
|
||||
|
||||
## 기본값
|
||||
|
||||
| 설정 | 기본값 | 근거 |
|
||||
|---|---:|---|
|
||||
| logical payload 최대 | 1,048,576 bytes | portability. 초과는 Claim Check |
|
||||
| global hard 최대 | 8,388,608 bytes | 어떤 destination도 넘을 수 없는 상한 |
|
||||
| header 총 크기 | 32,768 bytes | |
|
||||
| header 개수 | 64 | |
|
||||
| header key | 128 bytes | metric tag 안전 |
|
||||
| header value | 4,096 bytes | |
|
||||
| publish timeout | 5s | |
|
||||
| handler timeout | 30s | |
|
||||
| graceful shutdown drain | 30s | |
|
||||
| 일반 destination retry | 0회 | 자동 retry는 opt-in |
|
||||
| DLQ redrive batch | 100 | 한 번의 작업이 source를 덮치지 않게 |
|
||||
| Outbox relay batch | 100 | |
|
||||
| Outbox lease | 30s | |
|
||||
| Outbox polling | 500ms | |
|
||||
| metric dimension 상한 | 200 | cardinality 폭발 방지 |
|
||||
|
||||
`schema.codec`은 `application/json`, `schema.compatibility`는 `BACKWARD_TRANSITIVE`,
|
||||
`guarantees.delivery`는 `AT_LEAST_ONCE`, `retry.mode`는 `NONE`이 기본값이다. 자동 retry가 기본으로
|
||||
꺼져 있는 이유는 순서를 흐트러뜨리거나 비멱등 side effect를 두 번 실행하는 retry가 눈에 보이는
|
||||
실패보다 나쁘기 때문이다.
|
||||
|
||||
## Broker profile
|
||||
|
||||
브로커는 `app.messaging.brokers` 아래에 한 번만 기술한다. `type`이 어느 계열의 설정이 적용되는지
|
||||
결정하며, 다른 계열의 키(Kafka 항목의 `prefetch` 같은)는 무시되지 않고 부팅 실패로 거절된다 —
|
||||
무시하면 그 줄을 쓴 사람은 무언가가 적용됐다고 믿게 된다.
|
||||
|
||||
### Kafka
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
brokers:
|
||||
kafka-primary:
|
||||
type: kafka
|
||||
stable: true
|
||||
production: false
|
||||
bootstrap-servers: [broker-1:9093, broker-2:9093]
|
||||
enable-idempotence: true # stable에서 필수
|
||||
acks: all # stable에서 필수
|
||||
max-in-flight-requests-per-connection: 5 # 최대 5
|
||||
delivery-timeout: 30s
|
||||
enable-auto-commit: false # 항상 금지
|
||||
consumer-group: order-projection
|
||||
tls-enabled: false # production이면 필수
|
||||
authentication-enabled: false # production이면 필수
|
||||
```
|
||||
|
||||
### RabbitMQ
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
brokers:
|
||||
rabbit-primary:
|
||||
type: rabbitmq
|
||||
stable: true
|
||||
production: false
|
||||
addresses: [rabbit-1:5671]
|
||||
publisher-confirms: true # stable에서 필수
|
||||
publisher-returns: true # stable에서 필수
|
||||
mandatory: true # stable에서 필수
|
||||
confirm-timeout: 5s
|
||||
auto-ack: false # 항상 금지
|
||||
prefetch: 16
|
||||
quorum-queues: true # durable work queue 필수
|
||||
tls-enabled: false
|
||||
authentication-enabled: false
|
||||
```
|
||||
|
||||
`production: true`인 브로커는 `tls-enabled`와 `authentication-enabled`가 모두 참이어야 하고,
|
||||
그렇지 않으면 `KafkaProfileValidator` / `RabbitProfileValidator`가 부팅을 거절한다. 위 예시가
|
||||
`production: false`인 것은 이 페이지가 그대로 실행되는 fixture이기 때문이며, 실 배포는 셋 다 참이다.
|
||||
|
||||
## 보안
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
security:
|
||||
kafka-primary:
|
||||
producer: { type: SASL_SCRAM, credential-id: kafka-producer }
|
||||
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
|
||||
# admin은 application runtime에 설정하지 않는다
|
||||
hostname-verification: true
|
||||
access:
|
||||
publishable: [order-events]
|
||||
consumable: []
|
||||
administrable: []
|
||||
```
|
||||
|
||||
키는 `app.messaging.brokers`에 선언된 브로커 이름과 같아야 한다. `tls-enabled`와 `production`은
|
||||
브로커 쪽에만 있고 여기에 중복되지 않는다 — 하나의 브로커가 두 곳에서 기술되면 두 값이 어긋나는
|
||||
날이 오고, 어느 쪽이 이기는지는 아무도 모른다.
|
||||
|
||||
`credential-id`는 이름일 뿐이고 자격 증명 자체가 아니다. 실제 재료는 `CredentialProvider`가
|
||||
연결 시점에 해석하므로, 설정 덤프나 힙 덤프에서 나오는 것은 이름뿐이다. producer와 consumer는
|
||||
서로 다른 `credential-id`를 써야 하며, 같으면 부팅에 실패한다.
|
||||
|
||||
## Experimental / Optional
|
||||
|
||||
기본값은 전부 `false`다.
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
experimental:
|
||||
kafka-share: false
|
||||
pulsar: false
|
||||
nats: false
|
||||
bridge:
|
||||
spring-cloud-stream: false
|
||||
```
|
||||
|
||||
## Backpressure
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
backpressure:
|
||||
global-limit: 512
|
||||
per-destination-limit: 64 # global-limit 이하여야 한다
|
||||
```
|
||||
|
||||
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
|
||||
|
||||
## 바인딩되지 않는 키
|
||||
|
||||
섹션은 바인딩되는데 그 안의 키 하나가 오타인 경우는 접두사 오타와 달리 조용하다 — 섹션은 붙고,
|
||||
플랫폼은 뜨고, 바꾸러 온 그 설정만 적용되지 않는다. `MessagingConfigurationKeyValidator`가
|
||||
`app.messaging.destinations|brokers|security` 아래의 모든 키를 settings 레코드에서 파생한 목록과
|
||||
대조하고, 없는 키는 그 키 이름을 담아 부팅을 거절한다.
|
||||
|
||||
허용 키 목록은 이 문서가 아니라 레코드에서 나온다. 문서에 목록을 적으면 필드가 추가된 날 그
|
||||
목록이 틀리고, 오타를 잡으라고 만든 검사가 정상 필드를 거절하게 된다.
|
||||
|
||||
환경변수(`APP_MESSAGING_...`)는 이 검사의 대상이 아니다. `APP_MESSAGING_DESTINATIONS_ORDER_EVENTS_
|
||||
CONSUMER_PREFETCH`에서 entry 이름과 leaf를 가르는 밑줄은 둘 안에 있는 밑줄과 구별되지 않으므로,
|
||||
되돌려 쪼개려면 추측해야 한다. 여기서의 추측은 정상 배포를 거절하는 쪽으로 틀리며, 그것은 배포
|
||||
매니페스트에 손으로 적어야 하는 변수에서 오타 하나를 놓치는 것보다 나쁘다.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 기존 runtime → 신규 messaging platform cutover (MSG-015)
|
||||
|
||||
## 왜 기계적 매핑이 안 되는가
|
||||
|
||||
두 outbox 모델의 enum 이름이 겹치는데 의미가 반대다.
|
||||
|
||||
| 모델 | retryable | terminal |
|
||||
|---|---|---|
|
||||
| 기존 `OutboxEventStatus` | `FAILED` (`next_attempt_at` 보유) | `DEAD` |
|
||||
| 신규 `OutboxStatus` | `AMBIGUOUS` | `FAILED`, `EXHAUSTED` |
|
||||
|
||||
이름으로 매핑하면 **확정 거절이 무한 재시도**가 되고 **불확정이 park**된다. 그래서 application은
|
||||
자기 어휘(`OutboxPublishOutcome`)만 쓰고, 변환은 bridge adapter가 한다.
|
||||
|
||||
## 지금 반영된 것
|
||||
|
||||
- `OutboxPublishOutcome` — `CONFIRMED` / `AMBIGUOUS` / `REJECTED_BEFORE_SEND` /
|
||||
`REJECTED_AFTER_BROKER`. application이 소유하는 canonical 결과 타입이며, "리턴 or throw"만 가능한
|
||||
기존 어댑터를 위해 `OutboxMessagePublishPort.publishForOutcome`의 default가 `CONFIRMED`를 돌려준다.
|
||||
- `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` ArchUnit 규칙 — application-core가
|
||||
`dev.caskeleton.messaging..`를 import하면 빌드가 깨진다.
|
||||
- 반대 방향(신규 `PublishResult` → application outcome) 매핑 규칙을 테스트로 고정.
|
||||
|
||||
## 남은 것
|
||||
|
||||
- `messaging-platform-bridge` outbound leaf: validated application event → platform envelope,
|
||||
`PublishResult` → `OutboxPublishOutcome`. registry에 leaf를 추가하는 변경이라 별도 커밋.
|
||||
- golden contract 테스트: event/message ID, type, schema revision, partition/order/correlation/
|
||||
causation/tenant/trace, payload digest, wire version이 bytes 단위로 보존되는지.
|
||||
- 단일 publication authority: 기존 `OutboxPublicationAuthority` fence를 재사용해 writer/relay가
|
||||
동시에 ACTIVE가 되지 않도록. **dual write/publish는 금지** — 한 business fact가 두 durable store와
|
||||
두 relay로 나가는 상태가 cutover에서 가장 위험하다.
|
||||
- 첫 cutover 범위는 **transport만** 교체(저장소는 기존 유지). storage migration은 shadow read →
|
||||
authority switch → old backlog drain 순서로 별도 release.
|
||||
@@ -0,0 +1,75 @@
|
||||
# 전달 보장
|
||||
|
||||
## 왜 `EXACTLY_ONCE`가 없는가
|
||||
|
||||
어떤 브로커도 **외부 side effect를 포함한** exactly-once를 제공하지 않는다.
|
||||
실제로 존재하는 것은 at-least-once 전달 + 멱등하거나 transactional한 consumer의 조합이다.
|
||||
|
||||
플랫폼이 지킬 수 없는 이름을 enum에 두면 그 책임이 눈에 보이지 않는 곳으로 밀려난다.
|
||||
그래서 `DeliveryGuarantee`는 증거가 끝나는 지점에서 멈춘다.
|
||||
|
||||
```java
|
||||
public enum DeliveryGuarantee { AT_MOST_ONCE, AT_LEAST_ONCE }
|
||||
```
|
||||
|
||||
## Publish 결과는 boolean이 아니다
|
||||
|
||||
```java
|
||||
public enum PublishCompletion { CONFIRMED, REJECTED, AMBIGUOUS }
|
||||
```
|
||||
|
||||
`REJECTED`와 `AMBIGUOUS`를 하나의 "실패"로 합치면 중복 주문이 만들어진다.
|
||||
전자는 broker가 저장하지 않았음이 **확정**되어 포기해도 안전하고, 후자는 그렇지 않다.
|
||||
|
||||
| 상황 | 결과 |
|
||||
|---|---|
|
||||
| 로컬 validation 실패 | `REJECTED`, `NOT_TRANSMITTED` |
|
||||
| broker 명시적 reject / nack | `REJECTED` |
|
||||
| confirm 수신 | `CONFIRMED` |
|
||||
| Rabbit confirm + unroutable return | `REJECTED`, `UNROUTABLE` |
|
||||
| bytes 전송 후 connection loss | `AMBIGUOUS` |
|
||||
| confirm timeout | `AMBIGUOUS` |
|
||||
| adapter가 판정 불가 | 보수적으로 `AMBIGUOUS` |
|
||||
|
||||
`PublishResult` 생성자가 이 규칙을 강제한다. `CONFIRMED`인데 broker acceptance가 없거나,
|
||||
`AMBIGUOUS`인데 confirmation level을 주장하면 **객체 생성 자체가 실패**한다.
|
||||
|
||||
## Ordering
|
||||
|
||||
```java
|
||||
public enum OrderingScope { NONE, DESTINATION, PARTITION, KEY }
|
||||
```
|
||||
|
||||
순서는 partition·key·단일 consumer의 성질이지 destination 전체의 성질이 아니다.
|
||||
`GLOBAL`이 없는 이유가 이것이다.
|
||||
|
||||
`DestinationProfileValidator`가 다음을 거부한다.
|
||||
|
||||
- `ordering=KEY`인데 key resolver 없음
|
||||
- ordered destination인데 `ALLOW_REORDER` retry
|
||||
- `orderingImpact=PRESERVE`인데 재발행형 retry(`RETRY_DESTINATION`, `BROKER_DELAYED`)
|
||||
- `ordering=DESTINATION`인데 concurrency > 1
|
||||
- ordered destination인데 ordering unit당 in-flight > 1
|
||||
|
||||
## External side effect
|
||||
|
||||
```java
|
||||
public enum ExternalSideEffectGuarantee { NONE, IDEMPOTENCY_REQUIRED, INBOX_TRANSACTIONAL }
|
||||
```
|
||||
|
||||
`INBOX_TRANSACTIONAL`만이 "DB side effect와 중복 차단이 같은 transaction에서 commit된다"를 의미한다.
|
||||
Kafka transaction은 **Kafka 안에서만** 원자적이므로 이 값과 함께 설정하면
|
||||
`KafkaTransactionProfileValidator`가 거부한다. 두 개의 독립적인 commit을 하나로 착각하게 두지 않기 위해서다.
|
||||
|
||||
## Consumer settlement 순서
|
||||
|
||||
```text
|
||||
RECEIVED → DECODING → PROCESSING → HANDLER_SUCCEEDED → SETTLEMENT_SENDING
|
||||
├→ SETTLED
|
||||
└→ SETTLEMENT_UNKNOWN
|
||||
```
|
||||
|
||||
- handler는 broker ACK API를 호출하지 않는다.
|
||||
- `Success` 이후에만 source settlement한다.
|
||||
- `SETTLEMENT_UNKNOWN`은 성공이 아니다. redelivery 가능성을 의미한다.
|
||||
- `SettlementResult` 생성자가 `SETTLED`인데 `redeliveryPossible=true`인 조합을 거부한다.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Experimental 정책
|
||||
|
||||
## Stable과 Experimental의 차이
|
||||
|
||||
**Stable**은 공통 Contract Suite(`MessagingAdapterContract`)를 변경 없이 통과한 어댑터다.
|
||||
컴파일되는 어댑터가 아니라, 아래 7가지를 실제로 증명한 어댑터다.
|
||||
|
||||
```text
|
||||
publishesAndConfirms
|
||||
returnsAmbiguousWhenConfirmIsLost
|
||||
redeliversWhenSettlementIsLost
|
||||
preservesMessageIdAcrossRetryAndDlq
|
||||
keepsSourceUnsettledWhenDlqPublishFails
|
||||
rejectsOversizedPayloadBeforeTransport
|
||||
stopsAcceptingNewWorkDuringShutdown
|
||||
```
|
||||
|
||||
**Experimental**은 아직 그 증명이 끝나지 않은 어댑터다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 기본 비활성
|
||||
|
||||
```yaml
|
||||
messaging.experimental.kafka-share: false
|
||||
messaging.experimental.pulsar: false
|
||||
messaging.experimental.nats: false
|
||||
```
|
||||
|
||||
활성화하지 않으면 validator가 `MessagingCapabilityUnavailableException`을 던진다.
|
||||
Contract Suite가 아직 증명 중인 어댑터가 누군가의 기본 설정 때문에 load-bearing이 되어서는 안 된다.
|
||||
|
||||
### 2. Stable 모듈이 Experimental 모듈에 의존하지 않는다
|
||||
|
||||
Gradle 의존 그래프로 강제된다. `messaging-spring-boot-starter`의 `allowed_dependencies`에
|
||||
`messaging-kafka-share-experimental`, `messaging-pulsar-experimental`,
|
||||
`messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`가 **없다**.
|
||||
|
||||
`verifyCleanArchitectureDependencies`가 위반을 빌드 실패로 만든다.
|
||||
|
||||
### 3. Core 계약을 바꾸지 않는다
|
||||
|
||||
Experimental 어댑터는 브로커의 차이를 `MessagingCapabilities`로 표현할 뿐,
|
||||
`messaging-core-api`의 타입을 바꾸지 않는다.
|
||||
|
||||
### 4. 없는 기능을 광고하지 않는다
|
||||
|
||||
| 어댑터 | 광고하지 않는 것 | 이유 |
|
||||
|---|---|---|
|
||||
| Kafka Share Group | orderedStream, keyedOrdering, replay, brokerTransaction | 경쟁 소비자 + 개별 ack는 partition 순서를 유지할 수 없다 |
|
||||
| Pulsar | brokerTransaction | Pulsar에 있지만 플랫폼 Contract Suite로 증명되지 않았다 |
|
||||
| Pulsar (Shared) | keyedOrdering | round-robin 분배 |
|
||||
| NATS JetStream | nativeDeadLetter | delivery limit 초과 시 terminate할 뿐 라우팅하지 않는다 |
|
||||
| NATS JetStream | keyedOrdering | subject 기반 모델에 per-key 순서가 없다 |
|
||||
|
||||
`false`인 capability를 요구하는 profile은 startup에서 실패한다.
|
||||
조용히 약화되지 않는다.
|
||||
|
||||
### 5. 명시적 거부
|
||||
|
||||
| 조합 | 결과 |
|
||||
|---|---|
|
||||
| Kafka Share Group + ordering != NONE | 거부 |
|
||||
| Kafka Share Group + pause/resume | `MessagingCapabilityUnavailableException` |
|
||||
| Pulsar Shared + ordering=KEY | 거부 (Key_Shared 필요) |
|
||||
| Pulsar + ordering=DESTINATION | 거부 |
|
||||
| NATS Core + AT_LEAST_ONCE | 거부 (JetStream 필요) |
|
||||
| NATS ordered consumer + 경쟁 워커 > 1 | 거부 |
|
||||
| NATS + ordering=KEY | 거부 |
|
||||
|
||||
## Spring Cloud Stream bridge
|
||||
|
||||
Experimental이 아니라 **Optional**이다. 위험이 다르다.
|
||||
|
||||
Stream은 자체 binder 설정을 소유하므로, binding이 destination profile이 모르는
|
||||
serializer·error handling·acknowledgement mode를 조용히 획득할 수 있다.
|
||||
|
||||
따라서 브리지는 **플랫폼 보장에 의존하지 않는 destination에만** 허용한다.
|
||||
|
||||
```text
|
||||
ordering scope 선언 → 거부
|
||||
retry policy 선언 → 거부
|
||||
dead letter 선언 → 거부
|
||||
```
|
||||
|
||||
이 셋 중 하나라도 필요하면 native adapter를 쓴다. 거기서만 실제로 강제되기 때문이다.
|
||||
|
||||
## 승격 조건
|
||||
|
||||
Experimental → Stable로 올리려면 전부 필요하다.
|
||||
|
||||
1. `MessagingAdapterContract` 7개 테스트를 변경 없이 통과
|
||||
2. 장애 주입(연결 끊김, confirm 유실, settlement 유실) 하에서 통과
|
||||
3. 지원 브로커 버전 범위 명시 및 CI 검증
|
||||
4. `support-matrix.md`의 capability 표 갱신
|
||||
5. ADR 작성
|
||||
6. 기본 활성화 여부에 대한 별도 결정
|
||||
@@ -0,0 +1,113 @@
|
||||
# 마이그레이션 가이드
|
||||
|
||||
## 기존 Spring Kafka / Spring AMQP 코드에서
|
||||
|
||||
### 1. topic 이름을 코드에서 제거한다
|
||||
|
||||
```java
|
||||
// before
|
||||
kafkaTemplate.send("order.events.v1", key, payload);
|
||||
|
||||
// after
|
||||
publisher.publish(orderEvents, envelope, PublishOptions.defaults());
|
||||
```
|
||||
|
||||
`MessageDestination`은 logical name만 가진다. 물리 매핑은 destination profile이 소유한다.
|
||||
`DestinationName`의 패턴이 `topic://orders` 같은 값을 거부하므로 우회할 수 없다.
|
||||
|
||||
### 2. boolean 성공 판정을 없앤다
|
||||
|
||||
```java
|
||||
// before
|
||||
try { template.send(...).get(); success(); }
|
||||
catch (Exception e) { fail(); } // REJECTED와 AMBIGUOUS를 구분하지 못한다
|
||||
|
||||
// after
|
||||
PublishResult result = ...;
|
||||
switch (result.completion()) {
|
||||
case CONFIRMED -> success();
|
||||
case REJECTED -> abandon(); // broker가 저장하지 않음이 확정
|
||||
case AMBIGUOUS -> retrySameMessageId(result); // broker가 가지고 있을 수 있음
|
||||
}
|
||||
```
|
||||
|
||||
이 구분이 없으면 confirm 유실 한 번이 중복 주문 하나가 된다.
|
||||
|
||||
### 3. auto-commit / auto-ack를 끈다
|
||||
|
||||
```yaml
|
||||
# Kafka
|
||||
enable.auto.commit: false
|
||||
# RabbitMQ
|
||||
auto-ack: false
|
||||
```
|
||||
|
||||
둘 다 validator가 강제로 거부한다. 타이머 기반 commit은 handler가 실행되기도 전에
|
||||
메시지를 처리 완료로 표시한다.
|
||||
|
||||
### 4. handler에서 ack 호출을 제거한다
|
||||
|
||||
```java
|
||||
// before
|
||||
@KafkaListener(...)
|
||||
void handle(ConsumerRecord<?,?> record, Acknowledgment ack) {
|
||||
process(record);
|
||||
ack.acknowledge(); // 실패 시 순서가 애매해진다
|
||||
}
|
||||
|
||||
// after
|
||||
CompletionStage<HandleResult> handle(MessageDelivery<OrderCreated> delivery) {
|
||||
process(delivery.message().payload());
|
||||
return completedFuture(HandleResult.success());
|
||||
}
|
||||
```
|
||||
|
||||
settlement는 플랫폼이 수행한다. "성공한 뒤에만 ack"가 각 handler의 기억이 아니라
|
||||
플랫폼 불변식이 된다.
|
||||
|
||||
### 5. 중복을 정상 상황으로 다룬다
|
||||
|
||||
at-least-once는 중복을 전제한다. 세 가지 중 하나를 고른다.
|
||||
|
||||
| 방식 | 언제 |
|
||||
|---|---|
|
||||
| handler 자체 멱등 | 자연 멱등 연산 (upsert 등) |
|
||||
| Inbox | DB side effect가 있는 경우 |
|
||||
| Kafka transaction | Kafka → Kafka 파이프라인만 |
|
||||
|
||||
`ExternalSideEffectGuarantee`에 선언한다. `INBOX_TRANSACTIONAL`과 Kafka transaction을
|
||||
동시에 설정하면 거부된다. Kafka transaction은 DB를 포함하지 않는다.
|
||||
|
||||
### 6. 큰 payload는 Claim Check로
|
||||
|
||||
broker frame 크기를 키우지 않는다. broker 메모리, replication latency,
|
||||
consumer recovery가 동시에 나빠지고, 유계·검증 가능한 실패가 무계 실패로 바뀐다.
|
||||
|
||||
1 MiB 초과는 외부 저장소로 offload하고 digest를 포함한 참조만 발행한다.
|
||||
|
||||
## DB 마이그레이션
|
||||
|
||||
```text
|
||||
V1__messaging_outbox.sql
|
||||
V2__messaging_inbox.sql
|
||||
```
|
||||
|
||||
Outbox row는 business transaction과 같은 transaction에서 쓴다.
|
||||
Inbox reservation은 handler side effect와 같은 transaction에서 쓴다.
|
||||
별도 transaction이면 각 패턴이 닫으려던 창이 그대로 열려 있다.
|
||||
|
||||
## 단계적 전환
|
||||
|
||||
1. **publish만 전환** — 기존 consumer는 그대로. wire format은 reserved header가 추가될 뿐이다.
|
||||
2. **Outbox 도입** — publish 유실 창을 닫는다.
|
||||
3. **consume 전환** — handler를 `MessageHandler`로 옮기고 ack 호출을 제거한다.
|
||||
4. **Inbox 도입** — 중복 side effect를 닫는다.
|
||||
5. **retry·DLQ 정책 선언** — 이 시점까지 자동 retry는 0회다.
|
||||
|
||||
각 단계는 독립적으로 배포 가능하고, 되돌릴 수 있다.
|
||||
|
||||
## 되돌릴 수 없는 것
|
||||
|
||||
- 한 번 발행된 message type의 wire contract
|
||||
- 이미 retention 안에 있는 메시지의 schema
|
||||
- redrive된 메시지의 `messageId` (바뀌지 않는다 — 이것이 의도다)
|
||||
@@ -0,0 +1,113 @@
|
||||
# 운영 Runbook
|
||||
|
||||
## 배포 전 체크
|
||||
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew verifyRuntimeModuleMembership --console=plain
|
||||
./gradlew checkstyleMain --console=plain
|
||||
```
|
||||
|
||||
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
|
||||
|
||||
- ordered destination + reorder 가능 retry
|
||||
- `ordering=KEY` + key resolver 없음
|
||||
- payload 상한 > 8,388,608 bytes
|
||||
- DLQ 자기 참조 / retry 자기 참조
|
||||
- retry·DLQ 그래프 cycle
|
||||
- 미등록 retry·DLQ destination
|
||||
- M1 destination + manual settlement
|
||||
- `AT_LEAST_ONCE` + confirmation `NONE`
|
||||
- production profile + topology auto-create
|
||||
- broker topology가 manifest와 불일치
|
||||
|
||||
## 증상별 대응
|
||||
|
||||
### publish가 AMBIGUOUS로 쏟아진다
|
||||
|
||||
broker confirm 경로 문제다. 실패가 아니다.
|
||||
|
||||
1. `PublishEvidence.transmission`이 `MAY_HAVE_BEEN_TRANSMITTED`인지 확인
|
||||
2. Kafka: `delivery.timeout.ms`, ISR 상태, leader election 확인
|
||||
3. Rabbit: confirm timeout, channel 상태 확인
|
||||
4. Outbox를 쓰고 있다면 `status='AMBIGUOUS'` row가 같은 messageId로 재시도 중이다. **정상이다.**
|
||||
5. consumer 쪽 Inbox가 중복을 흡수하는지 확인
|
||||
|
||||
`AMBIGUOUS`를 실패로 취급해 새 messageId로 재발행하지 말 것. 중복이 복구 불가능해진다.
|
||||
|
||||
### DLQ가 비어 있는데 메시지가 사라졌다
|
||||
|
||||
DLQ publish 실패 시 source는 settlement되지 않는다. 메시지는 source에 남아 재전달된다.
|
||||
|
||||
1. `msg.failure-code`가 `DEAD_LETTER_*`인 로그 확인
|
||||
2. DLQ destination이 실제로 존재하는지 (topology validation)
|
||||
3. DLQ credential에 publish 권한이 있는지
|
||||
|
||||
### consumer lag이 한 partition에서만 증가한다
|
||||
|
||||
`ContiguousPartitionOffsetTracker`가 gap에서 멈춘 것이다. 설계된 동작이다.
|
||||
|
||||
commit은 **연속** 완료 offset까지만 전진한다. offset 11이 아직 실행 중이면
|
||||
10과 12가 끝나도 watermark는 10에 머문다. 12를 commit하면 consumer가 죽었을 때 11을 잃는다.
|
||||
|
||||
1. 해당 partition의 in-flight를 확인
|
||||
2. 느린 handler를 찾는다 (`handlerTimeout` 초과 여부)
|
||||
3. 필요하면 `PAUSE_PARTITION` retry가 걸려 있는지 확인
|
||||
|
||||
### 재시도 폭풍
|
||||
|
||||
`RetryPolicy.jitter=false`인지 확인한다. jitter 없이는 같은 초에 실패한 모든 consumer가
|
||||
같은 초에 재시도한다.
|
||||
|
||||
### shutdown이 오래 걸린다
|
||||
|
||||
`GracefulShutdownCoordinator`가 in-flight를 기다리는 중이다.
|
||||
|
||||
- `inFlight()`가 0이 되면 즉시 종료
|
||||
- drain deadline(기본 30초) 초과 시 남은 작업을 **unsettled로 포기**한다 → broker가 재전달
|
||||
- draining 시작 후 새 retry attempt는 만들지 않는다
|
||||
|
||||
## Destructive 작업
|
||||
|
||||
전부 `DestructiveOperationGuard`를 통과해야 한다.
|
||||
|
||||
| 조건 | 요구 |
|
||||
|---|---|
|
||||
| admin credential | application runtime은 보유하지 않음 |
|
||||
| `AdminApproval` | 유효기간 내 |
|
||||
| dry-run | 항상 허용 |
|
||||
|
||||
### Replay
|
||||
|
||||
```text
|
||||
기본: 격리된 consumer group (replay-<requestId>)
|
||||
기존 group 대상: 승인 티켓 필수
|
||||
```
|
||||
|
||||
기존 production group으로 replay하는 것은 "다시 읽기"가 아니라 **live consumer를 되감는 것**이다.
|
||||
그 사이의 모든 것이 재처리된다.
|
||||
|
||||
### Redrive
|
||||
|
||||
```text
|
||||
dry-run으로 후보 수 확인
|
||||
→ 승인 획득
|
||||
→ batch 100건 이하로 실행
|
||||
→ republish CONFIRMED 인 것만 DLQ에서 settlement
|
||||
```
|
||||
|
||||
`redriveId`로 재구동 루프를 추적한다. 같은 메시지가 반복해서 redrive되면
|
||||
근본 원인이 해결되지 않은 것이다.
|
||||
|
||||
### Offset reset
|
||||
|
||||
`KafkaOffsetResetExecutor`는 승인 predicate를 **생성자 인자**로 받는다.
|
||||
승인 소스 없이 조립된 runtime은 물리적으로 reset을 수행할 수 없다.
|
||||
|
||||
## Topology
|
||||
|
||||
production topology는 IaC가 만들고 애플리케이션은 **검증만** 한다.
|
||||
|
||||
`TopologyValidationRuntime`은 모든 불일치를 한 번에 보고하고 startup을 실패시킨다.
|
||||
partition 수가 다르면 destination이 광고하는 ordering 보장이 달라지고,
|
||||
`min.insync.replicas`가 없으면 `acks=all`의 의미가 달라진다.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Outbox · Inbox
|
||||
|
||||
## 두 패턴이 각각 무엇을 해결하는가
|
||||
|
||||
| 패턴 | 해결하는 문제 | 해결하지 않는 문제 |
|
||||
|---|---|---|
|
||||
| Transactional Outbox | DB commit과 publish 사이의 창(窓) | 중복 |
|
||||
| Inbox | 중복 delivery의 side effect | 유실 |
|
||||
|
||||
**둘 다 필요하다.** Outbox만으로는 exactly-once가 되지 않는다.
|
||||
|
||||
## Outbox
|
||||
|
||||
business transaction과 **같은 transaction**에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다.
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
UPDATE orders SET status = 'PLACED' WHERE id = ?;
|
||||
INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...);
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### relay
|
||||
|
||||
```text
|
||||
leaseBatch(100, 30s) -- lease로 다중 relay 인스턴스 안전
|
||||
→ publish (messageId 그대로)
|
||||
→ CONFIRMED → markPublished
|
||||
→ AMBIGUOUS → markAmbiguous (같은 messageId로 재시도 가능)
|
||||
→ REJECTED → markFailed
|
||||
```
|
||||
|
||||
### 핵심 규칙: ambiguous는 같은 messageId로 재시도
|
||||
|
||||
새 id를 발급하면 "전달됐을 수도 있는 메시지"가 "확실히 두 번째인 메시지"가 되어
|
||||
downstream의 어떤 중복 제거도 복구할 수 없다.
|
||||
failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를 잃는다.
|
||||
|
||||
`message_id`를 primary key로 둔 것도 같은 이유다. 어떤 코드 경로도 실수로 새 id를 붙일 수 없다.
|
||||
|
||||
### lease
|
||||
|
||||
```text
|
||||
status IN ('PENDING','AMBIGUOUS','IN_FLIGHT')
|
||||
AND (lease_expires_at IS NULL OR lease_expires_at <= now)
|
||||
AND next_attempt_at <= now
|
||||
AND attempts < maxAttempts
|
||||
```
|
||||
|
||||
`IN_FLIGHT`가 목록에 있는 것이 핵심이다. relay가 publish 도중 죽으면 row는 `IN_FLIGHT`로 남는데,
|
||||
이를 제외하면 그 메시지는 **영원히** 발행되지 않는다 — outbox가 막으려던 바로 그 실패다. 대신
|
||||
lease가 만료됐을 때만 회수하므로, 살아 있는 relay가 들고 있는 row는 회수되지 않는다.
|
||||
|
||||
회수는 **같은 `message_id`로** 이루어지고 `lease_token`이 1 증가한다. 새 id를 발급하면 "전달됐을
|
||||
수도 있는 메시지"가 "확실히 두 번째"가 되기 때문이다 (위의 AMBIGUOUS 논의와 같은 이유).
|
||||
|
||||
이 문단의 근거는 실제 PostgreSQL 컨테이너 레인이다:
|
||||
|
||||
- `OutboxPostgresIT#anExpiredLeaseBecomesClaimableAgain` — 만료된 lease의 재회수
|
||||
- `OutboxPostgresIT#anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — 같은 id, 증가한 token
|
||||
- `OutboxPostgresIT#aLeasedRowIsInvisibleToASecondRelayInstance` — 살아 있는 lease는 회수 불가
|
||||
- `OutboxPostgresIT#aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` — fencing
|
||||
|
||||
partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다.
|
||||
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
|
||||
|
||||
## Inbox
|
||||
|
||||
reservation과 side effect가 **같은 transaction**이어야 한다.
|
||||
|
||||
```java
|
||||
transactions.inTransaction(() -> {
|
||||
if (!inbox.reserve(messageId, consumerId, now)) {
|
||||
return InboxOutcome.duplicate(); // 이미 처리됨
|
||||
}
|
||||
return InboxOutcome.processed(sideEffect.get());
|
||||
});
|
||||
```
|
||||
|
||||
별도 transaction으로 예약하면 Inbox가 닫으려던 바로 그 창이 다시 열린다.
|
||||
|
||||
### 복합 키
|
||||
|
||||
`PRIMARY KEY (message_id, consumer_id)`.
|
||||
|
||||
message_id만으로 중복 제거하면 같은 event를 소비하는 두 번째 consumer가
|
||||
첫 번째에 의해 억제된다. 각 consumer가 한 번씩 처리해야 한다.
|
||||
|
||||
### retention
|
||||
|
||||
broker의 최대 redelivery window보다 **길어야** 한다.
|
||||
row를 먼저 지우면 늦게 도착한 redelivery가 두 번 처리된다.
|
||||
|
||||
## Debezium CDC 대안
|
||||
|
||||
polling relay 대신 WAL을 읽는다. polling interval과 lease 경합이 사라지지만
|
||||
인프라와 그 자체의 실패 모드가 추가된다.
|
||||
|
||||
wire contract는 동일하다. `DebeziumOutboxEventRouter`가 polling relay와 같은 reserved header를
|
||||
방출하므로 consumer는 어느 쪽이 발행했는지 구분할 수 없고, 전환은 배포 결정일 뿐 계약 변경이 아니다.
|
||||
|
||||
## Claim Check
|
||||
|
||||
1 MiB 초과 payload는 broker 프레임을 키우지 않고 외부 저장소로 offload한다.
|
||||
|
||||
`ClaimCheckReference`는 digest를 **필수**로 가진다. claim check는 메시지를 서로 다른 retention과
|
||||
replication을 가진 두 시스템으로 쪼개므로, consumer는 producer가 저장한 바로 그 bytes를 받았음을
|
||||
증명할 수 있어야 한다. 그렇지 않으면 잘린 객체와 정상 객체를 구분할 수 없다.
|
||||
|
||||
`ClaimCheckIntegrityGuard`는 fetch 전에 만료를, fetch 후에 크기와 digest를 검사한다.
|
||||
digest 불일치는 `DESERIALIZATION`이 아니라 **validation** 실패로 분류한다.
|
||||
bytes가 깨진 JSON인 게 아니라, 틀린 bytes이기 때문이다.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user