chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both this worktree and the main checkout before this session began: the initial HTTP Client platform implementation (previously untracked), the redis-lab removal, and the JPA / object-storage / notification integration work. Kept separate from this session's HTTP Client review response, which lands in the following commit, so the two bodies of work stay reviewable apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
@@ -21,6 +21,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
- name: Require the committed public-path security baseline
|
- name: Require the committed public-path security baseline
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -44,12 +47,18 @@ jobs:
|
|||||||
src/**/gradle.lockfile
|
src/**/gradle.lockfile
|
||||||
- name: Check quality, public paths, and dependency locks
|
- name: Check quality, public paths, and dependency locks
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --stacktrace
|
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
|
||||||
|
- name: Qualify opt-in inbound transports without skips
|
||||||
|
working-directory: src
|
||||||
|
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
|
||||||
|
|
||||||
sample-off:
|
sample-off:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -70,10 +79,13 @@ jobs:
|
|||||||
- name: Verify the gate matrix against the repository
|
- name: Verify the gate matrix against the repository
|
||||||
run: bash .github/scripts/verify-gate-matrix.sh
|
run: bash .github/scripts/verify-gate-matrix.sh
|
||||||
|
|
||||||
redis-standalone:
|
redis-sdk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -83,14 +95,15 @@ jobs:
|
|||||||
src/**/*.gradle
|
src/**/*.gradle
|
||||||
src/**/gradle-wrapper.properties
|
src/**/gradle-wrapper.properties
|
||||||
src/**/gradle.lockfile
|
src/**/gradle.lockfile
|
||||||
- name: Verify standalone Redis policy, provider, and composition contracts
|
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
|
||||||
|
# permit provenance, connection isolation, and the executor guard. There is no real-server
|
||||||
|
# lane yet — Tasks 10-17 add the contract suites that need one.
|
||||||
|
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: >-
|
run: >-
|
||||||
./gradlew
|
./gradlew
|
||||||
:application-core:redisPolicyContractTest
|
|
||||||
:shared-contract:edgeRateLimitContractTest
|
:shared-contract:edgeRateLimitContractTest
|
||||||
:adapter:outbound:cache-redis:check
|
:adapter:outbound:cache-redis:check
|
||||||
:app-bootstrap:redisCompositionTest
|
|
||||||
verifyCleanArchitectureDependencies
|
verifyCleanArchitectureDependencies
|
||||||
verifyEnvKeys
|
verifyEnvKeys
|
||||||
verifyPublicPathSnapshot
|
verifyPublicPathSnapshot
|
||||||
@@ -102,6 +115,9 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -132,6 +148,9 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -150,7 +169,7 @@ jobs:
|
|||||||
- quality-gates
|
- quality-gates
|
||||||
- sample-off
|
- sample-off
|
||||||
- gate-matrix-lint
|
- gate-matrix-lint
|
||||||
- redis-standalone
|
- redis-sdk
|
||||||
- jpa-candidate-evidence
|
- jpa-candidate-evidence
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -160,7 +179,7 @@ jobs:
|
|||||||
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
||||||
REDIS_RESULT: ${{ needs.redis-standalone.result }}
|
REDIS_RESULT: ${{ needs.redis-sdk.result }}
|
||||||
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
name: fileserver-nightly
|
||||||
|
|
||||||
|
# The environments that cannot run on every pull request: a real network filesystem, a foreign
|
||||||
|
# filesystem, and the long-running fault matrices. They are nightly rather than skipped because a
|
||||||
|
# green pull-request run is not certification of any of them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 18 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-nfs-ambiguity:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
FILESERVER_NFS_TESTS: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Start the NFSv4 certification environment
|
||||||
|
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
|
||||||
|
- name: Run the network-filesystem ambiguity suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Tear down the NFS environment
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f infra/fileserver/nfs/compose.yml down -v
|
||||||
|
|
||||||
|
fileserver-process-kill-matrix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the crash matrix and reconciliation suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
|
||||||
|
:application-core:test --tests '*FileReconciliationServiceTest'
|
||||||
|
--rerun-tasks
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-large-file-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the large-file and slow-client suites under a constrained heap
|
||||||
|
working-directory: src
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Xmx512m
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
|
||||||
|
--rerun-tasks
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-multi-instance-lease:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove no run commits bytes from a stale lease
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test --tests '*MultiInstanceWriterLeaseTest'
|
||||||
|
--rerun-tasks
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
name: fileserver-pr
|
||||||
|
|
||||||
|
# Every claim in docs/fileserver/support-matrix.md that says "Stable" is backed by a job here.
|
||||||
|
# A support level with no job behind it is a marketing claim, not an engineering one, and
|
||||||
|
# DocumentationCoverageTest fails the build when the two drift apart.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/application-core/src/**/fileserver/**'
|
||||||
|
- 'src/adapter/inbound/web/src/**/fileserver/**'
|
||||||
|
- 'src/adapter/outbound/fileserver/**'
|
||||||
|
- 'src/adapter/outbound/persistence-jpa/src/**/fileserver/**'
|
||||||
|
- 'src/app-bootstrap/src/**/fileserver/**'
|
||||||
|
- 'docs/fileserver/**'
|
||||||
|
# The capability is not only its Java files. A change to the bound settings, the shipped
|
||||||
|
# environment, the registry that documents it, or the container that has to give it a
|
||||||
|
# writable volume changes how it behaves at runtime just as surely — and those were the
|
||||||
|
# exact files that could previously ship unverified.
|
||||||
|
- 'src/app-bootstrap/src/main/resources/application.yml'
|
||||||
|
- 'src/.env'
|
||||||
|
- 'docs/registries/env-keys.yaml'
|
||||||
|
- 'src/Dockerfile'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'infra/nginx/**'
|
||||||
|
- 'infra/k8s/**'
|
||||||
|
- '.github/workflows/fileserver-pr.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-unit-and-architecture:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the fileserver application and architecture suites
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test
|
||||||
|
:app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*Fileserver*'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-local-ext4-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Certify the local content store against the shared contract
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-http-contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the servlet and reactive transport contracts
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-security-suite:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the path, filename, range, and problem-detail hardening suite
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
|
||||||
|
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-bounded-memory:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove transfer cost does not scale with file size
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||||
|
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
name: fileserver-release
|
||||||
|
|
||||||
|
# The gate a release must clear. Its job list is deliberately the same shape as the support matrix:
|
||||||
|
# nothing may be advertised at a support level whose evidence job is absent here.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fileserver-full-verification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Run the architecture-wide dependency and module verification
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
verifyCleanArchitectureDependencies
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
- name: Run the complete fileserver suite across every leaf
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:check
|
||||||
|
:adapter:inbound:web:check
|
||||||
|
:adapter:outbound:fileserver:check
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-documentation-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
fileserver-pvc-certification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
|
||||||
|
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
|
||||||
|
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
|
||||||
|
# when nothing had been applied anywhere.
|
||||||
|
- name: Check the certification manifest still says what the claim depends on
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
test -f "$manifest"
|
||||||
|
grep -q 'kind: PersistentVolumeClaim' "$manifest"
|
||||||
|
grep -q 'kind: Job' "$manifest"
|
||||||
|
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
|
||||||
|
# mode would certify a topology the support matrix says is uncertified.
|
||||||
|
grep -q 'ReadWriteOnce' "$manifest"
|
||||||
|
! grep -q 'ReadWriteMany' "$manifest"
|
||||||
|
- name: Certify the ReadWriteOnce claim on the release cluster
|
||||||
|
id: pvc-cluster-run
|
||||||
|
env:
|
||||||
|
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
|
||||||
|
echo "::warning::no release cluster configured; PVC certification was NOT run."
|
||||||
|
echo "The support matrix records this profile as Limited for exactly this reason:"
|
||||||
|
echo "the cluster result is produced by an operator against a real cluster and read"
|
||||||
|
echo "from docs/fileserver/storage-certification.md, not by this job."
|
||||||
|
echo "certified=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
|
||||||
|
export KUBECONFIG=/tmp/kubeconfig
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
echo "certified=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
fileserver-sensitive-telemetry-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:application-core:test --tests '*FileserverObservabilityTest'
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
name: httpclient-nightly
|
||||||
|
|
||||||
|
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
|
||||||
|
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
|
||||||
|
# fails, it does not skip.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
httpclient-fault-injection:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Inject TCP faults against a real upstream
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-performance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Certify pool, streaming, retry, and rotation bounds
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
|
|
||||||
|
httpclient-http3-experimental:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Experimental by design (D-08): the result is reported, never used to block a merge.
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Exercise the experimental HTTP/3 opt-in
|
||||||
|
working-directory: src
|
||||||
|
run: >-
|
||||||
|
./gradlew
|
||||||
|
:adapter:outbound:httpclient:test
|
||||||
|
-Phttp3.tests.enabled=true
|
||||||
|
--no-daemon
|
||||||
|
--stacktrace
|
||||||
@@ -26,6 +26,9 @@ jobs:
|
|||||||
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "README.md"
|
- "README.md"
|
||||||
- "src/README.md"
|
- "src/README.md"
|
||||||
|
- "src/**/README.md"
|
||||||
|
- "src/**/CLAUDE.md"
|
||||||
- "docs/**/*.md"
|
- "docs/**/*.md"
|
||||||
- ".github/**/*.md"
|
- ".github/**/*.md"
|
||||||
- ".github/workflows/link-check.yml"
|
- ".github/workflows/link-check.yml"
|
||||||
@@ -13,6 +15,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "README.md"
|
- "README.md"
|
||||||
- "src/README.md"
|
- "src/README.md"
|
||||||
|
- "src/**/README.md"
|
||||||
|
- "src/**/CLAUDE.md"
|
||||||
- "docs/**/*.md"
|
- "docs/**/*.md"
|
||||||
- ".github/**/*.md"
|
- ".github/**/*.md"
|
||||||
- ".github/workflows/link-check.yml"
|
- ".github/workflows/link-check.yml"
|
||||||
@@ -38,6 +42,8 @@ jobs:
|
|||||||
--root-dir .
|
--root-dir .
|
||||||
README.md
|
README.md
|
||||||
src/README.md
|
src/README.md
|
||||||
|
'src/**/README.md'
|
||||||
|
'src/**/CLAUDE.md'
|
||||||
'docs/**/*.md'
|
'docs/**/*.md'
|
||||||
'.github/**/*.md'
|
'.github/**/*.md'
|
||||||
fail: true
|
fail: true
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -32,7 +35,7 @@ jobs:
|
|||||||
src/**/*.gradle
|
src/**/*.gradle
|
||||||
src/**/gradle-wrapper.properties
|
src/**/gradle-wrapper.properties
|
||||||
src/**/gradle.lockfile
|
src/**/gradle.lockfile
|
||||||
- name: Run non-skipping Poster image V7 migration qualification
|
- name: Run non-skipping Poster image migration qualification
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
||||||
|
|
||||||
@@ -40,6 +43,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -58,6 +64,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
@@ -82,6 +91,9 @@ jobs:
|
|||||||
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
name: redis-production-readiness
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "23 18 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*-rc.*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
resolve-redis-readiness:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
selected: ${{ steps.resolve.outputs.selected }}
|
|
||||||
candidates: ${{ steps.resolve.outputs.candidates }}
|
|
||||||
selected_count: ${{ steps.resolve.outputs.selected_count }}
|
|
||||||
sentinel_required: ${{ steps.resolve.outputs.sentinel_required }}
|
|
||||||
cluster_required: ${{ steps.resolve.outputs.cluster_required }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- name: Generate the strict checked-in readiness control artifact
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew writeRedisCiMatrix --no-daemon --stacktrace
|
|
||||||
- id: resolve
|
|
||||||
name: Transport the generated matrix to job outputs
|
|
||||||
shell: python
|
|
||||||
run: |
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
matrix_path = Path(
|
|
||||||
"src/build/redis-evidence/control/redis-readiness-matrix.json"
|
|
||||||
)
|
|
||||||
matrix = json.loads(matrix_path.read_text(encoding="utf-8"))
|
|
||||||
if matrix["releaseQualification"] != "NOT_CLAIMED":
|
|
||||||
raise SystemExit("resolver control artifact must not claim release qualification")
|
|
||||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
|
|
||||||
stream.write(
|
|
||||||
"selected="
|
|
||||||
+ json.dumps(matrix["selected"], separators=(",", ":"))
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
stream.write(
|
|
||||||
"candidates="
|
|
||||||
+ json.dumps(
|
|
||||||
matrix["implementedCandidates"], separators=(",", ":")
|
|
||||||
)
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
stream.write(f"selected_count={matrix['selectedCount']}\n")
|
|
||||||
stream.write(
|
|
||||||
"sentinel_required="
|
|
||||||
+ str(matrix["topologyJobs"]["sentinel"]).lower()
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
stream.write(
|
|
||||||
"cluster_required="
|
|
||||||
+ str(matrix["topologyJobs"]["cluster"]).lower()
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-readiness-control
|
|
||||||
path: src/build/redis-evidence/control
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
redis-security:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:redisSecurityTest --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-security-evidence
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
redis-sentinel:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
if: ${{ needs.resolve-redis-readiness.outputs.sentinel_required == 'true' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:redisSentinelTest --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-sentinel-evidence
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
redis-cluster:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
if: ${{ needs.resolve-redis-readiness.outputs.cluster_required == 'true' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:redisClusterTest --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-cluster-evidence
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
redis-fault:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:redisFaultTest --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-fault-evidence
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
redis-compatibility:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:redisCompatibilityTest --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-compatibility-evidence
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
selected-card-readiness:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include: ${{ fromJSON(needs.resolve-redis-readiness.outputs.selected) }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew ${{ matrix.readinessTask }} --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-selected-${{ matrix.cardId }}
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
redis-all-candidates:
|
|
||||||
needs: resolve-redis-readiness
|
|
||||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- id: redis-tests
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew redisAllImplementedCandidates --no-daemon --stacktrace
|
|
||||||
- id: redis-evidence-sanitizer
|
|
||||||
if: always()
|
|
||||||
working-directory: src
|
|
||||||
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload --no-daemon --stacktrace
|
|
||||||
- if: ${{ always() && steps.redis-evidence-sanitizer.outcome == 'success' }}
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: redis-all-candidates-evidence
|
|
||||||
path: src/adapter/outbound/cache-redis/build/redis-evidence
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
redis-production-readiness:
|
|
||||||
needs:
|
|
||||||
- resolve-redis-readiness
|
|
||||||
- selected-card-readiness
|
|
||||||
if: ${{ always() && needs.resolve-redis-readiness.result == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Require the exact selected matrix result
|
|
||||||
shell: python
|
|
||||||
env:
|
|
||||||
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
|
|
||||||
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
|
|
||||||
run: |
|
|
||||||
import os
|
|
||||||
|
|
||||||
selected_count_text = os.environ["SELECTED_COUNT"]
|
|
||||||
selected_job_result = os.environ["SELECTED_JOB_RESULT"]
|
|
||||||
if not selected_count_text.isdecimal():
|
|
||||||
raise SystemExit("selected_count must be a non-negative integer")
|
|
||||||
selected_count = int(selected_count_text)
|
|
||||||
expected_result = "skipped" if selected_count == 0 else "success"
|
|
||||||
if selected_job_result != expected_result:
|
|
||||||
raise SystemExit(
|
|
||||||
"selected-card-readiness result mismatch: "
|
|
||||||
f"count={selected_count}, expected={expected_result}, "
|
|
||||||
f"actual={selected_job_result}"
|
|
||||||
)
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
||||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21.0.11+10"
|
|
||||||
cache: gradle
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
|
|
||||||
with:
|
|
||||||
name: redis-readiness-control
|
|
||||||
path: ${{ runner.temp }}/redis-readiness/control
|
|
||||||
- if: >-
|
|
||||||
${{
|
|
||||||
needs.resolve-redis-readiness.outputs.selected_count != '0'
|
|
||||||
&& needs.selected-card-readiness.result == 'success'
|
|
||||||
}}
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
|
|
||||||
with:
|
|
||||||
pattern: redis-selected-*
|
|
||||||
path: ${{ runner.temp }}/redis-readiness/selected
|
|
||||||
- name: Record the downloaded selected artifact inventory
|
|
||||||
shell: python
|
|
||||||
env:
|
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
|
||||||
SELECTED_JSON: ${{ needs.resolve-redis-readiness.outputs.selected }}
|
|
||||||
SELECTED_COUNT: ${{ needs.resolve-redis-readiness.outputs.selected_count }}
|
|
||||||
SELECTED_JOB_RESULT: ${{ needs.selected-card-readiness.result }}
|
|
||||||
REDIS_SELECTED_DIRECTORY: ${{ runner.temp }}/redis-readiness/selected
|
|
||||||
REDIS_CI_RESULT_FILE: ${{ runner.temp }}/redis-readiness/redis-ci-result.json
|
|
||||||
run: |
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
selected = json.loads(os.environ["SELECTED_JSON"])
|
|
||||||
if not isinstance(selected, list):
|
|
||||||
raise SystemExit("selected matrix must be a JSON array")
|
|
||||||
expected_names = sorted(
|
|
||||||
"redis-selected-" + entry["cardId"] for entry in selected
|
|
||||||
)
|
|
||||||
if len(expected_names) != int(os.environ["SELECTED_COUNT"]):
|
|
||||||
raise SystemExit("selected_count does not match the selected matrix")
|
|
||||||
if len(expected_names) != len(set(expected_names)):
|
|
||||||
raise SystemExit("selected matrix contains duplicate artifact names")
|
|
||||||
|
|
||||||
selected_directory = Path(os.environ["REDIS_SELECTED_DIRECTORY"])
|
|
||||||
actual_names = (
|
|
||||||
sorted(path.name for path in selected_directory.iterdir() if path.is_dir())
|
|
||||||
if selected_directory.is_dir()
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
if actual_names != expected_names:
|
|
||||||
raise SystemExit(
|
|
||||||
"downloaded selected artifact inventory mismatch: "
|
|
||||||
f"expected={expected_names}, actual={actual_names}"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"runId": os.environ["GITHUB_RUN_ID"],
|
|
||||||
"selectedCount": len(expected_names),
|
|
||||||
"selectedJobResult": os.environ["SELECTED_JOB_RESULT"],
|
|
||||||
"selectedArtifactNames": actual_names,
|
|
||||||
}
|
|
||||||
result_path = Path(os.environ["REDIS_CI_RESULT_FILE"])
|
|
||||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
result_path.write_text(
|
|
||||||
json.dumps(result, sort_keys=True, separators=(",", ":")) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count == '0' }}
|
|
||||||
working-directory: src
|
|
||||||
run: >-
|
|
||||||
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
|
|
||||||
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
|
|
||||||
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
|
|
||||||
--no-daemon --stacktrace
|
|
||||||
- if: ${{ needs.resolve-redis-readiness.outputs.selected_count != '0' }}
|
|
||||||
working-directory: src
|
|
||||||
run: >-
|
|
||||||
./gradlew verifyRedisSelectedEvidenceArtifacts redisProductionReadiness
|
|
||||||
-PredisControlDirectory=${{ runner.temp }}/redis-readiness/control
|
|
||||||
-PredisEvidenceDirectory=${{ runner.temp }}/redis-readiness/selected
|
|
||||||
-PredisCiResultFile=${{ runner.temp }}/redis-readiness/redis-ci-result.json
|
|
||||||
--no-daemon --stacktrace
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Redis SDK topology evidence.
|
||||||
|
#
|
||||||
|
# The lanes in infra/redis-sdk answer what the deterministic in-memory gateway cannot — Sentinel
|
||||||
|
# promotion behaviour, Cluster redirects, ACL coverage. docs/redis/support-matrix.md records which
|
||||||
|
# lane produced which evidence, and RedisSupportMatrixTest refuses an evidence claim that does not
|
||||||
|
# name the test class behind it.
|
||||||
|
#
|
||||||
|
# Three cadences, because the cost and the question differ:
|
||||||
|
#
|
||||||
|
# pull_request standalone only, current supported version. The cheapest lane that can still
|
||||||
|
# catch "this change cannot talk to a real Redis at all". A PR gate that starts
|
||||||
|
# three topologies is a PR gate people learn to ignore.
|
||||||
|
# schedule the full supported-version x topology matrix, nightly. This is where Sentinel
|
||||||
|
# promotion and Cluster redirect evidence comes from.
|
||||||
|
# workflow_dispatch one lane on demand, for reproducing a specific failure.
|
||||||
|
#
|
||||||
|
# A release candidate uses the nightly matrix run for its tag: `release-candidate` selects the full
|
||||||
|
# matrix on demand so an RC does not have to wait for the next scheduled run.
|
||||||
|
#
|
||||||
|
# Each lane has its own endpoint. A sentinel is not a data node and a cluster node is not the whole
|
||||||
|
# cluster, so the address, port, and (for Sentinel) the monitored primary's name are per-lane rather
|
||||||
|
# than one hardcoded 6379 that happens to be right for standalone only.
|
||||||
|
#
|
||||||
|
# The Gradle task is fail-closed on its own account: an unknown mode, a missing endpoint, a lane
|
||||||
|
# with no tagged test class, and a run that executed zero tests are all errors. This workflow does
|
||||||
|
# not need to re-check those, but it does have to keep the evidence, which is why every run uploads
|
||||||
|
# the JUnit XML together with the commit SHA, the server version and the resolved image digest. An
|
||||||
|
# evidence artifact that cannot say which image produced it is not evidence.
|
||||||
|
name: redis-sdk-topology
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "src/adapter/outbound/cache-redis/**"
|
||||||
|
- "infra/redis-sdk/**"
|
||||||
|
- ".github/workflows/redis-sdk-topology.yml"
|
||||||
|
schedule:
|
||||||
|
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
|
||||||
|
- cron: "30 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
topology:
|
||||||
|
description: standalone, sentinel, cluster, tls, or release-candidate for the full matrix
|
||||||
|
required: true
|
||||||
|
default: standalone
|
||||||
|
type: choice
|
||||||
|
options: [standalone, sentinel, cluster, tls, release-candidate]
|
||||||
|
redis_version:
|
||||||
|
description: server version tag
|
||||||
|
required: true
|
||||||
|
default: "7.4"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The matrix is computed rather than duplicated per trigger, so adding a supported version is one
|
||||||
|
# edit and no trigger can silently keep testing an old set.
|
||||||
|
lanes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.select.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- id: select
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "${{ github.event_name }}" in
|
||||||
|
pull_request)
|
||||||
|
matrix='{"include":[{"topology":"standalone","redis_version":"7.4"}]}'
|
||||||
|
;;
|
||||||
|
schedule)
|
||||||
|
matrix='{"include":[
|
||||||
|
{"topology":"standalone","redis_version":"7.2"},
|
||||||
|
{"topology":"standalone","redis_version":"7.4"},
|
||||||
|
{"topology":"standalone","redis_version":"8.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.4"},
|
||||||
|
{"topology":"sentinel","redis_version":"8.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.4"},
|
||||||
|
{"topology":"cluster","redis_version":"8.2"},
|
||||||
|
{"topology":"tls","redis_version":"7.4"},
|
||||||
|
{"topology":"tls","redis_version":"8.2"}]}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ "${{ inputs.topology }}" = "release-candidate" ]; then
|
||||||
|
matrix='{"include":[
|
||||||
|
{"topology":"standalone","redis_version":"7.2"},
|
||||||
|
{"topology":"standalone","redis_version":"7.4"},
|
||||||
|
{"topology":"standalone","redis_version":"8.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.2"},
|
||||||
|
{"topology":"sentinel","redis_version":"7.4"},
|
||||||
|
{"topology":"sentinel","redis_version":"8.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.2"},
|
||||||
|
{"topology":"cluster","redis_version":"7.4"},
|
||||||
|
{"topology":"cluster","redis_version":"8.2"},
|
||||||
|
{"topology":"tls","redis_version":"7.4"},
|
||||||
|
{"topology":"tls","redis_version":"8.2"}]}'
|
||||||
|
else
|
||||||
|
matrix='{"include":[{"topology":"${{ inputs.topology }}","redis_version":"${{ inputs.redis_version }}"}]}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf 'matrix=%s\n' "$(printf '%s' "$matrix" | tr -d '\n ')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
topology-evidence:
|
||||||
|
needs: lanes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10"
|
||||||
|
cache: gradle
|
||||||
|
cache-dependency-path: |
|
||||||
|
src/**/*.gradle
|
||||||
|
src/**/gradle-wrapper.properties
|
||||||
|
src/**/gradle.lockfile
|
||||||
|
- name: Start the topology
|
||||||
|
env:
|
||||||
|
REDIS_VERSION: ${{ matrix.redis_version }}
|
||||||
|
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" up -d --wait
|
||||||
|
- name: Record the image digest
|
||||||
|
id: image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
|
||||||
|
# reproduced once the tag moves.
|
||||||
|
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
|
||||||
|
"redis:${{ matrix.redis_version }}" 2>/dev/null || echo 'unresolved')"
|
||||||
|
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Run the topology contracts
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case '${{ matrix.topology }}' in
|
||||||
|
standalone) port=6379; extra='' ;;
|
||||||
|
sentinel) port=27010; extra='-Predis.topology.master=skeleton' ;;
|
||||||
|
cluster) port=7100; extra='' ;;
|
||||||
|
# The TLS lane's CA is generated at start-up, so the trust material is extracted from
|
||||||
|
# the lane rather than checked in. A checked-in key is a secret in the repository
|
||||||
|
# however loudly the file is named "test".
|
||||||
|
tls)
|
||||||
|
port=6390
|
||||||
|
docker compose -f ../infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt "$RUNNER_TEMP/redis-lane-ca.pem"
|
||||||
|
extra="-Predis.topology.trust-material=$RUNNER_TEMP/redis-lane-ca.pem"
|
||||||
|
;;
|
||||||
|
*) echo "unknown topology"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
./gradlew :adapter:outbound:cache-redis:redisTopologyTest --console=plain \
|
||||||
|
-Predis.topology.host=localhost \
|
||||||
|
-Predis.topology.port="$port" \
|
||||||
|
-Predis.topology.mode='${{ matrix.topology }}' \
|
||||||
|
$extra
|
||||||
|
- name: Write the evidence manifest
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
out=src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest
|
||||||
|
mkdir -p "$out"
|
||||||
|
cat > "$out/evidence-manifest.txt" <<MANIFEST
|
||||||
|
commit=${{ github.sha }}
|
||||||
|
workflow_run=${{ github.run_id }}
|
||||||
|
trigger=${{ github.event_name }}
|
||||||
|
topology=${{ matrix.topology }}
|
||||||
|
redis_version=${{ matrix.redis_version }}
|
||||||
|
image_digest=${{ steps.image.outputs.digest }}
|
||||||
|
MANIFEST
|
||||||
|
- name: Preserve the evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: redis-topology-${{ matrix.topology }}-${{ matrix.redis_version }}
|
||||||
|
path: |
|
||||||
|
src/adapter/outbound/cache-redis/build/test-results/redisTopologyTest/**
|
||||||
|
src/adapter/outbound/cache-redis/build/reports/tests/redisTopologyTest/**
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
- name: Stop the topology
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f "infra/redis-sdk/${{ matrix.topology }}/compose.yml" down -v
|
||||||
@@ -50,7 +50,8 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
|
|||||||
## Gradle 정책 권위
|
## Gradle 정책 권위
|
||||||
|
|
||||||
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
|
||||||
Gradle path, 허용 production project dependency edge
|
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||||
|
membership
|
||||||
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
||||||
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
||||||
architecture-wide verification task
|
architecture-wide verification task
|
||||||
@@ -101,7 +102,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
|
|||||||
|
|
||||||
## 모듈 책임
|
## 모듈 책임
|
||||||
|
|
||||||
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성은
|
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
|
||||||
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
|
||||||
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
|
||||||
`src/**/CLAUDE.md`를 함께 읽는다.
|
`src/**/CLAUDE.md`를 함께 읽는다.
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
|
|||||||
## Gradle policy authorities
|
## Gradle policy authorities
|
||||||
|
|
||||||
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
|
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
|
||||||
paths, Gradle paths, and allowed production project dependency edges.
|
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
|
||||||
|
memberships of both composition roots.
|
||||||
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
|
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
|
||||||
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
|
||||||
verification tasks.
|
verification tasks.
|
||||||
@@ -57,7 +58,7 @@ families; the nearest `src/**/CLAUDE.md` owns local rules.
|
|||||||
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
||||||
|
|
||||||
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
||||||
Read its `gradle_path` and `allowed_dependencies` from
|
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
|
||||||
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||||
|
|
||||||
## Layer workflow
|
## Layer workflow
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:mode=1777,size=128m
|
- /tmp:mode=1777,size=128m
|
||||||
- /var/tmp/heap:mode=1777,size=512m
|
- /var/tmp/heap:mode=1777,size=512m
|
||||||
|
# ---- Fileserver storage volume ------------------------------------------
|
||||||
|
# A named volume, not a tmpfs and not the read-only root. The Fileserver platform's default
|
||||||
|
# storage root is /var/lib/backend/files, and with a read-only root and no mount there was
|
||||||
|
# nowhere on the image it could legally write: enabling the capability failed on its first
|
||||||
|
# upload rather than at startup. The volume is declared unconditionally because a volume
|
||||||
|
# nobody writes to costs nothing, while a missing one costs an outage.
|
||||||
|
#
|
||||||
|
# Ownership: the image runs as uid/gid 1000 (see src/Dockerfile). Docker initialises a fresh
|
||||||
|
# named volume from the image path's ownership, so the directory is created in the image with
|
||||||
|
# that owner; a pre-existing volume or a host bind mount must be chowned to 1000:1000 by the
|
||||||
|
# operator, or every write is refused with a permission error the application reports as
|
||||||
|
# STORAGE_UNAVAILABLE.
|
||||||
|
volumes:
|
||||||
|
- fileserver-data:/var/lib/backend/files
|
||||||
# ---- Memory limit (D4) --------------------------------------------------
|
# ---- Memory limit (D4) --------------------------------------------------
|
||||||
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
|
# Must be set so -XX:MaxRAMPercentage=75 can compute a meaningful heap bound.
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
@@ -78,3 +92,8 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
retries: 3
|
retries: 3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Survives container replacement, which is the point: published content outlives the process
|
||||||
|
# that wrote it. Back this with real storage in any deployment that keeps files.
|
||||||
|
fileserver-data:
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Fileserver configuration
|
||||||
|
|
||||||
|
Every key below lives under `app.fileserver-platform` (environment form
|
||||||
|
`APP_FILESERVER_PLATFORM_*`). That namespace is the HTTP platform's alone: `app.fileserver.*`
|
||||||
|
belongs to the R2 tabular publication capability and `app.file-export.*` to the R1 CSV export, and
|
||||||
|
the three are deliberately separate so switching one on cannot switch on another.
|
||||||
|
|
||||||
|
While `app.fileserver-platform.enabled` is false none of these keys is bound at all — the
|
||||||
|
auto-configuration that binds them is not processed — so a malformed value in a block nobody
|
||||||
|
enabled cannot fail a startup. Once enabled, binding is strict: an unknown key under the prefix is
|
||||||
|
refused rather than ignored. The defaults are the conservative ones: the
|
||||||
|
capability is off, the admin plane is off, background reclamation is off, and there is no permissive
|
||||||
|
authorization fallback. Turning the capability on is a deliberate act, and so is every surface it
|
||||||
|
exposes.
|
||||||
|
|
||||||
|
## Minimum to start
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
enabled: true
|
||||||
|
instance-id: ${HOSTNAME} # writer-lease owner; must be unique per node
|
||||||
|
storage:
|
||||||
|
root: /var/lib/backend/files # absolute, outside any webroot or config dir
|
||||||
|
security:
|
||||||
|
access-policy: role-based # or supply your own FileAccessPolicy bean
|
||||||
|
observability:
|
||||||
|
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup fails, rather than degrading, when any of these is missing or unsafe:
|
||||||
|
|
||||||
|
| Condition | Why it is fatal |
|
||||||
|
| --- | --- |
|
||||||
|
| `security.access-policy` left at `required` with no `FileAccessPolicy` bean | a file capability that authorizes by default is worse than one that refuses to start |
|
||||||
|
| `observability.fingerprint-key` unset while metrics are on | an unkeyed digest of an enumerable identifier is reversible |
|
||||||
|
| the storage root fails a mandatory capability probe | a volume that cannot create atomically, keep staging and content on one FileStore, or refuse symlinks is unsafe, not degraded |
|
||||||
|
| `storage.publish-mode: atomic-move-required` on a volume where the probe could not prove an atomic move | the configured guarantee cannot be delivered |
|
||||||
|
| `security.access-policy: unenforced` under a `prod` profile | a value that was convenient in development must not survive promotion |
|
||||||
|
|
||||||
|
## Authorization — `security`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `access-policy` | `required` | `required` (supply your own bean), `role-based`, or `unenforced` |
|
||||||
|
| `read-roles` | `ROLE_FILE_READ` | grants metadata read and download |
|
||||||
|
| `write-roles` | `ROLE_FILE_WRITE` | grants create, append, finalize, delete, copy, move |
|
||||||
|
| `admin-roles` | `ROLE_FILE_ADMIN` | grants reverify and force-delete, and gates `/internal/fileserver/**` at the servlet chain |
|
||||||
|
|
||||||
|
There is no anonymous-read switch. Every Fileserver route is authenticated by the servlet chain
|
||||||
|
before any application policy is consulted, so such a setting could only ever have described a
|
||||||
|
permission the transport had already refused — a configuration that reads as if it grants access
|
||||||
|
and does not.
|
||||||
|
|
||||||
|
The three tiers do not inherit. An admin role cannot delete through the data plane, and a write role
|
||||||
|
cannot reach the management plane — a role model where "can delete" implied "can force-delete" would
|
||||||
|
make the audited plane reachable through the unaudited one.
|
||||||
|
|
||||||
|
`unenforced` authorizes everything and exists so a developer can exercise upload and download before
|
||||||
|
deciding on a role model. It is refused under a production profile.
|
||||||
|
|
||||||
|
## Storage — `storage`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `root` | `/var/lib/backend/files` | absolute path; the only place a path exists |
|
||||||
|
| `publish-mode` | `atomic-move-preferred` | `atomic-move-required`, `atomic-move-preferred`, `metadata-pointer` |
|
||||||
|
| `buffer-size` | `128KB` | bounds every transfer allocation; memory never scales with file size |
|
||||||
|
| `forbidden-root-ancestors` | `/app,/etc,/usr/share/nginx/html` | roots the storage root must not live under (webroot, config dirs) |
|
||||||
|
|
||||||
|
`root` must be absolute. A relative root resolves against the process working directory, which is
|
||||||
|
one path in a container and another in a test, so it is refused at binding time.
|
||||||
|
|
||||||
|
Three former keys are gone, pinned as constants instead: staging and content share one FileStore,
|
||||||
|
symbolic links are never followed, and the object and its directory are synced before READY. Each
|
||||||
|
is an invariant the atomic publish and the namespace boundary are built on — a deployment that
|
||||||
|
could switch one off would be running a different capability under the same name and the same
|
||||||
|
tests.
|
||||||
|
|
||||||
|
The storage provider has no selector either. There is exactly one implementation, and a `type` key
|
||||||
|
with one legal value is a promise of pluggability that nothing keeps.
|
||||||
|
|
||||||
|
## Upload, download, transfer
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `upload.max-file-size` | `100MB` | hard ceiling; also drives `spring.servlet.multipart.max-file-size` |
|
||||||
|
| `upload.max-request-size` | `110MB` | request envelope; must be at least `max-file-size` |
|
||||||
|
| `upload.initial-reservation` | `8MB` | quota reserved when the length is unknown |
|
||||||
|
| `upload.ttl` | `1h` | how long a resumable upload stays claimable |
|
||||||
|
| `upload.reservation-ttl` | `24h` | how long an unsettled quota reservation survives |
|
||||||
|
| `upload.lease-duration` | `30s` | writer lease; renewed at one third of this |
|
||||||
|
| `upload.max-parts` | `16` | multipart part ceiling |
|
||||||
|
| `upload.require-content-length` | `false` | refuse chunked raw uploads |
|
||||||
|
| `download.cache-control` | `private, no-store` | emitted on every content response |
|
||||||
|
| `download.inline-allowed` | `false` | scriptable content is always an attachment regardless |
|
||||||
|
| `download.max-ranges` | `1` | multi-range responses are opt-in |
|
||||||
|
| `download.max-range-bytes` | `100MB` | total bytes one ranged response may cover |
|
||||||
|
| `download.zero-copy-enabled` | `true` | hand large plaintext responses to the kernel |
|
||||||
|
| `download.zero-copy-minimum-bytes` | `16MB` | below this the syscall setup costs more than it saves |
|
||||||
|
| `transfer.core-size` / `max-size` / `queue-capacity` | `8` / `32` / `64` | blocking transfer pool bounds |
|
||||||
|
| `transfer.await-seconds` | `300` | how long a transfer may occupy a pool thread |
|
||||||
|
|
||||||
|
Zero copy changes no header and no status. When storage declines it — an unreadable region, an
|
||||||
|
unsupported backend — the response is streamed instead and is byte-identical.
|
||||||
|
|
||||||
|
## Verification — `verification`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `timeout` | `5s` | per-verifier ceiling |
|
||||||
|
| `require-media-type-verdict` | `false` | refuse a file whose type could not be determined |
|
||||||
|
| `inline-safe-profile` | `false` | accept scriptable content instead of quarantining it |
|
||||||
|
|
||||||
|
Set `inline-safe-profile: true` only when downloads are never served inline from a trusted origin.
|
||||||
|
|
||||||
|
## Quota and admission — `quota`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `instance-upload-permits` | `16` | concurrent uploads this node admits |
|
||||||
|
| `scope-upload-permits` | `4` | concurrent uploads one namespace admits |
|
||||||
|
| `direct-download-permits` | `64` | concurrent non-delegated downloads |
|
||||||
|
| `soft-high-water` | `0.70` | storage fraction at which pressure is reported |
|
||||||
|
| `hard-high-water` | `0.85` | storage fraction at which uploads are refused |
|
||||||
|
|
||||||
|
When the storage fraction cannot be read, admission treats it as unknown and does not apply the
|
||||||
|
high-water rule — a synthetic `0` would silently disable the guard, and a synthetic `1` would take
|
||||||
|
the capability down over a failed syscall.
|
||||||
|
|
||||||
|
## Background reclamation — `cleanup`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | run the cleanup worker on this node |
|
||||||
|
| `interval` | `60s` | fixed delay between batches, not fixed rate |
|
||||||
|
| `max-items` | `100` | items one batch may claim |
|
||||||
|
| `max-bytes` | `1GB` | bytes one batch may reclaim |
|
||||||
|
| `retry-backoff` | `5m` | how long a failed item waits before it is due again |
|
||||||
|
|
||||||
|
The worker deletes physical objects, so it is off until a deployment decides otherwise. A node
|
||||||
|
without it still queues cleanup items; another node or an operator reclaims them. An item that fails
|
||||||
|
eight times is abandoned rather than retried forever — it stays visible to an operator, parked
|
||||||
|
rather than discarded.
|
||||||
|
|
||||||
|
## Management plane — `admin`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | expose `/internal/fileserver/**` |
|
||||||
|
| `orphan-minimum-age` | `1h` | how long an unreferenced object must exist before a scan may name it |
|
||||||
|
|
||||||
|
Publishing content and committing its record are two steps. Anything younger than
|
||||||
|
`orphan-minimum-age` is assumed to be mid-commit rather than abandoned; shortening this makes
|
||||||
|
concurrent uploads look like orphans.
|
||||||
|
|
||||||
|
## Front-proxy delegation — `nginx`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | emit `X-Accel-Redirect` instead of a body |
|
||||||
|
| `internal-prefix` | `/__files/` | must be an `internal` location resolving to the content root |
|
||||||
|
| `object-suffix` | `.bin` | layout suffix the proxy appends |
|
||||||
|
| `minimum-size` | `16MB` | below this the application serves the transfer itself |
|
||||||
|
|
||||||
|
Delegation is decided only after authorization and the READY gate, so an internal redirect can only
|
||||||
|
ever name content the caller was already allowed to read.
|
||||||
|
|
||||||
|
## Protocols — `tus`
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled` | `false` | expose the tus 1.0 endpoints |
|
||||||
|
|
||||||
|
The HTTPbis resumable-upload draft-12 surface is experimental and documented in
|
||||||
|
[support-matrix.md](support-matrix.md).
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Fileserver — deviations from the design
|
||||||
|
|
||||||
|
The design specification and the implementation plan are frozen documents. Where implementation
|
||||||
|
found them under-specified or self-contradicting, the resolution is recorded here rather than by
|
||||||
|
editing the specification, and every entry names the test that pins the decision.
|
||||||
|
|
||||||
|
## Resolved inconsistencies in the state machine and contracts
|
||||||
|
|
||||||
|
### 1. `CREATED → FAILED` has no edge in the transition table
|
||||||
|
|
||||||
|
A create that fails after the record exists must end in `FAILED`, but the table has no direct edge.
|
||||||
|
The record therefore walks `CREATED → UPLOADING → FAILED`, which is also the honest reading: the
|
||||||
|
upload had been admitted before it failed.
|
||||||
|
|
||||||
|
Pinned by `UploadApplicationServiceTest` (application-core).
|
||||||
|
|
||||||
|
### 2. `ContentKey`'s alphabet admits a leading separator
|
||||||
|
|
||||||
|
The design's key pattern `[a-z0-9/_-]{16,200}` matches `/etc/passwd/...`. Rejecting an absolute path
|
||||||
|
at the value type would change a design-fixed contract, so the stricter shape check lives in
|
||||||
|
`PhysicalPathResolver`, per §12.2 rule 1 — the only place that turns an identifier into a path.
|
||||||
|
|
||||||
|
Pinned by `PhysicalPathResolverTest`.
|
||||||
|
|
||||||
|
### 3. `VERIFYING → DELETING` has no edge
|
||||||
|
|
||||||
|
Deleting a file that is mid-verification has no legal transition. The lifecycle service refuses it
|
||||||
|
with `409 FILE_NOT_READY` rather than inventing an edge, which matches the allowed-state list the
|
||||||
|
JPA `markDeleting` statement already enforced.
|
||||||
|
|
||||||
|
Pinned by `FileLifecycleServiceTest`.
|
||||||
|
|
||||||
|
### 4. `If-Match` is specified as an ETag but the lifecycle was designed around the row version
|
||||||
|
|
||||||
|
The HTTP contract sends an entity tag; the metadata store guards on a numeric version. The service
|
||||||
|
takes `Optional<String> expectedEtag` and compares against the record's strong validator, so the
|
||||||
|
precondition a client sends is the precondition that is checked.
|
||||||
|
|
||||||
|
Pinned by `FileLifecycleServiceTest`.
|
||||||
|
|
||||||
|
### 5. The filename policy left `:` intact
|
||||||
|
|
||||||
|
`C:\Windows\system.ini` sanitized to `C:Windowssystem.ini` — a drive-qualified name surviving into
|
||||||
|
display text and headers. `:` joined the structural strip set.
|
||||||
|
|
||||||
|
Pinned by `FileserverHardeningContractTest` and `AmbiguousFilesystemOperationDetectorTest`.
|
||||||
|
|
||||||
|
## Additions the design implies but does not specify
|
||||||
|
|
||||||
|
### 6. `fs_recovery_item`
|
||||||
|
|
||||||
|
§10.2 lists five core tables and none of them can hold the recovery queue, yet §29.3 requires one:
|
||||||
|
reconciliation reports files whose bytes and metadata disagree, and holding that list in memory
|
||||||
|
would lose exactly the cases a restart interrupted. Added in
|
||||||
|
`V2__fileserver_recovery_and_staging_cleanup.sql` with one open item per file, so repeated sweeps
|
||||||
|
update a worklist rather than accumulating a log.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 7. `fs_cleanup_item.upload_id`
|
||||||
|
|
||||||
|
A staging object is addressed by upload, not by file. Without this column a queued staging cleanup
|
||||||
|
could name only already-published content, so a cancelled or expired upload left bytes nothing could
|
||||||
|
find. Added in the same migration, with a check constraint that an item names exactly one target.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 8. `ContentReferenceLedger` and `StagingUploadLocator`
|
||||||
|
|
||||||
|
The orphan scan must ask whether a record still claims a physical object, and reconciliation must
|
||||||
|
map a file back to the upload that last staged it. Neither question is answerable through the
|
||||||
|
design's `FileMetadataStore` or `UploadSessionStore` as written. Rather than widen those
|
||||||
|
design-fixed interfaces, both are narrow single-method ports.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest` and `LocalOrphanScanAdapterTest`.
|
||||||
|
|
||||||
|
## Interpretations
|
||||||
|
|
||||||
|
### 9. Quota settlement is FIFO within a scope
|
||||||
|
|
||||||
|
Nothing links a reservation row to the upload that took it, and the design deliberately reclaims
|
||||||
|
stragglers by TTL and the `STALE_QUOTA_RESERVATION` cleanup type rather than threading a reservation
|
||||||
|
id through the upload session. `QuotaCommitGateway` therefore settles the oldest live reservation in
|
||||||
|
the file's namespace.
|
||||||
|
|
||||||
|
Which row closes does not change any quota decision: enforcement sums reserved and committed bytes
|
||||||
|
per scope and never reads an individual row. Concurrent uploads of different sizes can leave the
|
||||||
|
reserved total transiently high or low, and it converges as each settles. Durable usage with no live
|
||||||
|
reservation behind it — an upload that outlived its TTL — is still recorded, because a ledger that
|
||||||
|
silently under-counts is worse than one that is briefly imprecise.
|
||||||
|
|
||||||
|
Pinned by `PostgreSqlFileserverReclamationIntegrationTest`.
|
||||||
|
|
||||||
|
### 10. Zero copy is a channel transfer, not a file handoff
|
||||||
|
|
||||||
|
Task 22 asks for zero copy on local files; §19 forbids a `Path` leaving the storage adapter, and §5
|
||||||
|
of the plan forbids adding `Path` to the content store. WebFlux's zero-copy API takes a `Path`, so
|
||||||
|
that route is closed.
|
||||||
|
|
||||||
|
The servlet path takes the other one: `ZeroCopyDownloadGateway` receives a `WritableByteChannel` from
|
||||||
|
the transport and the storage adapter performs `FileChannel.transferTo` into it. That is a genuine
|
||||||
|
kernel-level transfer with no filesystem concept leaving storage. The reactive path continues to
|
||||||
|
stream with bounded demand.
|
||||||
|
|
||||||
|
Zero copy is an optimization with no observable difference: when storage declines, the response is
|
||||||
|
streamed and is byte-identical.
|
||||||
|
|
||||||
|
Pinned by `LocalStorageGatewayContractTest` and `ZeroCopyEligibilityTest`.
|
||||||
|
|
||||||
|
### 11. The Fileserver JPA stores are gated on the capability switch
|
||||||
|
|
||||||
|
The metadata store, session store, quota service, queues, ledger, and staging locator carry
|
||||||
|
`@ConditionalOnProperty(app.fileserver-platform.enabled)` even though the rest of
|
||||||
|
`adapter:outbound:persistence-jpa` is unconditional.
|
||||||
|
|
||||||
|
Without the gate, every composition root that includes the persistence module built these beans —
|
||||||
|
including `sample-portfolio`, which has no Fileserver — and each of them needs collaborators only
|
||||||
|
the Fileserver configuration provides. That is the same rule the design states for the transport
|
||||||
|
surface, applied to persistence: no surface appears merely because the dependency is present.
|
||||||
|
|
||||||
|
`FileStateMachine` is bound alongside them, in `FileserverStorageConfiguration`. It had no
|
||||||
|
production binding at all before, which made the metadata store unconstructible in any
|
||||||
|
component-scanned context.
|
||||||
|
|
||||||
|
Pinned by `SampleApplicationContextTest` (the capability off) and
|
||||||
|
`FileserverRuntimeAssemblyTest` (the capability on).
|
||||||
|
|
||||||
|
### 12. Transaction boundaries are owned by the application services, and are deliberately narrow
|
||||||
|
|
||||||
|
The design does not say where a transaction begins. The repository does:
|
||||||
|
`adapter:outbound:persistence-jpa` forbids a repository adapter from owning a `@Transactional`
|
||||||
|
boundary, and `application-core` owns them through `TransactionPort`. The Fileserver follows that
|
||||||
|
rule — every `Jpa*` store here declares no `@Transactional` of its own.
|
||||||
|
|
||||||
|
What is specific to this capability is how narrow the boundaries are. A boundary covers a contiguous
|
||||||
|
run of metadata writes and **stops before every storage call**, because a filesystem operation
|
||||||
|
inside a database transaction would hold a connection for the length of a byte transfer. The upload
|
||||||
|
path therefore has three boundaries, not one: acquire the lease, transfer the bytes, commit the
|
||||||
|
offset.
|
||||||
|
|
||||||
|
Where several stores must agree, they share one boundary:
|
||||||
|
|
||||||
|
| Unit | Why it is one boundary |
|
||||||
|
| --- | --- |
|
||||||
|
| reserve quota + insert record + create session | a reservation that outlived a failed insert holds capacity for a file that never existed |
|
||||||
|
| READY transition + quota commit | a finished file whose reservation was never converted holds capacity until the reservation expires |
|
||||||
|
| `markDeleting` + enqueue cleanup | a file that stopped being reachable with nothing queued to reclaim it is never collected |
|
||||||
|
| content delete settlement: reclaim + retire record + close queue item | half of it leaves the item to be retried against content that no longer exists |
|
||||||
|
|
||||||
|
What this cannot make atomic is the storage/metadata seam itself — no database boundary could. That
|
||||||
|
seam is exactly what the ambiguous-completion path and the reconciler exist for, and the one
|
||||||
|
hand-written compensation that remains (staging creation failing after the records committed) is
|
||||||
|
there for the same reason.
|
||||||
|
|
||||||
|
Pinned by `FileserverRoundTripContractTest` against real PostgreSQL; the application tests use
|
||||||
|
`DirectTransactions`, which runs a boundary inline and counts it.
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
### `AsyncContentStore`, `CapacityAwareContentStore`, `CopyCapableContentStore`, `DelegatedDownloadStore`
|
||||||
|
|
||||||
|
Four optional content-store SPIs are declared in `application-core` with no implementation. Each is
|
||||||
|
an extension point for a backend this template does not ship:
|
||||||
|
|
||||||
|
- `AsyncContentStore` — for a backend whose native client is non-blocking. The local platform is
|
||||||
|
blocking, and the reactive transport bridges to it on a dedicated I/O scheduler.
|
||||||
|
- `CapacityAwareContentStore` — capacity is reported through `StorageHealthPort` and
|
||||||
|
`StorageUsageProbe`, which the local platform implements.
|
||||||
|
- `CopyCapableContentStore` — server-side copy is delivered by `CopyContentGateway`; the local
|
||||||
|
platform has no cheaper primitive than a streamed copy.
|
||||||
|
- `DelegatedDownloadStore` — delegation is delivered at the transport boundary by the nginx
|
||||||
|
`X-Accel-Redirect` strategy, which needs no store participation.
|
||||||
|
|
||||||
|
`ContentStoreCapabilities` reports what the running store actually supports, so no unimplemented SPI
|
||||||
|
is advertised as available.
|
||||||
|
|
||||||
|
## Known deviation from the repository's application-layer contract
|
||||||
|
|
||||||
|
### 5. Fileserver application services are not `CommandUseCase` / `QueryUseCase`
|
||||||
|
|
||||||
|
`src/application-core/CLAUDE.md` requires every inbound port implementation to extend
|
||||||
|
`CommandUseCase` or `QueryUseCase` and to carry `@UseCaseCapability`, which declares its transaction
|
||||||
|
mode, idempotency and repository access. The Fileserver instead exposes multi-method services —
|
||||||
|
`UploadApplicationService`, `DownloadApplicationService`, `FileLifecycleService`,
|
||||||
|
`FileserverAdminService` and their `Default*` implementations.
|
||||||
|
|
||||||
|
This is a real deviation, not an oversight, and it is unenforced: the ArchUnit rules
|
||||||
|
`inbound_port_implementations_end_with_use_case` and
|
||||||
|
`inbound_port_implementations_declare_capability` only match types that implement `UseCase`, so a
|
||||||
|
service that never does is silently exempt. The capability contract that every other feature in
|
||||||
|
this repository declares is therefore absent here.
|
||||||
|
|
||||||
|
Two things follow from it. The transaction mode of each operation is expressed only by which
|
||||||
|
`TransactionPort` method the body happens to call, rather than declared and checked. And the
|
||||||
|
application layer holds transport policy it would not hold if each operation were a use case with
|
||||||
|
its own command: HTTP status codes on `FileserverErrorCode`, `Range` and conditional-request
|
||||||
|
parsing in `api.transfer`, and `Content-Disposition` construction.
|
||||||
|
|
||||||
|
The status mapping in particular is a deliberate trade rather than an accident. It lives in
|
||||||
|
`application-core` so the servlet transport, the reactive transport and the Nginx delegation path
|
||||||
|
cannot answer the same failure with three different statuses. Moving it to the transport layer
|
||||||
|
resolves the layering complaint and reintroduces exactly that drift, which is why this is an
|
||||||
|
architecture decision rather than a cleanup.
|
||||||
|
|
||||||
|
**Status: open, deliberately unresolved in this change set.** Closing it means roughly thirty
|
||||||
|
command/query use cases, a decision about where the shared status vocabulary lives, and a change to
|
||||||
|
the ArchUnit rules so a service that bypasses the contract fails the build instead of being exempt
|
||||||
|
from it. That belongs in its own ADR with its own review, and doing it inside a correctness patch
|
||||||
|
would mix a large mechanical refactor into changes that need to be readable.
|
||||||
|
|
||||||
|
Nothing here is pinned by a test, because the deviation is the absence of a constraint. The next
|
||||||
|
step is the ADR, not another test.
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Fileserver HTTP contract
|
||||||
|
|
||||||
|
Every public endpoint is listed here. `FileserverDocumentationCoverageTest` scans the controllers
|
||||||
|
and fails if one is missing, so this file cannot silently fall behind the code.
|
||||||
|
|
||||||
|
## Public endpoints
|
||||||
|
|
||||||
|
| Method | Path | Success | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/v1/files` | `201` READY, `202` VERIFYING | multipart single upload |
|
||||||
|
| POST | `/v1/files:raw` | `201`, `202` | the whole request body is the file |
|
||||||
|
| POST | `/v1/files:batch` | `200` | ordered per-part results; explicitly non-atomic |
|
||||||
|
| GET | `/v1/files/{fileId}` | `200` | public metadata; never a content key or path |
|
||||||
|
| GET | `/v1/files/{fileId}/content` | `200`, `206`, `304` | download |
|
||||||
|
| HEAD | `/v1/files/{fileId}/content` | `200`, `304` | identical headers, no body |
|
||||||
|
| DELETE | `/v1/files/{fileId}` | `202`, `204` | logical delete first |
|
||||||
|
| POST | `/v1/files/{fileId}:copy` | `202` | create-only target |
|
||||||
|
| POST | `/v1/files/{fileId}:move` | `200` | logical namespace change only |
|
||||||
|
| OPTIONS | `/v1/uploads` | `204` | tus capability discovery |
|
||||||
|
| POST | `/v1/uploads` | `201` | tus creation |
|
||||||
|
| HEAD | `/v1/uploads/{uploadId}` | `204` | tus offset |
|
||||||
|
| PATCH | `/v1/uploads/{uploadId}` | `204` | tus append |
|
||||||
|
| DELETE | `/v1/uploads/{uploadId}` | `204` | tus termination |
|
||||||
|
| POST | `/v1/experimental/draft12/uploads` | `201` | Experimental; off by default |
|
||||||
|
| PATCH | `/v1/experimental/draft12/uploads/{uploadId}` | `204` | Experimental; off by default |
|
||||||
|
|
||||||
|
## Management endpoints
|
||||||
|
|
||||||
|
Reachable only where both `app.fileserver-platform.enabled=true` and
|
||||||
|
`app.fileserver-platform.admin.enabled=true`, gated at the servlet chain on
|
||||||
|
`app.fileserver-platform.security.admin-roles`, and intended for a management
|
||||||
|
port rather than the public one.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| GET | `/internal/fileserver/storage-health` |
|
||||||
|
| GET | `/internal/fileserver/capabilities` |
|
||||||
|
| GET | `/internal/fileserver/orphans` |
|
||||||
|
| POST | `/internal/fileserver/orphans:reconcile` |
|
||||||
|
| POST | `/internal/fileserver/files/{fileId}:reverify` |
|
||||||
|
| POST | `/internal/fileserver/files/{fileId}:force-delete` |
|
||||||
|
| GET | `/internal/fileserver/uploads/incomplete` |
|
||||||
|
| POST | `/internal/fileserver/uploads:cleanup` |
|
||||||
|
|
||||||
|
## Status codes
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|---:|---|
|
||||||
|
| `200` | metadata, full GET, batch result, move |
|
||||||
|
| `201` | file or upload created |
|
||||||
|
| `202` | verification or physical cleanup deferred |
|
||||||
|
| `204` | append, cancel, bodyless update |
|
||||||
|
| `206` | satisfiable Range |
|
||||||
|
| `304` | validator matched on GET or HEAD |
|
||||||
|
| `400` | malformed header or header combination |
|
||||||
|
| `401` | unauthenticated |
|
||||||
|
| `403` / `404` | denied, or hidden under the existence-hiding profile |
|
||||||
|
| `409` | state, offset, or lease conflict |
|
||||||
|
| `410` | expired upload resource |
|
||||||
|
| `411` | `require-content-length` profile with no length |
|
||||||
|
| `412` | precondition failed |
|
||||||
|
| `413` | size or quota policy violation |
|
||||||
|
| `415` | upload media type not accepted |
|
||||||
|
| `416` | unsatisfiable Range; carries the real length |
|
||||||
|
| `422` | digest, signature, or scanner rejection |
|
||||||
|
| `429` | transfer admission or rate limit |
|
||||||
|
| `503` | storage or scanner unavailable |
|
||||||
|
| `504` | downstream timeout |
|
||||||
|
| `507` | out of storage capacity |
|
||||||
|
|
||||||
|
## Failure body
|
||||||
|
|
||||||
|
Every failure answers `application/problem+json` with a stable code and its URN:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "urn:fileserver:problem:upload-offset-mismatch",
|
||||||
|
"title": "Upload offset mismatch",
|
||||||
|
"status": 409,
|
||||||
|
"code": "UPLOAD_OFFSET_MISMATCH",
|
||||||
|
"retryable": true,
|
||||||
|
"ambiguous": false,
|
||||||
|
"reconciliationRequired": false,
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server-side exception message never appears. `ambiguous` is the field a client must read before
|
||||||
|
retrying: an ambiguous failure may already have taken effect.
|
||||||
|
|
||||||
|
## Header contract
|
||||||
|
|
||||||
|
| Header | Contract |
|
||||||
|
|---|---|
|
||||||
|
| `Content-Type` | client value is a claim; the verified type is stored separately |
|
||||||
|
| `Content-Disposition` | `attachment` by default; scriptable types are never inline |
|
||||||
|
| `Accept-Ranges` | `bytes` |
|
||||||
|
| `Range` | single range by default; multi-range only under an explicit budget |
|
||||||
|
| `Content-Range` | actual range on `206`; the unsatisfied form on `416` |
|
||||||
|
| `ETag` | strong validator derived from the SHA-256 |
|
||||||
|
| `Last-Modified` | metadata publication instant, never a filesystem timestamp |
|
||||||
|
| `Cache-Control` | `private, no-store` by default |
|
||||||
|
| `X-Content-Type-Options` | always `nosniff` on a download |
|
||||||
|
| `Retry-After` | on retryable `409`, `429`, `503`, and `504` |
|
||||||
|
| `X-Accel-Redirect` | internal only; never forwarded to a client |
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Fileserver runbooks
|
||||||
|
|
||||||
|
Each runbook names the exact metric that fires it and the exact command that resolves it. A runbook
|
||||||
|
whose trigger is "someone noticed" is not actionable, so every one below starts from a signal.
|
||||||
|
|
||||||
|
## Storage full
|
||||||
|
|
||||||
|
**Signal** — `fileserver.quota{result="rejected"}` rising, or `507` responses appearing.
|
||||||
|
|
||||||
|
Storage capacity is exhausted or the high-water guard tripped. Uploads are rejected before any bytes
|
||||||
|
are written, so nothing is corrupt; the system is refusing work it cannot complete.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s $ADMIN/internal/fileserver/storage-health | jq '.usedFraction, .usableBytes'
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||||
|
curl -s "$ADMIN/internal/fileserver/orphans?limit=200" | jq '[.[].sizeBytes] | add'
|
||||||
|
```
|
||||||
|
|
||||||
|
Drain the cleanup backlog first — it reclaims space the system already knows is dead. Only then
|
||||||
|
consider an orphan reconcile, and start with a dry run.
|
||||||
|
|
||||||
|
## Orphan growth
|
||||||
|
|
||||||
|
**Signal** — `fileserver.cleanup{result="skipped"}` climbing, or the orphan scan returning more
|
||||||
|
objects each run.
|
||||||
|
|
||||||
|
Physical objects exist with no metadata record pointing at them. This is not immediately dangerous —
|
||||||
|
nothing serves them — but it consumes capacity indefinitely.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always look first. A reconcile without dryRun=false is a plan, not an action.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||||
|
-H 'content-type: application/json' -d '{"limit":100}' | jq '.candidates'
|
||||||
|
|
||||||
|
# Apply only the fingerprints you were just shown.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/orphans:reconcile" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"dryRun":false,"limit":100,"maxBytes":1073741824,
|
||||||
|
"expectedFingerprints":["<from the dry run>"],"reasonCode":"ORPHAN_GROWTH_RUNBOOK"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Echoing the fingerprints is the safety property: an object that changed between the scan and the
|
||||||
|
apply is skipped rather than deleted.
|
||||||
|
|
||||||
|
## Verification backlog
|
||||||
|
|
||||||
|
**Signal** — `fileserver.verification.queue{age_bucket="old"}` non-zero, or files sitting in
|
||||||
|
VERIFYING.
|
||||||
|
|
||||||
|
A verifier is slow or unavailable. Files stay non-public, which is the correct failure direction: a
|
||||||
|
`RETRY` verdict never becomes an `ACCEPT`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s $ADMIN/internal/fileserver/capabilities | jq '.storageType'
|
||||||
|
# Once the verifier is healthy, quarantined files can be re-examined individually.
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/files/$FILE_ID:reverify"
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not clear the backlog by disabling verification. A file that reached READY without an accepting
|
||||||
|
verdict cannot be distinguished later from one that was verified.
|
||||||
|
|
||||||
|
## NFS ambiguity
|
||||||
|
|
||||||
|
**Signal** — problem documents carrying `"ambiguous": true`, or
|
||||||
|
`fileserver.transfer.interruption{reason="stale_handle"}`.
|
||||||
|
|
||||||
|
An operation's outcome could not be determined: the response was lost after the write or rename may
|
||||||
|
have landed. These are never retried automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The recovery queue holds the files awaiting a decision.
|
||||||
|
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconciliation compares the physical size and digest against the record and only confirms READY when
|
||||||
|
all four of key, size, digest, and version agree. Anything short of that is reported, never guessed.
|
||||||
|
|
||||||
|
## PVC remount
|
||||||
|
|
||||||
|
**Signal** — startup failure naming "atomic move", "same file store", or "not writable".
|
||||||
|
|
||||||
|
The volume was remounted somewhere the probe can no longer prove a required capability. The
|
||||||
|
application refuses traffic rather than serving from storage it cannot publish to atomically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the printed tuple with the certified one in `docs/fileserver/storage-certification.md`. A
|
||||||
|
mismatch in CSI driver, StorageClass, access mode, or mount options is the cause; the certification
|
||||||
|
does not carry across it.
|
||||||
|
|
||||||
|
## Nginx delegation failure
|
||||||
|
|
||||||
|
**Signal** — `fileserver.download.delegation{delegated="true"}` with client-visible `404`s.
|
||||||
|
|
||||||
|
The internal location is misconfigured, so the proxy cannot resolve the redirect it was handed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The internal prefix must resolve to the content root and must be marked `internal`.
|
||||||
|
grep -A5 '__files' infra/fileserver/nginx/nginx.conf
|
||||||
|
curl -s $ADMIN/internal/fileserver/capabilities | jq '.capabilities.delegatedDownload'
|
||||||
|
```
|
||||||
|
|
||||||
|
Turning delegation off is a safe immediate mitigation: the application serves the transfer itself,
|
||||||
|
slower but correct.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
app.fileserver-platform.nginx.enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup backlog
|
||||||
|
|
||||||
|
**Signal** — `fileserver.cleanup{result="deferred"}` rising, or reclaimed bytes flat while deletes
|
||||||
|
continue.
|
||||||
|
|
||||||
|
Items are being deferred faster than they drain. The usual cause is an active writer lease still
|
||||||
|
holding staging objects, which is correct behaviour, not a fault.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$ADMIN/internal/fileserver/uploads/incomplete?limit=100" \
|
||||||
|
| jq '[.[] | select(.leaseUntil != null)] | length'
|
||||||
|
curl -s -X POST "$ADMIN/internal/fileserver/uploads:cleanup?maxItems=500&maxBytes=10737418240"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the deferrals are all `ACTIVE_WRITER_LEASE`, the backlog resolves itself as those uploads expire.
|
||||||
|
Never delete staging content to clear a backlog: an upload that is mid-flight will corrupt.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Fileserver security model
|
||||||
|
|
||||||
|
## The rule everything else follows
|
||||||
|
|
||||||
|
Uploaded content is attacker-controlled. Every guard below exists because some part of the request
|
||||||
|
— the filename, the declared media type, the range, the offset — is a value the caller chose.
|
||||||
|
|
||||||
|
## Path safety
|
||||||
|
|
||||||
|
A client value never becomes a path. The physical key is server-generated, and `ContentKey`'s
|
||||||
|
character class excludes `.` entirely, so no traversal or extension-shaped segment survives
|
||||||
|
validation. `DefaultPhysicalPathResolver` is the only place an identifier becomes a `Path`, and it
|
||||||
|
normalizes and re-checks containment after construction rather than trusting the input.
|
||||||
|
|
||||||
|
Symlink refusal happens at open time, not only at construction. A parent directory can be replaced
|
||||||
|
between the two, so a check that ran only at path-building time would be a race, not a guard.
|
||||||
|
|
||||||
|
## Filename handling
|
||||||
|
|
||||||
|
`OriginalFilenamePolicy` strips path separators, NUL, quoting characters, and the colon — the last
|
||||||
|
because on Windows it opens both a drive reference and an NTFS alternate data stream, so a name that
|
||||||
|
keeps it is still path-shaped after the slashes are gone. Control characters and bidirectional
|
||||||
|
overrides are removed, dot runs collapsed, reserved device names guarded, and the result is bounded
|
||||||
|
in UTF-8 bytes.
|
||||||
|
|
||||||
|
The sanitized name is display data. It is never used to build a key, and it reaches a header only
|
||||||
|
through `ContentDispositionFactory`, which restricts the ASCII form and percent-encodes the UTF-8
|
||||||
|
form.
|
||||||
|
|
||||||
|
## Content type
|
||||||
|
|
||||||
|
The client's `Content-Type` is stored as a claim. The verified type comes from the verification
|
||||||
|
pipeline, and only the verified type is served. A claimed type that contradicts the content is
|
||||||
|
quarantined rather than corrected.
|
||||||
|
|
||||||
|
Scriptable types are never served inline, whatever the caller asked for: serving stored HTML or SVG
|
||||||
|
inline from an upload origin is a stored cross-site scripting primitive. Every download also carries
|
||||||
|
`X-Content-Type-Options: nosniff`.
|
||||||
|
|
||||||
|
## Verification precedence
|
||||||
|
|
||||||
|
`REJECT > QUARANTINE > RETRY > ACCEPT`. A verifier that times out or throws is `RETRY`, never a
|
||||||
|
silent pass, and an empty verifier chain answers `RETRY` rather than accepting. A file becomes
|
||||||
|
publicly readable only after an `ACCEPT`.
|
||||||
|
|
||||||
|
## Range safety
|
||||||
|
|
||||||
|
The range budget is enforced before content is opened, so a request naming many ranges is rejected
|
||||||
|
without amplifying into storage work. An unsatisfiable range answers `416` with the real length and
|
||||||
|
opens nothing.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
Every public operation calls the injected `FileAccessPolicy` before any quota reservation or storage
|
||||||
|
mutation, so a denial leaves no record, no reservation, and no staging object. Startup refuses to
|
||||||
|
run a production profile with an allow-all policy.
|
||||||
|
|
||||||
|
## Delegation
|
||||||
|
|
||||||
|
`X-Accel-Redirect` is emitted only after authorization and the READY gate, and only for a full,
|
||||||
|
unconditional response. The internal prefix must be an `internal` Nginx location; the front proxy
|
||||||
|
also strips any client-supplied delegation header so a caller cannot name an internal object.
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
No metric label, span attribute, or audit record carries a file id, upload id, filename, path, or
|
||||||
|
user id. Where correlation is needed the value is a keyed HMAC fingerprint — keyed because the
|
||||||
|
identifier space is enumerable and an unkeyed digest of it is reversible by brute force.
|
||||||
|
|
||||||
|
## Ambiguous failures
|
||||||
|
|
||||||
|
A failure whose operation may already have taken effect is reported as ambiguous and is never
|
||||||
|
retryable. On a network filesystem a lost response is indistinguishable from a rejection at the
|
||||||
|
socket level, so anything not provably safe is treated as ambiguous and sent to reconciliation.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Storage certification
|
||||||
|
|
||||||
|
## Why a certification is per-volume
|
||||||
|
|
||||||
|
Atomic rename, same-file-store guarantees, and symlink refusal are properties of a specific
|
||||||
|
filesystem behind a specific mount — not of "Kubernetes" or "a PVC". Change the CSI driver, the
|
||||||
|
StorageClass, the access mode, the backend, or the mount options and any of them can differ. A
|
||||||
|
certification that does not name all five is not transferable.
|
||||||
|
|
||||||
|
## What is certified
|
||||||
|
|
||||||
|
| Property | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| Same file store for staging and content | A rename across stores is a copy, so publication stops being atomic. |
|
||||||
|
| Atomic rename | The publish path's default strategy. |
|
||||||
|
| Atomic create (`O_EXCL`) | Makes a publish create-only rather than a silent overwrite. |
|
||||||
|
| Symlink refusal | Stops a replaced parent from redirecting a write outside the root. |
|
||||||
|
| Ranged read | The download contract depends on it. |
|
||||||
|
|
||||||
|
## Running the certification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
kubectl logs job/fileserver-pvc-certification
|
||||||
|
```
|
||||||
|
|
||||||
|
The job writes a machine-readable result to the claim itself, carrying the full tuple:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kubernetesVersion": "...",
|
||||||
|
"csiDriver": "...",
|
||||||
|
"storageClass": "...",
|
||||||
|
"accessMode": "ReadWriteOnce",
|
||||||
|
"backend": "ext2/ext3",
|
||||||
|
"mountOptions": "rw,relatime",
|
||||||
|
"atomicMove": true,
|
||||||
|
"sameFileStore": true,
|
||||||
|
"atomicCreate": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The job fails closed: a volume whose staging and content areas are on different stores is not
|
||||||
|
certified, because its publish would silently degrade to a copy.
|
||||||
|
|
||||||
|
## Network filesystems
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/fileserver/nfs/compose.yml up -d
|
||||||
|
FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
|
||||||
|
```
|
||||||
|
|
||||||
|
The mount is `hard`, deliberately. A `soft` mount converts a slow server into a short write, which
|
||||||
|
is exactly the corruption this design refuses to accept.
|
||||||
|
|
||||||
|
## Startup enforcement
|
||||||
|
|
||||||
|
`FileserverStartupValidator` re-runs the probe at boot and refuses to accept traffic when a required
|
||||||
|
capability is missing — `ATOMIC_MOVE_REQUIRED` on a filesystem that cannot prove an atomic move
|
||||||
|
fails closed rather than degrading silently.
|
||||||
|
|
||||||
|
## Adding a new store
|
||||||
|
|
||||||
|
Extend `ContentStoreContract` and pass it. A prose claim of compatibility is not accepted; the
|
||||||
|
contract is executable precisely so a future object-storage adapter has to demonstrate the same
|
||||||
|
offset, digest, and create-only behaviour the local store does.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Fileserver support matrix
|
||||||
|
|
||||||
|
A support level here is a claim about evidence, not about intent. Every row names the CI job that
|
||||||
|
produces that evidence; `FileserverDocumentationCoverageTest` fails the build if a row names a job
|
||||||
|
that does not exist, so a level can never outlive the test that justified it.
|
||||||
|
|
||||||
|
## Levels
|
||||||
|
|
||||||
|
| Level | What it means |
|
||||||
|
|---|---|
|
||||||
|
| Stable | Certified on every pull request. Contract changes are breaking changes. |
|
||||||
|
| Beta | Certified nightly. The contract may still change with a deprecation notice. |
|
||||||
|
| Limited | Certified on the release gate only, under stated constraints. |
|
||||||
|
| Compatibility | Accepted but not optimized; known caveats are listed inline. |
|
||||||
|
| Experimental | Off by default, unratified upstream, may change without notice. |
|
||||||
|
|
||||||
|
## Runtime profiles
|
||||||
|
|
||||||
|
| Profile | Level | CI job |
|
||||||
|
|---|---|---|
|
||||||
|
| Local filesystem (ext4) content store | Stable | `fileserver-local-ext4-contract` |
|
||||||
|
| Spring MVC transport (raw, multipart, batch, download) | Stable | `fileserver-http-contract` |
|
||||||
|
| Spring WebFlux transport | Experimental | `fileserver-http-contract` |
|
||||||
|
| Path, filename, range, and problem-detail hardening | Stable | `fileserver-security-suite` |
|
||||||
|
| Bounded-memory transfer | Stable | `fileserver-bounded-memory` |
|
||||||
|
| Application and architecture invariants | Stable | `fileserver-unit-and-architecture` |
|
||||||
|
| Runtime assembly (the capability starts with the flag on) | Stable | `fileserver-unit-and-architecture` |
|
||||||
|
| tus 1.0 resumable uploads | Stable | `fileserver-http-contract` |
|
||||||
|
| Crash-recovery matrix | Beta | `fileserver-process-kill-matrix` |
|
||||||
|
| NFSv4 ambiguity handling | Beta | `fileserver-nfs-ambiguity` |
|
||||||
|
| Large-file and slow-client performance | Beta | `fileserver-large-file-performance` |
|
||||||
|
| Multi-instance writer lease | Beta | `fileserver-multi-instance-lease` |
|
||||||
|
| Kubernetes ReadWriteOnce PVC | Limited | `fileserver-pvc-certification` (manifest checks in CI; cluster run is operator-driven) |
|
||||||
|
| Nginx `X-Accel-Redirect` delegation | Limited | `fileserver-http-contract` |
|
||||||
|
| Telemetry sensitive-data suppression | Stable | `fileserver-sensitive-telemetry-scan` |
|
||||||
|
| Documentation and support-claim coverage | Stable | `fileserver-documentation-gate` |
|
||||||
|
| Full release verification | Stable | `fileserver-full-verification` |
|
||||||
|
| HTTP resumable uploads draft-12 | Experimental | `fileserver-http-contract` |
|
||||||
|
|
||||||
|
### Why WebFlux is Experimental, not Stable
|
||||||
|
|
||||||
|
The reactive router, handlers and readers are now wired: `FileserverReactiveConfiguration`
|
||||||
|
contributes the scheduler, the handlers and a `RouterFunction` bean under
|
||||||
|
`@ConditionalOnWebApplication(type = REACTIVE)` plus the platform master switch. Previously nothing
|
||||||
|
built them at all, so "Stable" described the source tree rather than a running server.
|
||||||
|
|
||||||
|
It stays `Experimental` because the shipped composition cannot select it. `adapter:inbound:web`
|
||||||
|
also puts `DispatcherServlet` on the classpath — deliberately, so adding `spring-webflux` does not
|
||||||
|
drag a second embedded server onto the runtime — and Boot's application-type deduction therefore
|
||||||
|
resolves SERVLET. A fork that removes the servlet stack and adds a reactive server gets working
|
||||||
|
routes without editing any Fileserver code; the shipped template does not exercise that path.
|
||||||
|
|
||||||
|
Raising it to Stable requires a contract job that drives the routes over a running reactive server
|
||||||
|
rather than through direct construction.
|
||||||
|
|
||||||
|
## Explicitly not claimed
|
||||||
|
|
||||||
|
These have no job, and therefore no claim:
|
||||||
|
|
||||||
|
- An automated Kubernetes cluster result. `fileserver-pvc-certification` validates the manifest on
|
||||||
|
every release and applies it only when a release cluster is configured; without one it warns and
|
||||||
|
records that nothing was certified. The cluster tuple is produced by an operator and read from
|
||||||
|
[storage-certification.md](storage-certification.md).
|
||||||
|
|
||||||
|
- Kubernetes ReadWriteMany PVC. Concurrent writers across nodes are not certified.
|
||||||
|
- Windows NTFS as a production storage root. The filename policy strips the characters NTFS
|
||||||
|
reserves, but no job certifies the publish path there.
|
||||||
|
- Object storage as a content store. The contract exists (`ContentStoreContract`) but no adapter
|
||||||
|
implements it yet.
|
||||||
|
- Server-side malware scanning. The verification pipeline has the port and the verdict precedence;
|
||||||
|
no scanner is shipped.
|
||||||
|
|
||||||
|
## Where the rest is written down
|
||||||
|
|
||||||
|
- [configuration.md](configuration.md) — every `app.fileserver-platform.*` key, its default, and the
|
||||||
|
conditions that fail startup rather than degrade.
|
||||||
|
- [design-deviations.md](design-deviations.md) — where the implementation departs from the frozen
|
||||||
|
design, why, and the test that pins each decision.
|
||||||
|
- [http-contract.md](http-contract.md) — the wire contract.
|
||||||
|
- [security.md](security.md) — the threat model and what enforces each control.
|
||||||
|
- [operations.md](operations.md) — runbooks, each starting from a metric.
|
||||||
|
- [storage-certification.md](storage-certification.md) — how a volume is certified.
|
||||||
|
- [upgrade-guide.md](upgrade-guide.md) — what changes between versions.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Fileserver upgrade guide
|
||||||
|
|
||||||
|
## Enabling the capability
|
||||||
|
|
||||||
|
The Fileserver ships off. Nothing is registered — no endpoint, no thread pool, no metric — until it
|
||||||
|
is enabled explicitly.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
enabled: true
|
||||||
|
instance-id: ${HOSTNAME}
|
||||||
|
default-namespace: default
|
||||||
|
observability:
|
||||||
|
fingerprint-key: ${FILESERVER_FINGERPRINT_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
`instance-id` must be unique per instance: it is the writer-lease owner, and two nodes sharing one
|
||||||
|
would both believe they hold the same lease.
|
||||||
|
|
||||||
|
`fingerprint-key` is required and has no default. Startup fails without it rather than falling back
|
||||||
|
to an unkeyed digest, which would be reversible for an enumerable identifier space.
|
||||||
|
|
||||||
|
## Optional surfaces
|
||||||
|
|
||||||
|
Each is a separate switch, and each defaults to off:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ca-skeleton:
|
||||||
|
fileserver:
|
||||||
|
admin:
|
||||||
|
enabled: false # management plane; intended for a management port
|
||||||
|
tus:
|
||||||
|
enabled: false # tus 1.0 Stable
|
||||||
|
httpbis-draft12:
|
||||||
|
enabled: false # Experimental; unratified, may change without notice
|
||||||
|
nginx:
|
||||||
|
enabled: false # front-proxy delegation; needs a validated internal location
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
The metadata schema is installed as a capability migration and starts inactive:
|
||||||
|
|
||||||
|
```
|
||||||
|
V1__create_fileserver_metadata.sql → capability_schema_registry: jpa-fileserver-metadata-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate it deliberately. Enabling the capability without an activated schema fails at startup
|
||||||
|
rather than at the first upload.
|
||||||
|
|
||||||
|
## Choosing a publish mode
|
||||||
|
|
||||||
|
| Mode | When |
|
||||||
|
|---|---|
|
||||||
|
| `atomic-move-preferred` | Default. Uses an atomic rename when the probe proves one, else a metadata pointer. |
|
||||||
|
| `atomic-move-required` | Fail closed. Refuses to start on storage that cannot prove an atomic move. |
|
||||||
|
| `metadata-pointer` | For storage without atomic rename; publication is the metadata commit. |
|
||||||
|
|
||||||
|
Pick `atomic-move-required` when the storage is certified and you want a misconfiguration to surface
|
||||||
|
at boot rather than at publish time.
|
||||||
|
|
||||||
|
## Behaviour that will surprise you
|
||||||
|
|
||||||
|
- **A delete answers `202`, not `204`, when content still exists.** The file is already unreadable;
|
||||||
|
the physical reclaim is deferred. Treating `202` as a failure will produce spurious retries.
|
||||||
|
- **A batch upload answers `200` even when parts failed.** The batch is explicitly non-atomic, and a
|
||||||
|
single status could not report a partial outcome honestly. Read `results[].problem`.
|
||||||
|
- **An ambiguous failure must not be retried.** Check `"ambiguous": true` in the problem document.
|
||||||
|
- **`If-Match` takes the strong ETag, not a version number.** A client can only assert about the
|
||||||
|
representation it was actually served.
|
||||||
|
- **Inline rendering is refused for scriptable types** even when the caller asks for it.
|
||||||
|
|
||||||
|
## Verifying an upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :application-core:check :adapter:inbound:web:check \
|
||||||
|
:adapter:outbound:fileserver:check --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*Fileserver*' --console=plain
|
||||||
|
```
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# HTTP Client Platform — Operations Runbook
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| Metric | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `http.client.requests` | Physical attempt timer (Spring standard name, kept deliberately) |
|
||||||
|
| `http.client.logical.calls` | User-visible logical call timer |
|
||||||
|
| `http.client.attempts` | Attempt counter |
|
||||||
|
| `http.client.retry.count` | Retries by reason |
|
||||||
|
| `http.client.retry.exhausted` | Retry budget exhausted |
|
||||||
|
| `http.client.ambiguous` | Ambiguous outcomes |
|
||||||
|
| `http.client.timeout` | Timeouts by stage |
|
||||||
|
| `http.client.request.bytes` | Request wire bytes |
|
||||||
|
| `http.client.response.bytes` | Response bytes |
|
||||||
|
| `http.client.active` | In-flight attempts |
|
||||||
|
| `http.client.pool.connections` | Leased and available connections |
|
||||||
|
| `http.client.pool.pending` | Pool waiters |
|
||||||
|
| `http.client.pool.acquire.duration` | Pool wait time |
|
||||||
|
| `http.client.dns.duration` | DNS time |
|
||||||
|
| `http.client.connect.duration` | Connect time |
|
||||||
|
| `http.client.tls.duration` | TLS time |
|
||||||
|
| `http.client.circuit.state` | Circuit state |
|
||||||
|
| `http.client.bulkhead.rejected` | Bulkhead rejections |
|
||||||
|
| `http.client.rate_limit.rejected` | Local rate-limit rejections |
|
||||||
|
| `http.client.oauth.refresh` | Token refresh outcomes |
|
||||||
|
| `http.client.ssrf.rejected` | Dynamic target rejections |
|
||||||
|
|
||||||
|
`http.client.requests` counts attempts and `http.client.logical.calls` counts user calls. When they
|
||||||
|
diverge, retries are absorbing failures — which is the first thing to look at during an incident.
|
||||||
|
|
||||||
|
## Reading an incident
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| logical calls fine, attempts spiking | upstream degraded, retries absorbing it | `http.client.retry.count` by reason |
|
||||||
|
| `http.client.ambiguous` non-zero | non-idempotent writes reaching `SENT_NO_RESPONSE` | reconcile with the upstream; consider an idempotency key |
|
||||||
|
| pool pending climbing | pool too small or upstream slow | `http.client.pool.acquire.duration`, `pool.connections` |
|
||||||
|
| circuit open | sustained upstream failure | `http.client.circuit.state`; local rejections do not open it |
|
||||||
|
| `http.client.ssrf.rejected` non-zero | a caller is submitting internal URLs | Dynamic Target policy and audit trail |
|
||||||
|
|
||||||
|
## Actuator
|
||||||
|
|
||||||
|
`GET /actuator/httpclients` reports profile name, runtime generation, state, transport, API,
|
||||||
|
protocols, active leases, pool ceiling, credential type, TLS profile id, redirect flag, retry policy,
|
||||||
|
and capability warnings. Base URL, credentials, trust store paths, and resolved IPs are deliberately
|
||||||
|
absent: an actuator endpoint is reachable by more people than a secret store is.
|
||||||
|
|
||||||
|
## Rotation
|
||||||
|
|
||||||
|
Certificates and secrets rotate by building a new runtime generation and swapping the registry
|
||||||
|
pointer, never by mutating a live client. A connection pool holds sockets established under the
|
||||||
|
previous identity, so replacing material without replacing the pool leaves live connections
|
||||||
|
authenticated by a certificate that is meant to be gone.
|
||||||
|
|
||||||
|
```text
|
||||||
|
build new generation → validate → atomic swap → new calls use it
|
||||||
|
old generation → DRAINING → in-flight calls finish → no new retries → forced close at the drain deadline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shutdown
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUNNING → DRAINING
|
||||||
|
new logical calls refused or routed to the new generation
|
||||||
|
in-flight attempts complete
|
||||||
|
new retries refused
|
||||||
|
shutdown timeout
|
||||||
|
remaining calls cancelled
|
||||||
|
pool closed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry ownership
|
||||||
|
|
||||||
|
Exactly one of the application client, an external SDK, or the service mesh may own retries.
|
||||||
|
Two owners multiply traffic during an incident. Record the owner per upstream and check it whenever
|
||||||
|
a mesh retry policy changes.
|
||||||
|
|
||||||
|
## Error model
|
||||||
|
|
||||||
|
Every outbound failure is one of these stable types. The type is derived from the classified failure
|
||||||
|
category, not from whatever the engine happened to throw, so it means the same thing on Apache, JDK,
|
||||||
|
and Reactor Netty. Each carries `HttpFailureMetadata`: client, operation, method, URI **template**,
|
||||||
|
evidence, replayability, stage, retryability, attempt, elapsed, remaining deadline, status, trace id
|
||||||
|
— and nothing else.
|
||||||
|
|
||||||
|
| Exception | Raised when | Retryable |
|
||||||
|
|---|---|---|
|
||||||
|
| `HttpConfigurationException` | profile, operation, or capability configuration is invalid | never |
|
||||||
|
| `HttpTargetRejectedException` | target URI, host, port, header, or address policy refused the request | never |
|
||||||
|
| `HttpDnsException` | hostname resolution failed or timed out | yes, inside budget |
|
||||||
|
| `HttpPoolAcquireTimeoutException` | no connection or stream within the pending-acquire budget | yes, inside budget |
|
||||||
|
| `HttpConnectException` | socket connect failed | yes, inside budget |
|
||||||
|
| `HttpProxyException` | proxy connect, CONNECT tunnel, or proxy auth failed | yes, inside budget |
|
||||||
|
| `HttpTlsException` | TLS handshake failed | only a transient handshake timeout |
|
||||||
|
| `HttpRequestWriteException` | request headers or body could not be fully written | only when safely idempotent |
|
||||||
|
| `HttpResponseTimeoutException` | final headers or a body chunk did not arrive in time | only when safely idempotent |
|
||||||
|
| `HttpResponseTruncatedException` | the response ended before the body was complete | only when safely idempotent and undelivered |
|
||||||
|
| `HttpRemoteErrorException` | non-success status without a problem document | per the status rules |
|
||||||
|
| `HttpProblemDetailException` | non-success status with a bounded RFC 9457 document | per the status rules |
|
||||||
|
| `HttpRedirectRejectedException` | a hop violated hop count, origin, method, or replay policy | never |
|
||||||
|
| `HttpAuthenticationException` | credential materialization or refresh failed | never |
|
||||||
|
| `HttpSerializationException` | request encoding or response decoding failed | never |
|
||||||
|
| `HttpResponseTooLargeException` | wire or decoded bytes exceeded the profile limit | never |
|
||||||
|
| `HttpDeadlineExceededException` | the effective deadline was reached | never |
|
||||||
|
| `HttpCircuitOpenException` | the upstream circuit is open | never |
|
||||||
|
| `HttpBulkheadRejectedException` | no attempt or logical admission permit was available | never |
|
||||||
|
| `HttpRateLimitRejectedException` | the local attempt rate limit or retry budget rejected the attempt | never |
|
||||||
|
| `HttpAmbiguousExecutionException` | a non-idempotent request was sent and the outcome is unknown | never — reconcile instead |
|
||||||
|
|
||||||
|
## Traces
|
||||||
|
|
||||||
|
```text
|
||||||
|
http.client.operation logical internal span
|
||||||
|
└─ http.client.request attempt 1 CLIENT span
|
||||||
|
└─ http.client.request attempt 2 CLIENT span
|
||||||
|
```
|
||||||
|
|
||||||
|
W3C Trace Context is propagated with a Baggage allowlist. Dynamic Targets do not propagate trace
|
||||||
|
context by default. Retry reason and evidence are recorded as span events; credentials and remote
|
||||||
|
error bodies are never recorded as attributes.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# HTTP Client Platform — Performance Baseline
|
||||||
|
|
||||||
|
The certification lane asserts **resource bounds**, not throughput targets. Its purpose is to prove
|
||||||
|
that a failing upstream, a large body, or a rotation cannot consume unbounded memory, connections,
|
||||||
|
threads, or upstream traffic. Nothing here becomes a runtime adaptive default: every bound comes
|
||||||
|
from an explicit profile setting.
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# structural bounds only (default; still executes every test)
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --console=plain
|
||||||
|
|
||||||
|
# full certification, including machine-dependent bounds
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||||
|
-Pperformance.assertions.enabled=true --console=plain
|
||||||
|
|
||||||
|
# JMH benchmarks
|
||||||
|
./gradlew :adapter:outbound:httpclient:jmh --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Machine-dependent assertions are reported as explicitly skipped when the flag is absent — the lane
|
||||||
|
never silently degrades into a pass.
|
||||||
|
|
||||||
|
## Certified bounds
|
||||||
|
|
||||||
|
| Test | Bound | Kind |
|
||||||
|
|---|---|---|
|
||||||
|
| `RetryStormBudgetTest` | 10 000 logical calls against a failing upstream produce at most 11 000 physical attempts at a 10 % budget | structural |
|
||||||
|
| `LargeBodyResourceTest` | a 32 MiB streaming download consumes every byte without buffering the payload on the heap | structural + machine-dependent heap bound |
|
||||||
|
| `PoolSaturationPerformanceTest` | 24 concurrent calls against a 4-connection pool all reach a terminal outcome; none hang | structural |
|
||||||
|
| `Http2StreamSaturationTest` | 32 concurrent reactive streams share a 2-connection pool and complete | structural |
|
||||||
|
| `OAuthRefreshContentionTest` | 100 genuinely concurrent callers produce exactly one token request | structural |
|
||||||
|
| `RuntimeRotationDrainTest` | 50 rotations close all 50 retired generations and leave no drain thread | structural |
|
||||||
|
|
||||||
|
## Recording a baseline
|
||||||
|
|
||||||
|
When certifying a deployment, record alongside the numbers: the exact command, the commit, hardware,
|
||||||
|
JVM flags, the profile YAML under test, p50/p95/p99/max, peak heap, peak direct memory, thread count,
|
||||||
|
connection count, physical attempt count, and error count. A latency figure without its profile and
|
||||||
|
hardware is not a baseline; it is an anecdote.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Command | _fill in at certification time_ |
|
||||||
|
| Commit | _fill in_ |
|
||||||
|
| Hardware / JVM | _fill in_ |
|
||||||
|
| Profile under test | _fill in_ |
|
||||||
|
| p50 / p95 / p99 / max | _fill in_ |
|
||||||
|
| Peak heap / direct memory | _fill in_ |
|
||||||
|
| Threads / connections | _fill in_ |
|
||||||
|
| Physical attempts / errors | _fill in_ |
|
||||||
|
|
||||||
|
The table is intentionally left unfilled in the repository: publishing numbers measured on a build
|
||||||
|
agent as if they were a certified baseline would be worse than having none.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# HTTP Client Platform — Release Checklist
|
||||||
|
|
||||||
|
A release is complete when each item below is demonstrated by a command, not by review.
|
||||||
|
|
||||||
|
## Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:httpclient:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:spring62CompatibilityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --console=plain
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --console=plain # needs Docker
|
||||||
|
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
|
||||||
|
-Pperformance.assertions.enabled=true --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
python3 ../scripts/verify-httpclient-docs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completion criteria (design §33)
|
||||||
|
|
||||||
|
- [ ] Typed clients are the default entry point; H2 and H3 are separately authorised.
|
||||||
|
- [ ] H1–H4 cannot bypass timeout, host, TLS, auth, size, or observation policy.
|
||||||
|
- [ ] Apache, JDK, and Reactor produce identical result and exception metadata.
|
||||||
|
- [ ] Pool, DNS, connect, TLS, and retry backoff all fit inside the effective deadline.
|
||||||
|
- [ ] Every extra attempt is explained by idempotency, replayability, evidence, deadline, and budget.
|
||||||
|
- [ ] Non-idempotent `SENT_NO_RESPONSE` surfaces as `HttpAmbiguousExecutionException`.
|
||||||
|
- [ ] Pool and buffers are reclaimed after unread bodies, decode errors, cancels, and size rejections.
|
||||||
|
- [ ] OAuth2 refresh is single-flight and 401 replay happens at most once.
|
||||||
|
- [ ] Trust-all and hostname-verification bypass fail at startup.
|
||||||
|
- [ ] Canonicalisation, DNS/IP validation, redirect revalidation, and egress control all pass.
|
||||||
|
- [ ] No transparent retry occurs after the first delivered byte.
|
||||||
|
- [ ] No platform code blocks a Reactor event loop, proven by a BlockHound self-check.
|
||||||
|
- [ ] The negotiated wire protocol matches what the support matrix claims per transport.
|
||||||
|
- [ ] Logical calls and attempts are separate metrics with no forbidden label.
|
||||||
|
- [ ] DNS, pool, TLS, reset, partial response, and HTTP/2 GOAWAY are reproducible.
|
||||||
|
- [ ] Thread, heap, direct memory, pool, and retry budget bounds hold.
|
||||||
|
- [ ] The support matrix, configuration reference, security guide, runbook, and migration guide match the code.
|
||||||
|
|
||||||
|
## Experimental
|
||||||
|
|
||||||
|
Jetty HTTP/3 stays Experimental until `Http3CapabilityReport` reports QUIC and TLS 1.3 and the
|
||||||
|
contract subset it declares passes in a dedicated environment. It is never auto-configured by the
|
||||||
|
Stable starter.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Retry and Ambiguity
|
||||||
|
|
||||||
|
The platform never decides a retry from the HTTP method alone (design D-09). A second attempt
|
||||||
|
happens only when idempotency, body replayability, execution evidence, deadline, and retry budget
|
||||||
|
all permit it.
|
||||||
|
|
||||||
|
## Execution evidence
|
||||||
|
|
||||||
|
| Evidence | Meaning | Typical cause |
|
||||||
|
|---|---|---|
|
||||||
|
| `NOT_SENT` | Proven that the server never received the request | profile rejection, pool timeout, DNS failure, connect failure, pre-request TLS failure, HTTP/2 `REFUSED_STREAM` |
|
||||||
|
| `SENT_NO_RESPONSE` | Some or all of the request was written, no final header arrived | partial write, response-header timeout, connection reset |
|
||||||
|
| `RESPONSE_RECEIVED` | Final headers arrived, whatever the status | 2xx, 4xx, 5xx, redirect |
|
||||||
|
| `PARTIAL_RESPONSE` | Headers and part of the body arrived | reset during decode, interrupted stream |
|
||||||
|
|
||||||
|
`NOT_SENT` is only produced by a stage failure that proves it. A generic engine I/O error is never
|
||||||
|
upgraded to `NOT_SENT`, because that is exactly how a timeout becomes a duplicate payment.
|
||||||
|
|
||||||
|
## Body replayability
|
||||||
|
|
||||||
|
| Body | Replayability |
|
||||||
|
|---|---|
|
||||||
|
| immutable `byte[]` | `REPLAYABLE` |
|
||||||
|
| DTO plus a deterministic codec | `REPLAYABLE` |
|
||||||
|
| reopenable file or resource supplier | `REOPENABLE` |
|
||||||
|
| a single `InputStream` instance | `ONE_SHOT` |
|
||||||
|
| publisher factory | as declared |
|
||||||
|
| publisher instance | `ONE_SHOT` |
|
||||||
|
| multipart | the weakest part |
|
||||||
|
|
||||||
|
## Decision order
|
||||||
|
|
||||||
|
`DefaultRetryEligibilityEngine` evaluates in this order, and a later rule can never re-enable
|
||||||
|
something an earlier one forbade:
|
||||||
|
|
||||||
|
1. attempts exhausted → `RetryDenied.maxAttempts()`
|
||||||
|
2. retry budget empty → `RetryDenied.budgetExhausted()`
|
||||||
|
3. body not replayable → `RetryDenied.bodyNotReplayable()`
|
||||||
|
4. first byte already delivered → `RetryDenied.responseAlreadyDelivered()`
|
||||||
|
5. runtime draining → `RetryDenied.runtimeDraining()`
|
||||||
|
6. remaining deadline below the minimum attempt budget → `RetryDenied.deadline()`
|
||||||
|
7. permanent failure category → `RetryDenied.permanentFailure(...)`
|
||||||
|
8. `SENT_NO_RESPONSE` on an operation that is not safely idempotent → `AmbiguousFailure`
|
||||||
|
9. status- and failure-specific rules
|
||||||
|
|
||||||
|
## Status rules
|
||||||
|
|
||||||
|
| Status | Decision |
|
||||||
|
|---|---|
|
||||||
|
| 408 | retry inside deadline and budget |
|
||||||
|
| 425 | at most one retry, first attempt only |
|
||||||
|
| 429 | retry inside `Retry-After`, deadline, and budget |
|
||||||
|
| 401 | one refresh-and-replay, safe replayable operations only |
|
||||||
|
| 500 | denied unless the upstream registered it as transient **and** the operation is safely idempotent |
|
||||||
|
| 502, 503, 504 | retry for safely idempotent operations; ambiguous otherwise |
|
||||||
|
| other 4xx | denied |
|
||||||
|
|
||||||
|
## Ambiguity
|
||||||
|
|
||||||
|
A non-idempotent request that reached `SENT_NO_RESPONSE` raises
|
||||||
|
`HttpAmbiguousExecutionException`. It is a third answer on purpose: retrying may duplicate a side
|
||||||
|
effect, and reporting a plain failure would tell the caller the request did not happen, which may
|
||||||
|
be false. The caller reconciles, usually by querying the upstream or replaying with an idempotency
|
||||||
|
key.
|
||||||
|
|
||||||
|
## Budget and backoff
|
||||||
|
|
||||||
|
Retry tokens come from a per-upstream token bucket sized as a fraction of real traffic, so a failing
|
||||||
|
upstream cannot be flooded by retries from a healthy fleet. Backoff is exponential with full or
|
||||||
|
decorrelated jitter, bounded by `max-backoff`, by `Retry-After`, and by the remaining deadline. No
|
||||||
|
connection and no bulkhead permit is held while a backoff is waiting.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# HTTP Client Platform — Security Guide
|
||||||
|
|
||||||
|
## What the platform owns
|
||||||
|
|
||||||
|
`Authorization`, `Proxy-Authorization`, `Host`, `Content-Length`, `Transfer-Encoding`,
|
||||||
|
`Traceparent`, `Tracestate`, `Baggage`, and (unless a profile opts in) `Cookie` are platform-owned.
|
||||||
|
A caller cannot set them. `Idempotency-Key` is accepted only when the operation declares it. Any
|
||||||
|
header name or value containing CR or LF is rejected before the request is built.
|
||||||
|
|
||||||
|
## Target policy
|
||||||
|
|
||||||
|
A trusted profile accepts only a profile-relative URI template. An absolute URI is rejected rather
|
||||||
|
than sanitised: varying the destination is what H3 is for, and H3 has its own policy, credentials,
|
||||||
|
and address validation. Template variables are encoded per component, so a value containing `/`,
|
||||||
|
`?`, or `#` cannot change the shape of the request.
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Allowed: TLS 1.2 and 1.3, hostname verification, the JVM trust store, a per-profile custom CA, a
|
||||||
|
per-profile client certificate, mTLS, SNI and ALPN, and certificate rotation through a new runtime
|
||||||
|
generation.
|
||||||
|
|
||||||
|
Forbidden and unrepresentable: a trust-all trust manager, disabled hostname verification, ignoring
|
||||||
|
certificate errors, automatically trusting a production self-signed certificate, falling back to
|
||||||
|
plaintext after an HTTPS failure, and writing key material into configuration or logs.
|
||||||
|
|
||||||
|
Unknown CA, hostname mismatch, expired certificate, revoked certificate, protocol mismatch, and a
|
||||||
|
missing client certificate are permanent. Only a transient handshake timeout may be retried, inside
|
||||||
|
the deadline.
|
||||||
|
|
||||||
|
## Dynamic Target (SSRF)
|
||||||
|
|
||||||
|
Every hop — the first one included — runs the whole flow:
|
||||||
|
|
||||||
|
1. strict URI parse
|
||||||
|
2. scheme allowlist
|
||||||
|
3. reject userinfo and invalid ports
|
||||||
|
4. IDNA-canonicalise the host
|
||||||
|
5. host allowlist or suffix policy
|
||||||
|
6. resolve **every** A and AAAA answer
|
||||||
|
7. normalise each address, including IPv4-mapped IPv6
|
||||||
|
8. reject loopback, link-local, RFC1918, ULA, carrier-grade NAT, unspecified, multicast, cloud
|
||||||
|
metadata, and organisation-defined ranges
|
||||||
|
9. pin the connection to the approved addresses through the same validated resolver
|
||||||
|
10. apply response size and content policy
|
||||||
|
11. repeat for each redirect
|
||||||
|
|
||||||
|
Any forbidden address in the answer set rejects the whole target. Validating only the first answer
|
||||||
|
would let a host that resolves to one public and one private address through.
|
||||||
|
|
||||||
|
Dynamic profiles inherit no API key, OAuth token, Cookie, or default header, and no Cookie jar is
|
||||||
|
created. A specific host may be granted a credential only through an explicitly registered
|
||||||
|
`DynamicCredentialBinding`.
|
||||||
|
|
||||||
|
Application-level validation is not sufficient on its own. A network control — Kubernetes
|
||||||
|
NetworkPolicy, service-mesh egress policy, firewall, or proxy ACL — is an operational completion
|
||||||
|
requirement.
|
||||||
|
|
||||||
|
## Redirects
|
||||||
|
|
||||||
|
Disabled by default. Engine redirect handling is off in every transport so the platform can
|
||||||
|
re-validate each hop. 307 and 308 preserve method and body and are therefore allowed only for a
|
||||||
|
replayable body. Cross-origin hops are refused unless the profile opts in, and when they are
|
||||||
|
allowed `Authorization`, `Proxy-Authorization`, `Cookie`, and API-key headers are stripped.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Allowed tags: `clientName`, `operationName`, `method`, `uriTemplate`, `status`, `outcome`,
|
||||||
|
`transport`, `protocol`, `timeoutType`, `retryReason`, `evidence`, `circuitState`.
|
||||||
|
|
||||||
|
Rejected outright: full URL, query parameters, path variable values, user ID, raw tenant ID,
|
||||||
|
resolved IP, API key, token, Cookie, idempotency key, request or response body, exception message.
|
||||||
|
|
||||||
|
Failures are logged once, structured, at the end of a logical call. Retry attempts are DEBUG or span
|
||||||
|
events. URLs appear only as templates.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Streaming and Large Bodies
|
||||||
|
|
||||||
|
## Response lifecycle
|
||||||
|
|
||||||
|
A blocking streaming download returns `BlockingStreamingResponse`, never a bare `InputStream`.
|
||||||
|
Closing is idempotent and always releases the connection — after a full read, a partial read, a
|
||||||
|
decode failure, or a size rejection. The status is validated before any body byte is delivered, so a
|
||||||
|
failed download never becomes a half-consumed stream the caller has to reason about.
|
||||||
|
|
||||||
|
A reactive download emits bounded `DataBuffer` values. Buffers are released on completion, error,
|
||||||
|
and cancellation; a dropped buffer is direct memory nobody returns.
|
||||||
|
|
||||||
|
Wire bytes and decoded bytes are bounded independently, because a compressed payload passes a wire
|
||||||
|
check and then expands. Limits are enforced while reading, not after buffering.
|
||||||
|
|
||||||
|
## The first-byte boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
response headers received
|
||||||
|
→ nothing delivered yet
|
||||||
|
→ a read-only operation may still be retried
|
||||||
|
→ first InputStream read or first Flux onNext
|
||||||
|
→ transparent retry is permanently disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
`FirstByteDeliveryGuard` latches once and never resets. Retrying after delivery would replay a
|
||||||
|
stream the caller has already partly consumed, producing duplicated or reordered data that no
|
||||||
|
downstream code can detect.
|
||||||
|
|
||||||
|
## Request bodies
|
||||||
|
|
||||||
|
A reopenable body is opened once per attempt, which is what makes it replayable; reusing the
|
||||||
|
previous stream would silently send an empty body on the retry. A one-shot stream or publisher
|
||||||
|
instance is never retried. `ReactiveBodySource` takes a publisher *factory* rather than a publisher
|
||||||
|
so a reactive body can honestly declare itself replayable.
|
||||||
|
|
||||||
|
A multipart body is exactly as replayable as its weakest part.
|
||||||
|
|
||||||
|
## Server-sent events
|
||||||
|
|
||||||
|
Three budgets stay separate:
|
||||||
|
|
||||||
|
- `setupDeadline` — establishing the stream
|
||||||
|
- `streamingIdleTimeout` — silence once it is open
|
||||||
|
- `maxStreamDuration` — optional total lifetime
|
||||||
|
|
||||||
|
Applying the request-shaped `total-call` timeout to an SSE subscription would terminate a perfectly
|
||||||
|
healthy stream on schedule, so it is not applied.
|
||||||
|
|
||||||
|
`Last-Event-ID` is opt-in. Replaying from an id is only correct when the producer guarantees it;
|
||||||
|
sending it blindly can skip or duplicate events. Reconnects consume the retry budget like any other
|
||||||
|
physical attempt, and cancelling the subscription stops both the stream and any pending reconnect.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# HTTP Client Platform — Support Matrix
|
||||||
|
|
||||||
|
Grades follow design §6 and §29. A row is **Stable** only when the cross-transport contract suite
|
||||||
|
proves it; anything the suite cannot prove is **Experimental** and says so.
|
||||||
|
|
||||||
|
## Spring API
|
||||||
|
|
||||||
|
| API | Grade | Role | Constraint |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RestClient` | Stable | Blocking execution | Bounded concurrency and an effective deadline are mandatory |
|
||||||
|
| `WebClient` | Stable | Reactive, streaming, SSE | No blocking work on the event loop |
|
||||||
|
| HTTP Service Client (`@HttpExchange`) | Default | Declarative typed client | Operation metadata is mandatory |
|
||||||
|
| `RestTemplate` | Migration only | Moving existing calls | No new profile or feature |
|
||||||
|
| Generic Exchange (H2) | Restricted | Dynamic method, path, body | Base URL and policy are immutable |
|
||||||
|
| Dynamic Target (H3) | Restricted | User-supplied URL | Separate SSRF policy; inherits no credential |
|
||||||
|
| Native engine | Internal | Engine-specific configuration | Never an application-facing API |
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
| Transport | Blocking | Reactive | HTTP/1.1 | HTTP/2 | HTTP/3 | Grade | Verified by |
|
||||||
|
|---|---:|---:|---:|---:|---:|---|---|
|
||||||
|
| Apache HttpClient 5 (classic) | yes | no | yes | **no** | no | Stable (blocking default) | `httpClientStableContractTest`, `NegotiatedProtocolContractTest` |
|
||||||
|
| JDK HttpClient | yes | `sendAsync` | yes | yes (TLS/ALPN) | no | Stable (lightweight, blocking HTTP/2) | `NegotiatedProtocolContractTest` |
|
||||||
|
| Reactor Netty | limited | yes | yes | yes | experimental | Stable (reactive default) | `NegotiatedProtocolContractTest` |
|
||||||
|
| Jetty | facade | yes | yes | yes | yes | **Experimental** | `Http3OptInTest` only |
|
||||||
|
| Simple request factory | yes | no | limited | no | no | Local test only | rejected in production by `ClientProfileValidator` |
|
||||||
|
|
||||||
|
### Apache is HTTP/1.1 here, and why
|
||||||
|
|
||||||
|
Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable, and the library is — in its **async**
|
||||||
|
client. Spring's `HttpComponentsClientHttpRequestFactory` drives the **classic** client, which
|
||||||
|
speaks HTTP/1.1 only. `NegotiatedProtocolContractTest` measures this rather than assuming it: the
|
||||||
|
classic client fails outright against a prior-knowledge h2c server.
|
||||||
|
|
||||||
|
So `ApacheBlockingTransportProvider.capabilities()` declares HTTP/1.1, and a profile that pairs
|
||||||
|
Apache with `HTTP_2` is rejected at startup instead of quietly running HTTP/1.1 while this table
|
||||||
|
claims otherwise. **Blocking HTTP/2 is served by the JDK transport**; reactive HTTP/2 by Reactor
|
||||||
|
Netty. Both are measured from the client after a real TLS handshake, not read from configuration.
|
||||||
|
|
||||||
|
The JDK transport declares `routeScopedPool=false`, `boundedPendingAcquireQueue=false`, and
|
||||||
|
`dynamicTargetStable=false`. A profile that needs any of those is rejected at startup rather than
|
||||||
|
served with weaker guarantees. Choosing between Apache and JDK is therefore a real trade: Apache
|
||||||
|
gives route-scoped pooling and Dynamic Target pinning, JDK gives HTTP/2.
|
||||||
|
|
||||||
|
## Capability gates
|
||||||
|
|
||||||
|
| Capability | Gate |
|
||||||
|
|---|---|
|
||||||
|
| Dynamic Target (H3) | Apache and Reactor Netty only; JDK and Jetty are rejected |
|
||||||
|
| HTTP/3 | `experimentalAcknowledgement` must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||||
|
| Cross-origin redirect | opt-in per profile; credentials are stripped on the hop |
|
||||||
|
| Retry | evidence-based; never enabled by HTTP method alone |
|
||||||
|
|
||||||
|
## CI matrix
|
||||||
|
|
||||||
|
| Profile | Frequency | Release gate | Task |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Spring Framework 7.0 (repository baseline) | every PR | required | `spring70CompatibilityTest` |
|
||||||
|
| Spring Framework 6.2 API surface | every PR | required | `spring62CompatibilityTest` |
|
||||||
|
| Apache HC5 + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=apache` |
|
||||||
|
| JDK HttpClient + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=jdk` |
|
||||||
|
| Reactor Netty + WebClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=reactor` |
|
||||||
|
| SSRF / cardinality suite | every PR | required | `httpClientSecurityTest` |
|
||||||
|
| Toxiproxy fault suite | nightly, release | required | `httpClientFailureInjectionTest` |
|
||||||
|
| Event-loop blocking (BlockHound) | every PR | required | `httpClientBlockHoundTest` |
|
||||||
|
| Performance certification | nightly, release | required | `httpClientPerformanceTest -Pperformance.assertions.enabled=true` |
|
||||||
|
| Jetty HTTP/3 | nightly | Experimental, non-blocking | `test -Phttp3.tests.enabled=true` |
|
||||||
|
|
||||||
|
### Known limitation of the Spring 6.2 lane
|
||||||
|
|
||||||
|
This repository's Spring Boot 4.0 baseline pins Spring Framework 7, so a real 6.2 runtime cannot be
|
||||||
|
resolved here. `spring62CompatibilityTest` therefore verifies the **API surface**: the common
|
||||||
|
packages must not reference any Spring 7-only type, and `org.springframework.web.service.registry`
|
||||||
|
is confined to `…httpclient.spring7`. Executing the suite against an actual 6.2 distribution
|
||||||
|
requires a host project on that line. This limitation is stated rather than hidden behind a passing
|
||||||
|
check.
|
||||||
|
|
||||||
|
|
||||||
|
## What the suites do not prove
|
||||||
|
|
||||||
|
Stated so the matrix is read as a measurement rather than an aspiration.
|
||||||
|
|
||||||
|
| Gap | Why | What is proven instead |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP/2 frame injection (`REFUSED_STREAM`, arbitrary `GOAWAY`) | The fixture server exposes no frame-level control, and a purpose-built h2 server is a larger dependency than the guarantee is worth here | `Http2EvidenceMapperTest` proves the frame → evidence mapping, and `NegotiatedProtocolContractTest` proves h2 is really negotiated |
|
||||||
|
| Netty buffer-leak detection | Netty reports a leak when an unreferenced buffer is collected, which the suite does not force | `NettyLeakDetectionExtension` asserts the PARANOID detector is live and reports nothing; explicit release assertions in the streaming suites are the primary guarantee |
|
||||||
|
| Spring 6.2 runtime | This repository's Boot 4.0 baseline pins Spring 7 | `spring62CompatibilityTest` confines the common packages to the 6.2 API surface |
|
||||||
|
| Performance latency baseline | Numbers measured on a build agent are not a certification | `httpClientPerformanceTest` asserts structural bounds unconditionally; latency and heap bounds run under `-Pperformance.assertions.enabled=true` |
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Command policy
|
||||||
|
|
||||||
|
`src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml` is the
|
||||||
|
single source of truth for what this SDK is willing to do with each Redis command. Official server
|
||||||
|
metadata decides what a command *is*; this file decides what we allow.
|
||||||
|
|
||||||
|
A command that is not classified there is refused. Adding a command therefore means editing that
|
||||||
|
file, not writing code — and the edit is where the risk decision is made and reviewed.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `risk` | required | `R1` routine, `R2` needs an explicit permit, `R3` administrative, `R4` never allowed |
|
||||||
|
| `support` | required | `TYPED`, `ADVANCED_TYPED`, `RAW_ONLY`, `ADMIN_ONLY`, `VERSION_GATED`, `BLOCKED` |
|
||||||
|
| `minimum-version` | `7.2` | lowest server version that carries the command |
|
||||||
|
| `access` | derived from `support` | which ACL account may issue it |
|
||||||
|
| `blocking` | `false` | occupies its connection until the server replies |
|
||||||
|
| `optional-block` | `false` | the command also has a non-blocking form; only `XREAD` and `XREADGROUP` carry it |
|
||||||
|
| `read-only` | `false` | never mutates the dataset |
|
||||||
|
| `retry-safe` | `read-only` | may be retried after a failure that could have reached the server |
|
||||||
|
| `may-be-ambiguous` | `!read-only` | a failure may leave the outcome unknown |
|
||||||
|
| `timeout-profile` | derived | `FAST`, `COLLECTION`, `ADMIN`, `BLOCKING` |
|
||||||
|
| `key-spec` | `1 1 1` | where the keys are, or `none`, or `movable` |
|
||||||
|
| `required-policy` | – | the permit policy an R2 command demands |
|
||||||
|
|
||||||
|
## Rules the catalog enforces
|
||||||
|
|
||||||
|
- An R2 `ADVANCED_TYPED` command must name the permit policy it requires. There is no R2 command
|
||||||
|
that anyone may issue without an issued permit.
|
||||||
|
- An R4 command must be `BLOCKED`, and an R3 command must be `ADMIN_ONLY`. The type system refuses
|
||||||
|
the other combinations at load time.
|
||||||
|
- A `BLOCKED` command carries no ACL account, so no path in the SDK can reach it.
|
||||||
|
- A blocking command must use the `BLOCKING` timeout profile, and its request must declare a bounded
|
||||||
|
server block — unless it also declares `optional-block`, which only the two stream reads do.
|
||||||
|
- Deprecated command names stay `BLOCKED` even when the SDK offers their behaviour. The typed
|
||||||
|
sorted-set ranges issue `ZRANGE ... BYSCORE|BYLEX|REV`, not `ZRANGEBYSCORE`, so what the guard was
|
||||||
|
told and what reaches the wire are the same command.
|
||||||
|
|
||||||
|
## Where each support level is reachable from
|
||||||
|
|
||||||
|
| Support | Reachable from |
|
||||||
|
| --- | --- |
|
||||||
|
| `TYPED` | the typed operations, no permit |
|
||||||
|
| `ADVANCED_TYPED` | the typed operations, with the named permit |
|
||||||
|
| `VERSION_GATED` | a capability bean that exists only when the probe found the feature |
|
||||||
|
| `RAW_ONLY` | `sdk.raw`, and only with a deployment-registered approval |
|
||||||
|
| `ADMIN_ONLY` | `sdk.admin`, read-only diagnostics only |
|
||||||
|
| `BLOCKED` | nowhere |
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Operating the Redis SDK
|
||||||
|
|
||||||
|
## What the metrics can and cannot tell you
|
||||||
|
|
||||||
|
Every observation carries the command family, the deployment mode, and latency. None carries a key,
|
||||||
|
a field, a member, or a value — not because they would be large, but because a metric dimension
|
||||||
|
built from caller data is unbounded cardinality and, for most deployments, tenant identity in a
|
||||||
|
dashboard.
|
||||||
|
|
||||||
|
That means you can answer "which command family is slow" and "which one is failing", and you cannot
|
||||||
|
answer "which key is hot" from metrics. Use the admin plane's `SLOWLOG` projection for the first
|
||||||
|
question and `MEMORY USAGE` on a specific key for the second.
|
||||||
|
|
||||||
|
## The failures worth alerting on
|
||||||
|
|
||||||
|
| Signal | What it means | What to do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RedisCommandRejectedException` | the SDK refused before sending | a caller exceeded a declared bound; the reason names which one |
|
||||||
|
| `RedisCrossSlotException` | a multi-key command spans slots | the keys need a shared hash tag |
|
||||||
|
| `RedisAmbiguousExecutionException` | a write may or may not have applied | reconcile; the SDK will not retry it |
|
||||||
|
| `RedisCapabilityUnavailableException` | the server lacks the feature | a capability bean was constructed by hand, or the probe result changed |
|
||||||
|
| `SentinelFailoverObserver.ambiguousWriteCount` | non-idempotent writes lost to a promotion | each one needs reconciling; the count is the workload |
|
||||||
|
| `ClusterTopologyObserver.reshardingObserved` | `ASK`/`TRYAGAIN` seen | a slot migration is in progress; latency will be uneven until it ends |
|
||||||
|
|
||||||
|
## Things the SDK will never do for you
|
||||||
|
|
||||||
|
- Retry a non-idempotent write after a timeout. `ExecutionCertainty.AMBIGUOUS_FAILURE` is reported,
|
||||||
|
not resolved.
|
||||||
|
- Follow a cross-slot multi-key command by splitting it. It is refused instead.
|
||||||
|
- Read a whole collection, stream, or index. Every read declares a bound.
|
||||||
|
- Load a Lua script or a function library at request time. Both are deployment actions.
|
||||||
|
- Send a command it cannot classify.
|
||||||
|
- Tell you that an acknowledged write was lost. See below — this one is not a limitation you can
|
||||||
|
work around in application code.
|
||||||
|
|
||||||
|
## The write loss the client cannot see
|
||||||
|
|
||||||
|
Set these on every Redis node that can ever be a primary:
|
||||||
|
|
||||||
|
```
|
||||||
|
min-replicas-to-write 1
|
||||||
|
min-replicas-max-lag 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Without them a Sentinel promotion silently destroys acknowledged writes, and this is measured, not
|
||||||
|
theoretical. In `LiveRedisSentinelPromotionTest` on the 7.4 lane, Sentinel promoted the replica and
|
||||||
|
did not demote the old primary for **eleven seconds**. The client stayed connected to a primary that
|
||||||
|
had already been replaced, wrote, and was told `+OK` **2,086 times**. Every one of those writes was
|
||||||
|
discarded when the old primary resynced. Exactly one command failed.
|
||||||
|
|
||||||
|
Nothing on the client can detect this. The server answered, so the driver recorded a success, the
|
||||||
|
SDK recorded `CONFIRMED_SUCCESS`, and the caller was told the write landed. No metric here counts
|
||||||
|
it, `SentinelFailoverObserver` cannot count it, and no retry policy helps — there was no failure to
|
||||||
|
react to. A second run of the same promotion produced sixteen thousand writes, **zero** exceptions,
|
||||||
|
and the same silent loss.
|
||||||
|
|
||||||
|
With the two settings, the identical promotion lost **one** write and refused 2,020 with
|
||||||
|
`NOREPLICAS`, which the SDK reports as a definite, non-ambiguous failure the caller can act on. That
|
||||||
|
is the whole difference: an outage you can see instead of data you cannot.
|
||||||
|
|
||||||
|
The residual window is `min-replicas-max-lag` wide and cannot be closed by configuration alone. A
|
||||||
|
write that must survive a promotion under any circumstances needs `WAIT` after it, at the cost of a
|
||||||
|
round trip to the replica — decide that per write, not globally.
|
||||||
|
|
||||||
|
## Blocking work
|
||||||
|
|
||||||
|
Blocking pops and blocking stream reads run on a dedicated connection lane. If those saturate, the
|
||||||
|
symptom is blocking calls timing out while ordinary traffic is healthy — that is the lane doing its
|
||||||
|
job, not a fault. Size the blocking pool to the number of concurrent consumers, not to request rate.
|
||||||
|
|
||||||
|
## Pub/Sub
|
||||||
|
|
||||||
|
At-most-once. A subscriber that reconnects misses whatever arrived while it was gone, and there is
|
||||||
|
no replay. Durable business events belong in a stream with a consumer group, which is at-least-once
|
||||||
|
and therefore requires idempotent consumers.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Redis SDK support matrix
|
||||||
|
|
||||||
|
This file is a gate, not a summary. `RedisSupportMatrixTest` parses the tables below and fails when
|
||||||
|
the SDK grows a package or a capability that is not listed, so a module cannot ship without someone
|
||||||
|
stating its minimum version, its topology support, and what it does not do.
|
||||||
|
|
||||||
|
Design: `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`.
|
||||||
|
Delivery status and the decisions behind each module: `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Minimum Redis | Topology | Risk exposure | Sync | Reactive | Known limitations |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `api` | 7.2 | all | none | n/a | n/a | contract only; no driver types |
|
||||||
|
| `api/key` | 7.2 | all | none | n/a | n/a | slot tags must be low-cardinality |
|
||||||
|
| `api/codec` | 7.2 | all | none | n/a | n/a | no Java native serialization |
|
||||||
|
| `api/command` | 7.2 | all | none | n/a | n/a | permits never widen the ACL account |
|
||||||
|
| `api/error` | 7.2 | all | none | n/a | n/a | failure metadata carries no key or value |
|
||||||
|
| `api/operations` | 7.2 | all | none | n/a | n/a | contract only |
|
||||||
|
| `api/reactive` | 7.2 | all | none | n/a | n/a | Reactor confined to this package |
|
||||||
|
| `lettuce` | 7.2 | all | R1–R2 | yes | yes | pinned to Lettuce 6.8.2 |
|
||||||
|
| `lettuce/codec` | 7.2 | all | none | yes | yes | UTF-8 and byte array codecs only |
|
||||||
|
| `lettuce/command` | 7.2 | all | R1–R2 | yes | yes | policy catalog is the only command authority |
|
||||||
|
| `lettuce/connection` | 7.2 | all | none | yes | yes | five lanes; blocking work never shares the regular lane |
|
||||||
|
| `lettuce/observability` | 7.2 | all | none | yes | yes | command family only, never a key |
|
||||||
|
| `lettuce/operations` | 7.2 | all | R1–R2 | yes | yes | hash field TTL needs 7.4; sharded pub/sub needs 7.0; stream deletion needs 8.2 |
|
||||||
|
| `config` | 7.2 | all | none | n/a | n/a | permit provenance is HMAC-signed per process |
|
||||||
|
| `cluster` | 7.2 | cluster | none | n/a | n/a | slot arithmetic only; no redirect following |
|
||||||
|
| `programmability` | 7.2 | all | R2 | yes | no | transactions never roll back; scripts return one bulk reply; `FUNCTION LOAD` is admin-plane |
|
||||||
|
| `raw` | 7.2 | all | R2 | yes | no | `RAW_ONLY` commands only; movable key specs unapprovable |
|
||||||
|
| `admin` | 7.2 | all | R3 read-only | yes | no | replies are projected; no destructive command exists |
|
||||||
|
| `extensions` | 8.0 | all | none | yes | no | shared command runner; every extension declares its key |
|
||||||
|
| `extensions/json` | 8.0 | all | R1–R2 | yes | no | narrow JSONPath grammar; documents exchanged as text |
|
||||||
|
| `extensions/search` | 8.0 | all | R2 | yes | no | index names namespaced by the SDK; no drop index |
|
||||||
|
| `extensions/timeseries` | 8.0 | all | R1–R2 | yes | no | retention mandatory at creation |
|
||||||
|
| `extensions/probabilistic` | 8.0 | all | R1–R2 | yes | no | every answer is approximate by construction |
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Capability | Minimum Redis | Gate | Bean when absent |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `SHARDED_PUBSUB` | 7.0 | probe and catalog minimum | none |
|
||||||
|
| `FUNCTIONS` | 7.0 | probe and catalog minimum | none |
|
||||||
|
| `HASH_FIELD_EXPIRATION` | 7.4 | probe and catalog minimum | none |
|
||||||
|
| `HASH_FIELD_EXPIRATION_COMBINED` | 8.0 | probe and catalog minimum | none |
|
||||||
|
| `STREAM_ACKNOWLEDGE_DELETE` | 8.2 | probe and catalog minimum | none |
|
||||||
|
| `STREAM_NEGATIVE_ACKNOWLEDGE` | 8.8 | probe and catalog minimum | none, and no bean exists yet |
|
||||||
|
| `JSON` | 8.0 | probe is authoritative | none |
|
||||||
|
| `SEARCH` | 8.0 | probe is authoritative | none |
|
||||||
|
| `TIME_SERIES` | 8.0 | probe is authoritative | none |
|
||||||
|
| `PROBABILISTIC` | 8.0 | probe is authoritative | none |
|
||||||
|
|
||||||
|
## Certified versions
|
||||||
|
|
||||||
|
A version is certified by its lane producing evidence, not by the version number being newer. An
|
||||||
|
evidence claim here must name the test class that produced it; `RedisSupportMatrixTest` fails the
|
||||||
|
build on a row that claims anything else, so "verified" cannot be written into this table without a
|
||||||
|
test behind it.
|
||||||
|
|
||||||
|
All three lanes have now run on 7.4. The other declared versions are declared, not certified:
|
||||||
|
nothing in this repository has executed against 7.2 or 8.2.
|
||||||
|
|
||||||
|
| Topology | Versions declared | Evidence status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Standalone | 7.2, 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisGuardrailTest` on 7.4 |
|
||||||
|
| Sentinel | 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisSentinelPromotionTest` on 7.4 |
|
||||||
|
| Cluster | 7.4, 8.2 | `RedisTopologyContractTest`, `LiveRedisClusterTest` on 7.4 |
|
||||||
|
|
||||||
|
### What the standalone ACL run established
|
||||||
|
|
||||||
|
`RedisTopologyContractTest` runs the four accounts in `infra/redis-sdk/acl` against a live server and
|
||||||
|
asserts that each `CommandAccess` level grants exactly what the command policy catalog says it may
|
||||||
|
issue. Writing it found five defects that no amount of reading the files would have surfaced:
|
||||||
|
|
||||||
|
1. A Redis ACL file accepts neither comments nor line continuations — the original files did not load
|
||||||
|
at all, and the server refused to start.
|
||||||
|
2. The advanced account granted `SMEMBERS` and `SORT`, both `RAW_ONLY` and therefore the raw gateway
|
||||||
|
account's alone.
|
||||||
|
3. The ordinary account granted `SORT_RO` for the same reason.
|
||||||
|
4. The ordinary account could not run `PUBLISH`, `SUBSCRIBE`, or `PING`, all classified `TYPED`.
|
||||||
|
5. The ordinary account could not run `MULTI`, `EXEC`, `UNWATCH`, or `DISCARD`, also `TYPED`.
|
||||||
|
|
||||||
|
6. The admin account was missing twelve read-only diagnostics the catalog exposes — the `OBJECT`,
|
||||||
|
`PUBSUB`, `XINFO`, `FUNCTION LIST`/`STATS`, and `CLUSTER KEYSLOT` subcommands.
|
||||||
|
|
||||||
|
7. The cursor-scan reply budget was sized to the requested `COUNT`, which Redis treats as a hint —
|
||||||
|
a real `HSCAN COUNT 500` came back with 501 entries and the SDK refused a correct reply.
|
||||||
|
|
||||||
|
Points 2 and 3 are the ones that matter: the account is the last enforcement boundary, so an account
|
||||||
|
wider than the catalog silently removes the second control the design relies on.
|
||||||
|
|
||||||
|
### What the standalone guardrail run established
|
||||||
|
|
||||||
|
`LiveRedisGuardrailTest` wires the real guard, catalog, and typed operations to a live server —
|
||||||
|
the first time `LettuceRedisCommandGateway`, the one class that encodes commands, runs under the
|
||||||
|
SDK's own contracts rather than against the in-memory stand-in. It carries the plan's datasets: a
|
||||||
|
value at the 1 MiB ceiling, a hundred-thousand-field hash, hundred-thousand-member set and sorted
|
||||||
|
set, a twenty-thousand-element list, a stream trimmed to 1,000 while twenty thousand entries are
|
||||||
|
appended, and a five-hundred-command batch.
|
||||||
|
|
||||||
|
The assertions are about limits holding, not throughput. A guardrail test that measured absolute
|
||||||
|
speed would fail on a loaded laptop and teach nobody anything.
|
||||||
|
|
||||||
|
### What the Sentinel promotion run established
|
||||||
|
|
||||||
|
`LiveRedisSentinelPromotionTest` forces one real promotion and asserts several independent claims
|
||||||
|
about it. Every write carries a token unique to the run, so the list on the promoted primary is a
|
||||||
|
verbatim record of what happened and each per-call verdict can be checked against it.
|
||||||
|
|
||||||
|
It found the most serious defect in this delivery, and it is not in the SDK's code:
|
||||||
|
|
||||||
|
> **A superseded primary keeps acknowledging writes.** Sentinel promoted the replica at
|
||||||
|
> `05:56:12.503` and did not demote the old primary until `05:56:23.529` — eleven seconds in which
|
||||||
|
> the client, still connected, wrote and was told `+OK` **2,086 times**. Every one of those writes
|
||||||
|
> was discarded when the old primary resynced from the new one. Exactly **one** command failed. No
|
||||||
|
> client-side signal exists for this: the server answered, so the driver, the SDK, and the caller
|
||||||
|
> all correctly recorded a success.
|
||||||
|
|
||||||
|
`SentinelFailoverObserver` counts *ambiguous* writes, and its documentation used to call those "the
|
||||||
|
ones an operator has to reconcile". That was wrong by three orders of magnitude, and the class now
|
||||||
|
says so.
|
||||||
|
|
||||||
|
What closes the window is on the server, not the client. Re-running the identical promotion with
|
||||||
|
`min-replicas-to-write 1` and `min-replicas-max-lag 1` configured cut acknowledged-and-discarded
|
||||||
|
writes from **2,086 to 1**: the orphaned primary refused 2,020 writes with `NOREPLICAS`, which the
|
||||||
|
SDK translates to a definite, non-ambiguous failure the caller can act on. Both settings are now in
|
||||||
|
the lane, and `acknowledgedWriteLossIsBounded` ties the tolerated loss to the configured lag window,
|
||||||
|
so removing them makes the count jump by an order of magnitude and fails the test.
|
||||||
|
|
||||||
|
That assertion then caught a second version of the same mistake within a day of being written. The
|
||||||
|
first guarded run passed; the second failed with 2,099 lost writes, because the setting had been
|
||||||
|
written into the lane's `primary` service only. The two data nodes swap roles on every failover, so
|
||||||
|
a guardrail applied to whichever one happens to start as primary stops applying the moment the lane
|
||||||
|
does the thing it exists to do. Both nodes now take their whole configuration from one definition.
|
||||||
|
Three consecutive promotions in both directions since: 0, 0, and 1 acknowledged write lost.
|
||||||
|
|
||||||
|
The run also found a translator defect. A promotion closed the channel under an in-flight `RPUSH`
|
||||||
|
and the driver raised a bare `RedisException`, which matched no branch and fell through to a generic
|
||||||
|
failure reported as *definitely did not run*. Nothing about an unrecognised failure supports that
|
||||||
|
claim, and a caller who believes it retries a non-idempotent write. The fallback now treats an
|
||||||
|
unclassified write failure as ambiguous.
|
||||||
|
|
||||||
|
### What the Cluster run established
|
||||||
|
|
||||||
|
`LiveRedisClusterTest` checks the part of `sdk.cluster` that is pure client-side arithmetic against
|
||||||
|
the server that has the last word. The calculator agreed with `CLUSTER KEYSLOT` on every entry of a
|
||||||
|
corpus built from the brace rules a hand-written implementation gets wrong — an empty tag `{}`,
|
||||||
|
`foo{}{bar}`, `foo{{bar}}zap`, an unclosed brace, `}{`, the empty key, and non-ASCII keys — and the
|
||||||
|
rendered-key invariant holds: the slot the SDK computes from a tag alone equals the slot the server
|
||||||
|
computes from the whole rendered key.
|
||||||
|
|
||||||
|
Cross-slot refusal was checked in both directions, because a guard stricter than the cluster costs
|
||||||
|
availability for no reason and a looser one sends requests that cannot succeed. The same key pair
|
||||||
|
the guard refuses is the pair the server answers `CROSSSLOT` for.
|
||||||
|
|
||||||
|
Redirects were observed rather than assumed: a `MOVED` names the slot the client computed, and a
|
||||||
|
slot put into a real `MIGRATING`/`IMPORTING` state answers `ASK` for an absent key and `TRYAGAIN`
|
||||||
|
for a multi-key request that straddles the migration. The lane restores the slot to `STABLE`
|
||||||
|
afterwards, so a run leaves the cluster as it found it.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Redis and client upgrade gate
|
||||||
|
|
||||||
|
Changing the Redis server version or the Lettuce version is not a dependency bump. Both change what
|
||||||
|
commands exist, what they reply, and what an ACL account is allowed to do — all three are things this
|
||||||
|
SDK encodes as fixed decisions. The checks below must pass before either version moves, and each one
|
||||||
|
exists because skipping it produces a specific failure that only shows up in production.
|
||||||
|
|
||||||
|
## 1. Command metadata diff
|
||||||
|
|
||||||
|
Run the catalog drift check against the new server. Every command the server reports must be
|
||||||
|
classified in `src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml`.
|
||||||
|
|
||||||
|
*Why:* an unclassified command is refused by `CommandPolicyGuard`, so a server that grew a command
|
||||||
|
does not create a hole — but a command whose **risk changed upstream** and is still classified R1
|
||||||
|
here does. The diff is what surfaces that.
|
||||||
|
|
||||||
|
## 2. ACL regression
|
||||||
|
|
||||||
|
Re-run `ACL DRYRUN` for every account against every command the SDK can issue, using
|
||||||
|
`RedisAdminOperations.aclDryRun`.
|
||||||
|
|
||||||
|
*Why:* a permit never widens an ACL account, so the account is the last boundary. A new server
|
||||||
|
version that moved a command into a different ACL category silently turns a working call into a
|
||||||
|
runtime refusal on the first request that needs it.
|
||||||
|
|
||||||
|
## 3. Serializer golden bytes
|
||||||
|
|
||||||
|
Compare the encoded form of every registered codec against the stored golden bytes.
|
||||||
|
|
||||||
|
*Why:* a value written by the old version must still decode after the upgrade. A codec change that
|
||||||
|
looks harmless in a round-trip test is not harmless against data already in the instance.
|
||||||
|
|
||||||
|
## 4. Support matrix
|
||||||
|
|
||||||
|
Update `docs/redis/support-matrix.md`. `RedisSupportMatrixTest` fails when a module or capability is
|
||||||
|
missing, and the certified-version table must not claim a version until its topology lane has
|
||||||
|
actually run.
|
||||||
|
|
||||||
|
## 5. Topology suite
|
||||||
|
|
||||||
|
Run the standalone, Sentinel, and Cluster lanes declared in `infra/redis-sdk/`. A version is
|
||||||
|
certified by the lane passing, not by the version number being newer.
|
||||||
|
|
||||||
|
*Why:* failover certainty and cross-slot behaviour are the two things the in-memory fixture cannot
|
||||||
|
prove. `ExecutionCertainty` and `RedisSlotCalculator` are classification and arithmetic; whether the
|
||||||
|
driver actually behaves that way during a promotion or a resharding is only observable on a real
|
||||||
|
topology.
|
||||||
|
|
||||||
|
## 6. Rollback
|
||||||
|
|
||||||
|
Before the upgrade, record the previous server version, the previous Lettuce version, and the
|
||||||
|
`SCRIPT LOAD` digests of every registered script. A rollback is not complete until the digests
|
||||||
|
resolve again on the restored version.
|
||||||
|
|
||||||
|
*Why:* digests are cached per process and invalidated by `SCRIPT FLUSH` and by restarts. A rollback
|
||||||
|
that leaves a process holding digests the restored server does not know produces `NOSCRIPT` on
|
||||||
|
every scripted call until the cache is dropped.
|
||||||
|
|
||||||
|
## What this gate does not cover
|
||||||
|
|
||||||
|
Data migration. Nothing here moves or reshapes stored values; a change that alters what is stored,
|
||||||
|
rather than how it is addressed, needs its own plan.
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# Repository owner test: dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest
|
||||||
|
# Owner Gradle path: :app-bootstrap:test
|
||||||
|
# Semantic owner test: dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
|
||||||
|
# Semantic owner Gradle path: :adapter:outbound:objectstorage:test
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
claims:
|
claims:
|
||||||
- card_id: object-storage-managed-upload-single
|
- card_id: object-storage-managed-upload-single
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ Sentinel은 asynchronous replication의 zero-data-loss나 strong consistency를
|
|||||||
`min-replicas-to-write`, lag bound, replica acknowledgement가 설정돼도 acknowledgement 결과가
|
`min-replicas-to-write`, lag bound, replica acknowledgement가 설정돼도 acknowledgement 결과가
|
||||||
불명확한 mutation은 여전히 `INDETERMINATE`다.
|
불명확한 mutation은 여전히 `INDETERMINATE`다.
|
||||||
|
|
||||||
|
`min-replicas-to-write 1` + `min-replicas-max-lag 1`은 선택이 아니라 **필수**다. 미설정 시
|
||||||
|
promotion 중 교체된 구 primary가 계속 `+OK`를 반환하고 그 write는 resync에서 폐기된다. 7.4
|
||||||
|
레인 실측: 승격 후 강등까지 11초, 그 사이 **2,086건이 acknowledge된 뒤 소실**, 실패한 명령은
|
||||||
|
1건. 클라이언트는 이를 감지할 수단이 없다 — 서버가 응답했으므로 driver·SDK·호출자 모두
|
||||||
|
정상 성공으로 기록한다. 설정 후 동일 promotion에서 소실 1건, 나머지 2,020건은 `NOREPLICAS`로
|
||||||
|
명시 거부됐다. 근거: `docs/redis/operations.md`, `LiveRedisSentinelPromotionTest`.
|
||||||
|
|
||||||
### Disposable Multipass k3s qualification safety
|
### Disposable Multipass k3s qualification safety
|
||||||
|
|
||||||
qualification lab은 host k3s incident 조치 도구가 아니다. VM exact allowlist는
|
qualification lab은 host k3s incident 조치 도구가 아니다. VM exact allowlist는
|
||||||
|
|||||||
@@ -39,4 +39,6 @@ status: <stub|active>
|
|||||||
> **Note**: This is the canonical runbook template.
|
> **Note**: This is the canonical runbook template.
|
||||||
> Copy this file, rename it to match the `runbook://area/scenario` pattern (→ `area-scenario.md`),
|
> Copy this file, rename it to match the `runbook://area/scenario` pattern (→ `area-scenario.md`),
|
||||||
> fill in the frontmatter fields, replace section bodies with operational content,
|
> fill in the frontmatter fields, replace section bodies with operational content,
|
||||||
> then set `status: active` and remove from `STUB_ALLOWLIST` in `RunbookCoverageContractTest`.
|
> then set `status: active`. `LEGACY_STUB_DEBT` in `RunbookCoverageContractTest` is temporary
|
||||||
|
> containment for existing debt only; do not add a new stub there. Complete the runbook or adopt
|
||||||
|
> the future owned, expiring debt ledger.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
|
# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
|
||||||
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
||||||
# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange
|
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
||||||
/api/healthcheck
|
/api/healthcheck
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
# Release Hygiene Refactoring Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make every release-hygiene path truthful by fixing the sample-off architecture gate, aligning the Gradle 9.0.0 wrapper and CI validation, making Docker cache stages valid without `.git`, completing SpotBugs analysis classpaths, and removing the observed Gradle 10 deprecation.
|
||||||
|
|
||||||
|
**Architecture:** Leaf-specific architecture rules move to their owning leaf while root tests remain cross-module. Build inputs become explicit: Docker copies registry inputs, evidence-only Git validation executes only in evidence tasks, wrapper bytes/checksums are fixed, and SpotBugs derives auxiliary inputs from the source set it analyzes.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle 9.0.0 Groovy DSL, ArchUnit 1.3.0, SpotBugs Gradle plugin 6.5.6/SpotBugs 4.10.2, Bash, Docker/BuildKit, GitHub Actions.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preserve all 19 leaf identities and production dependency edges from `src/config/architecture/modules.json`.
|
||||||
|
- `domain-core` and `application-core` gain no framework, transport, database, or cloud dependency.
|
||||||
|
- Do not weaken an architecture rule with a global `allowEmptyShould(true)`.
|
||||||
|
- Keep Gradle at exactly `9.0.0` in this plan.
|
||||||
|
- Set `distributionSha256Sum=8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b`.
|
||||||
|
- The official Gradle 9.0.0 wrapper JAR SHA-256 is `76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3`.
|
||||||
|
- Pin `gradle/actions/wrapper-validation` to commit `3f131e8634966bd73d06cc69884922b02e6faf92` in workflows that invoke Gradle.
|
||||||
|
- Docker images do not receive `.git`; full evidence revisions arrive through `-PgitRevision`/CI attestation.
|
||||||
|
- SpotBugs dependency scopes are not widened to silence missing-class output.
|
||||||
|
- Agents do not stage, commit, amend, or push; commit steps from the generic workflow are replaced by diff/status evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Move the Object Storage Architecture Rule to Its Owning Leaf
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageArchitectureTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/build.gradle`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/gradle.lockfile`
|
||||||
|
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java:1586-1608`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: production classes under `dev.caskeleton.adapter.outbound.objectstorage..` and application/shared contracts already on the Object Storage test classpath.
|
||||||
|
- Produces: an owner-local ArchUnit rule named `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES`; a sample-off root suite with no Object Storage presence requirement.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reproduce the existing failing regression**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :app-bootstrap:sampleOffTest --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL only at `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES` because no matching classes are present.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the owner-local test before removing the root rule**
|
||||||
|
|
||||||
|
Create a package-local ArchUnit test that imports production classes from the Object Storage package and applies this rule:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@AnalyzeClasses(packages = "dev.caskeleton.adapter.outbound.objectstorage")
|
||||||
|
class ObjectStorageArchitectureTest {
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES =
|
||||||
|
methods()
|
||||||
|
.that()
|
||||||
|
.areDeclaredInClassesThat()
|
||||||
|
.resideInAPackage("..adapter.outbound.objectstorage..")
|
||||||
|
.and()
|
||||||
|
.areDeclaredInClassesThat()
|
||||||
|
.haveSimpleNameEndingWith("Adapter")
|
||||||
|
.and()
|
||||||
|
.arePublic()
|
||||||
|
.and()
|
||||||
|
.areNotStatic()
|
||||||
|
.should()
|
||||||
|
.notHaveRawReturnType(
|
||||||
|
JavaClass.Predicates.resideInAnyPackage(
|
||||||
|
"..adapter.outbound..",
|
||||||
|
"..adapter.inbound.web..",
|
||||||
|
"..adapter.outbound.persistence.."))
|
||||||
|
.allowEmptyShould(false);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the owner-local test dependency:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh only the Object Storage leaf lock state with its existing `resolveAndLockAll --write-locks`
|
||||||
|
task. This is a test-scope dependency; do not add a production project or external dependency edge.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the owner test while the root regression remains red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test --tests '*ObjectStorageArchitectureTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS with matching production adapter methods.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove only the misplaced root rule**
|
||||||
|
|
||||||
|
Delete the `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES` field from `CleanArchitectureTest`; do not change neighboring cross-module rules.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify both ownership paths**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :adapter:outbound:objectstorage:test :app-bootstrap:sampleOffTest --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, zero failed tests.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Record diff evidence without committing**
|
||||||
|
|
||||||
|
Run `git diff --check` and `git status --short`; retain the output for the task review.
|
||||||
|
|
||||||
|
### Task 2: Align and Validate the Gradle 9.0.0 Wrapper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/scripts/verify-gradle-wrapper.sh`
|
||||||
|
- Modify: `src/gradle/wrapper/gradle-wrapper.properties`
|
||||||
|
- Regenerate: `src/gradle/wrapper/gradle-wrapper.jar`, `src/gradlew`, `src/gradlew.bat`
|
||||||
|
- Modify: `.github/workflows/ci-quality-gates.yml`
|
||||||
|
- Modify: `.github/workflows/dependency-vulnerability.yml`
|
||||||
|
- Modify: `.github/workflows/jpa-r2-evidence.yml`
|
||||||
|
- Modify: `.github/workflows/object-storage-qualification.yml`
|
||||||
|
- Modify: `.github/workflows/redis-production-readiness.yml`
|
||||||
|
- Lock without modification: `.github/workflows/link-check.yml`
|
||||||
|
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: repository root as argument 1, wrapper properties/JAR, and every YAML workflow under `.github/workflows`.
|
||||||
|
- Produces: executable `verify-gradle-wrapper.sh` with exit 0 only for the exact Gradle 9.0.0 wrapper, the reviewed six-file workflow path/SHA-256 lock, the repository's restricted canonical workflow grammar, and jobs where an unconditional pinned validation step gates every reachable Gradle invocation.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing executable-contract tests**
|
||||||
|
|
||||||
|
Add a `DeveloperExperienceContractTest` case that runs:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Process process =
|
||||||
|
new ProcessBuilder("bash", ".github/scripts/verify-gradle-wrapper.sh", REPOSITORY_ROOT.toString())
|
||||||
|
.directory(REPOSITORY_ROOT.toFile())
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start();
|
||||||
|
assertThat(process.waitFor()).as(new String(process.getInputStream().readAllBytes(), UTF_8)).isZero();
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a second case that copies wrapper properties/JAR and workflows to `@TempDir`, changes the distribution checksum, runs the script against that fixture root, and asserts a non-zero exit. The production mutation this test catches is accepting a wrong wrapper or distribution checksum.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `.github/scripts/verify-gradle-wrapper.sh` does not exist and the checked-in wrapper is not the Gradle 9.0.0 JAR.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the wrapper verifier**
|
||||||
|
|
||||||
|
The Bash script must:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. require exactly one repository-root argument;
|
||||||
|
2. require the exact ordered eight-line wrapper-properties file, including the Gradle 9.0.0 URL
|
||||||
|
and distribution checksum from Global Constraints;
|
||||||
|
3. reject duplicate, alternate-separator, escaped, continued, reordered, or extra properties;
|
||||||
|
4. compare the wrapper JAR SHA-256 with the exact Gradle 9.0.0 JAR hash;
|
||||||
|
5. enumerate every top-level `.yml`/`.yaml` workflow, reject symlinks/special files, and compare the
|
||||||
|
exact sorted six-path set and SHA-256 values to the verifier's embedded reviewed workflow lock;
|
||||||
|
additions, removals, renames, or byte changes are failures;
|
||||||
|
6. structurally validate the supported block grammar before admission and emit specific diagnostics
|
||||||
|
for recognized noncanonical `jobs`/job/`steps` containers, flow collections, aliases, anchors,
|
||||||
|
tags, merge keys, encoded or multiline action scalars, and quoted/escaped run scalars; YAML
|
||||||
|
semantics outside this deliberately partial diagnostic parser remain covered by the primary
|
||||||
|
byte lock rather than an overclaim of complete Bash YAML parsing;
|
||||||
|
7. require every Gradle-running job to order checkout, the exact wrapper-validation action with
|
||||||
|
stable `id: gradle-wrapper-validation`, and every Gradle invocation;
|
||||||
|
8. accept the validation step only with its exact canonical name/id/uses fields and no `if`,
|
||||||
|
`continue-on-error`, `with`, `env`, timeout, or other weakening field;
|
||||||
|
9. finalize every Gradle step, not only the first. A Gradle step may have no condition or exactly
|
||||||
|
`${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}`; bare `always()`,
|
||||||
|
failure/cancelled paths, `continue-on-error`, and other reachability expressions fail closed;
|
||||||
|
10. treat literal run-block body text only as shell data, never as an action field, and require each
|
||||||
|
raw Gradle reference admitted by the gate to resolve to a canonical job;
|
||||||
|
11. print `gradle-wrapper-contract: PASS` only when every check succeeds.
|
||||||
|
```
|
||||||
|
|
||||||
|
For an intentional workflow edit, review the complete workflow diff, verify that no workflow path
|
||||||
|
is a symlink/special file, regenerate the entire sorted `sha256sum` list with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 the complete sorted embedded array in the same reviewed change. Never refresh only the
|
||||||
|
failing digest as a build-unblock shortcut.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Regenerate the wrapper twice and add the distribution checksum**
|
||||||
|
|
||||||
|
Run in `src/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew wrapper --gradle-version 9.0.0 --distribution-type bin
|
||||||
|
./gradlew wrapper --gradle-version 9.0.0 --distribution-type bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the exact `distributionSha256Sum` property immediately after `distributionUrl`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the pinned validation action to every Gradle workflow job**
|
||||||
|
|
||||||
|
After each checkout step and before setup/cache/build invokes Gradle, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
id: gradle-wrapper-validation
|
||||||
|
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
|
```
|
||||||
|
|
||||||
|
Jobs without a Gradle invocation do not need the action. A sanitizer that intentionally executes
|
||||||
|
after a failed test must use the exact guarded condition shown above so wrapper-validation failure
|
||||||
|
still prevents Gradle. Preserve that behavior in Redis rather than using bare `always()`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify GREEN and mutation rejection**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .github/scripts/verify-gradle-wrapper.sh .
|
||||||
|
cd src
|
||||||
|
./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: script prints `gradle-wrapper-contract: PASS`; focused tests pass; executable mutations
|
||||||
|
reject checksum/property overrides, missing validation per job, named/anonymous/quoted/escaped and
|
||||||
|
continued action variants, encoded run scalars, block/alias/merge/flow YAML forms, validation-step
|
||||||
|
control fields, Gradle steps reachable after validation failure, custom-shell or alternate-wrapper
|
||||||
|
paths, duplicate encoded jobs, workflow additions/removals/symlinks, and otherwise innocuous byte
|
||||||
|
drift through the primary workflow lock.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Record diff evidence without committing**
|
||||||
|
|
||||||
|
Run `sha256sum src/gradle/wrapper/gradle-wrapper.jar`, `git diff --check`, and `git status --short`.
|
||||||
|
|
||||||
|
### Task 3: Make Docker Build Configuration Inputs Explicit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Dockerfile:39-66`
|
||||||
|
- Modify: `src/Dockerfile.sample:50-75`
|
||||||
|
- Modify: `src/build.gradle:2153-2181` and all Redis evidence consumers
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/build.gradle` (leaf evidence consumers)
|
||||||
|
- Modify: `src/app-bootstrap/build.gradle`
|
||||||
|
- Modify: `src/sample-portfolio/build.gradle`
|
||||||
|
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `config/**`, Gradle source/build files, `-PgitRevision`, and the `bootJar` archive provider.
|
||||||
|
- Produces: `:app-bootstrap:stageDockerJar` and `:sample-portfolio:stageDockerJar`, each writing exactly `build/docker/application.jar`; evidence metadata is resolved only when a Redis evidence task executes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing build-contract tests**
|
||||||
|
|
||||||
|
Add tests that split each Dockerfile at its first `RUN ./gradlew` and assert the preceding section
|
||||||
|
uses repository-preserving `WORKDIR /build/src` and contains `COPY config/ ./config/`. Add tests
|
||||||
|
that require the Dockerfiles to run `stageDockerJar` and copy the exact
|
||||||
|
`build/docker/application.jar`, with no `ls | grep | head` selection. Add a test that runs
|
||||||
|
`./gradlew help -PgitRevision=0123456789abcdef0123456789abcdef01234567` from a temporary Git-less
|
||||||
|
copy containing the same files as the dependency-cache stage. Add three self-contained evidence-task
|
||||||
|
fixtures under temporary repository roots: one uses a `.git` directory, one uses a worktree `.git`
|
||||||
|
metadata file, and one uses a dangling `.git` symlink. All prepend a fake `git` to `PATH` and require
|
||||||
|
the exact named failure for `rev-parse` or `status` process errors; the symlink fixture must also prove
|
||||||
|
the link entry exists with `NOFOLLOW_LINKS`. These tests must copy the minimum build/registry inputs
|
||||||
|
and invoke the fixture wrapper; they must not assert or execute the ambient checkout's `.git`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because neither cache stage copies `config/**`, both select JARs with shell matching, and Git is resolved during configuration.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add deterministic Docker staging tasks**
|
||||||
|
|
||||||
|
In both executable modules register:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
tasks.register('stageDockerJar', Sync) {
|
||||||
|
dependsOn tasks.named('bootJar')
|
||||||
|
from(tasks.named('bootJar').flatMap { it.archiveFile })
|
||||||
|
into(layout.buildDirectory.dir('docker'))
|
||||||
|
rename { 'application.jar' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update both Dockerfiles**
|
||||||
|
|
||||||
|
Use `WORKDIR /build/src` so repository-relative registry paths resolve under `/build/src/**`, copy
|
||||||
|
`config/` before the first Gradle invocation, invoke the correct `stageDockerJar` task with the
|
||||||
|
existing release/revision properties, and copy only the fixed `build/docker/application.jar` path
|
||||||
|
into the runtime stage.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Move Redis Git evidence resolution to execution time**
|
||||||
|
|
||||||
|
Replace the eager `String` values with closures/providers invoked from evidence task actions:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
Closure<Map<String, String>> resolveRedisSourceEvidence = {
|
||||||
|
File gitMetadata = rootProject.file('../.git')
|
||||||
|
if (!java.nio.file.Files.exists(
|
||||||
|
gitMetadata.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
String attested = providers.gradleProperty('gitRevision')
|
||||||
|
.orElse(providers.environmentVariable('GITHUB_SHA'))
|
||||||
|
.orElse(providers.environmentVariable('GIT_SHA'))
|
||||||
|
.getOrElse('')
|
||||||
|
if (!(attested ==~ /[0-9a-f]{40}/)) {
|
||||||
|
throw new GradleException(
|
||||||
|
'Redis evidence requires an exact 40-character source revision.')
|
||||||
|
}
|
||||||
|
return [revision: attested, treeState: 'ATTESTED']
|
||||||
|
}
|
||||||
|
|
||||||
|
String headFailure = 'Redis evidence failed to resolve checked-out Git HEAD.'
|
||||||
|
def headExecution
|
||||||
|
try {
|
||||||
|
headExecution = providers.exec {
|
||||||
|
commandLine 'git', 'rev-parse', 'HEAD'
|
||||||
|
ignoreExitValue = true
|
||||||
|
}
|
||||||
|
if (headExecution.result.get().exitValue != 0) {
|
||||||
|
throw new GradleException(headFailure)
|
||||||
|
}
|
||||||
|
} catch (GradleException exception) {
|
||||||
|
if (exception.message == headFailure) {
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
throw new GradleException(headFailure, exception)
|
||||||
|
}
|
||||||
|
String checkedOut = headExecution.standardOutput.asText.getOrElse('').trim()
|
||||||
|
if (!(checkedOut ==~ /[0-9a-f]{40}/)) {
|
||||||
|
throw new GradleException(headFailure)
|
||||||
|
}
|
||||||
|
String supplied = providers.gradleProperty('gitRevision')
|
||||||
|
.orElse(providers.environmentVariable('GITHUB_SHA'))
|
||||||
|
.orElse(providers.environmentVariable('GIT_SHA'))
|
||||||
|
.orElse(checkedOut)
|
||||||
|
.getOrElse('')
|
||||||
|
if (!(supplied ==~ /[0-9a-f]{40}/)) {
|
||||||
|
throw new GradleException('Redis evidence requires an exact 40-character source revision.')
|
||||||
|
}
|
||||||
|
if (!checkedOut.isBlank() && supplied != checkedOut) {
|
||||||
|
throw new GradleException('Redis evidence source revision does not match checked-out HEAD.')
|
||||||
|
}
|
||||||
|
|
||||||
|
String statusFailure = 'Redis evidence failed to inspect checked-out Git status.'
|
||||||
|
def statusExecution
|
||||||
|
try {
|
||||||
|
statusExecution = providers.exec {
|
||||||
|
commandLine 'git', 'status', '--porcelain', '--untracked-files=normal'
|
||||||
|
ignoreExitValue = true
|
||||||
|
}
|
||||||
|
if (statusExecution.result.get().exitValue != 0) {
|
||||||
|
throw new GradleException(statusFailure)
|
||||||
|
}
|
||||||
|
} catch (GradleException exception) {
|
||||||
|
if (exception.message == statusFailure) {
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
throw new GradleException(statusFailure, exception)
|
||||||
|
}
|
||||||
|
String treeState = statusExecution.standardOutput.asText.getOrElse('').isBlank()
|
||||||
|
? 'CLEAN'
|
||||||
|
: 'DIRTY'
|
||||||
|
[revision: supplied, treeState: treeState]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each evidence-producing root `doLast` and each leaf evidence test's root-suite `afterSuite` resolves
|
||||||
|
this once and uses the returned values for all generated/validated artifacts. The resolver is
|
||||||
|
exposed as `rootProject.ext.resolveRedisSourceEvidence`; eager scalar ext properties are removed.
|
||||||
|
Non-evidence tasks never call the closure. Any repository-root `.git` filesystem entry is detected
|
||||||
|
without following symbolic links, so a directory, worktree metadata file, or dangling symlink always
|
||||||
|
selects the checkout branch. Both Git processes must start, exit zero, and return valid evidence
|
||||||
|
before `CLEAN` or `DIRTY` can be emitted. `ATTESTED` is reserved for a truly absent `.git` entry in
|
||||||
|
an explicitly Git-less build with an exact supplied revision; a Git execution failure must never
|
||||||
|
fall back to it.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify GREEN without `.git` and verify evidence mismatch failure**
|
||||||
|
|
||||||
|
Run the focused contract test, `./gradlew help` in the Git-less fixture with a 40-character
|
||||||
|
`gitRevision`, and one Redis evidence task in the real checkout. The Git-less help invocation must
|
||||||
|
pass; a Git-less Redis evidence task with a short revision must fail with the named message. Separate
|
||||||
|
self-contained fixtures must cover a `.git` directory whose `rev-parse` fails, a `.git` worktree file
|
||||||
|
whose `status` fails, and a dangling `.git` symlink whose Git invocation fails. Each fixture must
|
||||||
|
assert the corresponding named fail-closed diagnostic instead of accepting a generic non-zero exit.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run actual Docker smoke when Docker is available**
|
||||||
|
|
||||||
|
Run both image builds with `--no-cache`. If Docker is unavailable, record the exact blocker and leave these commands as remaining risk; do not claim Docker success from string tests.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Record diff evidence without committing**
|
||||||
|
|
||||||
|
Run `git diff --check` and `git status --short`.
|
||||||
|
|
||||||
|
### Task 4: Complete SpotBugs Auxiliary Classpaths and Remove the Gradle 10 Warning
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/build.gradle:208-360`
|
||||||
|
- Modify: `src/build.gradle:1760-1795`
|
||||||
|
- Test/verify: app-bootstrap redisComposition, inbound GraphQL main, inbound gRPC main SpotBugs tasks
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: every leaf's `SourceSetContainer` and the SpotBugs task named for each source set.
|
||||||
|
- Produces: each SpotBugs task's `auxClassPaths` containing `sourceSet.runtimeClasspath - sourceSet.output` and a required XML report whose analysis errors/missing classes are checked after execution; `verifyApplicationCoreDependencyPurity` uses a configuration-time `Project` reference and declares its execution-time configuration traversal incompatible with the configuration cache.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Capture the failing static-analysis evidence**
|
||||||
|
|
||||||
|
Run clean focused SpotBugs tasks and save output. Expected RED messages name Spring Session, `io.micrometer.context.ContextSnapshot`, and protobuf types as classes needed for analysis.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Capture the Gradle 10 deprecation RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew verifyApplicationCoreDependencyPurity --warning-mode=fail --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL on execution-time `Task.project` access.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Configure source-set-derived auxiliary classpaths**
|
||||||
|
|
||||||
|
After applying SpotBugs in each leaf, configure:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
sourceSets.configureEach { sourceSet ->
|
||||||
|
String taskName = "spotbugs${sourceSet.name.capitalize()}"
|
||||||
|
tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) {
|
||||||
|
auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output)
|
||||||
|
def xmlAnalysisReport = reports.maybeCreate('xml')
|
||||||
|
xmlAnalysisReport.required.set(true)
|
||||||
|
doLast {
|
||||||
|
List<String> analysisFailures =
|
||||||
|
spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile)
|
||||||
|
if (!analysisFailures.isEmpty()) {
|
||||||
|
throw new GradleException(
|
||||||
|
"${path}: SpotBugs analysis incomplete:\n " +
|
||||||
|
analysisFailures.join('\n '))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add compile/runtime dependencies solely for SpotBugs. The XML parser fails on a missing or
|
||||||
|
malformed report, malformed `Errors` counts, any `MissingClass`, and any analysis `Error`; ordinary
|
||||||
|
`BugInstance` findings remain governed by the existing main/test severity policy. Wire an
|
||||||
|
executable `verifySpotBugsAnalysisFailureContract` fixture into every leaf `check` so clean and
|
||||||
|
advisory-bug-only reports pass while missing-class and analysis-error reports fail.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove execution-time project access**
|
||||||
|
|
||||||
|
Resolve `Project applicationCoreProject = project(':application-core')` before registering
|
||||||
|
`verifyApplicationCoreDependencyPurity`; capture that variable in `doLast` instead of calling
|
||||||
|
`project(...)` from the task action. Because the action still traverses project configurations at
|
||||||
|
execution time, declare
|
||||||
|
`notCompatibleWithConfigurationCache('Inspects project configurations at execution time')` rather
|
||||||
|
than making an unsupported compatibility claim.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify GREEN**
|
||||||
|
|
||||||
|
Run `verifySpotBugsAnalysisFailureContract`, the three clean focused SpotBugs tasks, and
|
||||||
|
`verifyApplicationCoreDependencyPurity --warning-mode=fail`. Expected: exit 0, XML
|
||||||
|
`Errors errors="0" missingClasses="0"`, and no missing-analysis-class/deprecation output.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run release-hygiene aggregate verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
./gradlew clean check :app-bootstrap:sampleOffTest verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --console=plain --warning-mode=fail
|
||||||
|
cd ..
|
||||||
|
bash .github/scripts/verify-gate-matrix.sh
|
||||||
|
bash .github/scripts/verify-gradle-wrapper.sh .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every command exits 0; no skipped mandatory gate, missing SpotBugs class, or Gradle deprecation.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Record final diff evidence without committing**
|
||||||
|
|
||||||
|
Run `git diff --check`, `git diff --stat`, and `git status --short`. Dispatch the complete diff for architecture/spec and code-quality review.
|
||||||
|
|
||||||
|
## Plan Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: every release-hygiene design decision maps to Tasks 1-4.
|
||||||
|
- Type consistency: both executable modules expose the same `stageDockerJar` task and output path; Redis evidence uses one `Map<String,String>` resolver contract.
|
||||||
|
- Architecture: no production dependency edge changes are required.
|
||||||
|
- Test discipline: each behavior has a named failing command or executable mutation fixture before implementation.
|
||||||
|
- Commit policy: all generic commit steps are replaced with diff/status evidence.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Client-Safe Error Boundary Implementation Plan
|
||||||
|
|
||||||
|
> **Execution:** Follow `superpowers:test-driven-development`; request an independent code review
|
||||||
|
> before advancing to the next P1 batch.
|
||||||
|
|
||||||
|
**Goal:** Ensure public HTTP error envelopes contain only allowlisted messages and bounded safe
|
||||||
|
metadata, never raw exceptions or request values.
|
||||||
|
|
||||||
|
**Architecture:** The inbound web adapter maps operational codes to fixed public messages. The
|
||||||
|
sample consumer owns a parallel domain-code mapping. Exception diagnostics stay behind the
|
||||||
|
transport boundary.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 4.0.0, JUnit 6/JUnit Jupiter, AssertJ, MockMvc.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Preserve all completed P0 and verification-purity changes in the dirty worktree.
|
||||||
|
- Preserve every error code/status/category/retryable value.
|
||||||
|
- Preserve safe protocol details and required headers.
|
||||||
|
- Do not leak request DTOs or transport types into application/domain.
|
||||||
|
- Do not stage, commit, amend, or push.
|
||||||
|
|
||||||
|
### Task 1: Operational Handler RED Contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandlerTest.java`
|
||||||
|
- Modify: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/TransportErrorHandlingTest.java`
|
||||||
|
- Create: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/NoResourceFoundErrorHandlingTest.java`
|
||||||
|
|
||||||
|
- [x] Add secret-sentinel tests for mapping, illegal argument, adapter disabled, authentication,
|
||||||
|
authorization, precondition, pagination, and cursor exceptions.
|
||||||
|
- [x] Add validation tests proving rejected values, interpolated/default messages, and iterable
|
||||||
|
keys/indices are absent while normalized fields plus allowlisted reason codes/fixed messages remain.
|
||||||
|
- [x] Add transport tests proving raw request URLs and content-type values are not echoed.
|
||||||
|
- [x] Add a real MVC resource-resolver test for a sentinel-bearing static-resource 404.
|
||||||
|
- [x] Run the focused tests and record RED against the current raw-message implementation (30 tests, 9 expected failures).
|
||||||
|
|
||||||
|
### Task 2: Operational Allowlist Implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java`
|
||||||
|
- Create: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeValidationDetails.java`
|
||||||
|
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java`
|
||||||
|
- Modify: `src/adapter/inbound/web/README.md`
|
||||||
|
|
||||||
|
- [x] Add code-specific fixed operational messages with a safe category fallback.
|
||||||
|
- [x] Replace every public `ex.getMessage()`/rejected-value/raw-URL path.
|
||||||
|
- [x] Discard validation message/value data, normalize field paths, strip iterable keys/indices, and
|
||||||
|
emit only allowlisted reason codes with fixed messages.
|
||||||
|
- [x] Route both `NoHandlerFoundException` and `NoResourceFoundException` through the same safe 404 envelope.
|
||||||
|
- [x] Retain safe field/reason/expected-type/supported-method/media-type details and `Allow`.
|
||||||
|
- [x] Run the operational/transport tests and confirm GREEN.
|
||||||
|
|
||||||
|
### Task 3: Sample Domain RED and Implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioClientSafeErrorMessages.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java`
|
||||||
|
|
||||||
|
- [x] Add ID/title/reason sentinel tests and confirm RED (4 expected failures).
|
||||||
|
- [x] Map every `PortfolioErrorCode` to fixed public text and use it from the advice.
|
||||||
|
- [x] Confirm code/status/category remain unchanged and sentinels are absent.
|
||||||
|
|
||||||
|
### Task 4: Focused and Architecture Verification
|
||||||
|
|
||||||
|
- [x] Run `./gradlew :adapter:inbound:web:test --console=plain`.
|
||||||
|
- [x] Run `./gradlew :sample-portfolio:test --console=plain`.
|
||||||
|
- [x] Run focused Spotless/Checkstyle/SpotBugs tasks for both modules.
|
||||||
|
- [x] Run `./gradlew verifyCleanArchitectureDependencies --console=plain`.
|
||||||
|
- [x] Run `git diff --check` and request an independent read-only review.
|
||||||
|
- [x] Apply the independent review findings and receive a no-Critical/no-Important code re-review;
|
||||||
|
align this design/plan with the final validation and resource-404 contract.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Conditional Inbound Transport Boundary Implementation Plan
|
||||||
|
|
||||||
|
> **Execution:** Apply TDD independently per transport, then run exact no-skip qualification and an
|
||||||
|
> independent read-only review before beginning P2 cleanup.
|
||||||
|
|
||||||
|
**Goal:** Make GraphQL, gRPC, and WebSocket opt-in status truthful, fail closed on unsafe activation,
|
||||||
|
and release-blocked by real protocol evidence without adding them to the default runtime.
|
||||||
|
|
||||||
|
### Task 1: Runtime Membership and Opt-In Composition
|
||||||
|
|
||||||
|
**Files:** `src/config/architecture/modules.json`, `src/settings.gradle`, `src/build.gradle`,
|
||||||
|
`src/app-bootstrap/build.gradle`, app-bootstrap conditional transport tests
|
||||||
|
|
||||||
|
- [ ] Add and fail-closed validate exact `runtime_memberships` for all 19 leaves.
|
||||||
|
- [ ] Compare registry membership to both composition roots' direct production project edges.
|
||||||
|
- [ ] Add an isolated conditional-transport test classpath containing all three opt-in leaves.
|
||||||
|
- [ ] Prove the default graphs omit them and the explicit qualification graph contains them.
|
||||||
|
|
||||||
|
### Task 2: gRPC Safe Activation and Wire Errors
|
||||||
|
|
||||||
|
**Files:** `src/adapter/inbound/grpc/**`
|
||||||
|
|
||||||
|
- [ ] Add RED tests for disabled bean/listener absence and safe property defaults/validation.
|
||||||
|
- [ ] Add real Netty feature RPC tests for auth success/failure and reflection disabled.
|
||||||
|
- [ ] Add RED tests for throw, `onError(ApiErrorCarrier)`, and raw status sentinel paths.
|
||||||
|
- [ ] Implement loopback-only explicit insecure mode, required feature authentication policy, and
|
||||||
|
`ServerCall.close` sanitization.
|
||||||
|
- [ ] Update dependencies, locks, README, and CLAUDE truthfully.
|
||||||
|
|
||||||
|
### Task 3: GraphQL Real HTTP Boundary
|
||||||
|
|
||||||
|
**Files:** `src/adapter/inbound/graphql/**`
|
||||||
|
|
||||||
|
- [ ] Add random-port HTTP tests for auth, CORS, GraphiQL/introspection policy, and health.
|
||||||
|
- [ ] Add carrier/unknown exception sentinels and assert absence from the complete JSON response.
|
||||||
|
- [ ] Change production resolver/config only where the RED wire contract proves necessary.
|
||||||
|
- [ ] Update dependencies, locks, README, and CLAUDE truthfully.
|
||||||
|
|
||||||
|
### Task 4: WebSocket Safe Activation and Wire Boundary
|
||||||
|
|
||||||
|
**Files:** `src/adapter/inbound/websocket/**`
|
||||||
|
|
||||||
|
- [ ] Add RED settings/disabled-context tests and real STOMP origin/auth/subscription tests.
|
||||||
|
- [ ] Add RED broker-send and ERROR-frame sentinel tests.
|
||||||
|
- [ ] Add RED no-projection/no-broadcast plus safe projection broadcast tests.
|
||||||
|
- [ ] Implement disabled default, validated settings, inbound authorization, safe error handler, and
|
||||||
|
explicit primitive projection allowlist.
|
||||||
|
- [ ] Update dependencies, locks, README, and CLAUDE truthfully.
|
||||||
|
|
||||||
|
### Task 5: Exact No-Skip Release Gate
|
||||||
|
|
||||||
|
**Files:** `src/build.gradle`, `.github/workflows/ci-quality-gates.yml`,
|
||||||
|
`.github/ci-gate-matrix.yml`, `.github/scripts/verify-gate-matrix.sh`, wrapper manifest contract
|
||||||
|
|
||||||
|
- [ ] Register exact per-transport Test lanes with no-match/no-discovery/zero-skip enforcement.
|
||||||
|
- [ ] Register the aggregate `conditionalTransportQualification` task.
|
||||||
|
- [ ] Invoke it explicitly from the release-blocking quality job and add the gate-matrix record.
|
||||||
|
- [ ] Add semantic tests that fail if any required lane or workflow invocation disappears.
|
||||||
|
|
||||||
|
### Task 6: Verification and Review
|
||||||
|
|
||||||
|
- [ ] Run each leaf `check`, exact qualification, app-bootstrap composition contract, dependency
|
||||||
|
locks, env keys, architecture, public path, wrapper validation, and `git diff --check`.
|
||||||
|
- [ ] Run full `test`/`check` in proportion to the cross-cutting registry/build changes.
|
||||||
|
- [ ] Request independent read-only review; resolve all Critical/Important findings.
|
||||||
|
- [ ] Capture the batch in the LLM Wiki before final completion reporting.
|
||||||
|
|
||||||
|
### Explicit P2 Deferral
|
||||||
|
|
||||||
|
- GraphQL feature schema, field auth, cost/depth, persisted queries, DataLoader, subscriptions.
|
||||||
|
- gRPC TLS/mTLS, external bind, proto compatibility, deadlines, streaming/backpressure.
|
||||||
|
- WebSocket broker relay, multi-node delivery, resume/replay, backpressure, versioned feature catalog.
|
||||||
|
- Transport dashboards, SLO alerts, and provider/ingress qualification.
|
||||||
|
# Implementation status
|
||||||
|
|
||||||
|
- Completed on 2026-08-02.
|
||||||
|
- Verified by `conditionalTransportQualification`: GraphQL 8, gRPC 15, WebSocket 5,
|
||||||
|
composition 1; skipped 0.
|
||||||
|
- Verified by the real CI gate-matrix validator and focused bypass regression tests.
|
||||||
|
- Independent review result: READY, Critical 0 / Important 0 / Minor 0.
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# P2 Verification Governance Refactoring Plan
|
||||||
|
|
||||||
|
## Batch 1 — strict owner-local qualification
|
||||||
|
|
||||||
|
- [x] Add TestKit RED cases for empty source sets, missing FQCNs, disabled-only tests, and one valid
|
||||||
|
test.
|
||||||
|
- [x] Add the shared strict qualification convention.
|
||||||
|
- [x] Move conditional transport and Messaging task registration from root to owner projects.
|
||||||
|
- [x] Adopt the convention for object-storage, Poster migration, and composition qualifications.
|
||||||
|
- [x] Keep root tasks as absolute-path aggregators and verify all evidence XML.
|
||||||
|
- [x] Run focused TestKit, every migrated qualification lane, locks, and independent review.
|
||||||
|
|
||||||
|
Evidence: eight TestKit cases passed fresh; conditional transport ran 8/15/5/1 tests and Messaging
|
||||||
|
ran 15/6/4/29/28 tests with zero skips. All dependency locks passed. Object-storage and Poster
|
||||||
|
required-class preflights passed; protected AWS and Docker-backed full lanes remain environment-
|
||||||
|
qualified. Independent review closed with no remaining Critical, Important, or Minor findings.
|
||||||
|
|
||||||
|
## Batch 2 — tracked contract resources hard-fail
|
||||||
|
|
||||||
|
- [x] Add RED tests proving absent tracked files/directories fail instead of aborting.
|
||||||
|
- [x] Add `RepositoryContractResources` and inject the canonical repository root.
|
||||||
|
- [x] Replace stale tracked-resource assumptions in the contract corpus.
|
||||||
|
- [x] Preserve assumptions only for genuinely optional external infrastructure.
|
||||||
|
- [x] Run focused representative contracts, scan for stale skip language, and run app-bootstrap
|
||||||
|
`check`.
|
||||||
|
|
||||||
|
Evidence (2026-08-02): the fail-closed repository resolver is covered by 11 boundary tests;
|
||||||
|
Runbook coverage and lock-classification contracts passed with zero skips. Independent review found
|
||||||
|
and closed both direct-link and directory-enumeration symlink escapes. A fresh
|
||||||
|
`./gradlew :app-bootstrap:check --no-daemon --console=plain` passed (77 tasks; 18 executed, 59
|
||||||
|
up-to-date), and the final Batch 2 review reported zero Critical, Important, or Minor findings.
|
||||||
|
|
||||||
|
## Batch 3 — real gate-matrix mutation tests
|
||||||
|
|
||||||
|
- [x] Add temporary-fixture tests that execute the shell validator itself.
|
||||||
|
- [x] Make the validator accept a repository-root argument without changing default CI behavior.
|
||||||
|
- [x] Delete the duplicated Java command parser.
|
||||||
|
- [x] Cover deceptive names, suppression flags, missing/duplicate gates, and missing task wiring.
|
||||||
|
- [x] Run the focused contract, real repository validator, and wrapper verifier.
|
||||||
|
|
||||||
|
Evidence (2026-08-02): the initial focused RED compiled and reported seven failing contracts against
|
||||||
|
the old validator. Independent review found arbitrary project-qualified task matching, shorthand
|
||||||
|
step parsing, generic `name:` registration, relocated-script guard evidence, unsafe custom refs,
|
||||||
|
missing `check` wiring evidence, and process-tree cleanup gaps; each was closed with a regression
|
||||||
|
test or bounded cleanup. A final regex-boundary audit also closed custom-task and plugin-ref ERE
|
||||||
|
injection with literal-safe grammars and fixed-string plugin lookup. The final focused contract
|
||||||
|
passed all 16 tests using bounded
|
||||||
|
`ProcessBuilder` execution of the real shell script. `bash .github/scripts/verify-gate-matrix.sh`
|
||||||
|
passed with 27 gates (26 verified and one explicitly delegated),
|
||||||
|
`bash .github/scripts/verify-gradle-wrapper.sh .` passed, `bash -n` and
|
||||||
|
`:app-bootstrap:spotlessJavaCheck` passed, and `git diff --check` reported no whitespace errors.
|
||||||
|
|
||||||
|
## Batch 4 — Redis manifest JSON Schema conformance
|
||||||
|
|
||||||
|
- [x] Add invalid-manifest RED fixtures for bounds, patterns, required fields, and extra fields.
|
||||||
|
- [x] Validate the canonical schema and all manifests with Draft 2020-12 semantics.
|
||||||
|
- [x] Retain Java-catalog equality checks for cross-resource invariants.
|
||||||
|
- [x] Run the focused schema test, cache-redis `check`, and dependency-lock verification.
|
||||||
|
|
||||||
|
Evidence (2026-08-02): the initial focused RED compile failed on the deliberately missing
|
||||||
|
`RedisProgramManifestSchemaValidator` (six `cannot find symbol` errors). NetworkNT 3.0.2 now
|
||||||
|
validates the canonical schema against its bundled Draft 2020-12 meta-schema and validates the
|
||||||
|
exact six closed manifests under strict parsing/configuration. Mutation coverage exercises
|
||||||
|
additional properties, type, required, enum, minimum/maximum, pattern, duplicate JSON keys, and
|
||||||
|
an independent cross-resource duplicate-program-id Java invariant. The first GREEN attempt exposed
|
||||||
|
that the canonical ACL pattern rejected the existing `SCRIPT|LOAD` command form; the pattern was
|
||||||
|
narrowly relaxed before independent review identified that it also admitted dangerous commands.
|
||||||
|
A second RED run failed exactly two tests because the schema had no exact allowlist and accepted
|
||||||
|
`FLUSHALL`, `CONFIG|SET`, and `MODULE|LOAD`. The six canonical manifests contain 265 ACL command
|
||||||
|
occurrences and exactly 37 unique commands; `aclCommands.items` now uses that exact enum so adding
|
||||||
|
a command requires an explicit schema change. Review coverage also rejects a trailing manifest
|
||||||
|
JSON token and a duplicate schema key on the compile path, and pins invalid meta-schema diagnostics
|
||||||
|
to `/type:type`. Final verification passed:
|
||||||
|
`./gradlew :adapter:outbound:cache-redis:test --tests '*RedisProgramManifestContractTest' --console=plain`
|
||||||
|
(12 tests), `./gradlew :adapter:outbound:cache-redis:test
|
||||||
|
:adapter:outbound:cache-redis:spotlessJavaCheck --console=plain`,
|
||||||
|
`./gradlew :adapter:outbound:cache-redis:verifyDependencyLocks
|
||||||
|
:adapter:outbound:cache-redis:spotlessCheck --console=plain`, and
|
||||||
|
`./gradlew :adapter:outbound:cache-redis:check --console=plain`. The owner lock gained only
|
||||||
|
`com.networknt:json-schema-validator:3.0.2` and `com.ethlo.time:itu:1.14.0`; no
|
||||||
|
`tools.jackson.dataformat:jackson-dataformat-yaml` entry is present. `git diff --check` passed.
|
||||||
|
The configured owner `check` remained successful while its SpotBugs test report retained one
|
||||||
|
pre-existing `DMI_RANDOM_USED_ONLY_ONCE` finding in `RedisPrimitiveRuntimeServiceTest`; the new
|
||||||
|
schema validator and contract test introduced no SpotBugs finding.
|
||||||
|
|
||||||
|
## Batch 5 — registry and runbook governance
|
||||||
|
|
||||||
|
- [x] Enforce an exact catalog for every tracked registry, including object-storage readiness.
|
||||||
|
- [ ] Resolve every stable `required_test` ID exactly once and reject dangling mappings.
|
||||||
|
- [ ] Replace the Java runbook stub allowlist with owned, issue-linked, expiring debt data.
|
||||||
|
- [ ] Clarify tracked registry ownership and private-wiki provenance.
|
||||||
|
- [x] Run schema, object-storage readiness, runbook, app-bootstrap, and root checks. The checks
|
||||||
|
exercise the mechanically enforceable catalog/containment rules; the three semantic migrations
|
||||||
|
above remain explicitly blocked on project-owner evidence.
|
||||||
|
|
||||||
|
### Batch 5-A evidence — exact tracked registry catalog (2026-08-02)
|
||||||
|
|
||||||
|
The owner catalog now enumerates exactly eight regular, non-symlink direct children: seven
|
||||||
|
universal contract registries plus the specialized object-storage readiness registry. The initial
|
||||||
|
focused RED failed compilation on the deliberately absent `RegistryGovernanceCatalog` (13 symbol
|
||||||
|
errors). A second exact-version mutation RED proved that numeric coercion admitted
|
||||||
|
`schema_version: 1.5`; the implementation now requires the integer value `1`. Strict SnakeYAML
|
||||||
|
safe construction disables duplicate keys and aliases, enforces exact root keys, a non-empty list
|
||||||
|
of map rows, non-blank unique identities, the existing universal row policy, and the specialized
|
||||||
|
owner delegation/provenance policy. Missing, unknown, non-regular, symlinked, malformed, duplicate,
|
||||||
|
false-provenance, block-scalar spoofing, reordered-header, and fabricated-branch-header fixtures
|
||||||
|
fail closed.
|
||||||
|
|
||||||
|
Gradle declares `docs/registries` as a relative-path-sensitive `:app-bootstrap:test` directory
|
||||||
|
input. The object-storage owner declares its canonical readiness YAML as a relative-path-sensitive
|
||||||
|
file input and passes its absolute path through `objectstorage.readiness.registry`; its leaf test no
|
||||||
|
longer searches parent directories. The tracked specialized registry header is exactly four
|
||||||
|
ordered leading comment lines containing only the factual repository and semantic owner Gradle
|
||||||
|
paths and test FQCNs.
|
||||||
|
|
||||||
|
Fresh verification passed:
|
||||||
|
|
||||||
|
- `./gradlew :app-bootstrap:test --tests
|
||||||
|
dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest --console=plain`
|
||||||
|
- `./gradlew :adapter:outbound:objectstorage:test --tests
|
||||||
|
dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
|
||||||
|
--console=plain`
|
||||||
|
- `./gradlew :app-bootstrap:test --console=plain` (38 tasks; 2 executed)
|
||||||
|
- `./gradlew :app-bootstrap:check --console=plain` (77 tasks; 21 executed)
|
||||||
|
- `./gradlew :app-bootstrap:spotlessJavaCheck
|
||||||
|
:adapter:outbound:objectstorage:spotlessJavaCheck --console=plain`
|
||||||
|
- `git diff --check`, an exact direct-child regular-file audit, the owner-path `jq` audit, and
|
||||||
|
`yq eval 'true' docs/registries/*.yaml` (eight parsed documents)
|
||||||
|
|
||||||
|
### Batch 5-C partial containment evidence — legacy runbook stub debt (2026-08-02)
|
||||||
|
|
||||||
|
This is bounded containment, not completion of the owned, issue-linked, expiring debt-ledger item
|
||||||
|
above. The Java set is now named `LEGACY_STUB_DEBT`, contains exactly the 43 current
|
||||||
|
`status: stub` runbooks, and is checked bidirectionally against canonical tracked runbook files.
|
||||||
|
The stale `migration-failed.md` entry was removed because that runbook is already active. Active,
|
||||||
|
missing, template, and newly introduced stub drift now fail the same exact-set contract. Messages
|
||||||
|
and the runbook template forbid adding new legacy allowlist entries and direct maintainers to
|
||||||
|
complete the runbook or adopt the future governed ledger.
|
||||||
|
|
||||||
|
The focused RED failed only because `migration-failed.md` was an unexpected legacy-debt element.
|
||||||
|
After the containment change, the focused Runbook contract passed with 6 tests, zero failures, and
|
||||||
|
zero skips. Fresh verification also passed `:app-bootstrap:spotlessJavaCheck` and
|
||||||
|
`:app-bootstrap:check` (77 tasks; 18 executed, 59 up-to-date). Owner, issue, start/sunset,
|
||||||
|
expiry enforcement, and the private-wiki provenance migration remain deliberately incomplete and
|
||||||
|
the corresponding Batch 5 checkboxes remain open.
|
||||||
|
|
||||||
|
### Batch 5-B/C unresolved semantic migrations audit (2026-08-02)
|
||||||
|
|
||||||
|
These items are intentionally not marked complete. The seven universal registries contain 324
|
||||||
|
non-reference `required_test` occurrences and 216 unique IDs. There is no tracked selector
|
||||||
|
catalog, no Gradle declaration containing those IDs, and no ID that can currently be proven to
|
||||||
|
resolve to one exact module/task/class/method selector. Exact Java test-source literals cover only
|
||||||
|
17 IDs (45 occurrences, 40 in comments/Javadocs); 199 IDs have no exact source literal. Creating
|
||||||
|
216 selectors from namespaces or historical branch labels would manufacture execution evidence,
|
||||||
|
so the exact-linkage gate requires semantic owner confirmation or new tests before it can be
|
||||||
|
enabled.
|
||||||
|
|
||||||
|
The runbook corpus contains 43 stub documents, all with response owner `oncall` but no accountable
|
||||||
|
debt owner, real issue, approved expiry, or bounded debt window. The seven legacy registries contain
|
||||||
|
30 distinct `owner_branch` labels, none resolving to a current local/remote Git ref, while their
|
||||||
|
private-wiki paths are absent from a fresh clone. The repository files are now protected as the
|
||||||
|
tracked artifacts, but current owner IDs, historical-label migration, CODEOWNERS identities,
|
||||||
|
runbook expiry dates, the `INTERNAL_ERROR` reverse-link decision, and the four umbrella-runbook
|
||||||
|
retention decisions require real project-owner input. Placeholder owners, issues, selectors, and
|
||||||
|
sunsets were not added to make the checks pass.
|
||||||
|
|
||||||
|
## Batch 6 — bounded P2 cleanup
|
||||||
|
|
||||||
|
- [x] Extend link-check triggers and scan scope to module README/CLAUDE documents.
|
||||||
|
- [x] Make Poster migration gate labels version-neutral while preserving externally stable job IDs.
|
||||||
|
- [x] Replace fixed HTTP timeout sleeps with deterministic latch-controlled handlers.
|
||||||
|
- [x] Separate sample-off compile evidence from its minimal runtime proof if exact required tests can
|
||||||
|
be established without weakening coverage.
|
||||||
|
- [x] Run focused docs, CI, HTTP client, sample-off, and wrapper checks.
|
||||||
|
|
||||||
|
Batch 6 link/Poster evidence: test-first changes made the two focused app-bootstrap contracts fail
|
||||||
|
only for the absent module documentation scope and the legacy Poster V7 internal gate ID. The same
|
||||||
|
contracts then passed with exact pull/push/lychee scope, all 27 gate IDs, and the stable external
|
||||||
|
`poster-image-v7-migration` workflow job plus `posterImageMigrationTest` task mapping. The full
|
||||||
|
`DeveloperExperienceContractTest` and `ConditionalTransportQualificationContractTest` classes
|
||||||
|
passed, `posterImageMigrationTest` produced 4 tests with zero skips, and both the 27-entry gate
|
||||||
|
validator and Gradle wrapper verifier passed. The complete sorted six-workflow SHA-256 lock was
|
||||||
|
refreshed after review; app-bootstrap Java and sample-portfolio Spotless checks also passed. An
|
||||||
|
independent Batch 6 link/Poster read-only review found no Critical, Important, or Minor issues.
|
||||||
|
|
||||||
|
Batch 6 HTTP evidence: the focused synchronization contract first failed on exactly five fixed
|
||||||
|
sleeps across `OutboundHttpClientTest` (one), `OutboundHttpClientDeadlineTest` (one), and
|
||||||
|
`OutboundCallExecutorTest` (three). The HTTP handlers now signal `requestStarted`, await a bounded
|
||||||
|
`releaseResponse` latch, and are released in the caller's `finally` after the timeout result and
|
||||||
|
classification assertions. Executor workers now block on a bounded latch interruption point, with
|
||||||
|
the existing started/interrupted evidence and caller cleanup preserved. The four focused classes
|
||||||
|
passed 25 tests with zero failures, errors, or skips. A 3-second read-timeout mutation failed when
|
||||||
|
the handler's 1-second HTTP 204 fallback completed successfully, proving that the test cannot pass
|
||||||
|
via the separate 5-second logical deadline. The full owner `test` passed, and
|
||||||
|
`:adapter:outbound:httpclient:check` passed 29 tasks (16 executed, 13 up-to-date), including
|
||||||
|
Spotless, Checkstyle, SpotBugs, architecture dependencies, and environment-key verification. No
|
||||||
|
production source changed. Independent re-review found no remaining Critical, Important, or Minor
|
||||||
|
issues and found no cleanup leak or deadlock race.
|
||||||
|
|
||||||
|
Batch 6 sample-off evidence: the focused build contract first failed because the dedicated source
|
||||||
|
directory, compile lifecycle task, strict registration, and required FQCN did not exist. The
|
||||||
|
`sampleOffTest` source set now compiles all 204 ordinary test sources plus the dedicated contract
|
||||||
|
without `sample-portfolio`, while `sampleOffCompile` exposes that complete compile proof separately.
|
||||||
|
The externally stable `sampleOffTest` task is registered through the shared strict qualification
|
||||||
|
convention and executes only `SampleOffClasspathContractTest`; fresh XML reported exactly 1 test,
|
||||||
|
0 skipped, 0 failures, and 0 errors. The existing eight strict-convention functional contracts
|
||||||
|
passed, including missing-class, no-discovery, skip, and stale-evidence fail-closed cases. The
|
||||||
|
focused build contract, `sampleOffCompile`, gate-matrix validator, wrapper verifier, dependency-lock
|
||||||
|
verification, Spotless, and the full `:app-bootstrap:check` also passed; the full check completed 78
|
||||||
|
tasks (23 executed, 55 up-to-date). This is focused/owner evidence; the repository-wide Batch 6
|
||||||
|
aggregate is recorded below.
|
||||||
|
|
||||||
|
Batch 6 repository evidence (2026-08-02): the real gate-matrix validator passed all 27 entries
|
||||||
|
(26 locally verified and the protected AWS lane explicitly delegated-pending), the Gradle-wrapper
|
||||||
|
contract passed, `bash -n .github/scripts/verify-gate-matrix.sh` passed, all eight tracked registry
|
||||||
|
YAML documents parsed, the Redis Draft 2020-12 schema parsed as JSON, and `git diff --check`
|
||||||
|
reported no whitespace errors. The first repository `check` exposed a 503 in the first
|
||||||
|
`JwtJwksSecurityFilterIntegrationTest` request while static-analysis workers were running. The
|
||||||
|
single test passed in isolation, identifying a test-fixture scheduling race rather than a JWT
|
||||||
|
classification mismatch. The embedded OIDC server now owns a dedicated single daemon executor and
|
||||||
|
shuts it down in `close()`; the full eight-test security-boundary lane plus Checkstyle and Spotless
|
||||||
|
passed, and a fresh repository `check` subsequently passed with the same boundary lane included.
|
||||||
|
|
||||||
|
## Final verification and capture
|
||||||
|
|
||||||
|
- [x] Run full Gradle tests/checks and all repository validators.
|
||||||
|
- [x] Request an independent P2 code review, resolve actionable findings, and record semantic
|
||||||
|
blockers separately.
|
||||||
|
- [x] Update the LLM Wiki branch note and any honest derived raw documents.
|
||||||
|
|
||||||
|
Fresh aggregate evidence (2026-08-02):
|
||||||
|
|
||||||
|
- `./gradlew test --no-daemon --console=plain` — successful in 4m 24s (86 tasks).
|
||||||
|
- `./gradlew check --no-daemon --console=plain` — first run failed only on the OIDC test-fixture
|
||||||
|
race above; after the bounded fixture correction, successful in 4m 35s (260 tasks).
|
||||||
|
- Final post-review `./gradlew check --no-daemon --console=plain` — successful in 10m 33s
|
||||||
|
(260 tasks; 76 executed, 184 up-to-date). It regenerated the SampleRemoval result after the
|
||||||
|
source edit: 5 tests, zero skipped/failures/errors.
|
||||||
|
- `./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership
|
||||||
|
verifyDependencyLocks verifyPublicPathSnapshot verifyEnvKeys --no-daemon --console=plain` —
|
||||||
|
successful (23 tasks); all 19 leaf locks passed and two runtime compositions matched the registry.
|
||||||
|
- Real gate-matrix, wrapper, shell syntax, Redis JSON, registry YAML, and diff validators — all
|
||||||
|
successful; the protected AWS qualification remains explicitly delegated to its environment.
|
||||||
|
- Final `verifyDependencyLocks` rerun — successful in 24s with all 19 leaf tasks executed. The
|
||||||
|
tracked-file assumption audit now reports only four Docker/Testcontainers integration
|
||||||
|
assumptions; no registry or repository-contract assumption remains.
|
||||||
|
|
||||||
|
LLM Wiki capture evidence (2026-08-02): `raw/branch-notes/main.md` records the integrated P1/P2
|
||||||
|
implementation, decisions, validation commands, failures, evidence grades, and unresolved semantic
|
||||||
|
migrations. It links bidirectionally to one resolved error note, one interview-prep note, and one
|
||||||
|
blog-topic note. The vault's targeted structure lint passed all three derived documents. The branch
|
||||||
|
note passed its content, frontmatter, required-section, and wikilink checks but retained one explicit
|
||||||
|
`NAMING_VIOLATION`: repository policy requires `<branch-name>.md` (`main.md`) while the vault naming
|
||||||
|
rule permits only `feature|fix|chore|experiment-` branch-note prefixes. Neither policy was silently
|
||||||
|
weakened; the exact conflict is the recorded capture-validation blocker.
|
||||||
|
|
||||||
|
Independent aggregate review evidence (2026-08-02): the first pass reported zero critical,
|
||||||
|
three important, and two minor findings. Wiki capture closed the capture-pending finding; the two
|
||||||
|
remaining important items were reclassified as the three project-semantic blockers already kept
|
||||||
|
open in Batch 5. The two minor code findings were corrected with an exact test-fixture-only
|
||||||
|
GraphQL SpotBugs exclusion and registry-derived scanning of all 18 production leaves in
|
||||||
|
`SampleRemovalSmokeContractTest`. A follow-up audit also found and removed the last tracked-file
|
||||||
|
assumption/upward-directory search in `PortfolioErrorCodeRegistryMappingTest`, replacing it with a
|
||||||
|
canonical repository-root property, relative Gradle input, and missing-root/symlink-escape
|
||||||
|
fail-closed checks. The re-review found no new code defect; its only completion-evidence concern
|
||||||
|
was a stale SampleRemoval XML, addressed by the final repository `check` after these corrections.
|
||||||
|
The reviewer retained only the Wiki naming-policy disclosure and this Batch 5 checkbox wording as
|
||||||
|
minor documentation findings; both are now explicit here and in the branch note.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Redis Session HTTP Boundary Implementation Plan
|
||||||
|
|
||||||
|
> **Execution:** Follow test-driven development and request an independent read-only review before
|
||||||
|
> advancing to the remaining P1 work.
|
||||||
|
|
||||||
|
**Goal:** Prove browser-session security persists and fails closed across the real Spring Session ↔
|
||||||
|
Redis composition, without silent skips.
|
||||||
|
|
||||||
|
**Architecture:** The app-bootstrap composition test reuses its existing Redis test source set and
|
||||||
|
dependencies. It assembles inbound-web and cache-redis without adding a forbidden leaf-to-leaf edge.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 4.0.0, Spring Security 7, Spring Session 4, Testcontainers 2,
|
||||||
|
Redis 7.4 digest-pinned image, MockMvc, Gradle 9.
|
||||||
|
|
||||||
|
### Task 1: Explicit Docker No-Skip Gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app-bootstrap/build.gradle`
|
||||||
|
|
||||||
|
- [x] Exclude `redis-session-http` from ordinary `redisCompositionTest`.
|
||||||
|
- [x] Register `redisSessionHttpIntegrationTest` over the same source output/classpath with tag
|
||||||
|
inclusion, no-discovery failure, no-skip root-suite guard, UTC, rerun, and image-registry property.
|
||||||
|
- [x] Keep the Docker task outside ordinary `check`; reuse Spring Session 4.0.0 and lock only the
|
||||||
|
added `redisCompositionTestCompileClasspath` configuration.
|
||||||
|
|
||||||
|
### Task 2: Real Session HTTP RED Contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app-bootstrap/src/redisCompositionTest/java/dev/caskeleton/adapter/outbound/cache/redis/RedisSessionHttpBoundaryIntegrationTest.java`
|
||||||
|
|
||||||
|
- [x] Load and validate the approved digest-pinned Redis image; explicitly start the container.
|
||||||
|
- [x] Generate ephemeral TLS/ACL/password/HMAC material and assemble canonical SESSION-role
|
||||||
|
configuration with full hostname verification and explicit trust.
|
||||||
|
- [x] Cross CSRF, login, Spring Session filter, primitive snapshot, and hardened cookie creation.
|
||||||
|
- [x] Close context A and prove context B restores the authenticated principal from Redis.
|
||||||
|
- [x] Prove logout/tombstone rejects the old cookie and a stale repository save.
|
||||||
|
- [x] Stop Redis during lookup and prove fail-closed controller behavior with fixed diagnostics.
|
||||||
|
- [x] Record and resolve RED composition mismatches: response-commit session creation and framework
|
||||||
|
request-cache serialization.
|
||||||
|
|
||||||
|
### Task 3: CI Release Gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/ci-quality-gates.yml`
|
||||||
|
|
||||||
|
- [x] Add `:app-bootstrap:redisSessionHttpIntegrationTest` to the existing `redis-standalone` job.
|
||||||
|
- [x] Keep the existing required gate identity and matrix dependency unchanged.
|
||||||
|
|
||||||
|
### Task 4: Verification and Review
|
||||||
|
|
||||||
|
- [x] Run the explicit HTTP task and existing app-bootstrap Redis composition task.
|
||||||
|
- [x] Run the selected cache-redis session capability lane, dependency locks, env keys, architecture,
|
||||||
|
public-path snapshot, static analysis, and `git diff --check`.
|
||||||
|
- [x] Request an independent read-only review and resolve all Critical/Important findings.
|
||||||
|
|
||||||
|
### Verification Evidence
|
||||||
|
|
||||||
|
- `:app-bootstrap:redisSessionHttpIntegrationTest`: 1 test, 0 skipped, GREEN.
|
||||||
|
- `:adapter:outbound:cache-redis:redisSessionCapabilityTest`: GREEN with sanitized evidence.
|
||||||
|
- `:adapter:inbound:web:check`: unit/contract/static analysis and 13 no-skip JWT/CORS boundary
|
||||||
|
tests GREEN.
|
||||||
|
- `:app-bootstrap:check :app-bootstrap:redisCompositionTest`: 640 bootstrap tests (6 pre-existing
|
||||||
|
conditional Docker skips in the ordinary suite, not used as this gate's evidence), TestKit
|
||||||
|
contracts, 14 Redis composition tests, Checkstyle, SpotBugs, and Spotless GREEN.
|
||||||
|
- `verifyDependencyLocks verifyEnvKeys verifyCleanArchitectureDependencies
|
||||||
|
verifyPublicPathSnapshot`: GREEN for all 19 registered leaves.
|
||||||
|
- Review RED: final context reconciliation could retain the authentication saved at response commit;
|
||||||
|
host TLS/ACL material permissions were too broad; the CI task lacked a semantic workflow assertion.
|
||||||
|
- Review fixes: authoritative final empty/replacement context tests went RED then GREEN, async start
|
||||||
|
defers commit-hook persistence, host material is `0700`/`0600` and copied selectively into the
|
||||||
|
fixture, and the blocking Redis job is now asserted directly.
|
||||||
|
- Independent re-review: Critical 0, Important 0, Minor 0; batch READY.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Verification Purity Refactoring Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make stale-JAR and public-path verification strictly read-only while preserving explicit cleanup/update workflows.
|
||||||
|
|
||||||
|
**Architecture:** Extract only these two root Gradle concerns into applied scripts so the production tasks can be exercised by isolated Gradle TestKit fixtures. Verification tasks only observe and fail; `clean*` and `update*` tasks are the sole writers.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Gradle 9.0.0 Groovy DSL, Gradle TestKit, JUnit 5, AssertJ.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preserve all existing P0 changes in the dirty worktree.
|
||||||
|
- Preserve the 19-leaf registry and every production project dependency edge.
|
||||||
|
- Normal archive tasks and every `verify*` task must be read-only.
|
||||||
|
- `updatePublicPathSnapshot` requires `-PapprovePublicPathChange`.
|
||||||
|
- Agents do not stage, commit, amend, or push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Functional RED Contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java`
|
||||||
|
- Modify: `src/app-bootstrap/build.gradle`
|
||||||
|
- Modify: `src/app-bootstrap/gradle.lockfile`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: production scripts at `src/gradle/archive-hygiene.gradle` and `src/gradle/public-path-snapshot.gradle`.
|
||||||
|
- Produces: functional tests that execute real Gradle tasks and assert filesystem side effects.
|
||||||
|
|
||||||
|
- [x] Add an isolated `functionalTest` source set/task and its `functionalTestImplementation gradleTestKit()` dependency so Gradle's SLF4J provider cannot pollute ordinary tests.
|
||||||
|
- [x] Add a nested temporary archive fixture with root + `family:module` projects. Apply the production archive script, pre-create a stale traceable JAR and a nonmatching JAR, run `:family:module:jar`, `verifyNoStaleTraceableJars`, and `cleanStaleTraceableJars`, and assert exact preservation/deletion plus the full task-path diagnostic.
|
||||||
|
- [x] Add a temporary public-path fixture. Apply the production public-path script and assert missing/drifted snapshots are not written, the verifier rejects `-PapprovePublicPathChange`, and only the approved updater writes canonical content.
|
||||||
|
- [x] Confirm the contracts RED before the two production scripts exist. The first RED run used the ordinary test source set; after it exposed Gradle TestKit's SLF4J provider collision, move the contract and TestKit dependency to isolated `functionalTest` configurations and add their strict lock state.
|
||||||
|
|
||||||
|
### Task 2: Separate Archive Verification from Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/gradle/archive-hygiene.gradle`
|
||||||
|
- Modify: `src/build.gradle`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: root tasks `verifyNoStaleTraceableJars` and `cleanStaleTraceableJars` with no dependency between them.
|
||||||
|
|
||||||
|
- [x] Move traceable archive matching/discovery and both root tasks into the applied script.
|
||||||
|
- [x] Remove the stale-deleting `doFirst` from every `Jar` task while retaining manifest metadata.
|
||||||
|
- [x] Apply the script before leaf `check` dependencies are configured; task actions discover leaf JAR tasks at execution time.
|
||||||
|
- [x] Explicitly declare both archive tasks configuration-cache incompatible because their actions inspect subproject task models.
|
||||||
|
- [x] Run the focused functional test and confirm archive cases are GREEN.
|
||||||
|
|
||||||
|
### Task 3: Separate Public-Path Verification from Update
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/gradle/public-path-snapshot.gradle`
|
||||||
|
- Modify: `src/build.gradle`
|
||||||
|
- Modify: `src/README.md`
|
||||||
|
- Modify: `docs/security/public-paths-snapshot.txt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: read-only `verifyPublicPathSnapshot` and explicitly mutating `updatePublicPathSnapshot`.
|
||||||
|
|
||||||
|
- [x] Centralize canonical snapshot rendering in the script.
|
||||||
|
- [x] Make verification fail on missing env, missing snapshot, drift, and use of the approval property without any writes.
|
||||||
|
- [x] Make update require `-PapprovePublicPathChange`, create the parent directory, and write canonical content.
|
||||||
|
- [x] Replace documentation and snapshot instructions with `updatePublicPathSnapshot -PapprovePublicPathChange`.
|
||||||
|
- [x] Run the focused functional test and confirm all public-path cases are GREEN.
|
||||||
|
|
||||||
|
### Task 4: Focused and Architecture Verification
|
||||||
|
|
||||||
|
**Files:** none beyond Tasks 1-3.
|
||||||
|
|
||||||
|
- [x] Run `./gradlew :app-bootstrap:functionalTest --tests '*BuildVerificationPurityContractTest' --console=plain`.
|
||||||
|
- [x] Run `./gradlew :app-bootstrap:test --console=plain`; 640 ordinary tests pass after TestKit isolation (6 skipped), alongside the 9 functional contracts.
|
||||||
|
- [x] Run `./gradlew :app-bootstrap:verifyDependencyLocks --console=plain`.
|
||||||
|
- [x] Run `./gradlew :app-bootstrap:spotlessJavaCheck :app-bootstrap:checkstyleFunctionalTest :app-bootstrap:spotbugsFunctionalTest --console=plain`.
|
||||||
|
- [x] Run `./gradlew verifyNoStaleTraceableJars verifyPublicPathSnapshot --console=plain` and confirm both are read-only and pass on the current baseline.
|
||||||
|
- [x] Run `./gradlew verifyCleanArchitectureDependencies --console=plain`.
|
||||||
|
- [x] Run `git diff --check` and record `git status --short` without staging or committing.
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# Warning-Zero Build Refactoring Implementation Plan
|
||||||
|
|
||||||
|
> **For Codex:** REQUIRED SUB-SKILLS: use `superpowers:subagent-driven-development` for the
|
||||||
|
> independent owner-leaf batches, `superpowers:test-driven-development` for behavior changes,
|
||||||
|
> `superpowers:systematic-debugging` for any failure, and
|
||||||
|
> `superpowers:verification-before-completion` before reporting success.
|
||||||
|
|
||||||
|
**Goal:** Remove the audited compiler/static-analysis/test-output warning debt, preserve the approved
|
||||||
|
legacy compatibility boundaries, and make the blocking build fail on any future warning.
|
||||||
|
|
||||||
|
**Architecture:** Fix behavior in the owning leaf, preserve identity/framework/compatibility seams
|
||||||
|
with the narrowest justified suppressions, migrate deprecated provider APIs in their outbound leaf,
|
||||||
|
then enable root Gradle/CI gates only after all focused tasks are clean. No dependency edge or runtime
|
||||||
|
membership changes are permitted. The 19-leaf registry remains the dependency SSOT.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle multi-project build, JUnit 5, AssertJ, Mockito,
|
||||||
|
Error Prone, Checkstyle, SpotBugs, Jackson 3.0.2, Lettuce 6.8.1, AWS SDK v2, Testcontainers 2.
|
||||||
|
|
||||||
|
**Approved design:**
|
||||||
|
`docs/superpowers/specs/2026-08-02-warning-zero-build-design.md`
|
||||||
|
|
||||||
|
**Repository constraints:** The worktree already contains user/P0/P1/P2 changes. Preserve them,
|
||||||
|
never reset or rewrite unrelated files, and do not stage, commit, amend, or push. Agent tasks must
|
||||||
|
edit only their assigned files and report overlaps before proceeding.
|
||||||
|
|
||||||
|
## Task 1: Freeze warning evidence and add behavior regressions
|
||||||
|
|
||||||
|
**Owner leaves:** `adapter-inbound-web`, `adapter-outbound-notification`, `sample-portfolio`,
|
||||||
|
`app-bootstrap`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Add: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverterTest.java`
|
||||||
|
- Modify: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ETagsTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java`
|
||||||
|
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/async/AsyncGracefulShutdownBehaviorTest.java`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Add Turkish-default-locale regressions for JWT role uppercasing, notification route-key
|
||||||
|
lowercasing, and repository ACL lowercasing. Snapshot `Locale.getDefault()`, set
|
||||||
|
`Locale.forLanguageTag("tr-TR")`, and restore it in `finally`.
|
||||||
|
2. Add RED ETag cases for `"opaque,tag"`, weak `W/"opaque,tag"` inside a mixed list, malformed
|
||||||
|
unclosed quotes, wildcard, blank, stale, and ordinary multiple values.
|
||||||
|
3. Add a RED async case proving an exception raised in the submitted action reaches the test through
|
||||||
|
`Future.get()`.
|
||||||
|
4. Run the exact focused tests. Confirm the new locale/ETag cases fail for the intended reason; the
|
||||||
|
async change uses the existing `FutureReturnValueIgnored` compile diagnostic as its RED contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:inbound:web:test --tests '*JwtToAuthenticatedPrincipalConverterTest' --tests '*ETag*' --console=plain
|
||||||
|
./gradlew :adapter:outbound:notification:test --tests '*RoutingNotifier*' --console=plain
|
||||||
|
./gradlew :sample-portfolio:test --tests '*RepoStatsAclMapper*' --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*AsyncGracefulShutdownBehaviorTest' --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Do not change production code in this task; retain the behavior-test failures and compile warning
|
||||||
|
as the TDD/static-analysis baseline.
|
||||||
|
|
||||||
|
## Task 2: Correct locale, ETag, async, cleanup, and host-default behavior
|
||||||
|
|
||||||
|
**Owner leaves:** `adapter-inbound-web`, `adapter-outbound-notification`, `sample-portfolio`,
|
||||||
|
`app-bootstrap`, `application-core`, `shared-contract`, `adapter-outbound-fileserver`,
|
||||||
|
`adapter-outbound-httpclient`, `adapter-outbound-identifier`
|
||||||
|
|
||||||
|
**Production files:**
|
||||||
|
|
||||||
|
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java`
|
||||||
|
- Modify: `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java`
|
||||||
|
- Modify: `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java`
|
||||||
|
|
||||||
|
**Test/mechanical files:**
|
||||||
|
|
||||||
|
- Modify the nine audited implicit-charset sites in `CursorCodecTest`,
|
||||||
|
`RedisTrustMaterialProviderTest`, `OutboundHttpClientTest`,
|
||||||
|
`HmacUserPrincipalPseudonymizerTest`, `StreamingResponseBodyAllowedFixture`, and
|
||||||
|
`IdempotencyExecutorTest`.
|
||||||
|
- Modify the remaining audited test-only locale sites in `JwtDecoderConfigTest`,
|
||||||
|
`OutboundHttpClientTest`, `WorkLogReservedIntegrationEventMapperJsonTest`, `WorkLogIdTest`, and
|
||||||
|
`TraceParentTest`.
|
||||||
|
- Modify: `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java`
|
||||||
|
- Modify the four outbox cleanup classes under
|
||||||
|
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/`.
|
||||||
|
- Modify: `src/adapter/outbound/fileserver/src/test/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapterTest.java`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Use `Locale.ROOT` at the three production identifier sites and at audited test comparisons.
|
||||||
|
2. Replace `ETags` delimiter splitting with a quote-aware scanner. Split only on commas outside
|
||||||
|
quoted opaque tags; malformed quoting yields no match. Keep wildcard and weak-tag semantics.
|
||||||
|
3. Retain and observe the async `Future<?>`; unwrap `ExecutionException` only as required by the
|
||||||
|
test's existing assertion contract.
|
||||||
|
4. Replace empty cleanup catches with propagation or `IllegalStateException`/`UncheckedIOException`
|
||||||
|
preserving the original cause.
|
||||||
|
5. Replace implicit charset calls with `StandardCharsets.UTF_8`; replace `LocalDate.now()` test data
|
||||||
|
with the fixed intended date or an explicit UTC clock.
|
||||||
|
6. Convert byte-identical readability literals to text blocks and verify the exact expected strings.
|
||||||
|
7. Run the focused tests from Task 1 and the affected owner test suites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :application-core:test :shared-contract:test :adapter:inbound:web:test \
|
||||||
|
:adapter:outbound:notification:test :adapter:outbound:fileserver:test \
|
||||||
|
:adapter:outbound:httpclient:test :adapter:outbound:identifier:test \
|
||||||
|
:sample-portfolio:test :app-bootstrap:test --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 3: Preserve Redis invariants and migrate Lettuce calls
|
||||||
|
|
||||||
|
**Owner leaf:** `adapter-outbound-cache-redis`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocation.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisTopologyCommandRuntime.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSession.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStore.java`
|
||||||
|
- Add: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveInvocationTest.java`
|
||||||
|
- Add: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/VersionedRedisSessionStoreTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LettuceRedisRuntimeServiceTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisVersionedSessionRepositoryTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/RedisPrimitiveRuntimeServiceTest.java`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Add characterization regressions proving a value-equal descriptor from a different catalog is
|
||||||
|
rejected and the four session array records copy constructor inputs and accessor outputs. These
|
||||||
|
should pass before implementation because they justify preserving the invariants; the compiler
|
||||||
|
warnings are the RED executable contract for the suppression/migration work.
|
||||||
|
2. Keep descriptor reference equality and add constructor-only
|
||||||
|
`@SuppressWarnings("ReferenceEquality")` with an invariant rationale.
|
||||||
|
3. Qualify every ambiguous nested `ExpectedKind` reference with its enclosing record.
|
||||||
|
4. Keep Spring Session's `<T> T getAttribute(String)` signature and add method-only
|
||||||
|
`TypeParameterUnusedInFormals` suppression.
|
||||||
|
5. Preserve defensive copying for the four `VersionedRedisSessionStore` array records; apply exact
|
||||||
|
`ArrayRecordComponent` suppressions to those records and the private test fake only.
|
||||||
|
6. Convert canonical finite score strings to `BigDecimal`, build inclusive Lettuce `Range` values,
|
||||||
|
and use typed `zcount` and `zrangebyscoreWithScores(..., Limit.create(...))` overloads. Extend the
|
||||||
|
runtime proxy test to prove both overloads and their offset/count arguments.
|
||||||
|
7. Replace one-shot `new SecureRandom()` with one static final instance.
|
||||||
|
8. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:cache-redis:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:cache-redis:compileJava \
|
||||||
|
:adapter:outbound:cache-redis:compileTestJava --rerun-tasks --console=plain
|
||||||
|
./gradlew :adapter:outbound:cache-redis:spotbugsTest --rerun-tasks --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 4: Preserve HTTP retry and notification ciphertext invariants
|
||||||
|
|
||||||
|
**Owner leaves:** `adapter-outbound-httpclient`, `adapter-outbound-persistence-jpa`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicy.java`
|
||||||
|
- Modify: `src/adapter/outbound/httpclient/src/test/groovy/dev/caskeleton/adapter/outbound/httpclient/OutboundRetryPolicySpec.groovy`
|
||||||
|
- Modify: `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java`
|
||||||
|
- Modify: `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Add a characterization test using two `OutboundRetryPolicy` instances on one thread: policy A
|
||||||
|
context must not be visible to policy B, and `endCall()` must clear the owning context. It should
|
||||||
|
pass before implementation and justifies preserving the instance field; the compile warning is
|
||||||
|
the RED contract.
|
||||||
|
2. Keep the instance `ThreadLocal`; add field-only `ThreadLocalUsage` suppression with the isolation
|
||||||
|
reason.
|
||||||
|
3. Add/strengthen tests proving `NotificationCiphertext` clones nonce/ciphertext inputs and
|
||||||
|
accessors, compares arrays by content, hashes consistently, and never exposes bytes in
|
||||||
|
`toString()`.
|
||||||
|
4. Keep the record API and add exact record-level `ArrayRecordComponent` suppression.
|
||||||
|
5. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:httpclient:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 5: Migrate Jackson 3 messaging APIs
|
||||||
|
|
||||||
|
**Owner leaf:** `adapter-outbound-messaging`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/schema/LocalJsonSchemaRegistry.java`
|
||||||
|
- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java`
|
||||||
|
- Modify: `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Extend existing tests to freeze text-node validation and canonical envelope bytes.
|
||||||
|
2. Replace `isTextual()`/`textValue()` with `isString()`/`stringValue()`.
|
||||||
|
3. Replace `createGenerator(output)` with
|
||||||
|
`createGenerator(ObjectWriteContext.empty(), output, JsonEncoding.UTF8)`.
|
||||||
|
4. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :adapter:outbound:messaging:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:messaging:compileJava --rerun-tasks --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 6: Preserve legacy object storage and migrate provider APIs
|
||||||
|
|
||||||
|
**Owner leaves:** `application-core`, `adapter-outbound-objectstorage`, `sample-portfolio`,
|
||||||
|
`app-bootstrap` architecture tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java`
|
||||||
|
- Modify the six Java files under
|
||||||
|
`src/application-core/src/main/java/dev/caskeleton/application/storage/migration/`.
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectInspector.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java`
|
||||||
|
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java`
|
||||||
|
- Modify: `src/application-core/src/test/java/dev/caskeleton/application/objectstorage/ObjectStorageArchitectureContractTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactory.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/build.gradle`
|
||||||
|
- Modify: `src/adapter/outbound/objectstorage/gradle.lockfile` only if the toxiproxy dependency graph changes.
|
||||||
|
- Modify audited URL, Mockito varargs, range parser, text-block, and legacy characterization tests.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Add/retain lifecycle tests: `ObjectStoragePort`, `StoredObject`, and adapter-owned
|
||||||
|
`ObjectStorageSettings` remain `forRemoval=true`; migration types remain deprecated but are no
|
||||||
|
longer `forRemoval`.
|
||||||
|
2. Change the six migration mechanism types plus `AdoptLegacyPosterImageUseCase` to plain
|
||||||
|
`@Deprecated`. Add only exact `deprecation` suppressions at adoption implementation/configuration
|
||||||
|
consumers.
|
||||||
|
3. Add only the exact `removal` suppressions named by the design to legacy implementations,
|
||||||
|
controller/mapper/wiring, characterization classes, and single receipt methods.
|
||||||
|
4. Replace AWS `RetryPolicy`/old equal-jitter API with `StandardRetryStrategy`, half-jitter
|
||||||
|
exponential backoff, exact max attempts, and `retryStrategy(...)`. Assert normal/throttling
|
||||||
|
configuration in `S3AsyncClientFactoryTest`.
|
||||||
|
5. Keep the existing `org.testcontainers:testcontainers-toxiproxy` dependency, switch to its
|
||||||
|
Testcontainers 2 package, and use `ToxiproxyClient`/`Proxy` against an explicitly exposed proxy
|
||||||
|
port. Preserve cut/restore MinIO semantics; update the leaf lock only if resolution actually
|
||||||
|
changes.
|
||||||
|
6. Replace `new URL(String)` with `URI.create(...).toURL()`.
|
||||||
|
7. Replace Mockito's two-value varargs `thenReturn` with two chained single-value stubs.
|
||||||
|
8. Replace test-only range splitting with an asserted single-hyphen boundary; keep fingerprint
|
||||||
|
literal bytes identical when converting to a text block.
|
||||||
|
9. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :application-core:test :adapter:outbound:objectstorage:test \
|
||||||
|
:sample-portfolio:test --console=plain
|
||||||
|
./gradlew :adapter:outbound:objectstorage:check --console=plain
|
||||||
|
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||||
|
./gradlew verifyDependencyLocks --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
10. If Docker is available, run the MinIO fault source-set task. If unavailable, record the exact
|
||||||
|
environmental blocker; never suppress its deprecation to claim success.
|
||||||
|
|
||||||
|
## Task 7: Remove remaining mechanical Error Prone warnings
|
||||||
|
|
||||||
|
**Owner leaves:** `application-core`, `app-bootstrap`, `sample-portfolio`, and the exact test leaves
|
||||||
|
from the audit inventory
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `IdempotencyExecutor.java`, `IdempotencySettings.java`,
|
||||||
|
`SampleIdempotencySettings.java`, and matching tests.
|
||||||
|
- Modify: `TracingSampleRateResolver.java` and `TestTaxonomyArchitectureTest.java`.
|
||||||
|
- Modify: `CleanArchitectureTest.java`, `ManagementActuatorSecurityContractTest.java`,
|
||||||
|
`ProblemDetailDisabledConfigTest.java`, and the serialization violation fixture.
|
||||||
|
- Modify: `CreateWorkLogOutboxTest.java`, `WorkLogUseCasesTest.java`, and the remaining exact sample
|
||||||
|
test warning locations.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Replace five `Duration.ofHours(72)` sites with `Duration.ofDays(3)`.
|
||||||
|
2. Add the missing Javadoc summary and render annotation names as `{@code @WebMvcTest}`.
|
||||||
|
3. Add all 16 missing `@Override` annotations.
|
||||||
|
4. Replace Boolean wrapper comparison with the direct literal/assertion form.
|
||||||
|
5. Preserve the forbidden `new BigDecimal(double/float)` bytecode and add method-only
|
||||||
|
`BigDecimalLiteralDouble` suppressions with fixture rationale.
|
||||||
|
6. Replace the three test-only one-argument splits without changing each grammar:
|
||||||
|
limit-bearing CSV handling, equivalent mapping-path scanning, and exact byte-range parsing.
|
||||||
|
7. Run affected owner tests and rerun all compile tasks with Error Prone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :application-core:test :app-bootstrap:test :sample-portfolio:test --console=plain
|
||||||
|
./gradlew compileJava compileTestJava --rerun-tasks --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 8: Capture Redis lab expected failures and configure clean test JVMs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `infra/redis-lab/test/redis-lab-contract.sh`
|
||||||
|
- Add: `src/gradle/test-jvm-agents.gradle`
|
||||||
|
- Modify: `src/build.gradle`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Change `assert_fails` to capture stdout/stderr per invocation, require non-zero status, assert the
|
||||||
|
exact expected diagnostic with no extra lines, and print capture only on mismatch.
|
||||||
|
2. Run `bash -n infra/redis-lab/test/redis-lab-contract.sh`, then run the real Redis lab Gradle/shell
|
||||||
|
contract and verify successful output contains no leaked `redis-lab:` child diagnostics.
|
||||||
|
3. Add a dedicated `mockitoAgent` configuration per Java test project and a relocatable
|
||||||
|
`CommandLineArgumentProvider` in `src/gradle/test-jvm-agents.gradle`. Require exactly one
|
||||||
|
`mockito-core` jar and emit `-javaagent:<absolute jar>` plus test-only `-Xshare:off`.
|
||||||
|
4. Apply the script once from the root build and wire every ordinary/custom `Test` task without
|
||||||
|
changing production JVM arguments.
|
||||||
|
5. Run representative Mockito-heavy app-bootstrap, Redis, object-storage, and messaging tests and
|
||||||
|
verify no self-attachment/CDS warning is printed.
|
||||||
|
|
||||||
|
## Task 9: Enable warning-zero blocking gates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/build.gradle`
|
||||||
|
- Modify: `src/app-bootstrap/build.gradle`
|
||||||
|
- Modify: `.github/workflows/ci-quality-gates.yml`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. First run every `JavaCompile` task with `-Xlint:deprecation` and `-Xlint:unchecked`; resolve every
|
||||||
|
remaining diagnostic at the exact source owner.
|
||||||
|
2. Add `-Werror`, `-Xlint:deprecation`, and `-Xlint:unchecked` to every leaf `JavaCompile` task while
|
||||||
|
retaining Error Prone.
|
||||||
|
3. Remove root `checkstyleTest` and `spotbugsTest` `ignoreFailures=true`.
|
||||||
|
4. Remove app-bootstrap `sampleOffTest`, `functionalTest`, and `conditionalTransportTest`
|
||||||
|
Checkstyle/SpotBugs ignore overrides. Keep only `quarantineTest` non-blocking.
|
||||||
|
5. Add `--warning-mode=fail` to the blocking `quality-gates` Gradle invocation.
|
||||||
|
6. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew checkstyleTest spotbugsTest --rerun-tasks --console=plain
|
||||||
|
./gradlew check --warning-mode=fail --no-daemon --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 10: Fresh repository verification, review, and Wiki capture
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/superpowers/plans/2026-08-02-warning-zero-build-refactoring.md` only if execution
|
||||||
|
evidence exposes a plan correction.
|
||||||
|
- Modify external Wiki capture:
|
||||||
|
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md`
|
||||||
|
and `raw/errors/build-success-warning-debt-2026-08-02.md`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Run owner-focused tests for every changed leaf.
|
||||||
|
2. Run repository verification from `src/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --no-daemon --console=plain
|
||||||
|
./gradlew check --no-daemon --console=plain
|
||||||
|
./gradlew build --warning-mode=fail --no-daemon --console=plain
|
||||||
|
./gradlew clean build --warning-mode=all --no-daemon --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership \
|
||||||
|
verifyDependencyLocks verifyPublicPathSnapshot verifyEnvKeys \
|
||||||
|
--no-daemon --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the gate matrix, wrapper, shell syntax, XML findings/skips, and diff:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .github/scripts/verify-gate-matrix.sh
|
||||||
|
bash .github/scripts/verify-gradle-wrapper.sh .
|
||||||
|
bash -n infra/redis-lab/test/redis-lab-contract.sh
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Scan the fresh build log for `warning:`, deprecated/unchecked `Note:`, SpotBugs non-zero output,
|
||||||
|
OpenJDK/CDS warnings, Mockito self-attachment, and leaked expected-negative Redis diagnostics.
|
||||||
|
5. Confirm the skipped-test XML inventory is exactly the five approved optional-adapter contract
|
||||||
|
cases and no qualification source set skipped.
|
||||||
|
6. Dispatch independent code review over behavior fixes, legacy/provider migrations, and
|
||||||
|
Gradle/test-noise gates. Apply only evidence-backed findings and rerun affected/full gates.
|
||||||
|
7. Update the mandatory Wiki branch/error notes with changed files, commands, results, suppression
|
||||||
|
inventory, blocked environment-only qualifications, and evidence grade. Run per-file Wiki lint;
|
||||||
|
retain the known `main.md` naming-policy conflict without weakening either policy.
|
||||||
|
8. Report success only if the clean build is exit zero and the final log is warning/noise clean.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Web Security Boundary Implementation Plan
|
||||||
|
|
||||||
|
> **Execution:** Follow test-driven development and request an independent read-only review before
|
||||||
|
> advancing to Redis session/CSRF.
|
||||||
|
|
||||||
|
**Goal:** Make JWT/JWKS and CORS filter-boundary behavior hermetic, release-blocking, and impossible
|
||||||
|
to skip silently.
|
||||||
|
|
||||||
|
**Architecture:** Tests remain in inbound-web, use only existing dependencies, and cross the real
|
||||||
|
Spring Security filter chain. A tagged Gradle task isolates them from the ordinary unit suite.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 4.0.0, Spring Security 7, Nimbus JOSE JWT, JDK HttpServer,
|
||||||
|
MockMvc, Gradle 9.
|
||||||
|
|
||||||
|
### Task 1: Dedicated No-Skip Test Gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/adapter/inbound/web/build.gradle`
|
||||||
|
|
||||||
|
- [x] Register `webSecurityBoundaryTest` over `sourceSets.test` with tag inclusion, no-discovery
|
||||||
|
failure, no up-to-date reuse, UTC, and a root-suite skipped-count guard.
|
||||||
|
- [x] Exclude `security-boundary` from ordinary `test` and require the dedicated task from `check`.
|
||||||
|
- [x] Confirm 13 tagged tests are discovered with zero skips and no dependency/lock entry is added.
|
||||||
|
|
||||||
|
### Task 2: JWT/JWKS RED Contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/JwtJwksSecurityFilterIntegrationTest.java`
|
||||||
|
|
||||||
|
- [x] Add a loopback OIDC discovery/JWKS server with request counters and deterministic 503 mode.
|
||||||
|
- [x] Add RS256 token generation using ephemeral keys and conspicuous secret sentinels.
|
||||||
|
- [x] Prove lazy startup and valid bearer-to-principal conversion.
|
||||||
|
- [x] Prove exact expiry, issuer, audience, signature, unknown-kid, and JWKS-outage envelopes/headers.
|
||||||
|
- [x] Prove same-context recovery after a first-request JWKS 503 and prove mismatched discovery
|
||||||
|
metadata reaches the safe 500 `INTERNAL_AUTH_MISCONFIGURATION` filter boundary.
|
||||||
|
- [x] Run the dedicated task and record RED: unknown kid was classified as signature failure and a
|
||||||
|
first-request JWKS 503 escaped as `JwtDecoderInitializationException`/`AuthenticationServiceException`.
|
||||||
|
|
||||||
|
### Task 3: CORS RED Contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/CorsSecurityFilterIntegrationTest.java`
|
||||||
|
|
||||||
|
- [x] Prove approved credentialed preflight bypasses bearer authentication and emits exact headers.
|
||||||
|
- [x] Prove denied origin, disabled CORS, wildcard-without-credentials, and approved actual-origin behavior.
|
||||||
|
- [x] Assert bounded `Vary` behavior and no reflection of an unapproved sentinel origin.
|
||||||
|
- [x] Run the dedicated task: all five CORS filter-boundary contracts passed without production changes.
|
||||||
|
|
||||||
|
### Task 4: Minimal Production Fixes and Verification
|
||||||
|
|
||||||
|
- [x] If RED exposes a production mismatch, change only the owning classifier/security configuration
|
||||||
|
and keep stable error-code/header contracts intact.
|
||||||
|
- [x] Run `webSecurityBoundaryTest`, ordinary inbound-web `test`, module static analysis, `check`,
|
||||||
|
dependency-lock verification, architecture verification, and `git diff --check`.
|
||||||
|
- [x] Request an independent read-only review; add the requested same-context recovery and non-I/O
|
||||||
|
initialization-failure contracts, and bind the loopback server to an explicit IPv4 address.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,838 @@
|
|||||||
|
# Redis Wrapper and Typed API — repository adaptation and delivery status
|
||||||
|
|
||||||
|
- **Design:** `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`
|
||||||
|
- **Plan:** `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md`
|
||||||
|
- **Status date:** 2026-08-07
|
||||||
|
- **All 27 tasks delivered.** Sections 13–24 record what each one decided and what the topology
|
||||||
|
lanes found; `docs/redis/support-matrix.md` records which test produced which evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why the structure differs from the plan
|
||||||
|
|
||||||
|
The design and plan were written without the target repository attached, so they assume a
|
||||||
|
`backend-skeleton/` root with twelve standalone Gradle projects under `modules/redis/`, Kotlin DSL
|
||||||
|
build files, and the `io.backend.skeleton.redis` package root. The package README anticipates exactly
|
||||||
|
this and instructs the implementer to keep the structural contract while conforming to whatever
|
||||||
|
stronger rules the real repository already enforces.
|
||||||
|
|
||||||
|
This repository has three such rules, and all of them outrank the plan's file layout:
|
||||||
|
|
||||||
|
1. `src/config/architecture/modules.json` is a fail-closed registry of **exactly 19 leaf modules**,
|
||||||
|
re-validated by `src/settings.gradle` on every configuration. Adding twelve Gradle projects would
|
||||||
|
violate HARD-STOP condition 5 in `AGENTS.md`.
|
||||||
|
2. The build is Groovy DSL with `dependencyLocking(STRICT)`, so the plan's `libs.versions.toml`
|
||||||
|
entries and its Spring Data Redis 4.1 / Lettuce 7.6 pins cannot be introduced without regenerating
|
||||||
|
lock state. The repository is on Spring Boot 4.0.0 with **Lettuce 6.8.1**.
|
||||||
|
3. The package root is `dev.caskeleton`, not `io.backend.skeleton`.
|
||||||
|
|
||||||
|
The SDK therefore lives inside the already-registered `adapter:outbound:cache-redis` leaf, and each
|
||||||
|
designed module is a package. What the separate Gradle projects would have enforced —
|
||||||
|
dependency direction and driver containment — is enforced instead by
|
||||||
|
`RedisSdkModuleBoundaryTest`, which reads the source tree and fails on a forbidden import.
|
||||||
|
|
||||||
|
### Module mapping
|
||||||
|
|
||||||
|
| Design module | Package under `dev.caskeleton.adapter.outbound.cache.redis.sdk` |
|
||||||
|
| --- | --- |
|
||||||
|
| `redis-core-api` | `api`, `api.key`, `api.codec`, `api.command`, `api.error`, `api.operations`, `api.reactive` |
|
||||||
|
| `redis-core-lettuce` | `lettuce.codec`, `lettuce.command`, `lettuce.connection`, `lettuce.observability` |
|
||||||
|
| `redis-spring-boot-starter` | `config` |
|
||||||
|
| `redis-cluster` | `cluster` |
|
||||||
|
| `redis-programmability` | `programmability` |
|
||||||
|
| `redis-raw-gateway` | `raw` |
|
||||||
|
| `redis-admin-plane` | `admin` |
|
||||||
|
| `extensions/*` | `extensions.json`, `extensions.search`, `extensions.timeseries`, `extensions.probabilistic` |
|
||||||
|
| `redis-testkit` | `src/test` and the existing `redisTest` source set |
|
||||||
|
|
||||||
|
### Other adaptations, and the reason for each
|
||||||
|
|
||||||
|
| Plan says | Repository does | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `backend.redis.*` properties | `ca-skeleton.capabilities.redis-sdk.*` | Matches the existing capability property namespace and avoids colliding with `app.cache.redis`. |
|
||||||
|
| `RedisEnvelope` is a record with a `byte[]` component | Value class with the same accessors | ErrorProne `ArrayRecordComponent` is a blocking check in this build. |
|
||||||
|
| Jackson-based YAML policy loader | Explicit strict reader for a closed YAML subset | No Jackson or SnakeYAML on the main compile classpath, and a general YAML engine would accept anchors, merges, and duplicate keys inside a security policy file. |
|
||||||
|
| `VersionedJsonCodec` maps objects reflectively | Frames a versioned JSON envelope around a caller-supplied `RedisPayloadCodec` | Same guarantee — schema id, version, size ceiling, hard failure on an unknown version — without an object mapper the module cannot depend on. |
|
||||||
|
| Each task ends with `git commit` | No commits | `AGENTS.md` commit policy is `human-only`. |
|
||||||
|
| Gradle tasks `redis72Test` … `cluster82Test` | Not registered | They belong to Task 8's testkit half and Task 26; both need Docker-backed Testcontainers, which Milestone A does not reach. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Task status
|
||||||
|
|
||||||
|
| Task | Title | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Module graph and shared quality rules | **Done** as a package graph plus `RedisSdkModuleBoundaryTest` |
|
||||||
|
| 2 | Command policy catalog and metadata diff | **Done** |
|
||||||
|
| 3 | Version, topology, risk, permit, budget models | **Done** |
|
||||||
|
| 4 | Key namespace and slot-safe typed keys | **Done** |
|
||||||
|
| 5 | Codec registry and versioned envelope | **Done** |
|
||||||
|
| 6 | Stable error model and ambiguous execution | **Done** |
|
||||||
|
| 7 | Sync and reactive public API with parity test | **Done** |
|
||||||
|
| 8 | Properties, capability probe, connection isolation, permit authority | **Done** except the Testcontainers topology environments and their Gradle tasks |
|
||||||
|
| 9 | Policy-aware executor and observability | **Done** |
|
||||||
|
| 10 | String and Key/TTL operations, blocking and reactive | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 11 | Hash operations and the 7.4 field-TTL version gate | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 12 | Set and Sorted Set operations, blocking and reactive | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 13 | List operations and the bounded blocking lane | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 14 | Bitmap, bitfield, HyperLogLog, and geospatial operations | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 15 | Batch and pipeline | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 16 | Stream | **Done** against the in-memory gateway, including the Redis 8.2 deletion capability; `XNACK` (8.8) deferred, see §13 |
|
||||||
|
| 17 | Pub/Sub and sharded Pub/Sub | **Done** against the in-memory bus; no real-server evidence |
|
||||||
|
| 18–19 | Sentinel failover certainty, Cluster slot/redirect/topology | **Done** as pure logic with unit evidence; the fault-injection lane is Task 26 |
|
||||||
|
| 21 | Registered scripts and functions | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 20 | Transactions | **Done**, with the fixture reworked to defer inside a MULTI window, see §24 |
|
||||||
|
| 22 | Approved raw gateway | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 23 | Isolated admin plane | **Done** against the in-memory gateway; no real-server evidence |
|
||||||
|
| 24–25 | JSON, Search, Time Series, Probabilistic extensions | **Done** against the in-memory gateway; no real-module evidence |
|
||||||
|
| 26–27 | Topology/fault/ACL/performance harness, CI matrix and docs gates | **Done** — all three lanes have produced evidence on 7.4, see §21–§23 |
|
||||||
|
|
||||||
|
`RedisSdkModuleBoundaryTest.NOT_YET_IMPLEMENTED_MODULES` is the machine-checked version of the
|
||||||
|
"not started" rows: the test fails if a listed package appears without the list being updated, and
|
||||||
|
fails if an unlisted one is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What Milestone A actually guarantees
|
||||||
|
|
||||||
|
- Every command the SDK will ever run is classified in
|
||||||
|
`src/main/resources/redis-sdk/redis-command-policy.yml`. An unclassified command is refused by
|
||||||
|
`RedisCommandCatalog`, so a Redis upgrade cannot make a new command reachable by default.
|
||||||
|
- `KEYS`, `FLUSHALL`, `FLUSHDB`, `SHUTDOWN`, `DEBUG`, `EVAL`, `CONFIG SET`, and the deprecated
|
||||||
|
command names are `BLOCKED` with ACL account `NONE`.
|
||||||
|
- R2 commands cannot execute without both an issued permit and an `OperationBudget`, and a permit the
|
||||||
|
caller implemented itself fails provenance verification.
|
||||||
|
- Sync and reactive typed API surfaces are mechanically proven to be in parity.
|
||||||
|
- Metric and trace tags are a closed low-cardinality set with no key, field, member, or value in it.
|
||||||
|
- A write that timed out is reported as `RedisAmbiguousExecutionException` with `retryable=false`,
|
||||||
|
and `RedisFailureMetadata` rejects the retryable-and-ambiguous combination at construction.
|
||||||
|
|
||||||
|
## 4. What Milestone A does not guarantee
|
||||||
|
|
||||||
|
- No command has been executed against a real Redis server by this work. Every test is a unit or
|
||||||
|
contract test over fakes; the contract suites the plan defines for Tasks 10–17 do not exist yet.
|
||||||
|
- The typed operation interfaces have no implementation, so `RedisOperations` cannot be wired into a
|
||||||
|
Spring context yet. `RedisSdkSettings` is bound but no bean registration reads it.
|
||||||
|
- Cluster slot calculation is a caller-supplied function; the CRC16 implementation is Task 19.
|
||||||
|
## 5. Cleanup of everything the design does not specify
|
||||||
|
|
||||||
|
The leaf previously carried five Redis capabilities that this design does not describe — semantic
|
||||||
|
cache, session, request-replay idempotency, soft lease, and edge rate limit — together with their
|
||||||
|
evidence and readiness governance. All of it is removed, so the Redis surface is now exactly the
|
||||||
|
SDK.
|
||||||
|
|
||||||
|
| Removed | Scale |
|
||||||
|
| --- | --- |
|
||||||
|
| `cache-redis` non-SDK sources, tests, Lua programs, and the `redisTest` evidence source set | 188 main + 105 test + 18 evidence Java files, 52 resources |
|
||||||
|
| `cache-redis/build.gradle` | 626 lines → 22; ~50 evidence/readiness lanes gone |
|
||||||
|
| `app-bootstrap` Redis wiring, health contributor, material providers, `redisCompositionTest` source set | 15 files plus its Gradle tasks and configurations |
|
||||||
|
| `application-core/src/redisPolicyContractTest` | 1 file plus its source set |
|
||||||
|
| Root `build.gradle` Redis readiness/evidence/CI-matrix governance | 1,420 lines |
|
||||||
|
| `config/redis/`, `gradle/redis-test-images.properties`, `infra/redis-lab/`, `.github/workflows/redis-production-readiness.yml` | removed |
|
||||||
|
| `ci-quality-gates.yml` / `ci-gate-matrix.yml` | `redis-standalone` job retargeted to `redis-sdk` |
|
||||||
|
|
||||||
|
Kept deliberately: `shared-contract`'s `EdgeRateLimitPort` and its provider-neutral contract test.
|
||||||
|
It is a rate-limit port, not a Redis type, and the design's exclusion list covers business policy
|
||||||
|
rather than application ports.
|
||||||
|
|
||||||
|
Verified after the cleanup: `./gradlew test`, `verifyCleanArchitectureDependencies`,
|
||||||
|
`verifyEnvKeys`, `verifyDependencyLocks` all pass; `verify-gate-matrix.sh` reports 27 gates OK.
|
||||||
|
Dependency locks were regenerated for every module.
|
||||||
|
|
||||||
|
## 6. Task 10 — decisions a reviewer should check
|
||||||
|
|
||||||
|
The string and key/TTL operations landed in `sdk.lettuce.operations`, which is the package form of
|
||||||
|
the plan's `redis-core-lettuce/.../lettuce/operations`. Five things differ from a literal reading of
|
||||||
|
the plan, each for a stated reason.
|
||||||
|
|
||||||
|
| Decision | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| A narrow `RedisCommandGateway` seam sits between the typed operations and Lettuce; `LettuceRedisCommandGateway` is the only class that touches the driver. | The plan's contract suites run on Testcontainers, which this environment has no lane for. The seam lets the whole policy path — catalog, permit provenance, budget, admission order, decode — be proven deterministically, and it keeps driver containment real rather than asserted. It is not a substitute for the real-server evidence Task 26 owns. |
|
||||||
|
| Where design section 10 gives an R2 method only a permit (`multiGet`, `delete`, `unlink`, `rename`, `scan`) or only a budget (`append`, `getRange`, `setRange`), the SDK fills the missing half. | `CommandPolicyGuard` requires both for every R2 command. The caller-supplied half always wins; the other comes from `RedisOperationLimits` or a permit the SDK itself holds. Without this, half the designed R2 surface could not be admitted at all. |
|
||||||
|
| Increment-with-initial-TTL runs a registered Lua script, and the SDK loads that script itself inside the guarded `EVALSHA` invocation. | Redis 7.2–8.2 has no `INCR` variant carrying an expiry, and both two-command sequences leak a permanent counter on a crash. The `SCRIPT LOAD` that resolves the digest is therefore *not* separately admitted by the guard — it travels under the `EVALSHA` admission with the same `registered-script` permit and script budget. Proper script registration is Task 20/21's `programmability` module; this is the narrowest thing that makes the operation correct in the meantime. |
|
||||||
|
| No `INCREX` version-gated path. | No shipped Redis version has the command, so it is in neither the policy catalog nor `RedisCapability`. Adding a gate for a command that does not exist would be untestable. |
|
||||||
|
| `expire`/`expireAt` report `ABSENT` only when the condition was `ALWAYS`. | Redis answers `0` both for a missing key and for an unmet condition. With `ALWAYS` the only possible cause is a missing key; with any other condition the SDK reports `CONDITION_NOT_MET` rather than guessing. A non-positive TTL is refused outright instead of silently deleting the key. |
|
||||||
|
|
||||||
|
Coverage: 30 new tests (`RedisValueOperationsContractTest`, `RedisKeyOperationsContractTest`) over
|
||||||
|
permit provenance, budget ceilings, atomic counter creation, script reload after `NOSCRIPT`,
|
||||||
|
namespace-bounded paging, and blocking/reactive agreement. `lettuce/operations` is registered in
|
||||||
|
`RedisSdkModuleBoundaryTest.DESIGNED_MODULES`.
|
||||||
|
|
||||||
|
## 7. Task 11 — the field-TTL version gate
|
||||||
|
|
||||||
|
The gate design section 10.2 asks for is applied in two independent places, because either one alone
|
||||||
|
is weaker than it looks.
|
||||||
|
|
||||||
|
- `LettuceRedisHashFieldExpirationOperations.ifSupported(...)` returns empty below Redis 7.4, so a
|
||||||
|
composition root has nothing to inject and a caller cannot hold the API at all. This is the
|
||||||
|
"bean is absent on 7.2" property the plan's Redis 7.2 test asserts.
|
||||||
|
- `HEXPIRE`, `HPEXPIRE`, `HPERSIST`, `HTTL`, and `HPTTL` carry `minimum-version: "7.4"` in the policy
|
||||||
|
catalog, so `CommandPolicyGuard` refuses them on an older server even for a hand-built instance.
|
||||||
|
`guardRefusesFieldExpiryOnAnOlderServer` proves that second layer by forcing an instance into
|
||||||
|
existence against a 7.2 server and watching the guard reject it.
|
||||||
|
|
||||||
|
`entries` is R2 with a caller-supplied permit *and* budget, exactly as designed — it is the one hash
|
||||||
|
method the design gives both, so nothing is filled in for it. `HSCAN` gets the Task 10 treatment: an
|
||||||
|
SDK `cursor-scan` permit and a budget derived from the requested page, because
|
||||||
|
`scan(HashKey, ScanRequest)` carries neither. `HGETALL` and `HSCAN` replies are measured against the
|
||||||
|
budget before decoding, so an oversized hash is refused rather than materialised.
|
||||||
|
|
||||||
|
One Lettuce accommodation is worth knowing about: its only batched `HSET` takes a `Map`, which for a
|
||||||
|
`byte[]`-keyed connection means identity hashing. The seam therefore passes two positional lists and
|
||||||
|
`LettuceRedisCommandGateway.hashPutAll` is the single place that builds the map — never reading from
|
||||||
|
it, only iterating — with the ErrorProne check suppressed there and nowhere else.
|
||||||
|
|
||||||
|
## 8. Task 12 — the range commands are encoded, not borrowed
|
||||||
|
|
||||||
|
Design section 10.5 requires `rangeByScore`, `rangeByLex`, and a descending `rangeByRank`. Lettuce
|
||||||
|
6.8 has no typed `ZRANGE ... BYSCORE / BYLEX / REV`; its only typed paths are the deprecated
|
||||||
|
`ZRANGEBYSCORE`, `ZREVRANGEBYSCORE`, `ZRANGEBYLEX`, `ZREVRANGEBYLEX`, and `ZREVRANGE`.
|
||||||
|
|
||||||
|
Those five stay `BLOCKED`, exactly like `SETNX`, `GETSET`, `HMSET`, `RPOPLPUSH`, and `GEORADIUS`.
|
||||||
|
`LettuceRedisCommandGateway` encodes the modern command itself — `ZRANGE key min max
|
||||||
|
BYSCORE|BYLEX [REV] LIMIT offset count [WITHSCORES]` — through Lettuce's typed `dispatch` with a
|
||||||
|
fixed `CommandType.ZRANGE`, a fixed output, and arguments built from the already-rendered key. Every
|
||||||
|
range read therefore declares `ZRANGE` to the guard and sends `ZRANGE` on the wire, so the ACL
|
||||||
|
account and the catalog drift gate stay aligned with reality.
|
||||||
|
|
||||||
|
This is not the forbidden raw-command surface: there is no method anywhere that accepts a command
|
||||||
|
name, and the encoding lives in the one class that is already allowed to know the driver.
|
||||||
|
|
||||||
|
Everything else in Task 12 follows Task 10's rules. `SMEMBERS` has no method at all — the API offers
|
||||||
|
`scan` or permit-and-budget set algebra, and a test asserts no whole-set reader exists.
|
||||||
|
`SRANDMEMBER`, `SSCAN`, and `ZSCAN` get an SDK permit plus a derived budget because their signatures
|
||||||
|
carry neither; `SMOVE` takes the caller's multi-key permit; `SDIFF`/`SINTER`/`SUNION` and every range
|
||||||
|
read take both from the caller, and the reply is measured against the budget before it is decoded.
|
||||||
|
|
||||||
|
## 9. Task 13 — the blocking lane
|
||||||
|
|
||||||
|
`LettuceRedisBlockingListOperations` takes its own `RedisCommandGateway`, which the composition root
|
||||||
|
binds to a connection borrowed from `RedisConnectionKind.BLOCKING`. That parameter is the structural
|
||||||
|
form of design section 10.3's "separate bean, dedicated pool": a command that occupies its
|
||||||
|
connection until the server answers cannot be issued down the lane ordinary traffic shares, and the
|
||||||
|
type system is what stops it rather than a convention.
|
||||||
|
|
||||||
|
An unbounded wait is impossible on three independent levels: the request always declares its block,
|
||||||
|
`ListOperationRequests` refuses a non-positive one before building anything, and
|
||||||
|
`CommandPolicyGuard` refuses a block above the configured ceiling and sets the client timeout to the
|
||||||
|
block plus `TimeoutProfile.BLOCKING_MARGIN`. All three are asserted.
|
||||||
|
|
||||||
|
`BLMOVE` needs both authorisations and the design gives the caller only one, so the caller's
|
||||||
|
multi-key permit is verified in the operations layer while the SDK supplies the `blocking-pop`
|
||||||
|
permit the guard demands. Crossing two keys and occupying a connection are separate decisions and
|
||||||
|
the caller must still hold the first.
|
||||||
|
|
||||||
|
## 10. Task 14 — the ceiling that matters
|
||||||
|
|
||||||
|
A single `SETBIT` at an arbitrary offset allocates the whole prefix, so an unchecked offset is a
|
||||||
|
memory-exhaustion primitive rather than a write. `RedisOperationLimits.maxBitmapOffset` bounds every
|
||||||
|
bit offset — `GETBIT`, `SETBIT`, and each `BITFIELD` subcommand — before a command is built, and a
|
||||||
|
negative offset is refused outright.
|
||||||
|
|
||||||
|
`BITOP`, `PFCOUNT`, `PFMERGE`, and `GEOSEARCHSTORE` take the caller's multi-key permit; `BITCOUNT`,
|
||||||
|
`BITPOS`, `BITFIELD`, and `GEOSEARCH` take the caller's budget with an SDK permit. A geo search is
|
||||||
|
bounded three ways — its own `count`, the collection ceiling, and the caller's budget measured
|
||||||
|
against the reply before decoding.
|
||||||
|
|
||||||
|
## 11. Task 15 — the two decisions the design left open
|
||||||
|
|
||||||
|
`RedisBatch` exposes only `size()`, `keys()`, and `requestBytes()`, so it is opaque: nothing in the
|
||||||
|
public contract lets a caller put commands into one. The SDK therefore owns both the concrete batch
|
||||||
|
and the only way to fill it, and two questions had to be answered.
|
||||||
|
|
||||||
|
**What the builder covers.** `LettuceRedisBatch.Builder` covers the string, key, and hash surfaces
|
||||||
|
rather than mirroring all ~80 typed methods. Those are what pipelining is actually used for, each
|
||||||
|
extra method is one delegating line onto the existing request factories, and widening it later is
|
||||||
|
mechanical rather than a redesign. A batch built anywhere else is refused.
|
||||||
|
|
||||||
|
**Whether R2 commands may be batched.** They may, carrying their own permit and budget exactly as
|
||||||
|
they do alone. `BatchOptions` has no permit field, so the alternative was an R1-only batch — which
|
||||||
|
would have blocked the case where saving a round trip matters most. Both ceilings apply and the
|
||||||
|
smaller wins: the guard refuses an item that broke its own budget before the batch ceiling is even
|
||||||
|
checked.
|
||||||
|
|
||||||
|
Four properties are enforced rather than documented. Every item is admitted **before** any command
|
||||||
|
is sent, so one refused item cancels the batch instead of leaving it half-applied. Input index is
|
||||||
|
result index, failure or not. Items fail independently — `hasPartialFailure` is the caller's signal,
|
||||||
|
not an exception. And there is no retry path in the class at all, so a failed write is never
|
||||||
|
re-sent.
|
||||||
|
|
||||||
|
## 12. Task 17 — a subscription is not a command
|
||||||
|
|
||||||
|
Publishing goes through the guard like anything else. Subscribing does not: it has no reply to bound
|
||||||
|
and no timeout to apply, it occupies its connection for as long as it lives, and it therefore has
|
||||||
|
its own seam — `RedisPubSubGateway`, bound to a connection borrowed from
|
||||||
|
`RedisConnectionKind.PUBSUB`. A long-lived listener can never sit on the lane ordinary commands use.
|
||||||
|
|
||||||
|
What the guard would have checked is checked in `PubSubOperationRequests` instead: every channel and
|
||||||
|
pattern must belong to the process namespace, an empty subscription is refused, and a pattern
|
||||||
|
subscription demands the `pattern-subscribe` permit because the server decides how much a pattern
|
||||||
|
matches.
|
||||||
|
|
||||||
|
Lifecycle is the part that leaks if it is only documented. The blocking API returns an
|
||||||
|
`AutoCloseable` `Subscription`; the reactive API returns a `Flux` whose cancellation closes the
|
||||||
|
driver handle. Both are asserted against a bus that reports how many subscriptions are still open,
|
||||||
|
so an abandoned subscriber releasing its connection is a test, not a claim.
|
||||||
|
|
||||||
|
Sharded Pub/Sub is gated exactly like per-field expiry: `ifSupported` yields nothing below Redis
|
||||||
|
7.0, and `SPUBLISH` carries the same minimum in the catalog so the guard refuses it independently.
|
||||||
|
|
||||||
|
### Still outstanding
|
||||||
|
|
||||||
|
`application.yml`, `.env`, and `docs/registries/env-keys.yaml` still carry the property blocks of
|
||||||
|
the five removed capabilities. They bind nothing and the build is green with them present, but they
|
||||||
|
are dead configuration and should go in the same sweep that removes the corresponding capability
|
||||||
|
sections.
|
||||||
|
|
||||||
|
## 13. Task 16 — a stream entry, a payload field, and one command that had to be encoded
|
||||||
|
|
||||||
|
**One payload field.** `StreamKey<V>` carries exactly one payload codec and `StreamRecord<V>`
|
||||||
|
exactly one value, so the SDK writes exactly one field, named `payload` in
|
||||||
|
`StreamOperationRequests` and nowhere else. An entry that comes back with any other shape is
|
||||||
|
refused rather than half-decoded: a foreign producer's record is an anomaly the caller has to see,
|
||||||
|
not something to silently truncate into a `StreamRecord`.
|
||||||
|
|
||||||
|
**`XREAD` forced a catalog distinction.** `BLPOP` has no non-blocking form, so a request that omits
|
||||||
|
its block is a defect. `XREAD` does have one — the same command name is an ordinary bounded read
|
||||||
|
without `BLOCK`. The catalog previously modelled only "blocking", which would have meant either
|
||||||
|
rejecting every non-blocking stream read or excusing the stream reads from the rule that nothing
|
||||||
|
waits forever. Both were wrong, so `optional-block` was added to the policy schema and
|
||||||
|
`RedisCommandPolicy.requiresServerBlock()` now separates the two. `XREAD` and `XREADGROUP` are the
|
||||||
|
only commands that carry it. The blocking bean still takes a non-nullable `Duration`, and the guard
|
||||||
|
still refuses a non-positive block or one over the configured ceiling.
|
||||||
|
|
||||||
|
**A group read has exactly two legal offsets.** `NewForGroup` and `PendingForConsumer` are accepted;
|
||||||
|
`After` and `Latest` are refused. Reading a group from an arbitrary identifier would hand a consumer
|
||||||
|
entries the group already distributed elsewhere without moving the pending list — a duplicate
|
||||||
|
delivery the caller did not ask for. The mirror rule holds for the group-free read, which refuses
|
||||||
|
the two group offsets.
|
||||||
|
|
||||||
|
**`XAUTOCLAIM` is encoded, not borrowed.** Lettuce's typed `xautoclaim` returns `ClaimedMessages`,
|
||||||
|
which drops the third reply element: the identifiers that were pending but no longer exist in the
|
||||||
|
stream. `ClaimResult.deletedIds` is part of the SDK contract precisely because a consumer that
|
||||||
|
cannot see that list keeps sweeping the same tombstones forever. The command is therefore built in
|
||||||
|
`LettuceRedisCommandGateway` with `NestedMultiOutput`, the same precedent set by the sorted-set
|
||||||
|
ranges in §8 — the command declared to the guard is still the command on the wire, and no method
|
||||||
|
accepts a command name.
|
||||||
|
|
||||||
|
**Permits and budgets.** `XTRIM` runs under `bounded-collection-write`, the ranges under
|
||||||
|
`bounded-collection-read`, both reads under the new `stream-read` policy, and `XPENDING`/`XAUTOCLAIM`
|
||||||
|
under `stream-recovery`. None of the design's stream signatures carry a caller permit, so all four
|
||||||
|
are SDK permits; the caller-supplied bound is the mandatory `count`, which becomes both the guard's
|
||||||
|
budget and the ceiling checked against `maxCollectionElements`. There is no "read the whole stream"
|
||||||
|
call that can be written against this API.
|
||||||
|
|
||||||
|
**Redis 8.2 deletion landed; 8.8 `XNACK` did not.** `XACKDEL`/`XDELEX` are behind
|
||||||
|
`LettuceRedisStreamDeletionOperations.ifSupported(...)`, gated exactly like hash field expiry — the
|
||||||
|
capability probe decides whether a bean exists, and the catalog's 8.2 minimum refuses a hand-built
|
||||||
|
one. `XNACK` is deliberately not implemented: the pinned Lettuce 6.8.2 has no typed form for it, and
|
||||||
|
unlike `XAUTOCLAIM` its wire format cannot be verified against a driver or a released server, so
|
||||||
|
encoding it by hand would be inventing a protocol rather than adapting one. The capability, the
|
||||||
|
catalog entry, and the 8.8 minimum stay in place; the bean is the only missing piece and should be
|
||||||
|
added when the command is available in the driver or in a released server.
|
||||||
|
|
||||||
|
## 14. Tasks 18–19 — the parts that do not need a cluster to be true
|
||||||
|
|
||||||
|
Both tasks are specified against real Sentinel and Cluster environments, which this repository does
|
||||||
|
not yet have a lane for. What landed is the half that is decidable without one, and it is the half
|
||||||
|
the rest of the SDK depends on.
|
||||||
|
|
||||||
|
**The slot calculator is a pre-flight check, not a redirect handler.** `RedisSlotCalculator`
|
||||||
|
computes CRC-16/XMODEM over the hash tag exactly as Redis does, so `CommandPolicyGuard` can refuse a
|
||||||
|
cross-slot multi-key command before it is written. A server-side `CROSSSLOT` would arrive after the
|
||||||
|
request left the process, which is precisely the outcome the guard exists to prevent. The seam was
|
||||||
|
already there — the guard has always taken a `ToIntFunction<String>` — so this task filled it rather
|
||||||
|
than changing the pipeline. The published slots for `foo`, `bar`, and `hello` are asserted, so a
|
||||||
|
regression in the checksum shows up as a wrong number rather than as a cluster that quietly
|
||||||
|
mis-routes.
|
||||||
|
|
||||||
|
**An empty tag is not a tag.** `{}` hashes the whole key, matching Redis, and that is tested,
|
||||||
|
because the alternative — hashing an empty string — would collapse every such key onto one slot.
|
||||||
|
|
||||||
|
**A cluster scan is not a snapshot, and `ClusterScanCursor` refuses to pretend otherwise.** A sweep
|
||||||
|
is complete only when every primary has *answered* with a zero cursor; a primary that was never
|
||||||
|
asked counts as unfinished. Reporting completion after skipping a shard would let a caller conclude
|
||||||
|
a key does not exist when a whole shard was never looked at.
|
||||||
|
|
||||||
|
**Redirect counting separates two different incidents.** A trickle of `MOVED` means the client's
|
||||||
|
topology is stale; `ASK` and `TRYAGAIN` mean a resharding is in progress. The driver follows both
|
||||||
|
transparently, so neither is visible to a caller — `ClusterTopologyObserver` is what makes them
|
||||||
|
visible to an operator, and it accepts slot numbers and node identifiers only, never a key.
|
||||||
|
|
||||||
|
**`ExecutionCertainty` is the failover decision made explicit.** "The server refused it" and "the
|
||||||
|
connection died after the command was written" look identical to a caller and have opposite
|
||||||
|
consequences. `SentinelFailoverObserver.classify` returns `SAFE_TO_RETRY_FAILURE` only when the
|
||||||
|
command provably never reached the server; anything written and unanswered is `AMBIGUOUS_FAILURE`,
|
||||||
|
and `allowsAutomaticRetry` then defers to the command policy's `retry-safe` flag. A non-idempotent
|
||||||
|
write is therefore never resent by the pipeline, and each one is counted so an operator knows how
|
||||||
|
many need reconciling.
|
||||||
|
|
||||||
|
**The reconnect queue is bounded on purpose.** An unbounded queue turns a thirty-second promotion
|
||||||
|
into a thirty-second backlog that lands at once on a freshly promoted primary. Refusals past the
|
||||||
|
bound are counted so the bound can be tuned from evidence rather than guessed.
|
||||||
|
|
||||||
|
**What is still owed:** the fault-injection evidence. Nothing here proves how Lettuce actually
|
||||||
|
behaves during a promotion or a resharding — that is a real-topology lane and belongs to Task 26.
|
||||||
|
These types are the classification and accounting that lane will assert against.
|
||||||
|
|
||||||
|
## 15. Task 21 — scripts are a deployment artefact, and Task 20 is blocked on the fixture
|
||||||
|
|
||||||
|
**Nothing accepts a script body at call time.** `EVAL` is blocked in the command policy, so the only
|
||||||
|
reachable path is `EVALSHA` of a digest that `RedisScriptRegistry` obtained from a `SCRIPT LOAD` of
|
||||||
|
a reviewed `RegisteredRedisScript`. A script assembled from request data has the blast radius of the
|
||||||
|
whole keyspace; making registration a deployment step is what turns "we only run reviewed scripts"
|
||||||
|
from a convention into a structural property.
|
||||||
|
|
||||||
|
**Keys are declared, and that is what makes them checkable.** Every key goes into the request's key
|
||||||
|
list, so a script is namespace-checked and same-slot-checked exactly like any other multi-key
|
||||||
|
command. `RedisArgument` is a distinct type from a key for the same reason: a key smuggled through
|
||||||
|
`ARGV` would bypass both checks, and having the two be different types is what makes that a compile
|
||||||
|
problem rather than a review problem.
|
||||||
|
|
||||||
|
**A registered script returns one bulk reply.** That is a contract, not a limitation of
|
||||||
|
`RedisResultDecoder`. A nested Lua table forces the SDK to guess how deep the reply is and how each
|
||||||
|
level is typed, which is the ambiguity a typed API exists to remove. Encode the result and decode it
|
||||||
|
in the decoder.
|
||||||
|
|
||||||
|
**`NOSCRIPT` is the one automatic retry in the SDK.** The server rejects the call before running
|
||||||
|
anything, so reloading and re-issuing once repeats nothing. It is not a retry of an ambiguous write,
|
||||||
|
and no other failure is retried on this path.
|
||||||
|
|
||||||
|
**Functions are callable, not loadable.** `FUNCTION LOAD` is `ADMIN_ONLY` in the catalog and belongs
|
||||||
|
to the admin plane, so `RedisFunctionOperations` has no method that introduces server-side code.
|
||||||
|
`RegisteredRedisFunction` carries the library's semantic version because a library replaced under
|
||||||
|
the same name changes behaviour with no signal at the call site. A function declared read-only is
|
||||||
|
issued as `FCALL_RO`, which lets the server refuse a wrong declaration — worth more than the replica
|
||||||
|
routing it also buys.
|
||||||
|
|
||||||
|
**Task 20 is deliberately not half-done.** `WATCH`/`MULTI`/`EXEC` is implementable against Lettuce —
|
||||||
|
after `MULTI` the command futures complete when `EXEC` runs — but proving it needs a fixture that
|
||||||
|
models that deferral. The current `InMemoryRedisCommandGateway` completes every future eagerly, so a
|
||||||
|
transaction written against it would apply its writes *before* the `WATCH` conflict was detected: the
|
||||||
|
fixture would report a correct-looking conflict while the effects had already landed. A fake that
|
||||||
|
lies about atomicity is worse than no fake, so the transaction work is deferred until the fixture
|
||||||
|
grows a deferral model (or the real-server lane from Task 26 exists), rather than being landed
|
||||||
|
against a fixture that cannot falsify it.
|
||||||
|
|
||||||
|
## 16. Task 22 — the escape hatch, and why it is not an escape
|
||||||
|
|
||||||
|
The raw gateway exists because a few commands have no typed form worth building, not because
|
||||||
|
arbitrary command execution is acceptable. Everything about its shape follows from that.
|
||||||
|
|
||||||
|
**Two independent gates, neither decided at request time.** A command must be classified
|
||||||
|
`RAW_ONLY` in `redis-command-policy.yml` — the organization's decision about which commands may ever
|
||||||
|
leave through this door — *and* the deployment must have registered an `ApprovedRawCommand` for it
|
||||||
|
in `RawCommandApprovals`. Neither alone is enough. The approval carries the argument, request, and
|
||||||
|
reply ceilings and the timeout, so widening what may be sent is a deployment change, not a call-site
|
||||||
|
one.
|
||||||
|
|
||||||
|
**The token is bound to its registry.** `RawCommandApprovals.issue` is the only source, and
|
||||||
|
`verify` refuses a token from a different registry instance, a token issued for another policy, and
|
||||||
|
an approval that is not byte-for-byte the registered one. That last check is the one that matters:
|
||||||
|
without it a caller could present a widened copy of a real approval and keep the real policy id.
|
||||||
|
|
||||||
|
**Keys are parsed back, not taken on trust.** Arguments reach the gateway as opaque bytes, so the
|
||||||
|
catalog's key specification locates the key positions and `RedisOperationContext.parseKey` — the
|
||||||
|
same strict parse `SCAN` uses — turns each one back into a `QualifiedRedisKey`. A key outside the
|
||||||
|
bound namespace or one that does not follow the key grammar is refused before anything is sent. A
|
||||||
|
`movable` key specification cannot be checked without asking the server with `COMMAND
|
||||||
|
GETKEYSANDFLAGS`, so it is refused at registration time; `SORT` and `SORT_RO` are therefore
|
||||||
|
classified `RAW_ONLY` but not approvable until that lookup exists.
|
||||||
|
|
||||||
|
**Every RAW_ONLY command now names a permit policy.** The guard's rule is that an R2 command always
|
||||||
|
states the policy that authorised it. The raw path used to be the one place that rule did not hold,
|
||||||
|
so `raw-command` was added to the three `RAW_ONLY` entries and the gateway presents the SDK permit
|
||||||
|
for it. The approval registry still decides *which* commands a deployment may send; the permit is
|
||||||
|
what keeps the guard's invariant true on this path too.
|
||||||
|
|
||||||
|
**Everything else was already built.** Reachability, minimum version, risk refusal, and the timeout
|
||||||
|
profile come from the catalog; namespace and same-slot from the guard; the audit record from the
|
||||||
|
executor's observation, which carries the command family and latency and never a key or a value.
|
||||||
|
The one new seam method, `sendApprovedRaw`, takes a `CommandId` rather than a string — by the time
|
||||||
|
it is reached the identity has already been validated, classified, and matched to an approval.
|
||||||
|
|
||||||
|
## 17. Task 23 — the admin plane is defined by what it cannot do
|
||||||
|
|
||||||
|
Design section 14.2 lists what the admin plane must never reach. None of it is enforced by
|
||||||
|
`RedisAdminOperations` omitting a method — omission is not enforcement, because the next person to
|
||||||
|
add one would not notice. `FLUSHDB`, `FLUSHALL`, `SHUTDOWN`, `DEBUG`, `CONFIG SET`, `CONFIG REWRITE`,
|
||||||
|
`CLIENT KILL`, `ACL SETUSER`, `ACL DELUSER`, `SLOWLOG RESET`, `LATENCY RESET`, `SCRIPT FLUSH`,
|
||||||
|
`FUNCTION FLUSH`, and `MODULE UNLOAD` are all `BLOCKED` in the catalog, which means no path in the
|
||||||
|
SDK can send them, and a test asserts that list rather than trusting it.
|
||||||
|
|
||||||
|
**Every diagnostic is checked against the catalog before it is built.** Not classified
|
||||||
|
`ADMIN_ONLY`, or not read-only, and it is refused. That check is what stops a future addition to
|
||||||
|
this class from quietly becoming a write.
|
||||||
|
|
||||||
|
**Replies are projected, not forwarded.** A slow log entry carries the command family and drops the
|
||||||
|
arguments; a client entry carries id, age, idle, and last command and drops the peer address and the
|
||||||
|
connection name. Both are read by an operator and end up in dashboards and tickets, and the dropped
|
||||||
|
fields are exactly the caller and tenant identity that must not travel that way. The command family
|
||||||
|
is enough to find a call site; an address is not needed to find a leaking pool.
|
||||||
|
|
||||||
|
**A key is still a key.** `MEMORY USAGE` takes a `QualifiedRedisKey` and goes through the guard, so
|
||||||
|
an admin diagnostic cannot read a key outside the bound namespace. An absent key reports {@code -1},
|
||||||
|
not zero, because "this key uses no memory" and "this key does not exist" are different answers.
|
||||||
|
|
||||||
|
**Separation is structural, not documentary.** The plane takes its own gateway, bound to the admin
|
||||||
|
account's own connection, the same way the blocking operations take theirs. What that cannot enforce
|
||||||
|
is that the deployment actually configured a separate ACL account — which is precisely why the
|
||||||
|
dangerous commands are blocked catalog-wide rather than left to the credentials to prevent.
|
||||||
|
|
||||||
|
## 18. Tasks 24–25 — four extensions, one seam, and the checks the guard cannot do
|
||||||
|
|
||||||
|
All four extension families share `ExtensionCommandRunner`, so every extension command declares its
|
||||||
|
key and is namespace- and slot-checked exactly like a classic one. Sharing the runner is also what
|
||||||
|
stops them drifting apart on the parts that matter.
|
||||||
|
|
||||||
|
**The probe is the authority, the version is a pre-filter.** A managed Redis 8 with no module loaded
|
||||||
|
reports the version and not the commands, so each bean is created through `ifSupported(...)` and a
|
||||||
|
deployment without the module simply has no instance. Catalog minimums are the second gate, not the
|
||||||
|
first.
|
||||||
|
|
||||||
|
**Bounds are in the types, not in a caller's discipline.** A `JsonPath` is validated against a
|
||||||
|
narrow grammar — roots, members, indices, recursive descent — so a path assembled from request data
|
||||||
|
cannot become `$` and replace a whole document. A `TimeSeriesSample` series is created with a
|
||||||
|
retention or not at all; unlike a stream there is no per-append trim to fall back on. A `SearchQuery`
|
||||||
|
carries its offset, page size, and timeout, so "read the whole index" cannot be written. Every
|
||||||
|
probabilistic structure is reserved with an explicit error rate and capacity, because one created
|
||||||
|
implicitly by its first write gets server defaults and saturates into answering "probably present"
|
||||||
|
for everything.
|
||||||
|
|
||||||
|
**The interfaces say the answers are approximate.** `probablyContains`, `estimateCount`,
|
||||||
|
`estimateQuantile` — a false-positive rate does not become a correctness bug because someone read a
|
||||||
|
method called `contains`.
|
||||||
|
|
||||||
|
**Search is the one place the guard cannot help.** An `FT` command addresses an index, and an index
|
||||||
|
is not a key, so there is no key on the request to namespace-check. The index name is therefore a
|
||||||
|
validated type rendered with the process's namespace prefix by the operations class, and the key
|
||||||
|
prefix an index covers is rendered the same way. An index can only be created over — and queried
|
||||||
|
against — documents this process owns, and that rule lives in one method rather than in a review
|
||||||
|
checklist. `FT.DROPINDEX` is `BLOCKED` for the whole SDK: dropping an index is a destructive
|
||||||
|
operational action, and an accidental one is indistinguishable from a search that suddenly returns
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
**What is still owed:** evidence against real modules. Nothing here proves how RedisJSON, the query
|
||||||
|
engine, Time Series, or the probabilistic structures actually reply — the fixture answers with what
|
||||||
|
the design says they answer. That is Task 26's lane.
|
||||||
|
|
||||||
|
## 19. The "dead capability property blocks" item was wrong
|
||||||
|
|
||||||
|
Earlier notes in this delivery listed `app-bootstrap/src/main/resources/application.yml`, `src/.env`,
|
||||||
|
and `docs/registries/env-keys.yaml` as carrying dead property blocks for five removed capabilities
|
||||||
|
(cache, session, idempotency, lease, rate-limit), to be deleted together because `verifyEnvKeys` is
|
||||||
|
fail-closed.
|
||||||
|
|
||||||
|
That is not true for at least three of them. `ca-skeleton.capabilities.rate-limit.provider`,
|
||||||
|
`.idempotency.provider`, and `.lease.provider` are read at startup by
|
||||||
|
`dev.caskeleton.bootstrap.runtime.SecretSourceValidator`, which refuses to start when a provider is
|
||||||
|
selected without its HMAC secret, and `SecretSourceValidatorTest` covers all three. Deleting those
|
||||||
|
blocks would remove a live startup check and break the test.
|
||||||
|
|
||||||
|
`app.rate-limit.*` is a separate, also live tree bound by `EdgeRateLimitTransportSettings` in
|
||||||
|
`adapter:inbound:web`; it is not the same property as the capability selector above and the two must
|
||||||
|
not be conflated.
|
||||||
|
|
||||||
|
The `ca-skeleton.capabilities.cache.canonical.*` and `ca-skeleton.security.redis-session.*` blocks
|
||||||
|
have no binder that a source search finds, so they may genuinely be residue — but "no binder found"
|
||||||
|
is not the same as "unused", and removing keys from a fail-closed three-file invariant on that basis
|
||||||
|
is not a change worth making without auditing each key's consumers. No cleanup was performed.
|
||||||
|
|
||||||
|
## 20. Tasks 26–27 — the harness landed, the evidence did not
|
||||||
|
|
||||||
|
I previously described these two as blocked on a real server. That was wrong and worth correcting:
|
||||||
|
the *evidence* needs servers, but the harness, the ACL accounts, the docs gates, and the CI wiring
|
||||||
|
are all files, and they are now in the repository.
|
||||||
|
|
||||||
|
**What landed.**
|
||||||
|
|
||||||
|
- `infra/redis-sdk/{standalone,sentinel,cluster}/compose.yml` — three lanes, version-parameterised so
|
||||||
|
one file serves every row of the support matrix. Sentinel runs three sentinels because a
|
||||||
|
two-sentinel quorum cannot survive losing one, and a failover test that cannot lose a sentinel is
|
||||||
|
not testing failover. Cluster runs six nodes so a promotion can be forced without losing a shard,
|
||||||
|
and waits for slot assignment before tests start.
|
||||||
|
- `infra/redis-sdk/acl/*.acl` — one account per `CommandAccess` level, each deliberately narrower
|
||||||
|
than the SDK's own rules. The account is the last boundary and a permit never widens it, so a
|
||||||
|
mistake in the SDK is still refused by the server.
|
||||||
|
- `redisTopologyTest`, a Gradle lane tagged `redis-topology` and excluded from the default unit task.
|
||||||
|
It **fails closed**: selecting it without host, port, and mode is a `GradleException`, and
|
||||||
|
`RedisTopologyEndpoint` refuses to default to `localhost:6379`. A topology test that silently
|
||||||
|
passes because it never connected is worse than not having one.
|
||||||
|
- `docs/redis/support-matrix.md`, which `RedisSupportMatrixTest` parses. A package or a capability
|
||||||
|
that is not listed fails the build, so stating the support level is part of shipping a module
|
||||||
|
rather than a follow-up someone remembers. The certified-version table says "lane declared, not
|
||||||
|
run" for all three topologies, and the test asserts that string — a certified version cannot be
|
||||||
|
claimed from a lane that has never produced evidence.
|
||||||
|
- `docs/redis/command-policy.md`, `operations.md`, `upgrade-guide.md`. The upgrade guide states why
|
||||||
|
each check exists, not just that it is required: an unclassified command is refused, but a command
|
||||||
|
whose risk changed upstream and is still classified R1 here is not; a rollback that leaves a
|
||||||
|
process holding stale script digests produces `NOSCRIPT` on every scripted call.
|
||||||
|
- `.github/workflows/redis-sdk-topology.yml`, manual-dispatch only, plus two new entries in
|
||||||
|
`.github/ci-gate-matrix.yml` — the support matrix as a release-blocking contract test, and the
|
||||||
|
topology evidence as explicitly `delegated-pending`. The gate count moved from 27 to 29.
|
||||||
|
|
||||||
|
**What did not land: the evidence.** No assertion in `RedisTopologyContractTest` yet exercises a
|
||||||
|
promotion, a resharding, an ACL denial, or the guardrail datasets from the plan (1 MiB string,
|
||||||
|
hundred-thousand-element collections, a million-entry trimmed stream, a five-hundred-command
|
||||||
|
pipeline). Writing those assertions against a lane that has never been started would produce tests
|
||||||
|
whose first run is also their first review, so the lane is fail-closed and the support matrix says
|
||||||
|
plainly that nothing is certified. That is the honest state, and the harness is what makes closing
|
||||||
|
it a bounded piece of work rather than a project.
|
||||||
|
|
||||||
|
## 21. The standalone lane ran, and it found five defects
|
||||||
|
|
||||||
|
The lane in `infra/redis-sdk/standalone` was started against Redis 7.4 and
|
||||||
|
`RedisTopologyContractTest` now asserts, for every account in `infra/redis-sdk/acl`, that the
|
||||||
|
`CommandAccess` level grants exactly what the command policy catalog says it may issue. Seven tests
|
||||||
|
pass. Getting there required fixing five things that reading the files would never have surfaced:
|
||||||
|
|
||||||
|
1. **The ACL files did not load at all.** A Redis `aclfile` accepts nothing but complete `user`
|
||||||
|
lines — no comments, no line continuations — and the server refused to start. The rationale moved
|
||||||
|
to `infra/redis-sdk/acl/README.md`, and the four accounts are concatenated into
|
||||||
|
`all-accounts.acl` because Redis takes one `aclfile`.
|
||||||
|
2. **The advanced account granted `SMEMBERS` and `SORT`.** Both are `RAW_ONLY`, so they belong to the
|
||||||
|
raw gateway account alone. This is the defect worth caring about: the ACL account is the last
|
||||||
|
enforcement boundary and a permit never widens it, so an account wider than the catalog silently
|
||||||
|
removes the second control the whole raw-gateway design rests on.
|
||||||
|
3. **The ordinary account granted `SORT_RO`,** for the same reason.
|
||||||
|
4. **The ordinary account could not run `PUBLISH`, `SUBSCRIBE`, or `PING`,** all classified `TYPED`.
|
||||||
|
5. **The ordinary account could not run `MULTI`, `EXEC`, `UNWATCH`, or `DISCARD`,** also `TYPED`.
|
||||||
|
|
||||||
|
6. **The admin account was missing twelve read-only diagnostics** the catalog exposes: the `OBJECT`,
|
||||||
|
`PUBSUB`, and `XINFO` subcommands, `FUNCTION LIST`/`STATS`, and `CLUSTER KEYSLOT`. Closing this
|
||||||
|
also forced a decision: `FUNCTION LOAD` is `ADMIN_ONLY` but not read-only, and granting it to an
|
||||||
|
account named `admin-readonly` would make the name a lie. Loading a library is a deployment
|
||||||
|
action with its own credentials, so the assertion covers read-only `ADMIN_ONLY` commands only.
|
||||||
|
|
||||||
|
There was also a defect in the test itself, which is worth recording because it is the failure mode
|
||||||
|
this kind of test usually dies of: `ACL DRYRUN` checks arity *before* permission, so probing a
|
||||||
|
command with the wrong number of arguments answers "wrong number of arguments" for an account that
|
||||||
|
would have been refused anyway. Reading that as a grant makes the test pass while the account is
|
||||||
|
wrong. The probe now walks argument counts until the server actually answers the permission
|
||||||
|
question. A second one followed it: a command the server does not carry answers "not found", and
|
||||||
|
skipping that without checking the catalog's minimum version is how a real ACL gap hides behind a
|
||||||
|
module that happens not to be installed. An absent command is now only tolerated when the catalog
|
||||||
|
already says the server is too old for it.
|
||||||
|
|
||||||
|
`docs/redis/support-matrix.md` records standalone 7.4 as "ACL contract verified"; Sentinel and
|
||||||
|
Cluster remain "lane declared, not run", and `RedisSupportMatrixTest` still asserts that string.
|
||||||
|
|
||||||
|
**Still owed on this task:** the guardrail datasets (1 MiB string, hundred-thousand-element
|
||||||
|
collections, a million-entry trimmed stream, a five-hundred-command pipeline) and the fault
|
||||||
|
injection — promotion on the Sentinel lane, resharding on the Cluster lane. Those are the assertions
|
||||||
|
`ExecutionCertainty` and `RedisSlotCalculator` were built to be checked against.
|
||||||
|
|
||||||
|
## 22. The guardrail run found the first real SDK defect
|
||||||
|
|
||||||
|
`LiveRedisGuardrailTest` is the first thing that puts `LettuceRedisCommandGateway` under the SDK's
|
||||||
|
own contracts against a live server. Everything before it ran against
|
||||||
|
`InMemoryRedisCommandGateway`, which is a deterministic stand-in and answers what the design says it
|
||||||
|
should — so an encoding or budgeting mistake could not show up there by construction.
|
||||||
|
|
||||||
|
It found one immediately, and it is a good example of the class of bug a fake cannot catch:
|
||||||
|
|
||||||
|
**The cursor-scan reply budget was sized to the requested `COUNT`.** Redis treats `COUNT` as a hint,
|
||||||
|
not a limit: it walks whole hash buckets and listpack entries and returns what it found. A real
|
||||||
|
`HSCAN` asked for 500 came back with 501, and the SDK rejected a perfectly correct reply — a refusal
|
||||||
|
the caller can neither act on nor avoid. `RedisOperationContext.scanBudget` now accepts the
|
||||||
|
configured scan ceiling plus a fixed overshoot allowance, which is still a bound: a server returning
|
||||||
|
an order of magnitude more than it was asked for is refused. All four scan sites (key, hash, set,
|
||||||
|
sorted set) use it.
|
||||||
|
|
||||||
|
The rest of the datasets passed unchanged: the 1 MiB value ceiling holds and one byte over never
|
||||||
|
leaves the process; a hundred-thousand-field hash refuses `HGETALL` and is only reachable by cursor;
|
||||||
|
a stream trimmed to 1,000 stays trimmed while twenty thousand entries are appended; a
|
||||||
|
five-hundred-command batch reports every item positionally.
|
||||||
|
|
||||||
|
**Still owed:** Sentinel promotion and Cluster resharding. Those need their own lanes started, and
|
||||||
|
they are where `ExecutionCertainty` and `RedisSlotCalculator` finally get checked against reality.
|
||||||
|
|
||||||
|
## 23. The Sentinel and Cluster lanes ran, and the worst defect was not in the code
|
||||||
|
|
||||||
|
Both remaining lanes now produce evidence. `docs/redis/support-matrix.md` records which test
|
||||||
|
produced which, and `RedisSupportMatrixTest` no longer asserts the literal string
|
||||||
|
`"lane declared, not run"` — that gate worked only until the lanes ran, and a gate that has to be
|
||||||
|
deleted the moment it binds was never a gate. It now requires every evidence claim to name a test
|
||||||
|
class that exists in the source tree, which is a rule that survives the lanes running.
|
||||||
|
|
||||||
|
### The harness had to be fixed before it could produce anything
|
||||||
|
|
||||||
|
Neither compose file could have worked. Both published no ports, and more importantly both would
|
||||||
|
have advertised container-internal addresses: Sentinel answers `get-master-addr-by-name` with the
|
||||||
|
address it monitors and the client dials that itself, and a cluster client reads `CLUSTER SHARDS`
|
||||||
|
and connects to every node it names. On a bridge network a host client resolves a topology it cannot
|
||||||
|
reach. Both lanes now use host networking with fixed ports, which is the only arrangement where the
|
||||||
|
address the topology advertises is the address the client can use.
|
||||||
|
|
||||||
|
Three smaller harness defects went with it: the endpoint record assumed the declared address was a
|
||||||
|
data node (on the Sentinel lane it is a sentinel, so ACL assertions were being asked of the
|
||||||
|
sentinel's own accounts); the CI workflow passed `6379` for all three lanes; and `redisTopologyTest`
|
||||||
|
was cacheable, so Gradle reported a previous run's verdict as the current one against a lane that
|
||||||
|
had since been restarted and promoted. Lane selection is now derived from the declared mode
|
||||||
|
(`redis-topology & lane-<mode>`) so a promotion test is never selected on a standalone lane and
|
||||||
|
never silently skipped either.
|
||||||
|
|
||||||
|
### The finding: a superseded primary keeps acknowledging writes
|
||||||
|
|
||||||
|
This is the most serious thing this delivery has surfaced, and none of it is in the SDK's code.
|
||||||
|
|
||||||
|
Sentinel promoted the replica at `05:56:12.503` and did not demote the old primary until
|
||||||
|
`05:56:23.529`. For those eleven seconds the client stayed connected to a primary that had already
|
||||||
|
been replaced, wrote, and was told `+OK` **2,086 times**. Every one of those writes was discarded
|
||||||
|
when the old primary resynced from the new one — the server's own log says so:
|
||||||
|
`Partial resynchronization not accepted: Requested offset for second ID was 9897663, but I can reply
|
||||||
|
up to 9731839`. Exactly **one** command failed in the whole run.
|
||||||
|
|
||||||
|
There is no client-side signal for this. The server answered, so the driver recorded a success, the
|
||||||
|
SDK recorded `CONFIRMED_SUCCESS`, and the caller was told the write landed. A second run made the
|
||||||
|
point harder: sixteen thousand attempts, **zero** exceptions, 2,086 acknowledged writes gone.
|
||||||
|
|
||||||
|
`SentinelFailoverObserver` counts *ambiguous* writes and its documentation called those "the ones an
|
||||||
|
operator has to reconcile". That was wrong by three orders of magnitude — the writes that actually
|
||||||
|
needed reconciling were the confirmed ones, and no counter on the client can be made to include
|
||||||
|
them. The class now says so instead of implying it measures something it cannot.
|
||||||
|
|
||||||
|
What closes the window is server-side. Re-running the identical promotion with
|
||||||
|
`min-replicas-to-write 1` and `min-replicas-max-lag 1` cut acknowledged-and-discarded writes from
|
||||||
|
**2,086 to 1**: the orphaned primary refused 2,020 writes with `NOREPLICAS`, which the SDK already
|
||||||
|
translates to a definite, non-ambiguous failure. Both settings are in the lane, and
|
||||||
|
`acknowledgedWriteLossIsBounded` ties the tolerated loss to the configured lag window rather than to
|
||||||
|
a magic number.
|
||||||
|
|
||||||
|
### The assertion immediately caught a second version of the same mistake
|
||||||
|
|
||||||
|
The first run with the setting passed. The second failed, with 2,099 lost writes — because the
|
||||||
|
setting had been written into the `primary` service only. These two nodes swap roles on every
|
||||||
|
failover, so a guardrail applied to whichever one happens to start as primary stops applying the
|
||||||
|
moment the lane does the thing it exists to do. Both data nodes now take their whole configuration
|
||||||
|
from one definition, which makes the asymmetry impossible to reintroduce. Three consecutive
|
||||||
|
promotions in both directions since: 0, 0, and 1 acknowledged write lost.
|
||||||
|
|
||||||
|
### One real translator defect
|
||||||
|
|
||||||
|
The promotion closed the channel under an in-flight `RPUSH` and Lettuce raised a bare
|
||||||
|
`RedisException`, which matched no branch of `LettuceExceptionTranslator` and fell through to a
|
||||||
|
generic failure reported with `ambiguous=false` — that is, as a write that *definitely did not run*.
|
||||||
|
Nothing about an unrecognised failure supports that claim, and a caller who believes it retries a
|
||||||
|
non-idempotent write. The fallback now treats an unclassified write failure as ambiguous, which is
|
||||||
|
the safe direction, and two unit tests pin both branches.
|
||||||
|
|
||||||
|
### Cluster: the arithmetic holds
|
||||||
|
|
||||||
|
`LiveRedisClusterTest` checked `RedisSlotCalculator` against `CLUSTER KEYSLOT` over a corpus built
|
||||||
|
from the brace rules a hand-written implementation gets wrong — `{}`, `a{}b`, `foo{}{bar}`,
|
||||||
|
`foo{{bar}}zap`, `foo{bar}{zap}`, `{`, `}`, `}{`, an unclosed brace, the empty key, and non-ASCII
|
||||||
|
keys. No disagreements, and the result was reproduced independently against the server outside the
|
||||||
|
test. The rendered-key invariant holds too: the slot the SDK computes from a tag alone equals the
|
||||||
|
slot the server computes from the whole rendered key, which is what makes the two-step design sound.
|
||||||
|
|
||||||
|
Cross-slot refusal was checked in both directions, because a guard stricter than the cluster costs
|
||||||
|
availability for nothing and a looser one sends requests that cannot succeed; the pair the guard
|
||||||
|
refuses is the pair the server answers `CROSSSLOT` for. Redirects were observed rather than assumed:
|
||||||
|
a `MOVED` names the slot the client computed, and a slot put into a real `MIGRATING`/`IMPORTING`
|
||||||
|
state answers `ASK` for an absent key and `TRYAGAIN` for a multi-key request that straddles the
|
||||||
|
migration. The lane restores the slot to `STABLE`, so a run leaves the cluster as it found it.
|
||||||
|
|
||||||
|
Nothing in `sdk.cluster` needed changing. That is worth recording as an outcome, not treated as the
|
||||||
|
test having nothing to say: the calculator is the one piece of this SDK that silently degrades into
|
||||||
|
wrong refusals and wrong admissions if it is off by one, and it is now checked rather than assumed.
|
||||||
|
|
||||||
|
### Where this leaves the task
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Unit | 288 tests, 0 failures |
|
||||||
|
| Standalone lane | 14 tests, 0 failures |
|
||||||
|
| Sentinel lane | 8 tests, 0 failures, three promotions in both directions |
|
||||||
|
| Cluster lane | 14 tests, 0 failures |
|
||||||
|
| `check` + architecture/env/public-path | green for `adapter:outbound:cache-redis` |
|
||||||
|
| `verify-gate-matrix.sh` | 29 gates, 27 verified, 2 delegated-pending, OK |
|
||||||
|
|
||||||
|
Defects found and fixed across the whole evidence effort: six in the ACL accounts, two in the ACL
|
||||||
|
test itself, one in the scan budget, four in the topology harness, one in the Sentinel lane's
|
||||||
|
configuration, one in the exception translator, and one documentation claim that was wrong by three
|
||||||
|
orders of magnitude.
|
||||||
|
|
||||||
|
`:app-bootstrap:test --tests '*CleanArchitectureTest'` passes. It briefly did not, on
|
||||||
|
`NO_UUID_RANDOM_IN_CONTROLLER` in `application.fileserver.cleanup.CleanupItem` — untracked
|
||||||
|
in-progress work from a different feature that was being edited while this evidence ran. The
|
||||||
|
identifier factories have since moved to `CleanupRequest` and no direct `UUID.randomUUID` or
|
||||||
|
`UuidCreator` call remains in `application-core`, so the rule is satisfied by the current sources
|
||||||
|
rather than waived.
|
||||||
|
|
||||||
|
**Task 20 is the only implementation task left.**
|
||||||
|
|
||||||
|
## 24. Task 20 — the fixture had to learn to defer before the contract meant anything
|
||||||
|
|
||||||
|
Task 20 was deferred back at section 15 for a reason that turned out to be the whole task: the
|
||||||
|
in-memory fixture executes every command the moment it is called, so a transaction written against
|
||||||
|
it would have passed while proving the opposite of what it claimed. The writes would already have
|
||||||
|
happened before the commit, and a watch conflict would have had nothing left to discard.
|
||||||
|
|
||||||
|
The controller chose the full option — every command available inside the window, and the fixture
|
||||||
|
reworked to match — over a narrow hand-picked subset.
|
||||||
|
|
||||||
|
### Deferral is one property, not a hundred and eleven
|
||||||
|
|
||||||
|
`RedisCommandGateway` has 111 methods and every one of them returns a `CompletionStage`. That is not
|
||||||
|
incidental: deferral is a property of the *connection*, so it can be implemented once rather than
|
||||||
|
per command.
|
||||||
|
|
||||||
|
On the production side it costs nothing at all. Lettuce already defers everything issued after
|
||||||
|
`MULTI` and completes those futures from the `EXEC` reply, so `LettuceRedisCommandGateway` needed no
|
||||||
|
change to any existing method — only the five new seam methods (`watch`, `unwatch`,
|
||||||
|
`beginTransaction`, `commitTransaction`, `discardTransaction`). `commitTransaction` returns a
|
||||||
|
boolean rather than a list of results, because the per-command stages resolve themselves and the
|
||||||
|
only thing `EXEC` alone can say is whether it ran.
|
||||||
|
|
||||||
|
On the test side, `DeferringRedisCommandGateway` is a `java.lang.reflect.Proxy` that records an
|
||||||
|
invocation, hands back an unfinished future, and replays it against the fixture at commit — which is
|
||||||
|
exactly when Redis runs it. The 1,996-line fixture was not edited for it. The consequence that
|
||||||
|
matters: a command added to the seam later cannot forget to be transactional.
|
||||||
|
|
||||||
|
The one part that does need the data is the watch check, so that lives in the fixture. It hashes the
|
||||||
|
watched key's current contents rather than incrementing a counter at each of the sixteen mutation
|
||||||
|
sites — a counter is something a seventeenth mutation can silently fail to update, and a hash is not.
|
||||||
|
|
||||||
|
### What the contract refuses to let a caller do
|
||||||
|
|
||||||
|
`QueuedReply.value()` throws before the commit. The alternative — returning `null` or a zero for a
|
||||||
|
command the server has only answered `+QUEUED` to — is the trap the type exists to remove.
|
||||||
|
|
||||||
|
`TransactionResult` reports exactly two outcomes, "executed" and "a watched key changed so nothing
|
||||||
|
ran", and neither is a rollback. Redis has none: a command that fails at runtime inside `EXEC` does
|
||||||
|
not undo the ones around it, and the proxy reproduces that faithfully by failing one future and
|
||||||
|
leaving the rest alone.
|
||||||
|
|
||||||
|
`RedisTransactionQueue` is write-only, which is a contract rather than an unfinished surface. A read
|
||||||
|
inside the window cannot be branched on — its reply does not exist until every command has already
|
||||||
|
been chosen — so accepting one would only offer a way to write code that looks conditional and is
|
||||||
|
not. Reads a transaction depends on belong before it, under `WATCH`.
|
||||||
|
|
||||||
|
Queued commands go through `QueueingRedisCommandExecutor`, which is `SyncRedisCommandExecutor` with
|
||||||
|
the wait removed and *nothing else* changed. The same `CommandPolicyGuard` admits them, so namespace,
|
||||||
|
slot, permit, and budget rules hold identically: a transaction is not a way around the guard, and a
|
||||||
|
test asserts that a foreign-namespace key is refused inside a window exactly as it is outside one.
|
||||||
|
|
||||||
|
### Three defects the tests found
|
||||||
|
|
||||||
|
1. **A callback returning nothing crashed the transaction.** `Optional.of` on a null body result
|
||||||
|
threw an NPE after a perfectly successful commit. A transaction with no interesting return value
|
||||||
|
is entirely normal, so the result now carries an empty value for it and the invariant only forbids
|
||||||
|
a value on a transaction that did not execute.
|
||||||
|
2. **`RedisTransactionQueue.delete` could never succeed.** `DEL` is R2 in the catalog because it
|
||||||
|
accepts any number of keys, so it needs a permit and a budget even when a transaction queues
|
||||||
|
exactly one. The queue presents the SDK's own permit rather than making every caller thread one
|
||||||
|
through for a single-key delete.
|
||||||
|
3. **The first conflict test was contending with itself.** It wrote the watched key through the same
|
||||||
|
gateway — that is, from inside the very window it was supposed to be contending with — so the
|
||||||
|
write was queued rather than applied and the transaction timed out instead of conflicting. A
|
||||||
|
competing writer has to come from another connection, and the test now has one. This is the kind
|
||||||
|
of mistake that would have produced a green test if the fixture had not been deferring.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Unit | 296 tests, 0 failures |
|
||||||
|
| `check` | green for `adapter:outbound:cache-redis` |
|
||||||
|
|
||||||
|
**Every implementation task in the plan is now done.**
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Release Hygiene Refactoring Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-01
|
||||||
|
**Status:** approved by the user's instruction to apply the preceding review
|
||||||
|
**Scope:** release-blocking architecture test, Gradle wrapper supply-chain integrity, Docker build configuration inputs, SpotBugs analysis completeness, and the observed Gradle 10 deprecation
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repository-wide review found that the 19-leaf Clean Architecture dependency model is healthy,
|
||||||
|
but the release surface is not green:
|
||||||
|
|
||||||
|
- `:app-bootstrap:sampleOffTest` fails because a whole-composition Object Storage ArchUnit rule is
|
||||||
|
evaluated on the intentionally sample-free classpath with `allowEmptyShould(false)`.
|
||||||
|
- the two Dockerfiles run Gradle before copying configuration-time registry inputs, while the root
|
||||||
|
build also requires a Git checkout during configuration even though the Docker context excludes
|
||||||
|
`.git`;
|
||||||
|
- `gradle-wrapper.properties` selects Gradle 9.0.0 while the checked-in wrapper JAR is from another
|
||||||
|
official Gradle release, and the distribution checksum is absent;
|
||||||
|
- clean SpotBugs analysis reports missing Spring Session, Micrometer Context Propagation, and
|
||||||
|
protobuf classes;
|
||||||
|
- a root task calls `Task.project` during execution, which is deprecated and scheduled to fail in
|
||||||
|
Gradle 10.
|
||||||
|
|
||||||
|
This design deliberately closes those release-hygiene defects before changing idempotency, outbox,
|
||||||
|
security, or sample data behavior. Each later subsystem gets a separate design and plan so that a
|
||||||
|
reviewer can accept or revert it independently.
|
||||||
|
|
||||||
|
## Considered Approaches
|
||||||
|
|
||||||
|
### Approach A: weaken the existing global gates
|
||||||
|
|
||||||
|
Set ArchUnit rules to allow empty matches, ignore SpotBugs missing-class messages, and make Docker
|
||||||
|
configuration registries optional. This is the smallest diff, but it makes the architecture and
|
||||||
|
static-analysis gates less trustworthy. Rejected.
|
||||||
|
|
||||||
|
### Approach B: patch each symptom in place
|
||||||
|
|
||||||
|
Condition the ArchUnit rule on a sample flag, copy only the two currently missing registry files,
|
||||||
|
and add the three currently missing SpotBugs JARs manually. This would pass today's cases but would
|
||||||
|
recur whenever another leaf, registry, source set, or dependency is added. Rejected because it
|
||||||
|
duplicates ownership knowledge.
|
||||||
|
|
||||||
|
### Approach C: align ownership and derive inputs from the owning model
|
||||||
|
|
||||||
|
Move the leaf-specific architecture rule to the Object Storage leaf, keep root tests responsible
|
||||||
|
for cross-leaf registration, treat `config/**` as a declared Docker configuration input, move Git
|
||||||
|
evidence checks to the evidence task execution phase, align the wrapper artifacts to one version,
|
||||||
|
and derive SpotBugs auxiliary inputs from each analyzed source set's runtime classpath. Selected.
|
||||||
|
|
||||||
|
## Architecture Test Ownership
|
||||||
|
|
||||||
|
`adapter-outbound-objectstorage` owns rules about the public types of its production adapter methods.
|
||||||
|
The rule moves out of `app-bootstrap` and runs in the Object Storage module's normal test suite.
|
||||||
|
It remains strict: the Object Storage module must contain matching production classes and the rule
|
||||||
|
must not globally allow an empty `should` clause.
|
||||||
|
|
||||||
|
`app-bootstrap` continues to own cross-module rules. Its sample-off suite verifies that production
|
||||||
|
composition works without `sample-portfolio`; it does not require sample-only leaves to be present.
|
||||||
|
The existing module registry and dependency verification remain the SSOT for leaf coverage.
|
||||||
|
|
||||||
|
## Gradle Wrapper Integrity
|
||||||
|
|
||||||
|
Gradle 9.0.0 remains the selected version for this refactoring. The wrapper scripts, properties, and
|
||||||
|
JAR are regenerated from Gradle 9.0.0 in a trusted environment. The official 9.0.0 binary
|
||||||
|
distribution SHA-256 is recorded as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper properties are one exact ordered eight-line byte contract, preventing Java Properties
|
||||||
|
duplicate-key, separator, escape, and continuation semantics from overriding the reviewed values.
|
||||||
|
The complete six-file workflow path set and every workflow's SHA-256 are embedded as a reviewed
|
||||||
|
byte lock in the verifier. This is the primary completeness boundary: YAML has aliases, encoded
|
||||||
|
keys, duplicate-key overrides, custom shells, and other equivalent representations that a partial
|
||||||
|
Bash parser cannot safely model. Any workflow addition, removal, rename, symlink replacement, or
|
||||||
|
byte change fails until the complete workflow diff is intentionally reviewed and the sorted lock
|
||||||
|
is refreshed in the same change.
|
||||||
|
|
||||||
|
The restricted block-style workflow grammar remains defense in depth and supplies actionable
|
||||||
|
diagnostics for ordinary drift. Every Gradle-running job uses an unconditional validation step
|
||||||
|
with a stable ID and the action pinned by commit SHA. Checkout and validation precede every Gradle
|
||||||
|
invocation, not only the first; a cleanup/sanitizer step that intentionally uses `always()` also
|
||||||
|
requires the validation step's successful outcome. This is consistent with the repository's
|
||||||
|
existing pinned `actions/setup-java` policy and prevents wrapper failure from being bypassed by
|
||||||
|
step conditions.
|
||||||
|
|
||||||
|
## Docker Configuration Contract
|
||||||
|
|
||||||
|
Both Docker build dependency-cache stages preserve the repository layout with `WORKDIR /build/src`
|
||||||
|
and copy the complete `config/**` tree before invoking Gradle. The parent `/build` is therefore the
|
||||||
|
repository root expected by registry `source_path: src/**` entries. This is intentional: Gradle
|
||||||
|
configuration registries and their repository-relative path base are build inputs, while the
|
||||||
|
registry's exact internal file list may evolve.
|
||||||
|
|
||||||
|
Git revision validation no longer runs unconditionally while the build script is being configured.
|
||||||
|
A root-owned resolver is invoked once from each root evidence action or leaf evidence test's
|
||||||
|
root-suite completion action; eager scalar evidence properties are removed. Only evidence-producing
|
||||||
|
tasks resolve the checkout revision during their execution. Docker builds provide
|
||||||
|
`-PgitRevision=<40 lowercase hex>` and do not copy `.git` into the image context.
|
||||||
|
|
||||||
|
The boot JAR path is obtained from Gradle's archive output contract rather than selecting the first
|
||||||
|
filesystem match. The final images retain the existing digest-pinned base image, non-root user,
|
||||||
|
read-only root filesystem, and JRE-only runtime.
|
||||||
|
|
||||||
|
## SpotBugs and Gradle 10 Compatibility
|
||||||
|
|
||||||
|
Every SpotBugs task analyzes a named source set and receives that source set's runtime classpath as
|
||||||
|
its auxiliary analysis classpath, excluding its own compiled output. Custom test source sets are
|
||||||
|
covered by the same rule. No production dependency scope is widened merely to silence SpotBugs.
|
||||||
|
|
||||||
|
Missing-analysis-class output is treated as a gate failure. The clean gate must produce zero
|
||||||
|
`classes needed for analysis were missing` messages.
|
||||||
|
|
||||||
|
The observed Gradle 10 deprecation is removed by capturing the application-core project during
|
||||||
|
configuration instead of calling `Task.project` from the task action. The dependency-purity gate
|
||||||
|
still traverses that project's configurations during execution, so it explicitly opts out of the
|
||||||
|
configuration cache rather than claiming serializable declared inputs it does not have.
|
||||||
|
|
||||||
|
## Error Handling and Failure Semantics
|
||||||
|
|
||||||
|
- sample-off fails only for a real production composition or architecture violation;
|
||||||
|
- an empty Object Storage rule in its owning module is a test failure;
|
||||||
|
- a wrapper JAR or distribution checksum mismatch fails before Gradle build logic executes in CI;
|
||||||
|
- missing Docker configuration input fails with a named build-contract test rather than an opaque
|
||||||
|
settings error;
|
||||||
|
- invalid or absent `gitRevision` fails only an evidence task that requires it;
|
||||||
|
- SpotBugs missing classes fail static analysis instead of producing a successful partial report.
|
||||||
|
|
||||||
|
## Verification Design
|
||||||
|
|
||||||
|
The implementation follows red-green-refactor. Each behavior has a regression test or executable
|
||||||
|
contract that fails before the production/configuration change:
|
||||||
|
|
||||||
|
1. reproduce `sampleOffTest` failure, then add an owner-module architecture test and remove the
|
||||||
|
misplaced global rule;
|
||||||
|
2. add wrapper property and workflow contract assertions before regenerating the wrapper;
|
||||||
|
3. extend Docker contract tests so a cache-stage Gradle configuration fixture requires `config/**`
|
||||||
|
and accepts an attested `gitRevision` without `.git`;
|
||||||
|
4. add Gradle build-contract coverage for source-set-derived SpotBugs auxiliary classpaths, the
|
||||||
|
removed execution-time `Task.project` access, and the explicit configuration-cache opt-out;
|
||||||
|
5. run focused gates, then the clean repository-wide gate and gate-matrix script.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- no dependency version upgrade beyond aligning the wrapper to the already selected Gradle 9.0.0;
|
||||||
|
- no business/domain behavior changes;
|
||||||
|
- no idempotency, outbox, Poster publication, security, DTO, or database migration changes;
|
||||||
|
- no broad extraction of the 3,768-line root build script in this phase;
|
||||||
|
- no agent-created branch, stage, commit, amend, or push.
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
- Object Storage-specific ArchUnit rules live with Object Storage.
|
||||||
|
- Root architecture rules remain strict and cross-module only.
|
||||||
|
- Gradle stays at 9.0.0 and gains exact wrapper/distribution validation.
|
||||||
|
- Docker copies `config/**`; Git evidence is execution-scoped and supplied by `gitRevision`.
|
||||||
|
- SpotBugs uses source-set runtime classpaths and fails on missing analysis classes.
|
||||||
|
- The dependency-purity task avoids execution-time `Task.project` access and truthfully declares
|
||||||
|
its configuration-cache incompatibility while it still inspects project configurations.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Client-Safe Error Boundary Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-02
|
||||||
|
**Status:** approved by the user's instruction to apply the detailed P1/P2 review sequentially
|
||||||
|
**Scope:** HTTP error envelopes in `adapter:inbound:web` and the `sample-portfolio` domain advice
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Several handlers pass `Exception#getMessage()`, rejected request values, or a raw request URL into
|
||||||
|
the public error envelope. Those values are not a stable API contract and can contain identifiers,
|
||||||
|
tokens, uploaded values, configuration details, or internal diagnostics. Persistence and outbound
|
||||||
|
dependency failures already use fixed client-safe messages; the rest of the HTTP boundary must
|
||||||
|
follow the same rule.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The inbound adapter owns a message allowlist keyed by stable error code. Handlers may expose only:
|
||||||
|
|
||||||
|
- stable `code`, `category`, HTTP status, and `retryable` from `ApiErrorCode`;
|
||||||
|
- fixed, code-specific client messages;
|
||||||
|
- bounded structural details such as field name, validation reason code, expected Java type,
|
||||||
|
supported HTTP methods, or supported media types.
|
||||||
|
|
||||||
|
They must not expose exception messages, rejected values, raw request URLs, adapter/configuration
|
||||||
|
diagnostics, opaque cursors, authentication diagnostics, resource identifiers, or duplicate domain
|
||||||
|
values. Bean Validation interpolated/default messages are also discarded because custom templates
|
||||||
|
can include the validated value. Validation details contain only normalized server-owned property
|
||||||
|
names plus allowlisted reason codes and fixed messages; collection/map keys and indices are removed.
|
||||||
|
|
||||||
|
`ClientSafeErrorMessages` is extended for skeleton-wide operational codes. The sample keeps its
|
||||||
|
domain wording in a separate package-private `PortfolioClientSafeErrorMessages`, preserving the
|
||||||
|
rule that production modules do not know sample business concepts.
|
||||||
|
|
||||||
|
## Public Messages
|
||||||
|
|
||||||
|
Representative mappings are fixed as follows:
|
||||||
|
|
||||||
|
- `MAPPING_FAILED` → `Request data could not be mapped`;
|
||||||
|
- `BAD_PARAMETER` → `Request parameter is invalid`;
|
||||||
|
- `INVALID_TOKEN` → `Authentication token is invalid`;
|
||||||
|
- `UNAUTHENTICATED` → `Authentication is required`;
|
||||||
|
- authorization denials → `Access is denied`;
|
||||||
|
- `PRECONDITION_FAILED` → `Resource state changed; refresh and retry`;
|
||||||
|
- page/cursor failures → generic corrective text, with safe field/reason details retained;
|
||||||
|
- `ADAPTER_DISABLED` and internal classifications → `Internal server error`;
|
||||||
|
- domain not-found/conflict/invariant codes → fixed noun-level text with no ID/title value.
|
||||||
|
|
||||||
|
Transport overrides use fixed wording and retain only safe protocol metadata. For example, 405
|
||||||
|
still emits `Allow`, while both controller-route (`NoHandlerFoundException`) and static-resource
|
||||||
|
(`NoResourceFoundException`) 404s use the same envelope without echoing the request URL.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests inject conspicuous secret sentinels into exception messages, rejected values, URLs, tokens,
|
||||||
|
IDs, and duplicate titles. Every resulting response must preserve its status/code/category while
|
||||||
|
excluding the sentinel from both `error.message` and `error.details`.
|
||||||
|
|
||||||
|
Validation tests additionally place sentinels in interpolated/default messages and iterable
|
||||||
|
keys/indices. A real MockMvc resource-resolution request verifies the Spring 7
|
||||||
|
`NoResourceFoundException` path rather than calling the advice method directly.
|
||||||
|
|
||||||
|
The focused module suites remain the primary verification:
|
||||||
|
|
||||||
|
- `:adapter:inbound:web:test` for operational and transport handlers;
|
||||||
|
- `:sample-portfolio:test` for domain advice and sample wire behavior;
|
||||||
|
- `verifyCleanArchitectureDependencies` for dependency direction.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- no change to error codes, categories, statuses, or retryability;
|
||||||
|
- no suppression of server-side logs or tracing in this batch;
|
||||||
|
- no application/domain dependency on HTTP response types;
|
||||||
|
- no generic exception-message sanitizer based on regexes or truncation;
|
||||||
|
- no staging, commit, amend, or push by an agent.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Conditional Inbound Transport Boundary Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-02
|
||||||
|
**Status:** approved by the user's instruction to apply the detailed P1/P2 review sequentially
|
||||||
|
**Scope:** the opt-in GraphQL, gRPC, and WebSocket leaf modules and their release evidence
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The three leaves are registered and tested independently, but neither `app-bootstrap` nor
|
||||||
|
`sample-portfolio` has a production dependency on them. That omission is intentional: adding a
|
||||||
|
classpath edge today would activate GraphQL, start a plaintext/reflection-enabled gRPC server by
|
||||||
|
default, and unconditionally expose a wildcard-origin STOMP broker that serializes arbitrary domain
|
||||||
|
events. The leaf documentation nevertheless describes sample contributions that do not exist, and
|
||||||
|
the ordinary root `check` can become `NO-SOURCE` without a transport-specific positive-count and
|
||||||
|
zero-skip qualification gate.
|
||||||
|
|
||||||
|
P1 therefore makes opt-in status executable and makes accidental activation fail closed. It does
|
||||||
|
not add these leaves to the default runtime or claim the P2 production baselines.
|
||||||
|
|
||||||
|
## Runtime Membership SSOT
|
||||||
|
|
||||||
|
Every entry in `config/architecture/modules.json` gains an exact `runtime_memberships` array whose
|
||||||
|
values are limited to the two composition roots: `app-bootstrap` and `sample-portfolio`.
|
||||||
|
|
||||||
|
- A composition root includes itself in its membership.
|
||||||
|
- Direct production `api`/`implementation`/`compileOnly`/`runtimeOnly` project dependencies must
|
||||||
|
equal the registry members for that root, excluding the root itself.
|
||||||
|
- An empty array means the leaf is built and architecture-checked but absent from both shipped
|
||||||
|
runtime graphs. GraphQL, gRPC, WebSocket, and Mongo remain in this state.
|
||||||
|
- Test fixtures and custom qualification configurations do not change production membership.
|
||||||
|
|
||||||
|
Settings validation is fail-closed for missing, duplicate, or unknown membership names. A Gradle
|
||||||
|
verification task compares the registry to both composition roots and is part of `check`.
|
||||||
|
|
||||||
|
## Explicit Qualification Composition
|
||||||
|
|
||||||
|
`app-bootstrap` owns a `conditionalTransportTest` source set whose classpath explicitly includes
|
||||||
|
the three opt-in leaves. It proves that the opt-in artifacts resolve together while the registry
|
||||||
|
still declares them absent from both default runtime graphs. It is evidence composition, not a new
|
||||||
|
production dependency edge.
|
||||||
|
|
||||||
|
The root registers exact qualification `Test` tasks for GraphQL, gRPC, and WebSocket. Each task:
|
||||||
|
|
||||||
|
- names required test classes rather than broad discovery;
|
||||||
|
- fails on no match or no discovery;
|
||||||
|
- always reruns in UTC;
|
||||||
|
- fails if the root suite reports any skipped test.
|
||||||
|
|
||||||
|
An aggregate `conditionalTransportQualification` task depends on the composition contract and all
|
||||||
|
three exact lanes. CI invokes it explicitly from the existing release-blocking quality job, and the
|
||||||
|
gate matrix records the task.
|
||||||
|
|
||||||
|
## gRPC P1 Boundary
|
||||||
|
|
||||||
|
gRPC activation becomes explicit and local-only until a later TLS/mTLS design exists:
|
||||||
|
|
||||||
|
- `enabled=false` and `reflectionEnabled=false` are defaults; missing properties create no runner,
|
||||||
|
health manager, reflection service, or listener.
|
||||||
|
- The current insecure credential mode requires an explicit local-development override and a
|
||||||
|
loopback bind address. Non-loopback insecure bind fails startup.
|
||||||
|
- Feature services require a caller-supplied authentication policy/interceptor. Missing or invalid
|
||||||
|
metadata returns stable `UNAUTHENTICATED`; valid metadata reaches the service.
|
||||||
|
- Health remains a local lifecycle probe; reflection is a separate explicit flag.
|
||||||
|
- The error interceptor wraps `ServerCall.close`, so handler throws, listener throws, ordinary
|
||||||
|
`responseObserver.onError`, and raw `StatusRuntimeException` all pass the same sanitizer.
|
||||||
|
Recognized `ApiErrorCarrier` causes produce stable code/category trailers; unrecognized status
|
||||||
|
descriptions become fixed `INTERNAL_ERROR` with no raw diagnostic.
|
||||||
|
|
||||||
|
A real ephemeral Netty unary service verifies authentication, reflection-off, all error paths, and
|
||||||
|
sentinel redaction. TLS/mTLS, external bind, deadlines, streaming, and protobuf compatibility are
|
||||||
|
P2 and remain unclaimed.
|
||||||
|
|
||||||
|
## GraphQL P1 Boundary
|
||||||
|
|
||||||
|
GraphQL remains classpath-selected: its absence from the default runtime is the disable mechanism,
|
||||||
|
and the qualification classpath is the explicit opt-in mechanism. The wire lane starts a real
|
||||||
|
random-port MVC server and crosses HTTP JSON, Spring Security, and CORS.
|
||||||
|
|
||||||
|
It verifies unauthenticated rejection, authenticated health success, allowed/disallowed origins,
|
||||||
|
GraphiQL disabled, production-style introspection disabled, stable carrier errors, unknown errors,
|
||||||
|
and absence of distinct secret sentinels from the complete response body. The existing resolver is
|
||||||
|
changed only if a failing wire contract proves unsafe behavior.
|
||||||
|
|
||||||
|
Feature schema/resolvers, field authorization, depth/cost, persisted queries, DataLoader, schema
|
||||||
|
compatibility, and subscriptions remain P2.
|
||||||
|
|
||||||
|
## WebSocket P1 Boundary
|
||||||
|
|
||||||
|
WebSocket gains `ca-skeleton.websocket.enabled=false`; both configuration and broadcaster are
|
||||||
|
conditional. Enabled settings reject wildcard/blank origins and invalid endpoint/destination
|
||||||
|
shapes.
|
||||||
|
|
||||||
|
The inbound channel requires an authenticated handshake principal, permits subscription only to
|
||||||
|
the configured server topic, permits authenticated application sends under `/app/**`, and rejects
|
||||||
|
client sends to `/topic/**`. A custom STOMP error handler emits only a fixed client-safe code.
|
||||||
|
|
||||||
|
The broadcaster no longer serializes arbitrary `@DomainEvent` objects. It consults an explicit
|
||||||
|
projection allowlist; an event without exactly one projection is not sent. Projection output is a
|
||||||
|
bounded primitive map, not the domain object graph.
|
||||||
|
|
||||||
|
A real random-port WebSocket/STOMP lane verifies disabled absence, origin/auth/connect/subscribe,
|
||||||
|
server push, broker-send rejection, error redaction, and no projection/no broadcast. The simple
|
||||||
|
broker remains local/R1 only; broker relay, cross-node durability, replay, backpressure, and a
|
||||||
|
domain-specific versioned projection catalog remain P2.
|
||||||
|
|
||||||
|
## Documentation Truthfulness
|
||||||
|
|
||||||
|
Leaf READMEs and CLAUDE files describe only code that exists. Sample GraphQL schemas, gRPC services,
|
||||||
|
and WebSocket publishers are future adoption examples, not current runtime features. Each document
|
||||||
|
states the activation switch, exact P1 evidence, and unimplemented P2 limits.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- adding any of the three leaves to a shipped default runtime;
|
||||||
|
- adding a production project dependency edge outside the registry;
|
||||||
|
- claiming production readiness from local loopback/simple-broker tests;
|
||||||
|
- implementing sample feature APIs or domain payloads;
|
||||||
|
- staging, committing, amending, or pushing changes.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# P2 Verification Governance Refactoring Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Remove the remaining fail-open verification paths without changing production behavior or adding
|
||||||
|
unadopted runtime capabilities. P2 strengthens qualification tasks, tracked contract resources,
|
||||||
|
CI parser evidence, JSON Schema conformance, registry ownership, and bounded documentation debt.
|
||||||
|
|
||||||
|
## Scope and sequence
|
||||||
|
|
||||||
|
1. Move strict qualification `Test` registration to each owner leaf through one shared convention.
|
||||||
|
2. Resolve tracked repository contract resources from an explicit repository root and fail when
|
||||||
|
tracked files or directories are absent.
|
||||||
|
3. Exercise the real gate-matrix shell validator through isolated mutation fixtures.
|
||||||
|
4. Validate every Redis program manifest with the committed Draft 2020-12 schema.
|
||||||
|
5. Make the tracked registry set explicit, resolve every `required_test` identifier, and govern
|
||||||
|
temporary runbook stubs with owners and expiry dates.
|
||||||
|
6. Apply bounded P2 cleanup: module-doc link coverage, migration-neutral gate labels, and
|
||||||
|
deterministic outbound HTTP timeout tests.
|
||||||
|
|
||||||
|
Each item is independently reviewable. A later item may reuse infrastructure from an earlier item,
|
||||||
|
but no batch may weaken an existing check while waiting for a subsequent batch.
|
||||||
|
|
||||||
|
## Qualification convention
|
||||||
|
|
||||||
|
The owner project applies `gradle/strict-qualification-test.gradle` and registers its own exact
|
||||||
|
qualification tasks. The root project only aggregates absolute task paths and validates resulting
|
||||||
|
JUnit XML.
|
||||||
|
|
||||||
|
Every strict qualification task must:
|
||||||
|
|
||||||
|
- name at least one required FQCN;
|
||||||
|
- depend on compilation and fail before test execution when any required class file is absent;
|
||||||
|
- use exact JUnit filters with no-match and no-discovery failures enabled;
|
||||||
|
- force fresh execution in UTC and emit JUnit XML;
|
||||||
|
- reject skipped tests and require a positive, failure-free XML count.
|
||||||
|
|
||||||
|
This applies to conditional transports, Messaging evidence lanes, object-storage release lanes,
|
||||||
|
the Poster migration lane, and the app-bootstrap conditional-composition proof. Ordinary optional
|
||||||
|
or quarantine tests are deliberately excluded.
|
||||||
|
|
||||||
|
## Repository contract resources
|
||||||
|
|
||||||
|
`app-bootstrap` injects `ca.repository.root` into contract tests. A package-private resolver
|
||||||
|
normalizes the root, rejects traversal, and exposes `requireTrackedFile` and
|
||||||
|
`requireTrackedDirectory`. Missing tracked resources are assertion failures, never assumptions.
|
||||||
|
Assumptions remain valid only for truly optional external infrastructure.
|
||||||
|
|
||||||
|
## CI parser evidence
|
||||||
|
|
||||||
|
The gate-matrix validator accepts an optional repository-root argument. Contract tests construct a
|
||||||
|
minimal temporary repository fixture and invoke the actual shell script. Mutations for deceptive
|
||||||
|
step names, execution-suppressing flags, missing or duplicated gates, and unregistered tasks must
|
||||||
|
produce non-zero exits with stable diagnostics. Java must not contain a second parser.
|
||||||
|
|
||||||
|
## Schema and registry governance
|
||||||
|
|
||||||
|
- Redis manifests are validated by a Draft 2020-12 implementation in addition to existing catalog
|
||||||
|
cross-checks.
|
||||||
|
- A registry catalog has an exact one-to-one relationship with tracked `docs/registries/*.yaml`.
|
||||||
|
- Stable `required_test` IDs resolve through a tracked catalog to a single owner Gradle path and
|
||||||
|
source test/method. Unknown, duplicate, and dangling mappings fail.
|
||||||
|
- Temporary runbook stubs are listed in tracked debt data with owner, issue, start, and sunset.
|
||||||
|
Missing or expired debt entries fail.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No GraphQL feature schema, cost/depth policy, gRPC TLS/streaming, WebSocket relay, or other
|
||||||
|
production capability is introduced.
|
||||||
|
- No lockfile consolidation, version-catalog migration, JVM test-suite migration, or broad module
|
||||||
|
boundary change is included.
|
||||||
|
- Root Gradle capability extraction and a typed settings/build registry model remain separate
|
||||||
|
refactors unless their benefit can be proven without expanding this verification change.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Each batch starts with a focused failing contract and finishes with its owner `check`. Final
|
||||||
|
verification runs root `test`, `check`, architecture/dependency/runtime membership gates, CI shell
|
||||||
|
validators, dependency locks, public-path/env gates, and `git diff --check`.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Redis Session HTTP Boundary Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-02
|
||||||
|
**Status:** approved by the user's instruction to apply the reviewed P1/P2 work sequentially
|
||||||
|
**Scope:** composition of inbound browser-session security with the outbound versioned Redis session repository
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Inbound-web unit contracts prove CSRF, fixation, hardened cookie settings, and primitive security
|
||||||
|
snapshot behavior with `MockHttpSession`/in-memory repositories. Cache-redis contracts prove the
|
||||||
|
versioned session repository and Lua semantics against Redis. No test currently crosses the actual
|
||||||
|
Spring Session filter, production SecurityFilterChain, real Redis, and a second application context.
|
||||||
|
|
||||||
|
Putting this test in inbound-web would require a forbidden dependency on the outbound Redis leaf.
|
||||||
|
The composition root already depends on both leaves and owns the `redisCompositionTest` source set,
|
||||||
|
so app-bootstrap is the correct boundary owner.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a tagged `redis-session-http` integration contract under app-bootstrap's existing
|
||||||
|
`redisCompositionTest` source set. Ordinary `redisCompositionTest` excludes the tag. A new explicit
|
||||||
|
`redisSessionHttpIntegrationTest` task includes only that tag, fails on no discovery or any skip,
|
||||||
|
always reruns, pins UTC, and passes the checked-in Redis image registry path.
|
||||||
|
|
||||||
|
The task is deliberately not attached to ordinary local `check`, because it requires Docker. It is
|
||||||
|
added to the existing release-blocking `redis-standalone` CI job, which is the Docker-capable Redis
|
||||||
|
lane. Docker availability and container startup are attempted directly; no condition, assumption,
|
||||||
|
or environment flag may convert absence into a skip.
|
||||||
|
|
||||||
|
The test loads `redis.approved.image` from `src/gradle/redis-test-images.properties` and rejects an
|
||||||
|
unpinned reference. It creates an ephemeral CA/server certificate and a named, least-privilege ACL
|
||||||
|
user, then connects with TLS, full hostname verification, and explicit CA trust. A
|
||||||
|
runtime-generated Redis password and 32-byte HMAC are supplied through caller-owned versioned
|
||||||
|
material; no secret value is checked in, passed on the Redis command line, or logged. Missing
|
||||||
|
Docker or OpenSSL is a hard failure, not a skip.
|
||||||
|
|
||||||
|
The custom source set needs the Spring Session API at compile time. App-bootstrap therefore adds
|
||||||
|
`spring-session-core` only to `redisCompositionTestImplementation`; the existing version is reused
|
||||||
|
and the lockfile records the new custom compile configuration without changing a dependency
|
||||||
|
version.
|
||||||
|
|
||||||
|
## HTTP/Session Contract
|
||||||
|
|
||||||
|
1. A state-changing request without CSRF is 403.
|
||||||
|
2. Accessing the CSRF endpoint emits the configured Secure, non-HttpOnly CSRF cookie.
|
||||||
|
3. Login with matching cookie/header creates only the bounded primitive authentication snapshot.
|
||||||
|
4. The session cookie is host-only, Secure, HttpOnly, SameSite=Lax, path `/`, and session-scoped.
|
||||||
|
5. After the first web context closes, a second independent context restores `/whoami` from the
|
||||||
|
same cookie through real Redis.
|
||||||
|
6. Logout force-revokes/tombstones the session; the old cookie is unauthenticated and a previously
|
||||||
|
loaded stale session object cannot save over the tombstone.
|
||||||
|
7. If Redis becomes unavailable during session lookup, the request fails closed before the
|
||||||
|
protected controller and the surfaced exception graph contains only the repository's fixed
|
||||||
|
availability message, not endpoint/password/session material.
|
||||||
|
|
||||||
|
The RED run exposed two production composition gaps which are part of this boundary:
|
||||||
|
|
||||||
|
- the primitive security-context repository must wrap the response and persist before response
|
||||||
|
commit, otherwise a successful response can commit before the first session is created;
|
||||||
|
- the API security chain disables Spring Security's request cache, otherwise an unauthenticated
|
||||||
|
request stores a `DefaultSavedRequest` framework graph that the primitive session codec correctly
|
||||||
|
rejects.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Inbound-web remains provider-neutral and has no outbound dependency.
|
||||||
|
- Cache-redis keeps Redis keys, Lua, codec, HMAC, and tombstone policy private.
|
||||||
|
- App-bootstrap assembles both adapters only for a cross-module composition contract.
|
||||||
|
- No production dependency edge or dependency version changes; only a custom-test compile
|
||||||
|
configuration is added to the existing lock entry.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Redis Sentinel/Cluster sessions (production activation explicitly rejects them today);
|
||||||
|
- browser-engine proof of SameSite behavior;
|
||||||
|
- credential/certificate rotation qualification (the fixture still uses mandatory TLS, full
|
||||||
|
hostname verification, explicit trust, and a named ACL user);
|
||||||
|
- attaching Docker work to ordinary `check`;
|
||||||
|
- staging, commit, amend, or push by an agent.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Verification Purity Refactoring Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-02
|
||||||
|
**Status:** approved by the user's instruction to apply the P1/P2 review sequentially
|
||||||
|
**Scope:** stale traceable JAR verification/cleanup and public-path snapshot verification/update
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two root Gradle verification paths currently mutate files while they are expected to be safe gates:
|
||||||
|
|
||||||
|
- every `Jar` task deletes stale traceable archives in `doFirst`, and
|
||||||
|
`verifyNoStaleTraceableJars` depends on `cleanStaleTraceableJars`;
|
||||||
|
- `verifyPublicPathSnapshot` creates a missing snapshot and updates drift when
|
||||||
|
`-PapprovePublicPathChange` is supplied.
|
||||||
|
|
||||||
|
That makes `check` capable of hiding the state it is meant to detect. This batch restores the
|
||||||
|
standard contract: verification observes and fails, while explicitly named maintenance tasks own
|
||||||
|
writes.
|
||||||
|
|
||||||
|
## Considered Approaches
|
||||||
|
|
||||||
|
### Keep the root build logic in place and inspect source text in tests
|
||||||
|
|
||||||
|
This is the smallest diff, but a source assertion cannot prove task side effects. Rejected.
|
||||||
|
|
||||||
|
### Invoke the entire repository build from a copied checkout
|
||||||
|
|
||||||
|
This tests the actual root build but requires copying all 19 leaves and resolving every root plugin
|
||||||
|
for two small contracts. It is slow and couples the tests to unrelated configuration. Rejected.
|
||||||
|
|
||||||
|
### Extract only the two task concerns into applied Gradle scripts and exercise them with TestKit
|
||||||
|
|
||||||
|
Selected. The production root applies the same scripts that an isolated functional fixture uses.
|
||||||
|
The fixture observes exit status and filesystem state, so it proves behavior rather than source
|
||||||
|
shape. This is a bounded extraction required for testability, not the broad P2 root-build rewrite.
|
||||||
|
|
||||||
|
## Archive Hygiene Contract
|
||||||
|
|
||||||
|
`gradle/archive-hygiene.gradle` owns stale traceable archive discovery and the two root tasks:
|
||||||
|
|
||||||
|
- `verifyNoStaleTraceableJars` reports every stale archive and fails without deleting anything;
|
||||||
|
- `cleanStaleTraceableJars` deletes only names matching the traceable archive pattern for a known
|
||||||
|
`Jar` task and never deletes the current archive;
|
||||||
|
- normal `jar`/`bootJar` execution never performs cleanup.
|
||||||
|
|
||||||
|
The existing traceable version naming and manifest metadata remain unchanged.
|
||||||
|
|
||||||
|
## Public-Path Snapshot Contract
|
||||||
|
|
||||||
|
`gradle/public-path-snapshot.gradle` owns canonicalization and two root tasks:
|
||||||
|
|
||||||
|
- `verifyPublicPathSnapshot` fails when the env file or committed snapshot is missing, when content
|
||||||
|
drifts, or when the update-only approval property is passed to the verifier. It never creates
|
||||||
|
directories or writes files;
|
||||||
|
- `updatePublicPathSnapshot` requires `-PapprovePublicPathChange` and writes the canonical snapshot.
|
||||||
|
|
||||||
|
A clean-worktree requirement is intentionally not used: the normal update workflow necessarily has
|
||||||
|
an intentional `.env` change. Explicit task naming, the approval property, and the resulting diff
|
||||||
|
are the review boundary.
|
||||||
|
|
||||||
|
The canonical header names `updatePublicPathSnapshot`, so documentation and the committed snapshot
|
||||||
|
do not instruct users to mutate through a verification task.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`BuildVerificationPurityContractTest` runs from an isolated `functionalTest` source set using Gradle
|
||||||
|
TestKit against temporary projects that apply the production scripts directly. Keeping TestKit off
|
||||||
|
the ordinary `testRuntimeClasspath` prevents Gradle's SLF4J provider from replacing Logback during
|
||||||
|
Spring tests. It proves:
|
||||||
|
|
||||||
|
1. a normal `jar` leaves a matching stale archive untouched;
|
||||||
|
2. verification fails and preserves the stale archive;
|
||||||
|
3. explicit cleanup deletes the stale archive but preserves the current archive;
|
||||||
|
4. missing/drifted public-path snapshots cause read-only failure;
|
||||||
|
5. the verifier rejects the update approval property;
|
||||||
|
6. only the explicit updater with approval creates or changes the snapshot.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- no change to archive naming, versions, manifests, production dependency versions, or project edges;
|
||||||
|
- only the new isolated functional-test configurations are added to `app-bootstrap/gradle.lockfile`;
|
||||||
|
- no public-path allow-list value change;
|
||||||
|
- no broad root Gradle convention-plugin migration;
|
||||||
|
- no staging, commit, amend, or push by an agent.
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
# Warning-Zero Build Refactoring Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-02
|
||||||
|
**Status:** Approved design, pending written-spec review
|
||||||
|
**Scope:** Java compilation, Error Prone, Checkstyle, SpotBugs, test JVM diagnostics, expected-negative
|
||||||
|
shell-contract output, and intentional legacy/architecture-test compatibility seams.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the standard repository build both functionally green and warning-clean. A successful build
|
||||||
|
must no longer conceal compiler warnings, test-source SpotBugs findings, ignored Checkstyle
|
||||||
|
findings, deprecated third-party API calls, or expected-negative subprocess diagnostics that look
|
||||||
|
like real failures.
|
||||||
|
|
||||||
|
The final local proof is a fresh `./gradlew clean build --warning-mode=all --no-daemon
|
||||||
|
--console=plain` with:
|
||||||
|
|
||||||
|
- exit code zero;
|
||||||
|
- zero compiler/Error Prone warnings;
|
||||||
|
- zero Checkstyle and SpotBugs findings in every executed source set;
|
||||||
|
- zero `SpotBugs ended with exit code 1` messages;
|
||||||
|
- zero OpenJDK CDS warnings from test JVMs;
|
||||||
|
- no successful Redis lab contract printing its expected-negative child diagnostics;
|
||||||
|
- only the five currently intentional optional-adapter/TestKit skips, with no qualification lane
|
||||||
|
silently skipped.
|
||||||
|
|
||||||
|
## Baseline Evidence
|
||||||
|
|
||||||
|
The fresh pre-change command completed successfully in 20 minutes 26 seconds with 283 of 283 tasks
|
||||||
|
executed. Success did not mean warning-clean:
|
||||||
|
|
||||||
|
- 123 compiler warning diagnostics across 19 warning rules (122 distinct file-line/rule
|
||||||
|
coordinates because one line emits two separate removal diagnostics);
|
||||||
|
- one test-source SpotBugs `DMI_RANDOM_USED_ONLY_ONCE` finding;
|
||||||
|
- ten OpenJDK CDS warning lines from Mockito-using test JVMs;
|
||||||
|
- 82 `redis-lab:` expected-negative stderr lines;
|
||||||
|
- five intentional skipped tests;
|
||||||
|
- no test failure, compiler error, Checkstyle finding, SpotBugs analysis error, or missing analysis
|
||||||
|
class.
|
||||||
|
|
||||||
|
The Gradle Problems report is an informational index over compiler diagnostics, not a separate
|
||||||
|
defect. It must become empty as a consequence of removing the underlying warnings; it must not be
|
||||||
|
hidden.
|
||||||
|
|
||||||
|
### Warning inventory traceability
|
||||||
|
|
||||||
|
| Rule | Diagnostic instances | Required resolution |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `removal` | 46 | Exact legacy lifecycle/suppression policy in section 4 |
|
||||||
|
| `MissingOverride` | 16 | Add annotations to the implementing test fakes in section 2 |
|
||||||
|
| `StringCaseLocaleUsage` | 10 | `Locale.ROOT` behavior fixes and test cleanup in sections 1–2 |
|
||||||
|
| `SameNameButDifferent` | 9 | Qualify the two Redis nested enum types in section 2 |
|
||||||
|
| `DefaultCharset` | 9 | Explicit UTF-8 test data in sections 1–2 |
|
||||||
|
| `ArrayRecordComponent` | 7 | Exact record policies and copy regressions in section 2 |
|
||||||
|
| `CanonicalDuration` | 5 | `Duration.ofDays(3)` in section 2 |
|
||||||
|
| `StringSplitter` | 4 | ETag scanner plus three grammar-specific test fixes in sections 1–2 |
|
||||||
|
| `EmptyCatch` | 4 | Cleanup failure propagation in section 1 |
|
||||||
|
| `StringConcatToTextBlock` | 2 | Byte-identical text blocks in section 2 |
|
||||||
|
| `InvalidBlockTag` | 2 | Inline-code annotation names in section 2 |
|
||||||
|
| `BigDecimalLiteralDouble` | 2 | Method-only intentional-fixture suppressions in section 5 |
|
||||||
|
| `TypeParameterUnusedInFormals` | 1 | Spring Session method-only suppression in section 2 |
|
||||||
|
| `ThreadLocalUsage` | 1 | Instance-isolation regression and field-only suppression in section 2 |
|
||||||
|
| `ReferenceEquality` | 1 | Redis catalog identity regression and constructor-only suppression in section 2 |
|
||||||
|
| `MissingSummary` | 1 | Public Javadoc summary in section 2 |
|
||||||
|
| `JavaTimeDefaultTimeZone` | 1 | Fixed date/explicit zone in section 1 |
|
||||||
|
| `FutureReturnValueIgnored` | 1 | Observe the future in section 1 |
|
||||||
|
| `BooleanLiteral` | 1 | Literal assertion cleanup in section 2 |
|
||||||
|
|
||||||
|
This table accounts for all 123 Error Prone/compiler-warning diagnostics. The separate
|
||||||
|
`-Xlint:deprecation,unchecked` inventory is covered by the third-party migrations and exact legacy
|
||||||
|
seam policy below; it is not allowed to disappear through a source-set suppression.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not remove the legacy poster-image endpoint, `StoredObjectResponse`, raw-key compatibility
|
||||||
|
data, or legacy object-storage adapters during warning cleanup.
|
||||||
|
- Do not switch the sample runtime from legacy to publication mode without the separately required
|
||||||
|
API, data-adoption, dual-read, and external-consumer approvals.
|
||||||
|
- Do not apply module-wide or task-wide suppression for `removal`, `deprecation`, `unchecked`, or
|
||||||
|
Error Prone rules.
|
||||||
|
- Do not weaken architecture rules or change deliberately forbidden bytecode merely to silence a
|
||||||
|
fixture warning.
|
||||||
|
- Do not make quarantine tests blocking; their separate sunset and reporting policy remains
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. Fix behavior defects at their source before applying any suppression.
|
||||||
|
2. Use suppression only where a framework signature, identity invariant, intentional violation
|
||||||
|
fixture, or approved compatibility seam makes the warning inapplicable.
|
||||||
|
3. Scope every suppression to the smallest class, method, field, constructor, or fixture that
|
||||||
|
explains it, with a nearby rationale.
|
||||||
|
4. Replace deprecated third-party APIs with their typed current equivalents and verify behavior,
|
||||||
|
not only compilation.
|
||||||
|
5. Capture expected-negative diagnostics and assert them exactly; never discard stderr globally.
|
||||||
|
6. Add blocking gates only after the current warning inventory is clean.
|
||||||
|
|
||||||
|
## Component Design
|
||||||
|
|
||||||
|
### 1. Real behavior defects
|
||||||
|
|
||||||
|
#### Locale-independent identifiers
|
||||||
|
|
||||||
|
Use `Locale.ROOT` for security roles, notification configuration keys, repository ACL names, and
|
||||||
|
test comparisons. Add Turkish-default-locale regressions that restore the original default locale
|
||||||
|
in `finally`:
|
||||||
|
|
||||||
|
- `JwtToAuthenticatedPrincipalConverter`: `admin` must always become `ROLE_ADMIN`;
|
||||||
|
- `RoutingNotifier`: diagnostic keys for `EMAIL` must remain `app.notification.routes.email...`;
|
||||||
|
- `RepoStatsAclMapper`: `IDEA/Repo` must normalize to `idea/repo`.
|
||||||
|
|
||||||
|
This is a correctness fix: the current code can generate dotless/dotted Turkish-I variants in
|
||||||
|
authorization and operational identifiers.
|
||||||
|
|
||||||
|
#### Quote-aware ETag list parsing
|
||||||
|
|
||||||
|
Do not replace `String.split(",")` with another delimiter-only splitter. A comma is legal inside a
|
||||||
|
quoted opaque entity tag. `ETags` will use a small scanner that:
|
||||||
|
|
||||||
|
- splits only on commas outside a quoted string;
|
||||||
|
- preserves weak-tag prefixes and the existing trimming behavior;
|
||||||
|
- treats malformed/unclosed quotes as non-matching input rather than guessing a token;
|
||||||
|
- preserves wildcard and ordinary multi-value behavior.
|
||||||
|
|
||||||
|
Regressions cover a single comma-bearing tag, a mixed list containing a weak comma-bearing tag,
|
||||||
|
ordinary lists, wildcard, stale values, blank input, and malformed quoting.
|
||||||
|
|
||||||
|
#### Asynchronous and cleanup failures
|
||||||
|
|
||||||
|
- `AsyncGracefulShutdownBehaviorTest` retains the returned `Future<?>` and observes `get()` so a
|
||||||
|
background assertion or exception cannot disappear.
|
||||||
|
- Outbox test cleanup methods propagate or wrap resource-destruction failures with the original
|
||||||
|
cause instead of using empty catches.
|
||||||
|
- Tests use fixed dates, UTF-8, and explicit locale rather than host defaults.
|
||||||
|
|
||||||
|
### 2. Production warning cleanup with preserved invariants
|
||||||
|
|
||||||
|
#### Redis primitive ownership
|
||||||
|
|
||||||
|
`RedisPrimitiveInvocation` intentionally requires descriptor object identity. Value equality would
|
||||||
|
admit a descriptor created by another catalog and weaken the closed-catalog invariant. Keep the
|
||||||
|
reference comparison, add an exact constructor-level `ReferenceEquality` suppression, and add a
|
||||||
|
regression proving value-equal but non-identical cross-catalog descriptors are rejected.
|
||||||
|
|
||||||
|
Qualify both nested `ExpectedKind` types with their enclosing record names rather than renaming the
|
||||||
|
types. This removes `SameNameButDifferent` without changing bytecode or package-local consumers.
|
||||||
|
|
||||||
|
#### Framework-owned generic signature
|
||||||
|
|
||||||
|
`RedisVersionedSession.<T>getAttribute(String)` must retain Spring Session's inherited signature.
|
||||||
|
Apply a method-only `TypeParameterUnusedInFormals` suppression with the interface-contract reason.
|
||||||
|
|
||||||
|
#### Instance-owned retry context
|
||||||
|
|
||||||
|
`OutboundRetryPolicy` keeps its instance `ThreadLocal`. Making it static would leak call context
|
||||||
|
between policy instances on the same thread. Add a field-only `ThreadLocalUsage` suppression and a
|
||||||
|
regression proving policy A's context is invisible to policy B and is cleared by `endCall()`.
|
||||||
|
|
||||||
|
#### Array-bearing records
|
||||||
|
|
||||||
|
- `NotificationCiphertext` retains its public array components because it already clones inputs and
|
||||||
|
accessors, implements content-based equality/hash code, and redacts `toString`. Add focused
|
||||||
|
defensive-copy/equality/redaction tests and an exact record-level suppression.
|
||||||
|
- The four internal session command/outcome records in `VersionedRedisSessionStore` remain internal
|
||||||
|
transport envelopes. Preserve defensive copies, document that generated record equality is not
|
||||||
|
their contract, add constructor/accessor copy tests, and suppress `ArrayRecordComponent` on each
|
||||||
|
exact record.
|
||||||
|
- The private test fake in `RedisVersionedSessionRepositoryTest` receives the same exact nested-type
|
||||||
|
treatment; no public type is changed.
|
||||||
|
|
||||||
|
#### Mechanical behavior-neutral fixes
|
||||||
|
|
||||||
|
- Express 72 hours as `Duration.ofDays(3)` in application/bootstrap/sample settings and matching
|
||||||
|
tests.
|
||||||
|
- Add the missing public Javadoc summary in `TracingSampleRateResolver`, and render annotation names
|
||||||
|
such as `@WebMvcTest` as inline `{@code ...}` rather than accidental block tags.
|
||||||
|
- Add missing `@Override` annotations in sample test fakes.
|
||||||
|
- Replace readability-only string concatenations with text blocks where the literal bytes remain
|
||||||
|
identical.
|
||||||
|
- Replace Boolean wrapper comparisons with boolean literals.
|
||||||
|
- For the three test-only delimiter warnings, preserve each existing grammar explicitly: retain CSV
|
||||||
|
empty-token filtering with a limit-bearing split or scanner, scan mapping-path segments without
|
||||||
|
changing leading/trailing-empty behavior, and parse the single HTTP byte-range hyphen with an
|
||||||
|
asserted `indexOf` boundary. These are not allowed to inherit the ETag scanner because their
|
||||||
|
grammars differ.
|
||||||
|
|
||||||
|
### 3. Third-party API migration
|
||||||
|
|
||||||
|
#### Jackson 3
|
||||||
|
|
||||||
|
In `LocalJsonSchemaRegistry`, replace deprecated `JsonNode.isTextual()`/`textValue()` with
|
||||||
|
`isString()`/`stringValue()`. Existing type guards remain, and JSON schema identity/reference/value
|
||||||
|
tests prove identical acceptance and rejection behavior.
|
||||||
|
|
||||||
|
In `DeterministicEnvelopeWriter`, replace the deprecated convenience call with
|
||||||
|
`jsonFactory.createGenerator(ObjectWriteContext.empty(), output, JsonEncoding.UTF8)`, the
|
||||||
|
non-deprecated Jackson 3.0.2 overload. Preserve canonical byte output; the existing deterministic
|
||||||
|
envelope golden tests are the behavior gate.
|
||||||
|
|
||||||
|
#### Lettuce
|
||||||
|
|
||||||
|
Convert both finite canonical scores to `BigDecimal`, build one inclusive
|
||||||
|
`Range<? extends Number>` for each invocation, and call the typed `zcount(key, range)` and
|
||||||
|
`zrangebyscoreWithScores(key, range, Limit.create(offset, count))` overloads. Preserve inclusive
|
||||||
|
bounds, offset, count, and exact reply mapping. A dynamic-proxy regression verifies both typed
|
||||||
|
overloads are selected; sorted-set primitive contract tests verify results.
|
||||||
|
|
||||||
|
#### AWS SDK retry
|
||||||
|
|
||||||
|
Replace old `RetryPolicy` and core `EqualJitterBackoffStrategy` with `StandardRetryStrategy`, the
|
||||||
|
retries API half-jitter exponential backoff, `maxAttempts`, and
|
||||||
|
`ClientOverrideConfiguration.Builder.retryStrategy`. Tests assert maximum attempts and normal versus
|
||||||
|
throttling backoff configuration. The focused object-storage check must cover provider assembly;
|
||||||
|
compile-only success is insufficient.
|
||||||
|
|
||||||
|
#### Testcontainers Toxiproxy
|
||||||
|
|
||||||
|
Use the Testcontainers 2 toxiproxy package and a typed `ToxiproxyClient`/`Proxy` with an explicit
|
||||||
|
exposed proxy port. Fault tests must still prove cut and restore behavior against MinIO. Dependency
|
||||||
|
and lock changes stay inside the object-storage leaf.
|
||||||
|
|
||||||
|
#### Remaining JDK/generic deprecations
|
||||||
|
|
||||||
|
- Replace deprecated `new URL(String)` test construction with `URI.create(...).toURL()`.
|
||||||
|
- Replace the varargs `thenReturn(firstFuture, secondFuture)` stub in
|
||||||
|
`S3ConditionalObjectControlStoreTest` with two chained single-value `thenReturn(...)` calls, so
|
||||||
|
Mockito does not create the unchecked generic `CompletableFuture<PutObjectResponse>[]` array.
|
||||||
|
- Resolve every `-Xlint:deprecation,unchecked` location individually; do not suppress the source
|
||||||
|
set.
|
||||||
|
|
||||||
|
### 4. Legacy object-storage compatibility seam
|
||||||
|
|
||||||
|
The canonical object-storage ports and sample publication path already exist. The legacy runtime is
|
||||||
|
still selected in local/test configuration and cannot be deleted solely to silence warnings.
|
||||||
|
|
||||||
|
Keep `@Deprecated(forRemoval = true)` on the genuinely replaced whole-byte contracts:
|
||||||
|
|
||||||
|
- `ObjectStoragePort`;
|
||||||
|
- `StoredObject`;
|
||||||
|
- `ObjectStorageSettings`.
|
||||||
|
|
||||||
|
Apply `removal` suppression only to exact compatibility owners:
|
||||||
|
|
||||||
|
- `ObjectStoragePort` for its legacy receipt return type;
|
||||||
|
- `FilesystemObjectStorageAdapter` and `S3ObjectStorageAdapter`;
|
||||||
|
- `UploadPosterImageUseCase`;
|
||||||
|
- the legacy bean method in `PosterImageApiConfig`;
|
||||||
|
- `LegacyPosterImageController`;
|
||||||
|
- `PosterWebMapper.toStoredObjectResponse`;
|
||||||
|
- named legacy characterization test classes and single legacy-receipt test methods.
|
||||||
|
|
||||||
|
The six `application.storage.migration` types and `AdoptLegacyPosterImageUseCase` are the mechanism
|
||||||
|
used to complete data adoption and currently have no replacement. Change their lifecycle marker
|
||||||
|
from `@Deprecated(forRemoval = true)` to plain `@Deprecated`; use exact `deprecation` suppression
|
||||||
|
only inside adoption implementation/configuration. Keep the application-core architecture contract
|
||||||
|
requiring `forRemoval=true` only for `ObjectStoragePort` and `StoredObject`. Keep the adapter-owned
|
||||||
|
`ObjectStorageSettings` marker and add its lifecycle assertion in the object-storage leaf.
|
||||||
|
|
||||||
|
This keeps migration debt visible without falsely claiming that the migration mechanism itself is
|
||||||
|
ready for removal.
|
||||||
|
|
||||||
|
### 5. Test/static-analysis/output cleanup
|
||||||
|
|
||||||
|
#### SpotBugs
|
||||||
|
|
||||||
|
Reuse one static `SecureRandom` in `RedisPrimitiveRuntimeServiceTest` rather than constructing a
|
||||||
|
one-shot generator. After all test reports are clean, make every ordinary and custom test-source
|
||||||
|
SpotBugs task included by `check` blocking. SpotBugs analysis errors and missing classes remain
|
||||||
|
separately fail-closed.
|
||||||
|
|
||||||
|
#### Intentional architecture fixtures
|
||||||
|
|
||||||
|
Keep prohibited `BigDecimal(double/float)` constructor bytecode and apply method-only
|
||||||
|
`BigDecimalLiteralDouble` suppressions. Fix unrelated warnings in allowed fixtures normally. A
|
||||||
|
suppression must never replace the forbidden operation the ArchUnit test is supposed to detect.
|
||||||
|
|
||||||
|
#### Redis lab expected failures
|
||||||
|
|
||||||
|
Change `assert_fails` to capture stdout/stderr per case, assert a non-zero exit and the exact expected
|
||||||
|
diagnostic, reject extra lines, and print the capture only when the assertion fails. Do not redirect
|
||||||
|
to `/dev/null` and do not silence the Gradle `Exec` task globally.
|
||||||
|
|
||||||
|
#### Mockito/CDS
|
||||||
|
|
||||||
|
Provide `mockito-core` to test JVMs as an explicit startup `-javaagent` through a relocatable Gradle
|
||||||
|
argument provider. This removes reliance on Java 21+ runtime self-attachment. Add test-JVM-only
|
||||||
|
`-Xshare:off` because Mockito's bootstrap append otherwise prints the harmless CDS warning. No
|
||||||
|
production JVM argument changes.
|
||||||
|
|
||||||
|
#### Skips
|
||||||
|
|
||||||
|
Retain exactly these five intentional app-bootstrap contract skips:
|
||||||
|
|
||||||
|
- `emailNotificationAdapterRunsOnlyWhenConfigured()`;
|
||||||
|
- `slackNotificationAdapterRunsOnlyWhenConfigured()`;
|
||||||
|
- `redisCacheAdapterRunsOnlyWhenEnabled()`;
|
||||||
|
- `messagingBrokerAdapterRunsOnlyWhenConfigured()`;
|
||||||
|
- `DisabledOptionalAdapterFixture.wouldFailIfItEverRan()`.
|
||||||
|
|
||||||
|
Qualification tasks continue to require positive discovery, at least one executed test, zero skips,
|
||||||
|
and fresh XML, so this policy cannot turn a selected qualification lane green without execution.
|
||||||
|
Any additional skip, or any of these five moving outside its named optional-adapter contract, fails
|
||||||
|
the inventory check.
|
||||||
|
|
||||||
|
### 6. Warning-zero enforcement
|
||||||
|
|
||||||
|
After all existing warnings are removed:
|
||||||
|
|
||||||
|
- configure every leaf `JavaCompile` task with `-Werror`, `-Xlint:deprecation`, and
|
||||||
|
`-Xlint:unchecked` in the root build policy;
|
||||||
|
- retain Error Prone on the same compile tasks so its warnings are promoted by `-Werror`;
|
||||||
|
- remove the root `checkstyleTest`/`spotbugsTest` warning-only policy and the app-bootstrap
|
||||||
|
`sampleOffTest`, `functionalTest`, and `conditionalTransportTest` Checkstyle/SpotBugs
|
||||||
|
`ignoreFailures` overrides, making every such task included by `check` blocking;
|
||||||
|
- retain exact suppression comments as the only approved exception mechanism;
|
||||||
|
- run Gradle with `--warning-mode=fail` in the warning-clean CI lane so Gradle API deprecations also
|
||||||
|
fail rather than print.
|
||||||
|
|
||||||
|
`quarantineTest` remains non-blocking by design. Protected AWS/Docker qualifications remain separate
|
||||||
|
environment evidence and are not converted into local unit tests.
|
||||||
|
|
||||||
|
## File Ownership and Expected Change Groups
|
||||||
|
|
||||||
|
### Root build policy
|
||||||
|
|
||||||
|
- `src/build.gradle`
|
||||||
|
- `src/gradle/test-jvm-agents.gradle`, defining the relocatable Mockito `-javaagent` argument
|
||||||
|
provider and test-only `-Xshare:off` policy, applied once by the root build
|
||||||
|
- `.github/workflows/ci-quality-gates.yml`, adding `--warning-mode=fail` to the blocking
|
||||||
|
`quality-gates` Gradle invocation
|
||||||
|
|
||||||
|
### Production leaves
|
||||||
|
|
||||||
|
- `src/application-core`
|
||||||
|
- `src/adapter/inbound/web`
|
||||||
|
- `src/adapter/outbound/cache-redis`
|
||||||
|
- `src/adapter/outbound/fileserver`
|
||||||
|
- `src/adapter/outbound/httpclient`
|
||||||
|
- `src/adapter/outbound/identifier`
|
||||||
|
- `src/adapter/outbound/messaging`
|
||||||
|
- `src/adapter/outbound/notification`
|
||||||
|
- `src/adapter/outbound/objectstorage`
|
||||||
|
- `src/adapter/outbound/persistence-jpa`
|
||||||
|
- `src/app-bootstrap`
|
||||||
|
- `src/sample-portfolio`
|
||||||
|
- `src/shared-contract`
|
||||||
|
|
||||||
|
Every focused command is derived from the owning leaf's `gradle_path` in
|
||||||
|
`src/config/architecture/modules.json`; no production dependency edge changes are permitted unless
|
||||||
|
the registry is deliberately updated and its architecture verifier passes.
|
||||||
|
|
||||||
|
### Tests and shell contract
|
||||||
|
|
||||||
|
- owning leaf tests adjacent to every behavior change
|
||||||
|
- exact architecture violation fixtures under app-bootstrap test sources
|
||||||
|
- `infra/redis-lab/test/redis-lab-contract.sh`
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Add failing behavioral regressions for locale, ETag parsing, async exception observation,
|
||||||
|
cleanup propagation, Redis descriptor identity, and retry-context isolation.
|
||||||
|
2. Implement those behavior fixes and run owner-focused tests.
|
||||||
|
3. Remove behavior-neutral compiler/Error Prone warnings per leaf, using only exact justified
|
||||||
|
suppressions.
|
||||||
|
4. Migrate Jackson, Lettuce, AWS SDK, Testcontainers, URL, and generic stubs; run their focused
|
||||||
|
behavior/qualification tests.
|
||||||
|
5. Correct legacy lifecycle markers and exact compatibility suppressions; run application-core,
|
||||||
|
object-storage, sample, and architecture contracts.
|
||||||
|
6. Clean test-only warnings, SpotBugs, Mockito/CDS, and Redis-lab output.
|
||||||
|
7. Enable blocking compiler, Checkstyle, SpotBugs, and Gradle warning gates.
|
||||||
|
8. Run focused checks, architecture validators, dependency locks, full tests, full check, and the
|
||||||
|
fresh warning-clean build.
|
||||||
|
9. Update the LLM Wiki branch note and the warning-debt error note with resolved evidence or exact
|
||||||
|
remaining environmental blockers.
|
||||||
|
|
||||||
|
## Verification Strategy
|
||||||
|
|
||||||
|
### Focused verification
|
||||||
|
|
||||||
|
- Each behavior change follows RED → GREEN with the owning leaf test.
|
||||||
|
- Static-only warning fixes use the exact `compileJava`, `compileTestJava`, Checkstyle, or SpotBugs
|
||||||
|
task as the failing/passing executable contract.
|
||||||
|
- Third-party API migrations run behavior tests that exercise request mapping, retry/backoff,
|
||||||
|
sorted-set bounds, schema parsing, or network-fault cut/restore semantics.
|
||||||
|
- Legacy suppressions are checked by architecture tests that reject old imports outside the named
|
||||||
|
compatibility surface.
|
||||||
|
|
||||||
|
### Repository verification
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --no-daemon --console=plain
|
||||||
|
./gradlew check --no-daemon --console=plain
|
||||||
|
./gradlew build --warning-mode=fail --no-daemon --console=plain
|
||||||
|
./gradlew clean build --warning-mode=all --no-daemon --console=plain
|
||||||
|
./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership \
|
||||||
|
verifyDependencyLocks verifyPublicPathSnapshot verifyEnvKeys \
|
||||||
|
--no-daemon --console=plain
|
||||||
|
```
|
||||||
|
|
||||||
|
Also verify the real gate matrix, wrapper contract, shell syntax, warning-report XML, skipped-test
|
||||||
|
inventory, and `git diff --check`.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- If a suggested warning fix changes a public signature or weakens an identity/security invariant,
|
||||||
|
retain the behavior and use an exact documented suppression backed by a regression.
|
||||||
|
- If three attempted fixes in one warning family fail or expose cross-module coupling, stop that
|
||||||
|
family and revisit the design instead of stacking suppressions.
|
||||||
|
- If the AWS retry or Toxiproxy migration cannot reproduce old behavior, report that qualification
|
||||||
|
as blocked; do not claim warning-zero by suppressing the deprecation.
|
||||||
|
- If a warning originates only in generated code, prove the generated source owner and configure
|
||||||
|
that exact generated boundary; do not disable warnings for handwritten sources.
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- **ETag grammar regression:** use quote-aware focused tests before replacing the parser.
|
||||||
|
- **Authorization drift:** test role normalization under Turkish locale.
|
||||||
|
- **Redis catalog weakening:** retain identity comparison and test cross-catalog rejection.
|
||||||
|
- **AWS retry semantic drift:** assert maximum attempts and backoff classes/policies, then run the
|
||||||
|
object-storage provider tests.
|
||||||
|
- **Legacy data stranding:** preserve legacy activation and characterization until the separate
|
||||||
|
data/API migration gates are approved.
|
||||||
|
- **Hidden diagnostics:** capture-and-assert expected stderr; never discard it.
|
||||||
|
- **Suppression creep:** exact annotations plus architecture/import checks prevent module-wide
|
||||||
|
exemptions.
|
||||||
|
- **Build duration:** use owner-focused RED/GREEN loops and reserve full clean builds for integration
|
||||||
|
checkpoints and final proof.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The work is complete only when:
|
||||||
|
|
||||||
|
1. All behavior regressions and focused owner checks pass.
|
||||||
|
2. Every compiler task passes with `-Werror`, deprecation lint, unchecked lint, and Error Prone.
|
||||||
|
3. Every ordinary/custom Checkstyle and SpotBugs task included by `check` is blocking and clean.
|
||||||
|
4. Legacy warnings are limited to no output because exact compatibility code is explicitly and
|
||||||
|
locally justified; no module/task-wide suppression exists.
|
||||||
|
5. The Redis lab successful contract prints only its success summary and unexpected child
|
||||||
|
diagnostics still fail the test with captured evidence.
|
||||||
|
6. Test JVMs print no CDS/self-attachment warning.
|
||||||
|
7. Full test, check, build, dependency, architecture, runtime-membership, env, public-path, wrapper,
|
||||||
|
gate-matrix, shell, and diff validators pass.
|
||||||
|
8. The final fresh clean-build log contains no `warning:`, deprecated/unchecked `Note:`, SpotBugs
|
||||||
|
non-zero message, OpenJDK warning, or leaked expected-negative Redis diagnostic.
|
||||||
|
9. LLM Wiki capture records commands, results, resolved warning counts, suppressions, and any
|
||||||
|
environment-only qualification not executed locally.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Web Security Boundary Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-02
|
||||||
|
**Status:** approved by the user's instruction to apply the reviewed P1/P2 work sequentially
|
||||||
|
**Scope:** JWT/OIDC/JWKS and CORS behavior at the `adapter:inbound:web` Spring Security filter boundary
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The module has unit contracts for JWT validators, exception classification, envelope writers, and
|
||||||
|
CORS settings. It does not yet prove that a real bearer request crosses issuer discovery, JWKS
|
||||||
|
retrieval, signature/claim validation, principal conversion, `SecurityFilterChain`, and the public
|
||||||
|
error envelope. CORS configuration is likewise untested at the filter boundary, where preflight
|
||||||
|
ordering relative to authentication is the important behavior.
|
||||||
|
|
||||||
|
These are release-boundary checks and must not silently skip because an external IdP, environment
|
||||||
|
variable, or optional flag is absent.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a dedicated `webSecurityBoundaryTest` task that reuses the ordinary test output/classpath and
|
||||||
|
runs only JUnit tests tagged `security-boundary`. Ordinary `test` excludes that tag so each contract
|
||||||
|
runs once. The dedicated task:
|
||||||
|
|
||||||
|
- fails when no tests are discovered;
|
||||||
|
- disables up-to-date reuse;
|
||||||
|
- fails the root suite when any test reports `SKIPPED`;
|
||||||
|
- is required by the inbound-web `check` task;
|
||||||
|
- uses UTC and no environment-dependent conditions or assumptions.
|
||||||
|
|
||||||
|
JWT tests use a JDK loopback `HttpServer` bound to `127.0.0.1` on an ephemeral port. It serves the
|
||||||
|
minimum OIDC discovery document and JWKS response. Tests generate ephemeral RSA keys and compact
|
||||||
|
RS256 JWTs with the already-resolved Nimbus dependency; no new library or external network is
|
||||||
|
allowed. Each failure case uses a fresh server and Spring context to prevent decoder/JWK cache
|
||||||
|
cross-contamination.
|
||||||
|
|
||||||
|
CORS tests build the production `SecurityConfig` and real `springSecurityFilterChain` with direct
|
||||||
|
configuration properties. They issue real preflight and actual-origin MockMvc requests. A test JWT
|
||||||
|
decoder bean is allowed here because CORS ordering—not token decoding—is the owned boundary.
|
||||||
|
|
||||||
|
## JWT/JWKS Contract
|
||||||
|
|
||||||
|
- application context startup performs zero discovery/JWKS calls (lazy decoder);
|
||||||
|
- a correctly signed token reaches a protected controller and exposes the expected
|
||||||
|
`AuthenticatedPrincipal` subject/roles;
|
||||||
|
- expiry beyond the configured 60-second skew, issuer mismatch, audience mismatch, wrong
|
||||||
|
signature, and unknown `kid` produce their exact stable 401 error codes and bounded
|
||||||
|
`WWW-Authenticate`/`Retry-After` headers;
|
||||||
|
- deterministic JWKS 503 produces `AUTH_JWKS_UNAVAILABLE`, HTTP 503, and `Retry-After: 30`;
|
||||||
|
- after that first-request 503, the same lazy decoder/context retries initialization and succeeds
|
||||||
|
once the JWKS endpoint recovers;
|
||||||
|
- discovery metadata that is fetched successfully but is internally inconsistent produces the
|
||||||
|
fixed 500 `INTERNAL_AUTH_MISCONFIGURATION` envelope rather than a raw initialization exception;
|
||||||
|
- responses never contain the bearer token, issuer URL, `kid`, JWK material, or internal decoder
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
|
## CORS Contract
|
||||||
|
|
||||||
|
- an approved credentialed preflight to an authenticated endpoint succeeds before bearer
|
||||||
|
authentication and emits exact origin/credentials/method/header/max-age policy;
|
||||||
|
- an unapproved origin receives 403 without allow-origin or allow-credentials reflection;
|
||||||
|
- disabled CORS emits no CORS response headers;
|
||||||
|
- wildcard origin without credentials returns `*` and no credentials header;
|
||||||
|
- an approved actual-origin request receives matching CORS and bounded `Vary` headers;
|
||||||
|
- wildcard plus credentials remains a settings startup failure (already covered by settings tests).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- external IdP/TLS/rotation rehearsal;
|
||||||
|
- browser-engine SameSite behavior;
|
||||||
|
- Redis-backed session continuity (the next P1 batch);
|
||||||
|
- new test libraries, Docker, or changes to production dependency direction;
|
||||||
|
- staging, commit, amend, or push by an agent.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
# Fileserver Superpowers Package
|
||||||
|
|
||||||
|
## 포함 파일
|
||||||
|
|
||||||
|
- `fileserver-platform-design.md` — Fileserver 플랫폼 설계 확정안
|
||||||
|
- `fileserver-platform-implementation-plan.md` — 33개 TDD 작업으로 분해한 구현 계획
|
||||||
|
- `VALIDATION.md` — 문서 정적 검증 결과
|
||||||
|
- `validate_fileserver_docs.py` — 검증 재실행 스크립트
|
||||||
|
|
||||||
|
## 저장소 배치 위치
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/superpowers/specs/2026-08-07-fileserver-platform-design.md
|
||||||
|
docs/superpowers/plans/2026-08-07-fileserver-platform-implementation-plan.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
1. 실제 Backend Skeleton 구조와 root package를 대조한다.
|
||||||
|
2. 설계서의 모듈 경계를 저장소에 반영한다.
|
||||||
|
3. 구현 계획 Task 1부터 순서대로 실행한다.
|
||||||
|
4. 각 Task에서 실패 테스트를 확인한 뒤 구현한다.
|
||||||
|
5. Milestone A~D마다 전체 검증 Gate를 실행한다.
|
||||||
|
|
||||||
|
실행에는 `superpowers:subagent-driven-development` 방식이 권장된다.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Fileserver Superpowers 문서 검증
|
||||||
|
|
||||||
|
**결과:** PASS
|
||||||
|
|
||||||
|
## 파일
|
||||||
|
|
||||||
|
- `fileserver-platform-design.md` — 1893 lines, 59904 bytes, SHA-256 `ee7b21277b254b9606a9ec6e34118a10fba3abbe818b43cbce9ae832102411e6`
|
||||||
|
- `fileserver-platform-implementation-plan.md` — 3422 lines, 131608 bytes, SHA-256 `9a443852ab3a7e4a2232c1b443d4cb8d3478a4954d70510173d3e0ac1d3d2125`
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
- [x] **fileserver-platform-design.md exists** — /mnt/data/fileserver-platform-design.md
|
||||||
|
- [x] **fileserver-platform-implementation-plan.md exists** — /mnt/data/fileserver-platform-implementation-plan.md
|
||||||
|
- [x] **design title** — True
|
||||||
|
- [x] **plan header** — required Superpowers header
|
||||||
|
- [x] **design code fences** — count=94
|
||||||
|
- [x] **plan code fences** — count=416
|
||||||
|
- [x] **design placeholder scan** — hits=[]
|
||||||
|
- [x] **plan placeholder scan** — hits=[]
|
||||||
|
- [x] **design section coverage** — missing=[]
|
||||||
|
- [x] **design topic: MVC** — missing=[]
|
||||||
|
- [x] **design topic: WebFlux** — missing=[]
|
||||||
|
- [x] **design topic: local/PVC/NFS** — missing=[]
|
||||||
|
- [x] **design topic: content/metadata separation** — missing=[]
|
||||||
|
- [x] **design topic: upload** — missing=[]
|
||||||
|
- [x] **design topic: download** — missing=[]
|
||||||
|
- [x] **design topic: publish** — missing=[]
|
||||||
|
- [x] **design topic: security** — missing=[]
|
||||||
|
- [x] **design topic: resumable** — missing=[]
|
||||||
|
- [x] **design topic: observability** — missing=[]
|
||||||
|
- [x] **blocking core port leakage** — hits=[]
|
||||||
|
- [x] **task count** — count=33
|
||||||
|
- [x] **task numbering** — numbers=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]
|
||||||
|
- [x] **task block completeness** — {}
|
||||||
|
- [x] **unique create paths** — {}
|
||||||
|
- [x] **plan scope coverage** — missing=[]
|
||||||
|
- [x] **no Redis carryover** — search term=redis
|
||||||
|
- [x] **no deprecated nginx token design** — token mapper removed
|
||||||
|
|
||||||
|
## 검증 범위의 한계
|
||||||
|
|
||||||
|
- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.
|
||||||
|
- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path('/mnt/data')
|
||||||
|
DESIGN = ROOT / 'fileserver-platform-design.md'
|
||||||
|
PLAN = ROOT / 'fileserver-platform-implementation-plan.md'
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
checks: list[tuple[str, bool, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def add(name: str, ok: bool, detail: str) -> None:
|
||||||
|
checks.append((name, ok, detail))
|
||||||
|
if not ok:
|
||||||
|
errors.append(f'{name}: {detail}')
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
for path in (DESIGN, PLAN):
|
||||||
|
add(f'{path.name} exists', path.exists(), str(path))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print('\n'.join(errors), file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
design = DESIGN.read_text(encoding='utf-8')
|
||||||
|
plan = PLAN.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
add('design title', design.startswith('# Fileserver Platform 설계서'), True.__str__())
|
||||||
|
add('plan header', plan.startswith('# Fileserver Platform Implementation Plan\n\n> **For agentic workers:**'), 'required Superpowers header')
|
||||||
|
add('design code fences', design.count('```') % 2 == 0, f"count={design.count('```')}")
|
||||||
|
add('plan code fences', plan.count('```') % 2 == 0, f"count={plan.count('```')}")
|
||||||
|
|
||||||
|
for label, text in [('design', design), ('plan', plan)]:
|
||||||
|
forbidden = [r'\bTBD\b', r'\bTODO\b', r'implement later', r'fill in details', r'Similar to Task']
|
||||||
|
hits = [p for p in forbidden if re.search(p, text, re.I)]
|
||||||
|
add(f'{label} placeholder scan', not hits, f'hits={hits}')
|
||||||
|
|
||||||
|
required_design_sections = [
|
||||||
|
'## 5. 지원 매트릭스',
|
||||||
|
'## 6. 전체 아키텍처',
|
||||||
|
'## 9. 상태 머신과 invariant',
|
||||||
|
'## 10. Metadata Store 설계',
|
||||||
|
'## 11. Content Store Port',
|
||||||
|
'## 12. Local Filesystem Adapter',
|
||||||
|
'## 14. Publish와 완료 처리',
|
||||||
|
'## 15. Upload Application 설계',
|
||||||
|
'## 19. HTTP API',
|
||||||
|
'## 20. Range와 Conditional Request',
|
||||||
|
'## 21. Spring MVC Adapter',
|
||||||
|
'## 22. Spring WebFlux Adapter',
|
||||||
|
'## 23. Nginx 전송 위임',
|
||||||
|
'## 24. 재개 가능한 업로드',
|
||||||
|
'## 27. 보안 정책',
|
||||||
|
'## 28. 다중 인스턴스와 NFS',
|
||||||
|
'## 30. 관측성',
|
||||||
|
'## 33. 테스트 전략',
|
||||||
|
'## 37. 완료 정의',
|
||||||
|
]
|
||||||
|
missing_sections = [s for s in required_design_sections if s not in design]
|
||||||
|
add('design section coverage', not missing_sections, f'missing={missing_sections}')
|
||||||
|
|
||||||
|
source_topics = {
|
||||||
|
'MVC': ['Spring MVC Adapter', 'MvcTransferExecutorProperties'],
|
||||||
|
'WebFlux': ['Spring WebFlux Adapter', 'DataBuffer'],
|
||||||
|
'local/PVC/NFS': ['Kubernetes PVC', 'NFSv4.1', 'Local Filesystem Adapter'],
|
||||||
|
'content/metadata separation': ['Content Store Port', 'Metadata Store 설계'],
|
||||||
|
'upload': ['Upload Application 설계', 'multipart', 'application/octet-stream'],
|
||||||
|
'download': ['Range와 Conditional Request', 'ETag', 'If-Range'],
|
||||||
|
'publish': ['ATOMIC_MOVE_REQUIRED', 'METADATA_POINTER', 'AmbiguousCompletionException'],
|
||||||
|
'security': ['traversal', 'symlink', 'READY gate'],
|
||||||
|
'resumable': ['tus 1.0 Stable', 'draft-12 Experimental'],
|
||||||
|
'observability': ['Metric', 'Trace', 'Audit'],
|
||||||
|
}
|
||||||
|
for topic, needles in source_topics.items():
|
||||||
|
missing = [n for n in needles if n not in design]
|
||||||
|
add(f'design topic: {topic}', not missing, f'missing={missing}')
|
||||||
|
|
||||||
|
# Core Port snippet must not expose adapter types.
|
||||||
|
port_match = re.search(r'### 11\.2 Blocking SPI\n(.*?)### 11\.3 Async SPI', design, re.S)
|
||||||
|
port_text = port_match.group(1) if port_match else ''
|
||||||
|
forbidden_port_types = ['java.nio.file.Path', 'org.springframework.core.io.Resource', 'DataBuffer', 'Flux<']
|
||||||
|
port_hits = [x for x in forbidden_port_types if x in port_text]
|
||||||
|
add('blocking core port leakage', bool(port_match) and not port_hits, f'hits={port_hits}')
|
||||||
|
|
||||||
|
# Task structure.
|
||||||
|
task_matches = list(re.finditer(r'^### Task (\d+):', plan, re.M))
|
||||||
|
task_numbers = [int(m.group(1)) for m in task_matches]
|
||||||
|
add('task count', len(task_numbers) == 33, f'count={len(task_numbers)}')
|
||||||
|
add('task numbering', task_numbers == list(range(1, 34)), f'numbers={task_numbers}')
|
||||||
|
|
||||||
|
missing_task_blocks: dict[int, list[str]] = {}
|
||||||
|
for idx, match in enumerate(task_matches):
|
||||||
|
end = task_matches[idx + 1].start() if idx + 1 < len(task_matches) else plan.find('\n## 3.', match.start())
|
||||||
|
segment = plan[match.start():end]
|
||||||
|
required = [
|
||||||
|
'**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:',
|
||||||
|
'**Step 3:', '**Step 4:', '**Step 5:', 'Expected:', 'git commit'
|
||||||
|
]
|
||||||
|
missing = [item for item in required if item not in segment]
|
||||||
|
if missing:
|
||||||
|
missing_task_blocks[int(match.group(1))] = missing
|
||||||
|
add('task block completeness', not missing_task_blocks, json.dumps(missing_task_blocks, ensure_ascii=False))
|
||||||
|
|
||||||
|
create_paths = re.findall(r'^- Create: `([^`]+)`', plan, re.M)
|
||||||
|
duplicates = {path: count for path, count in Counter(create_paths).items() if count > 1}
|
||||||
|
add('unique create paths', not duplicates, json.dumps(duplicates, ensure_ascii=False))
|
||||||
|
|
||||||
|
required_plan_topics = [
|
||||||
|
'Task 10: Storage capability probe',
|
||||||
|
'Task 11: Streaming append',
|
||||||
|
'Task 13: Atomic move와 metadata pointer publish',
|
||||||
|
'Task 18: HTTP Range',
|
||||||
|
'Task 21: Spring WebFlux raw·multipart upload',
|
||||||
|
'Task 23: Nginx `X-Accel-Redirect`',
|
||||||
|
'Task 26: 다중 인스턴스 writer lease',
|
||||||
|
'Task 27: tus 1.0 Stable',
|
||||||
|
'Task 28: HTTPbis resumable upload draft-12 Experimental',
|
||||||
|
'Task 29: HTTP Problem Detail과 보안 hardening',
|
||||||
|
'Task 32: Filesystem, HTTP, fault, performance Testkit',
|
||||||
|
'Task 33: CI matrix',
|
||||||
|
]
|
||||||
|
missing_plan_topics = [x for x in required_plan_topics if x not in plan]
|
||||||
|
add('plan scope coverage', not missing_plan_topics, f'missing={missing_plan_topics}')
|
||||||
|
|
||||||
|
add('no Redis carryover', 'redis' not in design.lower() and 'redis' not in plan.lower(), 'search term=redis')
|
||||||
|
add('no deprecated nginx token design', 'DelegatedPathToken' not in design + plan and 'opaque-token' not in design + plan, 'token mapper removed')
|
||||||
|
|
||||||
|
status = 'PASS' if not errors else 'FAIL'
|
||||||
|
report = ROOT / 'fileserver-superpowers-validation.md'
|
||||||
|
lines = [
|
||||||
|
'# Fileserver Superpowers 문서 검증',
|
||||||
|
'',
|
||||||
|
f'**결과:** {status}',
|
||||||
|
'',
|
||||||
|
'## 파일',
|
||||||
|
'',
|
||||||
|
f'- `{DESIGN.name}` — {len(design.splitlines())} lines, {len(design.encode())} bytes, SHA-256 `{sha256(DESIGN)}`',
|
||||||
|
f'- `{PLAN.name}` — {len(plan.splitlines())} lines, {len(plan.encode())} bytes, SHA-256 `{sha256(PLAN)}`',
|
||||||
|
'',
|
||||||
|
'## 검증 항목',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
for name, ok, detail in checks:
|
||||||
|
lines.append(f"- [{'x' if ok else ' '}] **{name}** — {detail}")
|
||||||
|
|
||||||
|
lines += [
|
||||||
|
'',
|
||||||
|
'## 검증 범위의 한계',
|
||||||
|
'',
|
||||||
|
'- 현재 Backend Skeleton 저장소가 입력되지 않아 Gradle compilation, integration test, Nginx execution, PVC·NFS certification은 실행하지 않았다.',
|
||||||
|
'- 본 검증은 설계·계획 문서의 구조, 내부 일관성, 범위 추적성, 미확정 표식과 중복 경로를 확인한 정적 검증이다.',
|
||||||
|
]
|
||||||
|
report.write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
'status': status,
|
||||||
|
'errors': errors,
|
||||||
|
'checks': len(checks),
|
||||||
|
'design_lines': len(design.splitlines()),
|
||||||
|
'plan_lines': len(plan.splitlines()),
|
||||||
|
'task_count': len(task_numbers),
|
||||||
|
'report': str(report),
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
raise SystemExit(0 if not errors else 1)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# HTTP Client Superpowers 설계 패키지
|
||||||
|
|
||||||
|
이 패키지는 `Java/Spring 외부 HTTP Client 플랫폼 설계 심층 리서치`를 기반으로 작성한 설계서와 구현 계획서다.
|
||||||
|
|
||||||
|
## 파일
|
||||||
|
|
||||||
|
- `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||||
|
- `VALIDATION.md`
|
||||||
|
- `validate_httpclient_docs.py`
|
||||||
|
|
||||||
|
## 구현 기준
|
||||||
|
|
||||||
|
- Java 21
|
||||||
|
- Gradle Kotlin DSL
|
||||||
|
- 공통 API는 Spring Framework 6.2 기준
|
||||||
|
- Spring Framework 7.0 호환성 검증
|
||||||
|
- Apache HttpClient 5 + RestClient
|
||||||
|
- JDK HttpClient + RestClient
|
||||||
|
- Reactor Netty + WebClient
|
||||||
|
- Jetty HTTP/3 Experimental
|
||||||
|
|
||||||
|
실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과 정책 의미론은 유지한다.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# HTTP Client Superpowers 문서 검증
|
||||||
|
|
||||||
|
**검증 결과:** PASS
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
- 설계서 존재 및 최소 구조: PASS
|
||||||
|
- 구현 계획서 존재 및 최소 구조: PASS
|
||||||
|
- Task 번호 연속성: PASS
|
||||||
|
- Task별 Files·Interfaces·Step 1~5·Expected·Commit: PASS
|
||||||
|
- Markdown code fence 균형: PASS
|
||||||
|
- Placeholder scan: PASS
|
||||||
|
- 중복 Create 경로: PASS
|
||||||
|
- 핵심 설계 범위: PASS
|
||||||
|
- 핵심 구현 범위: PASS
|
||||||
|
|
||||||
|
## 통계
|
||||||
|
|
||||||
|
- explicitly forbidden signature documented: ApacheHttpClient nativeApacheClient()
|
||||||
|
- explicitly forbidden signature documented: HttpClient nativeJdkClient()
|
||||||
|
- explicitly forbidden signature documented: WebClient.Builder mutableBuilder()
|
||||||
|
- explicitly forbidden signature documented: RestClient.Builder mutableBuilder()
|
||||||
|
- design lines=1956, bytes=64493
|
||||||
|
- plan lines=3635, bytes=158401
|
||||||
|
- tasks=38, create_paths=306
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.
|
||||||
|
- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.
|
||||||
|
- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.
|
||||||
+3635
File diff suppressed because it is too large
Load Diff
+1956
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
base = Path('/mnt/data')
|
||||||
|
design_path = base / 'httpclient-platform-design.md'
|
||||||
|
plan_path = base / 'httpclient-platform-implementation-plan.md'
|
||||||
|
errors = []
|
||||||
|
notes = []
|
||||||
|
|
||||||
|
def read(p):
|
||||||
|
if not p.exists():
|
||||||
|
errors.append(f'missing file: {p}')
|
||||||
|
return ''
|
||||||
|
return p.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
design = read(design_path)
|
||||||
|
plan = read(plan_path)
|
||||||
|
|
||||||
|
# Basic size and structure
|
||||||
|
if len(design.splitlines()) < 1200:
|
||||||
|
errors.append(f'design unexpectedly short: {len(design.splitlines())} lines')
|
||||||
|
if len(plan.splitlines()) < 2500:
|
||||||
|
errors.append(f'plan unexpectedly short: {len(plan.splitlines())} lines')
|
||||||
|
|
||||||
|
# Task continuity and task internals
|
||||||
|
matches = list(re.finditer(r'^### Task (\d+): (.+)$', plan, flags=re.M))
|
||||||
|
nums = [int(m.group(1)) for m in matches]
|
||||||
|
expected = list(range(1, (max(nums) if nums else 0) + 1))
|
||||||
|
if nums != expected:
|
||||||
|
errors.append(f'task numbers not continuous: {nums[:5]}...{nums[-5:] if nums else []}')
|
||||||
|
|
||||||
|
for i, m in enumerate(matches):
|
||||||
|
start = m.start()
|
||||||
|
end = matches[i+1].start() if i+1 < len(matches) else plan.find('\n## 3. Plan Self-Review Checklist', start)
|
||||||
|
if end == -1:
|
||||||
|
end = len(plan)
|
||||||
|
block = plan[start:end]
|
||||||
|
n = m.group(1)
|
||||||
|
for token in ['**Files:**', '**Interfaces:**', '**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:']:
|
||||||
|
if token not in block:
|
||||||
|
errors.append(f'Task {n} missing {token}')
|
||||||
|
if 'git commit -m ' not in block:
|
||||||
|
errors.append(f'Task {n} missing commit command')
|
||||||
|
if 'Expected:' not in block:
|
||||||
|
errors.append(f'Task {n} missing expected result')
|
||||||
|
|
||||||
|
# Markdown fence balance
|
||||||
|
for name, text in [('design', design), ('plan', plan)]:
|
||||||
|
count = len(re.findall(r'^```', text, flags=re.M))
|
||||||
|
if count % 2:
|
||||||
|
errors.append(f'{name} has unbalanced code fences: {count}')
|
||||||
|
|
||||||
|
# Placeholder scan
|
||||||
|
patterns = {
|
||||||
|
'TBD': r'\bTBD\b',
|
||||||
|
'TODO': r'\bTODO\b',
|
||||||
|
'implement later': r'implement later',
|
||||||
|
'fill in': r'fill in',
|
||||||
|
'similar to task': r'similar to Task',
|
||||||
|
'placeholder': r'placeholder',
|
||||||
|
}
|
||||||
|
for name, text in [('design', design), ('plan', plan)]:
|
||||||
|
for label, pat in patterns.items():
|
||||||
|
if re.search(pat, text, flags=re.I):
|
||||||
|
errors.append(f'{name} contains placeholder pattern: {label}')
|
||||||
|
|
||||||
|
# Duplicate create path scan
|
||||||
|
create_paths = re.findall(r'^- Create: `([^`]+)`', plan, flags=re.M)
|
||||||
|
dupes = sorted({p for p in create_paths if create_paths.count(p) > 1})
|
||||||
|
if dupes:
|
||||||
|
errors.append(f'duplicate Create paths: {dupes}')
|
||||||
|
|
||||||
|
# Required design coverage
|
||||||
|
required_design_terms = [
|
||||||
|
'H1 Typed Service Client', 'H2 Generic Exchange', 'H3 Dynamic Target',
|
||||||
|
'ExecutionEvidence', 'BodyReplayability', 'OperationIdempotency',
|
||||||
|
'Named Client Profile', 'Apache HttpClient 5', 'Reactor Netty',
|
||||||
|
'Retry Coordinator', 'Circuit Breaker', 'Rate Limiter', 'Bulkhead',
|
||||||
|
'OAuth2', 'TLS', 'SSRF', 'Streaming', 'SSE', 'HTTP/3',
|
||||||
|
'Spring Framework 6.2', 'Spring 7', 'RestTemplate'
|
||||||
|
]
|
||||||
|
for term in required_design_terms:
|
||||||
|
if term not in design:
|
||||||
|
errors.append(f'design missing term: {term}')
|
||||||
|
|
||||||
|
required_plan_terms = [
|
||||||
|
'httpclient-core-api', 'httpclient-transport-apache', 'httpclient-transport-jdk',
|
||||||
|
'httpclient-transport-reactor-netty', 'httpclient-dynamic-target',
|
||||||
|
'httpclient-spring-boot-starter', 'HttpAmbiguousExecutionException',
|
||||||
|
'first response byte', 'DNS/IP Pinning', 'SingleFlightTokenLoader',
|
||||||
|
'httpClientStableContractTest', 'spring62CompatibilityTest',
|
||||||
|
'spring70CompatibilityTest'
|
||||||
|
]
|
||||||
|
for term in required_plan_terms:
|
||||||
|
if term not in plan:
|
||||||
|
errors.append(f'plan missing term: {term}')
|
||||||
|
|
||||||
|
# Core API should not deliberately expose native clients in design signatures.
|
||||||
|
for forbidden_signature in [
|
||||||
|
'ApacheHttpClient nativeApacheClient()',
|
||||||
|
'HttpClient nativeJdkClient()',
|
||||||
|
'WebClient.Builder mutableBuilder()',
|
||||||
|
'RestClient.Builder mutableBuilder()'
|
||||||
|
]:
|
||||||
|
# These appear in an explicit "do not provide" code block. Note rather than fail.
|
||||||
|
if forbidden_signature in design:
|
||||||
|
notes.append(f'explicitly forbidden signature documented: {forbidden_signature}')
|
||||||
|
|
||||||
|
# Record task count and file counts
|
||||||
|
notes.append(f'design lines={len(design.splitlines())}, bytes={len(design.encode())}')
|
||||||
|
notes.append(f'plan lines={len(plan.splitlines())}, bytes={len(plan.encode())}')
|
||||||
|
notes.append(f'tasks={len(nums)}, create_paths={len(create_paths)}')
|
||||||
|
|
||||||
|
report = base / 'httpclient-superpowers-validation.md'
|
||||||
|
status = 'PASS' if not errors else 'FAIL'
|
||||||
|
report_text = [
|
||||||
|
'# HTTP Client Superpowers 문서 검증', '',
|
||||||
|
f'**검증 결과:** {status}', '',
|
||||||
|
'## 검증 항목', '',
|
||||||
|
f'- 설계서 존재 및 최소 구조: {"PASS" if design else "FAIL"}',
|
||||||
|
f'- 구현 계획서 존재 및 최소 구조: {"PASS" if plan else "FAIL"}',
|
||||||
|
f'- Task 번호 연속성: {"PASS" if nums == expected else "FAIL"}',
|
||||||
|
f'- Task별 Files·Interfaces·Step 1~5·Expected·Commit: {"PASS" if not any("Task " in e for e in errors) else "FAIL"}',
|
||||||
|
f'- Markdown code fence 균형: {"PASS" if not any("code fences" in e for e in errors) else "FAIL"}',
|
||||||
|
f'- Placeholder scan: {"PASS" if not any("placeholder" in e for e in errors) else "FAIL"}',
|
||||||
|
f'- 중복 Create 경로: {"PASS" if not dupes else "FAIL"}',
|
||||||
|
f'- 핵심 설계 범위: {"PASS" if not any("design missing" in e for e in errors) else "FAIL"}',
|
||||||
|
f'- 핵심 구현 범위: {"PASS" if not any("plan missing" in e for e in errors) else "FAIL"}',
|
||||||
|
'', '## 통계', ''
|
||||||
|
]
|
||||||
|
report_text += [f'- {note}' for note in notes]
|
||||||
|
if errors:
|
||||||
|
report_text += ['', '## 오류', ''] + [f'- {e}' for e in errors]
|
||||||
|
else:
|
||||||
|
report_text += ['', '## 결론', '',
|
||||||
|
'- 설계 결정과 구현 작업의 정적 추적성이 확인됐다.',
|
||||||
|
'- 실제 저장소가 제공되지 않았으므로 Gradle compile, integration, fault, security, performance test는 아직 실행되지 않았다.',
|
||||||
|
'- 계획의 Java 21, Gradle Kotlin DSL, root package는 명시된 구현 가정이다.']
|
||||||
|
report.write_text('\n'.join(report_text) + '\n', encoding='utf-8')
|
||||||
|
|
||||||
|
print(status)
|
||||||
|
for note in notes:
|
||||||
|
print(note)
|
||||||
|
for e in errors:
|
||||||
|
print('ERROR:', e)
|
||||||
|
sys.exit(0 if not errors else 1)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Storage certification job.
|
||||||
|
#
|
||||||
|
# A PersistentVolumeClaim is not a filesystem contract. Whether an atomic rename, a same-file-store
|
||||||
|
# guarantee, or symlink refusal actually holds depends on the CSI driver, the StorageClass, the
|
||||||
|
# access mode, the backend, and the mount options — so this job records all five alongside the probe
|
||||||
|
# result. A certification without that tuple is not transferable to another cluster.
|
||||||
|
#
|
||||||
|
# The job writes a machine-readable result to the claim itself so the evidence lives with the volume
|
||||||
|
# it describes.
|
||||||
|
#
|
||||||
|
# kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||||
|
# kubectl logs job/fileserver-pvc-certification
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: fileserver-certification
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: fileserver
|
||||||
|
app.kubernetes.io/component: certification
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
# Left unset on purpose: the certification is only meaningful for the class it actually ran on,
|
||||||
|
# so the operator names it explicitly rather than inheriting a cluster default.
|
||||||
|
storageClassName: ""
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: fileserver-pvc-certification
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: fileserver
|
||||||
|
app.kubernetes.io/component: certification
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: fileserver
|
||||||
|
app.kubernetes.io/component: certification
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
fsGroup: 10001
|
||||||
|
containers:
|
||||||
|
- name: certify
|
||||||
|
image: eclipse-temurin:21-jdk
|
||||||
|
env:
|
||||||
|
- name: FILESERVER_STORAGE_ROOT
|
||||||
|
value: /var/lib/backend/files
|
||||||
|
- name: KUBERNETES_VERSION
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.annotations['certification.fileserver/kubernetes-version']
|
||||||
|
- name: CSI_DRIVER
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.annotations['certification.fileserver/csi-driver']
|
||||||
|
- name: STORAGE_CLASS
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.annotations['certification.fileserver/storage-class']
|
||||||
|
- name: ACCESS_MODE
|
||||||
|
value: ReadWriteOnce
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="${FILESERVER_STORAGE_ROOT}"
|
||||||
|
mkdir -p "${ROOT}/staging" "${ROOT}/content"
|
||||||
|
|
||||||
|
# Atomic rename within one file store is the property the publish path depends on.
|
||||||
|
echo probe > "${ROOT}/staging/probe"
|
||||||
|
if mv "${ROOT}/staging/probe" "${ROOT}/content/probe" 2>/dev/null; then
|
||||||
|
ATOMIC_MOVE=true
|
||||||
|
else
|
||||||
|
ATOMIC_MOVE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same device means a rename is a metadata operation rather than a copy.
|
||||||
|
STAGING_DEV=$(stat -c %d "${ROOT}/staging")
|
||||||
|
CONTENT_DEV=$(stat -c %d "${ROOT}/content")
|
||||||
|
[ "${STAGING_DEV}" = "${CONTENT_DEV}" ] && SAME_STORE=true || SAME_STORE=false
|
||||||
|
|
||||||
|
# O_EXCL create is what makes a publish create-only rather than an overwrite.
|
||||||
|
if (set -o noclobber; echo x > "${ROOT}/content/excl") 2>/dev/null; then
|
||||||
|
ATOMIC_CREATE=true
|
||||||
|
else
|
||||||
|
ATOMIC_CREATE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${ROOT}/certification-result.json" <<RESULT
|
||||||
|
{
|
||||||
|
"kubernetesVersion": "${KUBERNETES_VERSION:-unknown}",
|
||||||
|
"csiDriver": "${CSI_DRIVER:-unknown}",
|
||||||
|
"storageClass": "${STORAGE_CLASS:-unknown}",
|
||||||
|
"accessMode": "${ACCESS_MODE}",
|
||||||
|
"backend": "$(stat -f -c %T "${ROOT}")",
|
||||||
|
"mountOptions": "$(findmnt -no OPTIONS --target "${ROOT}" || echo unknown)",
|
||||||
|
"atomicMove": ${ATOMIC_MOVE},
|
||||||
|
"sameFileStore": ${SAME_STORE},
|
||||||
|
"atomicCreate": ${ATOMIC_CREATE}
|
||||||
|
}
|
||||||
|
RESULT
|
||||||
|
cat "${ROOT}/certification-result.json"
|
||||||
|
|
||||||
|
# Fail closed: a volume that cannot publish atomically must not be certified silently.
|
||||||
|
[ "${SAME_STORE}" = "true" ] || { echo "staging and content are on different stores"; exit 1; }
|
||||||
|
volumeMounts:
|
||||||
|
- name: storage
|
||||||
|
mountPath: /var/lib/backend/files
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumes:
|
||||||
|
- name: storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: fileserver-certification
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Network-filesystem certification environment.
|
||||||
|
#
|
||||||
|
# A local filesystem cannot reproduce the failures this environment exists to test: a rename whose
|
||||||
|
# acknowledgement is lost, a stale file handle after the server restarts, and a client that keeps
|
||||||
|
# writing across a network cut. Those are precisely the cases where "the write failed, retry it" is
|
||||||
|
# the wrong conclusion, so they are certified against a real NFS server rather than a mock.
|
||||||
|
#
|
||||||
|
# Opt in with FILESERVER_NFS_TESTS=true; the default test run does not start this.
|
||||||
|
#
|
||||||
|
# docker compose -f infra/fileserver/nfs/compose.yml up -d
|
||||||
|
# FILESERVER_NFS_TESTS=true ./gradlew :adapter:outbound:fileserver:test
|
||||||
|
#
|
||||||
|
# To exercise the ambiguity paths:
|
||||||
|
# docker compose -f infra/fileserver/nfs/compose.yml restart nfs-server # stale handles
|
||||||
|
# docker network disconnect fileserver-nfs <client> # lost responses
|
||||||
|
|
||||||
|
services:
|
||||||
|
nfs-server:
|
||||||
|
image: erichough/nfs-server:2.2.1
|
||||||
|
container_name: fileserver-nfs-server
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
NFS_EXPORT_0: "/exports *(rw,sync,no_subtree_check,no_root_squash,fsid=0)"
|
||||||
|
NFS_VERSION: "4.2"
|
||||||
|
NFS_LOG_LEVEL: DEBUG
|
||||||
|
volumes:
|
||||||
|
- nfs-exports:/exports
|
||||||
|
ports:
|
||||||
|
- "2049:2049"
|
||||||
|
networks:
|
||||||
|
- fileserver-nfs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rpcinfo", "-t", "localhost", "nfs", "4"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
nfs-client:
|
||||||
|
image: eclipse-temurin:21-jdk
|
||||||
|
container_name: fileserver-nfs-client
|
||||||
|
privileged: true
|
||||||
|
depends_on:
|
||||||
|
nfs-server:
|
||||||
|
condition: service_healthy
|
||||||
|
# hard,intr is the correct production mount: a soft mount turns a slow server into a silent
|
||||||
|
# short write, which is exactly the corruption the design refuses to accept.
|
||||||
|
command: >
|
||||||
|
bash -c "mkdir -p /mnt/fileserver &&
|
||||||
|
mount -t nfs4 -o hard,timeo=50,retrans=2 nfs-server:/ /mnt/fileserver &&
|
||||||
|
tail -f /dev/null"
|
||||||
|
volumes:
|
||||||
|
- ../../..:/workspace:ro
|
||||||
|
networks:
|
||||||
|
- fileserver-nfs
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nfs-exports:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
fileserver-nfs:
|
||||||
|
name: fileserver-nfs
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Fileserver front-proxy configuration.
|
||||||
|
#
|
||||||
|
# The application authorizes every download and then hands the transfer to Nginx with
|
||||||
|
# X-Accel-Redirect. Two properties make that safe, and both are enforced here rather than assumed:
|
||||||
|
#
|
||||||
|
# 1. /__files/ is `internal`, so it is reachable ONLY through an internal redirect the application
|
||||||
|
# issued. A direct request from a client returns 404 and never touches the content root.
|
||||||
|
# 2. The application never emits an absolute path. It emits a relative URI below /__files/, and
|
||||||
|
# the alias below is the only place that prefix becomes a filesystem location.
|
||||||
|
#
|
||||||
|
# Keep `alias` in sync with the storage root's content directory. A mismatch is a startup
|
||||||
|
# misconfiguration, not a runtime fallback: the application's startup validator checks that the
|
||||||
|
# internal mapping was proven before it accepts traffic.
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
sendfile_max_chunk 2m;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# Uploads stream through to the application; buffering a large body to disk here would double
|
||||||
|
# the write and defeat the streaming upload path.
|
||||||
|
proxy_request_buffering off;
|
||||||
|
client_max_body_size 0;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
|
||||||
|
# Public API. Everything, including download authorization, is decided by the application.
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# The application must never see a client-supplied delegation header: it would let a
|
||||||
|
# caller name an arbitrary internal object.
|
||||||
|
proxy_set_header X-Accel-Redirect "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Internal transfer location. Not reachable from outside; see property (1) above.
|
||||||
|
location /__files/ {
|
||||||
|
internal;
|
||||||
|
alias /srv/files/content/;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
sendfile_max_chunk 2m;
|
||||||
|
|
||||||
|
# Uploaded content is never trusted to describe itself.
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Disposition $upstream_http_content_disposition always;
|
||||||
|
add_header Cache-Control $upstream_http_cache_control always;
|
||||||
|
add_header ETag $upstream_http_etag always;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# HTTP Client Platform — local test topology
|
||||||
|
|
||||||
|
The suites drive these dependencies through Testcontainers and in-process fixtures, so nothing here
|
||||||
|
is required to run `./gradlew :adapter:outbound:httpclient:test`. These files exist for the nightly
|
||||||
|
lane and for reproducing a failure locally with the same images and ports CI uses.
|
||||||
|
|
||||||
|
| Directory | Purpose | Used by |
|
||||||
|
|---|---|---|
|
||||||
|
| `toxiproxy/` | TCP fault injection (latency, reset, bandwidth) | `httpClientFailureInjectionTest` |
|
||||||
|
| `tls/` | how the TLS and mTLS material is produced | TLS and mTLS suites |
|
||||||
|
| `proxy/` | forward proxy with CONNECT and proxy authentication | proxy contract suite |
|
||||||
|
| `oauth2/` | token endpoint behaviour under contention | OAuth2 suites |
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# OAuth2 fixture
|
||||||
|
|
||||||
|
`OAuth2Fixture` exposes a token endpoint backed by the same deterministic fixture server as the rest
|
||||||
|
of the suite.
|
||||||
|
|
||||||
|
It counts token requests, which is what makes design §20.3's single-flight guarantee provable rather
|
||||||
|
than assumed: a hundred genuinely concurrent callers must produce exactly one token request. It can
|
||||||
|
also issue rotating token values, so a stale cached token is detectable, and queue a failure status
|
||||||
|
to exercise the refresh-failure path.
|
||||||
|
|
||||||
|
The token endpoint is configured as its own Named Client Profile, separate from the upstream it
|
||||||
|
issues tokens for.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Forward proxy fixture
|
||||||
|
|
||||||
|
`ProxyFixture` runs an in-process forward proxy so the proxy lane needs no external service.
|
||||||
|
|
||||||
|
| Factory | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `ProxyFixture.openProxy()` | accepts CONNECT and tunnels to the target |
|
||||||
|
| `ProxyFixture.authenticatingProxy(user, password)` | answers `407` until `Proxy-Authorization` matches |
|
||||||
|
|
||||||
|
The fixture records every request line and every `Proxy-Authorization` value it saw, which is what
|
||||||
|
lets the suite prove design §24.3: proxy credentials never appear on the target request, and a proxy
|
||||||
|
CONNECT failure is reported as `HttpProxyException` rather than as a target TLS failure.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# TLS fixtures
|
||||||
|
|
||||||
|
Certificates are generated **in process** by `TlsFixture`, not checked in. A committed private key
|
||||||
|
is a private key that leaks, and design §21.2 forbids key material in the repository.
|
||||||
|
|
||||||
|
`TlsFixture` produces, from a throwaway CA created per test run:
|
||||||
|
|
||||||
|
| Fixture | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `TlsFixture.trusted()` | a server certificate valid for the loopback host |
|
||||||
|
| `TlsFixture.hostnameMismatch()` | a certificate whose SAN does not match the connection host |
|
||||||
|
| `TlsFixture.expired()` | an already-expired certificate |
|
||||||
|
| `clientHandshake(true)` | client key material for the mTLS lane |
|
||||||
|
|
||||||
|
All three failure cases must classify as permanent (design §21.3) — never retried, never downgraded
|
||||||
|
to plaintext.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Fault-injection topology for the HTTP Client Platform failure suite (design §28.1, §28.3).
|
||||||
|
#
|
||||||
|
# The suite normally drives Toxiproxy through Testcontainers. This compose file exists for the
|
||||||
|
# nightly lane and for reproducing a failure locally with the exact same image and ports.
|
||||||
|
services:
|
||||||
|
toxiproxy:
|
||||||
|
image: ghcr.io/shopify/toxiproxy:2.9.0
|
||||||
|
container_name: httpclient-toxiproxy
|
||||||
|
ports:
|
||||||
|
- "8474:8474" # control API
|
||||||
|
- "18080:18080" # proxied upstream: plaintext
|
||||||
|
- "18443:18443" # proxied upstream: TLS
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/toxiproxy-cli", "list"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# Disposable Redis qualification lab
|
|
||||||
|
|
||||||
This directory owns the lifecycle contract for the isolated three-node k3s lab. It does not contain
|
|
||||||
Redis workloads, credentials, certificates, or qualification evidence.
|
|
||||||
|
|
||||||
## Fixed topology
|
|
||||||
|
|
||||||
| Instance | CPU | Memory | Disk | Role |
|
|
||||||
| --- | ---: | ---: | ---: | --- |
|
|
||||||
| `ca-redis-lab-server` | 2 | 3G | 12G | k3s server |
|
|
||||||
| `ca-redis-lab-agent-1` | 2 | 2560M | 12G | k3s agent |
|
|
||||||
| `ca-redis-lab-agent-2` | 2 | 2560M | 12G | k3s agent |
|
|
||||||
|
|
||||||
The lab uses pod CIDR `10.52.0.0/16`, service CIDR `10.53.0.0/16`, and context
|
|
||||||
`ca-redis-lab`. `versions.env` pins the k3s version and Multipass image. Traefik and ServiceLB are
|
|
||||||
disabled.
|
|
||||||
|
|
||||||
## Safety model
|
|
||||||
|
|
||||||
All state, rendered cloud-init, kubeconfigs, tokens, and raw observations are mode-restricted
|
|
||||||
beneath the ignored `src/build/redis-lab` directory. Every canonical ancestor from the repository
|
|
||||||
root through `src/build/redis-lab`, plus runtime children, is validated before observation or
|
|
||||||
mutation; a symlink or real-path escape fails closed. The lifecycle never exports `KUBECONFIG`,
|
|
||||||
merges a kubeconfig, or writes the user's default kubeconfig.
|
|
||||||
|
|
||||||
Host observation and lab access deliberately use different explicit targets:
|
|
||||||
|
|
||||||
- host read-only queries copy the default kubeconfig into
|
|
||||||
`src/build/redis-lab/observations/host-kubeconfig` and use its unchanged original context;
|
|
||||||
- lab read-only queries use `src/build/redis-lab/kubeconfig` and exact context `ca-redis-lab`.
|
|
||||||
|
|
||||||
This split preserves the host context identity while ensuring no kubectl call relies on an implicit
|
|
||||||
target. Host kubectl mutations are not part of the lifecycle. The observation-only host copy is
|
|
||||||
removed after fingerprint and CIDR observation on success and every handled failure path.
|
|
||||||
|
|
||||||
Each exact name gets a private mode-`0600` rendered cloud-init file beneath
|
|
||||||
`src/build/redis-lab/cloud-init`. It writes only the non-secret ownership marker
|
|
||||||
`RUN_ID|VM_NAME` to `/var/lib/ca-redis-lab/ownership` as `root:root` mode `0600`; launch uses only
|
|
||||||
that rendered file.
|
|
||||||
|
|
||||||
Each name is then atomically reserved as `PENDING` in `run.state` before its bounded launch. The
|
|
||||||
state starts with an exact per-run identity, and each `PENDING`/`CREATED`/`RECONCILE` entry carries
|
|
||||||
that same identity. A successful launch becomes `CREATED` only after a bounded
|
|
||||||
`multipass exec <name> -- sudo cat /var/lib/ca-redis-lab/ownership` returns the exact marker.
|
|
||||||
Timeout, launch error, missing/foreign marker, signal, promotion failure, or uncertain cleanup
|
|
||||||
enters `RECONCILE`.
|
|
||||||
|
|
||||||
Cleanup transitions a recorded entry to `RECONCILE`, bounded-polls the exact instance and marker,
|
|
||||||
and issues `multipass delete --purge <exact-name>` only after the marker matches. It atomically
|
|
||||||
removes only an entry whose delete succeeded. A late-created matching instance is deleted; an
|
|
||||||
absent instance, unreadable marker, mismatched/foreign marker, or failed delete is retained as a
|
|
||||||
tombstone and fails closed without an unproven delete. Existing instance-bearing state blocks a
|
|
||||||
new `preflight`, `up`, or `run`; a rejected new run does not clean the prior run, and `down` is the
|
|
||||||
retry/reconciliation entry point. Existing
|
|
||||||
allowlisted names without owned state cause `up` to stop before reservation/launch and are never
|
|
||||||
adopted or deleted. Wildcards, `--all`, global purge, and discovered-instance deletion are
|
|
||||||
forbidden.
|
|
||||||
|
|
||||||
The lifecycle lock is nonblocking and exclusive. External children close its descriptor by
|
|
||||||
default, including detached infrastructure descendants and `run --` commands; only lock
|
|
||||||
acquisition retains descriptor 9.
|
|
||||||
|
|
||||||
Multipass list/launch/info/exec/transfer/delete, installation/join, and kubectl calls have fixed
|
|
||||||
time bounds. `up` succeeds only after the exact server and two agents all report `Ready=True`
|
|
||||||
within the bounded poll budget; incomplete or not-ready inventory enters marker-proven run-owned
|
|
||||||
cleanup.
|
|
||||||
|
|
||||||
The k3s runtime is amd64-only and fail-closed in this slice. `versions.env` pins the immutable
|
|
||||||
release URL and exact SHA-256 for `v1.33.3+k3s1`. The lifecycle performs a bounded host download,
|
|
||||||
verifies the digest, transfers the binary to each exact VM, verifies the transferred digest and
|
|
||||||
reported binary version inside each VM, and only then installs/starts it. It does not execute a
|
|
||||||
network installer or a `curl | sh` pipeline.
|
|
||||||
|
|
||||||
The generated lab kubeconfig is accepted only in the pinned single-cluster/single-context/
|
|
||||||
single-user block grammar. A tracked AWK state machine has one explicit transition for every
|
|
||||||
allowlisted line and publishes no output until the complete document reaches its exact final
|
|
||||||
state. It rejects missing, duplicate, reordered, unknown, whitespace-altered, quoted, tagged, or
|
|
||||||
explicit keys; anchors, aliases, merge keys, tabs, CRLF, document markers, trailing content, and
|
|
||||||
all flow collections except exact `preferences: {}`. Only the exact cluster/context/user identity,
|
|
||||||
`current-context`, and loopback API server are rewritten. CA data, client certificate/key data,
|
|
||||||
and an optional canonical namespace are byte-preserved.
|
|
||||||
|
|
||||||
Rendering uses a same-directory `kubeconfig.next`, applies mode `0600`, and replaces the
|
|
||||||
destination only after render and permission success. The renderer must be a readable regular
|
|
||||||
non-symlink file at its canonical tracked path, and both destination paths are protected by the
|
|
||||||
runtime symlink contract. Renderer, permission, or move failure removes both candidate and
|
|
||||||
destination, performs no lab `kubectl`, and enters exact marker-proven current-run cleanup.
|
|
||||||
|
|
||||||
Assigned Service ClusterIPs cannot prove the host service CIDR. When a host kubeconfig exists,
|
|
||||||
callers must supply one or more canonical, comma- or space-separated IPv4 CIDRs through
|
|
||||||
`REDIS_LAB_HOST_SERVICE_CIDRS`. Missing, malformed, or overlapping input fails before launch:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
|
|
||||||
infra/redis-lab/bin/redis-lab preflight
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
The real lifecycle is for a trusted local or dedicated runner only:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab preflight
|
|
||||||
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab up
|
|
||||||
infra/redis-lab/bin/redis-lab down
|
|
||||||
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 infra/redis-lab/bin/redis-lab run -- command
|
|
||||||
REDIS_LAB_HOST_SERVICE_CIDRS=10.43.0.0/16 \
|
|
||||||
infra/redis-lab/bin/redis-lab run --retain-on-failure -- command
|
|
||||||
```
|
|
||||||
|
|
||||||
`run` establishes its cleanup obligation before entering the inner `up`, keeps it through the
|
|
||||||
post-up/pre-command handoff and user command, then tears down after command success or failure and
|
|
||||||
compares the canonical pre/post host fingerprints after cleanup. A successful direct `up` retains
|
|
||||||
the lab by design. Local `--retain-on-failure` intentionally leaves the recorded lab for diagnosis
|
|
||||||
and skips an isolation-success claim; `CI=true` rejects that option before launch.
|
|
||||||
|
|
||||||
The blocking contract is VM-free:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src
|
|
||||||
./gradlew :adapter:outbound:cache-redis:redisLabContractTest --console=plain
|
|
||||||
```
|
|
||||||
|
|
||||||
It injects fake infrastructure commands. Hosted CI must run only this contract, never the real lab.
|
|
||||||
The contract executes a copied lifecycle in
|
|
||||||
`src/build/redis-lab-contract/repository`, seals `PATH` to explicit fakes/safe wrappers, and compares
|
|
||||||
a byte-level snapshot proving it did not modify the real repository's `src/build/redis-lab`. It
|
|
||||||
also exercises direct/run signal cleanup, rendered-child symlink rejection, successful and
|
|
||||||
late-create marker proof, absent/foreign-marker tombstones, second-run state preservation,
|
|
||||||
CREATED cleanup uncertainty, rejected-run preservation of prior `CREATED` and `RECONCILE` state,
|
|
||||||
the post-up/pre-command signal handoff, the canonical kubeconfig mutation matrix, missing/symlinked
|
|
||||||
renderer rejection, fail-closed `.next`/permission/move publication, and infrastructure/user
|
|
||||||
background-child lock non-inheritance. This is deterministic fake-runtime evidence only; it is not
|
|
||||||
live Multipass, k3s, kubectl, network, or host-isolation qualification.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
|||||||
#cloud-config
|
|
||||||
package_update: false
|
|
||||||
package_upgrade: false
|
|
||||||
ssh_pwauth: false
|
|
||||||
disable_root: true
|
|
||||||
write_files:
|
|
||||||
- path: /etc/sysctl.d/90-ca-redis-lab.conf
|
|
||||||
owner: root:root
|
|
||||||
permissions: "0644"
|
|
||||||
content: |
|
|
||||||
net.ipv4.ip_forward=1
|
|
||||||
runcmd:
|
|
||||||
- [mkdir, -p, /etc/rancher/k3s]
|
|
||||||
- [sysctl, --system]
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
BEGIN {
|
|
||||||
state = "start"
|
|
||||||
invalid = 0
|
|
||||||
output_count = 0
|
|
||||||
|
|
||||||
if (target != "ca-redis-lab") {
|
|
||||||
invalid = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function remember(line) {
|
|
||||||
output[++output_count] = line
|
|
||||||
}
|
|
||||||
|
|
||||||
function is_credential_line(line, prefix, value) {
|
|
||||||
if (index(line, prefix) != 1) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
value = substr(line, length(prefix) + 1)
|
|
||||||
return value ~ /^[A-Za-z0-9+\/=_-]+$/
|
|
||||||
}
|
|
||||||
|
|
||||||
function is_namespace_line(line, value) {
|
|
||||||
if (index(line, " namespace: ") != 1) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
value = substr(line, length(" namespace: ") + 1)
|
|
||||||
return value ~ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
if (invalid) {
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (index($0, "\t") != 0 || index($0, "\r") != 0 ||
|
|
||||||
$0 ~ /^[[:space:]]*(---|\.\.\.)([[:space:]]|$)/) {
|
|
||||||
invalid = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if ($0 != "preferences: {}" &&
|
|
||||||
(index($0, "{") != 0 || index($0, "}") != 0 ||
|
|
||||||
index($0, "[") != 0 || index($0, "]") != 0)) {
|
|
||||||
invalid = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state == "start" && $0 == "apiVersion: v1") {
|
|
||||||
api_version_count += 1
|
|
||||||
state = "apiVersion"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "apiVersion" && $0 == "clusters:") {
|
|
||||||
clusters_count += 1
|
|
||||||
state = "clusters"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "clusters" && $0 == "- cluster:") {
|
|
||||||
cluster_item_count += 1
|
|
||||||
state = "cluster-item"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "cluster-item" &&
|
|
||||||
is_credential_line($0, " certificate-authority-data: ")) {
|
|
||||||
ca_data_count += 1
|
|
||||||
state = "ca-data"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "ca-data" &&
|
|
||||||
$0 == " server: https://127.0.0.1:6443") {
|
|
||||||
server_count += 1
|
|
||||||
state = "server"
|
|
||||||
remember(" server: https://" address ":6443")
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "server" && $0 == " name: default") {
|
|
||||||
cluster_name_count += 1
|
|
||||||
state = "cluster-name"
|
|
||||||
remember(" name: " target)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "cluster-name" && $0 == "contexts:") {
|
|
||||||
contexts_count += 1
|
|
||||||
state = "contexts"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "contexts" && $0 == "- context:") {
|
|
||||||
context_item_count += 1
|
|
||||||
state = "context-item"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "context-item" && $0 == " cluster: default") {
|
|
||||||
context_cluster_count += 1
|
|
||||||
state = "context-cluster"
|
|
||||||
remember(" cluster: " target)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "context-cluster" && is_namespace_line($0)) {
|
|
||||||
namespace_count += 1
|
|
||||||
state = "optional-namespace"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if ((state == "context-cluster" || state == "optional-namespace") &&
|
|
||||||
$0 == " user: default") {
|
|
||||||
context_user_count += 1
|
|
||||||
state = "context-user"
|
|
||||||
remember(" user: " target)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "context-user" && $0 == " name: default") {
|
|
||||||
context_name_count += 1
|
|
||||||
state = "context-name"
|
|
||||||
remember(" name: " target)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "context-name" && $0 == "current-context: default") {
|
|
||||||
current_context_count += 1
|
|
||||||
state = "current-context"
|
|
||||||
remember("current-context: " target)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "current-context" && $0 == "kind: Config") {
|
|
||||||
kind_count += 1
|
|
||||||
state = "kind"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "kind" && $0 == "preferences: {}") {
|
|
||||||
preferences_count += 1
|
|
||||||
state = "preferences"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "preferences" && $0 == "users:") {
|
|
||||||
users_count += 1
|
|
||||||
state = "users"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "users" && $0 == "- name: default") {
|
|
||||||
user_name_count += 1
|
|
||||||
state = "user-name"
|
|
||||||
remember("- name: " target)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "user-name" && $0 == " user:") {
|
|
||||||
user_body_count += 1
|
|
||||||
state = "user-body"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "user-body" &&
|
|
||||||
is_credential_line($0, " client-certificate-data: ")) {
|
|
||||||
client_cert_count += 1
|
|
||||||
state = "client-cert"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (state == "client-cert" &&
|
|
||||||
is_credential_line($0, " client-key-data: ")) {
|
|
||||||
client_key_count += 1
|
|
||||||
state = "client-key"
|
|
||||||
remember($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
invalid = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
END {
|
|
||||||
if (invalid || state != "client-key" ||
|
|
||||||
api_version_count != 1 || clusters_count != 1 ||
|
|
||||||
cluster_item_count != 1 || ca_data_count != 1 ||
|
|
||||||
server_count != 1 || cluster_name_count != 1 ||
|
|
||||||
contexts_count != 1 || context_item_count != 1 ||
|
|
||||||
context_cluster_count != 1 || namespace_count > 1 ||
|
|
||||||
context_user_count != 1 || context_name_count != 1 ||
|
|
||||||
current_context_count != 1 || kind_count != 1 ||
|
|
||||||
preferences_count != 1 || users_count != 1 ||
|
|
||||||
user_name_count != 1 || user_body_count != 1 ||
|
|
||||||
client_cert_count != 1 || client_key_count != 1) {
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for (line_number = 1; line_number <= output_count; line_number += 1) {
|
|
||||||
print output[line_number]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
clusters:
|
|
||||||
- cluster:
|
|
||||||
certificate-authority-data: preserve-default-ca-canary
|
|
||||||
server: https://192.0.2.10:6443
|
|
||||||
name: ca-redis-lab
|
|
||||||
contexts:
|
|
||||||
- context:
|
|
||||||
cluster: ca-redis-lab
|
|
||||||
namespace: team-default
|
|
||||||
user: ca-redis-lab
|
|
||||||
name: ca-redis-lab
|
|
||||||
current-context: ca-redis-lab
|
|
||||||
kind: Config
|
|
||||||
preferences: {}
|
|
||||||
users:
|
|
||||||
- name: ca-redis-lab
|
|
||||||
user:
|
|
||||||
client-certificate-data: preserve-default-client-cert-canary
|
|
||||||
client-key-data: preserve-default-client-key-canary
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
clusters:
|
|
||||||
- cluster:
|
|
||||||
certificate-authority-data: preserve-default-ca-canary
|
|
||||||
server: https://192.0.2.10:6443
|
|
||||||
name: ca-redis-lab
|
|
||||||
contexts:
|
|
||||||
- context:
|
|
||||||
cluster: ca-redis-lab
|
|
||||||
user: ca-redis-lab
|
|
||||||
name: ca-redis-lab
|
|
||||||
current-context: ca-redis-lab
|
|
||||||
kind: Config
|
|
||||||
preferences: {}
|
|
||||||
users:
|
|
||||||
- name: ca-redis-lab
|
|
||||||
user:
|
|
||||||
client-certificate-data: preserve-default-client-cert-canary
|
|
||||||
client-key-data: preserve-default-client-key-canary
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
clusters:
|
|
||||||
- cluster:
|
|
||||||
certificate-authority-data: preserve-default-ca-canary
|
|
||||||
server: https://127.0.0.1:6443
|
|
||||||
name: default
|
|
||||||
contexts:
|
|
||||||
- context:
|
|
||||||
cluster: default
|
|
||||||
user: default
|
|
||||||
name: default
|
|
||||||
current-context: default
|
|
||||||
kind: Config
|
|
||||||
preferences: {}
|
|
||||||
users:
|
|
||||||
- name: default
|
|
||||||
user:
|
|
||||||
client-certificate-data: preserve-default-client-cert-canary
|
|
||||||
client-key-data: preserve-default-client-key-canary
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
K3S_VERSION=v1.33.3+k3s1
|
|
||||||
MULTIPASS_IMAGE=24.04
|
|
||||||
K3S_AMD64_URL=https://github.com/k3s-io/k3s/releases/download/v1.33.3%2Bk3s1/k3s
|
|
||||||
K3S_AMD64_SHA256=f03cad6610cf5b2903d8a9ac3d6716690e53dab461b09c07b0c913a262166abc
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# ACL accounts
|
||||||
|
|
||||||
|
One file per `CommandAccess` level. Each account is deliberately narrower than the SDK's own rules:
|
||||||
|
the account is the last enforcement boundary and a permit issued inside the process never widens it,
|
||||||
|
so a mistake in the SDK is still refused by the server.
|
||||||
|
|
||||||
|
A Redis ACL file accepts nothing but complete `user` lines — no comments and no line continuations —
|
||||||
|
which is why the rationale lives here instead of inline. Password material is supplied at deploy
|
||||||
|
time; none of these files carries one.
|
||||||
|
|
||||||
|
| File | Account | Grants |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `application.acl` | `CommandAccess.APPLICATION` | the typed data-structure commands over `prod:*`, with the dangerous and deprecated names denied |
|
||||||
|
| `application-advanced.acl` | `APPLICATION_ADVANCED` | the above plus pub/sub, transactions, and the registered-script path. `EVAL` is absent: only `EVALSHA` of an already loaded digest is reachable |
|
||||||
|
| `raw-gateway.acl` | `RAW_GATEWAY` | exactly the commands the catalog classifies `RAW_ONLY`, and nothing else |
|
||||||
|
| `all-accounts.acl` | – | the four accounts concatenated; Redis takes one `aclfile`, so this is what a deployment loads |
|
||||||
|
| `admin-readonly.acl` | `ADMIN_READONLY` | read-only diagnostics. Every destructive counterpart is denied here and blocked in the command policy catalog — two independent controls for the same rule |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
user ca-skeleton-admin-readonly on nopass ~* resetchannels -@all +info +dbsize +time +lastsave +memory|usage +memory|stats +slowlog|get +slowlog|len +latency|latest +latency|history +client|list +client|info +command|info +command|docs +command|count +command|getkeysandflags +config|get +acl|dryrun +acl|whoami +cluster|info +cluster|slots +cluster|shards +cluster|nodes +object|encoding +object|freq +object|idletime +pubsub|channels +pubsub|numsub +pubsub|shardchannels +xinfo|stream +xinfo|groups +xinfo|consumers +function|list +function|stats +cluster|keyslot
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
user ca-skeleton-application-advanced on nopass sanitize-payload ~prod:* resetchannels &prod:* -@all +@read +@write +@string +@hash +@list +@set +@sortedset +@bitmap +@hyperloglog +@geo +@stream +@pubsub +@transaction +evalsha +evalsha_ro +script|load +script|exists +fcall +fcall_ro -keys -flushdb -flushall -shutdown -debug -eval -eval_ro -smembers -sort -sort_ro -randomkey -migrate -swapdb -select
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
user ca-skeleton-application on nopass sanitize-payload ~prod:* resetchannels &prod:* -@all +@connection +@pubsub +@transaction +@read +@write +@string +@hash +@list +@set +@sortedset +@bitmap +@hyperloglog +@geo +@stream -keys -flushdb -flushall -shutdown -debug -sort -sort_ro -smembers -randomkey -migrate -swapdb -select
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
user ca-skeleton-raw-gateway on nopass sanitize-payload ~prod:* resetchannels -@all +smembers +sort +sort_ro
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Standalone lane for the Redis SDK topology tests.
|
||||||
|
#
|
||||||
|
# The version is a build argument rather than a pinned image so the same file serves every row of
|
||||||
|
# the support matrix. Nothing here is a production topology: no persistence, no TLS, no replication.
|
||||||
|
# It exists to answer "does the driver behave the way the SDK claims", which is a question the
|
||||||
|
# in-memory fixture cannot answer at all.
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: "redis:${REDIS_VERSION:-7.4}"
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --appendonly
|
||||||
|
- "no"
|
||||||
|
- --save
|
||||||
|
- ""
|
||||||
|
- --aclfile
|
||||||
|
- /etc/redis/acl/all-accounts.acl
|
||||||
|
volumes:
|
||||||
|
- ../acl:/etc/redis/acl:ro
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "[ \"$$(redis-cli --user ca-skeleton-application --pass fixture-application --no-auth-warning ping)\" = PONG ]"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 15
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# TLS lane for the Redis SDK topology tests.
|
||||||
|
#
|
||||||
|
# Standalone in shape, but the point is the transport: the SDK's TLS settings — enabled, hostname
|
||||||
|
# verification, trust material, client certificate — are configuration nothing else exercises, and
|
||||||
|
# a TLS path that has never carried a command is a claim rather than a capability.
|
||||||
|
#
|
||||||
|
# The certificates are generated at start-up rather than checked in. A checked-in key is a secret in
|
||||||
|
# the repository however loudly the file is named "test", and a lane that regenerates its own
|
||||||
|
# material also proves the trust configuration actually matters: point the client at the wrong CA
|
||||||
|
# and it fails, which is what the qualification has to show.
|
||||||
|
services:
|
||||||
|
certs:
|
||||||
|
# The redis image carries no openssl, so certificate generation gets an image that does. The
|
||||||
|
# alternative — checking the material in — puts a private key in the repository.
|
||||||
|
image: alpine/openssl:latest
|
||||||
|
user: root
|
||||||
|
volumes:
|
||||||
|
- tls:/tls
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
if [ -f /tls/redis.crt ]; then exit 0; fi
|
||||||
|
openssl genrsa -out /tls/ca.key 2048
|
||||||
|
openssl req -x509 -new -nodes -key /tls/ca.key -sha256 -days 1 \
|
||||||
|
-subj "/CN=ca-skeleton-test-ca" -out /tls/ca.crt
|
||||||
|
openssl genrsa -out /tls/redis.key 2048
|
||||||
|
openssl req -new -key /tls/redis.key -subj "/CN=localhost" -out /tls/redis.csr
|
||||||
|
printf 'subjectAltName=DNS:localhost,IP:127.0.0.1' > /tls/redis.ext
|
||||||
|
openssl x509 -req -in /tls/redis.csr -CA /tls/ca.crt -CAkey /tls/ca.key \
|
||||||
|
-CAcreateserial -out /tls/redis.crt -days 1 -sha256 -extfile /tls/redis.ext
|
||||||
|
chmod 644 /tls/redis.key /tls/ca.key
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: "redis:${REDIS_VERSION:-7.4}"
|
||||||
|
depends_on:
|
||||||
|
certs:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- ../acl:/etc/redis/acl:ro
|
||||||
|
- tls:/tls:ro
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
# Plaintext is off entirely. A lane that accepts both proves nothing about the TLS path,
|
||||||
|
# because a misconfigured client would quietly fall back and still pass.
|
||||||
|
- --port
|
||||||
|
- "0"
|
||||||
|
- --tls-port
|
||||||
|
- "6379"
|
||||||
|
- --tls-cert-file
|
||||||
|
- /tls/redis.crt
|
||||||
|
- --tls-key-file
|
||||||
|
- /tls/redis.key
|
||||||
|
- --tls-ca-cert-file
|
||||||
|
- /tls/ca.crt
|
||||||
|
- --tls-auth-clients
|
||||||
|
- "no"
|
||||||
|
- --appendonly
|
||||||
|
- "no"
|
||||||
|
- --save
|
||||||
|
- ""
|
||||||
|
- --aclfile
|
||||||
|
- /etc/redis/acl/all-accounts.acl
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6390}:6379"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- >-
|
||||||
|
[ "$$(redis-cli --tls --cacert /tls/ca.crt
|
||||||
|
--user ca-skeleton-application --pass fixture-application --no-auth-warning ping)" = PONG ]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tls:
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Redis Wrapper 및 Typed API 설계 패키지
|
||||||
|
|
||||||
|
이 패키지는 Spring 기반 Backend Skeleton에서 Redis 자료구조와 명령을 폭넓게 제공하기 위한 설계서와 구현 계획서다.
|
||||||
|
|
||||||
|
## 문서
|
||||||
|
|
||||||
|
- `docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md`
|
||||||
|
- 범위와 비지원 범위
|
||||||
|
- Redis 버전·배포 모드
|
||||||
|
- 모듈과 의존 규칙
|
||||||
|
- 자료구조별 동기·Reactive Typed API
|
||||||
|
- R1~R4 명령 노출 정책
|
||||||
|
- permit·budget·Raw Gateway·Admin Plane
|
||||||
|
- namespace·직렬화·TTL·timeout·retry·오류·관측성·ACL
|
||||||
|
- Standalone·Sentinel·Cluster
|
||||||
|
- 테스트·CI·완료 정의
|
||||||
|
|
||||||
|
- `docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md`
|
||||||
|
- 27개 구현 작업
|
||||||
|
- 작업별 생성·수정 파일
|
||||||
|
- 작업 간 입력·출력 인터페이스
|
||||||
|
- 실패 테스트, 실행 명령, 최소 구현, 통과 검증, 커밋
|
||||||
|
- Redis 7.2·7.4·8.2·8.10 및 Sentinel·Cluster 테스트 작업
|
||||||
|
|
||||||
|
- `VALIDATION.md`
|
||||||
|
- 문서 구조와 계획 완전성에 대한 정적 검증 결과
|
||||||
|
|
||||||
|
## 핵심 결정
|
||||||
|
|
||||||
|
1. classic Redis 자료구조는 최대한 Typed API로 제공한다.
|
||||||
|
2. 고비용·Blocking·다중 키 명령은 permit와 `OperationBudget`을 요구한다.
|
||||||
|
3. Typed API에 아직 없는 R1·R2 명령은 승인형 Raw Gateway로 제공한다.
|
||||||
|
4. 운영 명령은 별도 Admin Plane, 파괴적 명령은 SDK 차단으로 분리한다.
|
||||||
|
5. command catalog는 Redis 공식 metadata에서 생성하고 조직 정책을 오버레이한다.
|
||||||
|
6. 동기와 Reactive API를 정식 지원하고 같은 내부 async primitive를 공유한다.
|
||||||
|
7. 일반·Blocking·Transaction·Pub/Sub·Admin 연결을 격리한다.
|
||||||
|
8. timeout 후 write는 자동 재시도하지 않고 실행 결과 불명을 표현한다.
|
||||||
|
|
||||||
|
## 적용 전제
|
||||||
|
|
||||||
|
현재 Backend Skeleton 저장소가 첨부되지 않아 경로와 Gradle 구조는 목표 구조로 확정했다. 실제 저장소에 적용할 때 기존 package naming, convention plugin, dependency management가 더 강한 기준을 이미 갖고 있다면 구조적 계약은 유지하면서 해당 규칙에 맞춘다.
|
||||||
|
|
||||||
|
입력 Markdown이 참조한 309행 Excel 워크북은 현재 작업 공간에 존재하지 않았다. 따라서 정확한 command matrix는 구현 과정에서 `COMMAND DOCS`, `COMMAND INFO`, `COMMAND GETKEYSANDFLAGS`를 읽어 재생성하고 정책 오버레이를 적용하도록 설계했다.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 정적 검증 결과
|
||||||
|
|
||||||
|
- **결과:** PASS
|
||||||
|
- **검사 수:** 29
|
||||||
|
- **설계서 SHA-256:** `e742ea78f4f40c2f5ed65093a71d2c85c27da52b0761374030a5ca64143aea63`
|
||||||
|
- **계획서 SHA-256:** `6592a37373a79bc2ccf9434fc3516d8273d9f9369e392d5e4f3360a46d6398c0`
|
||||||
|
|
||||||
|
| 검사 | 결과 | 세부 |
|
||||||
|
|---|---|---|
|
||||||
|
| 설계서 파일 존재 | PASS | docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md |
|
||||||
|
| 설계서 코드 펜스 균형 | PASS | fences=70 |
|
||||||
|
| 설계서 미확정 표식 없음 | PASS | none |
|
||||||
|
| 계획서 파일 존재 | PASS | docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-implementation-plan.md |
|
||||||
|
| 계획서 코드 펜스 균형 | PASS | fences=276 |
|
||||||
|
| 계획서 미확정 표식 없음 | PASS | none |
|
||||||
|
| 계획 작업 수 | PASS | 27 tasks |
|
||||||
|
| 모든 작업 Step 1 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 Step 2 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 Step 3 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 Step 4 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 Step 5 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 **Files:** 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 **Interfaces:** 보유 | PASS | 27/27 |
|
||||||
|
| 모든 작업 git commit -m 보유 | PASS | 27/27 |
|
||||||
|
| Create 경로 중복 없음 | PASS | none |
|
||||||
|
| Superpowers 계획 헤더 | PASS | required header present |
|
||||||
|
| 설계 입력 제약 명시 | PASS | missing workbook handled explicitly |
|
||||||
|
| Typed/Advanced/Raw/Admin 4단계 | PASS | four exposure tiers |
|
||||||
|
| classic 자료구조 범위 | PASS | all classic groups present |
|
||||||
|
| 동기·Reactive parity 계획 | PASS | API parity covered |
|
||||||
|
| permit 위조 검증 | PASS | provenance verification covered |
|
||||||
|
| 토폴로지 task 선행 등록 | PASS | test tasks available before contracts |
|
||||||
|
| 환경 파일 생명주기 일관성 | PASS | create once, extend twice |
|
||||||
|
| 잘못된 Persistent factory 없음 | PASS | constructor usage consistent |
|
||||||
|
| 공유 async primitive | PASS | sync/reactive executor share invocation |
|
||||||
|
| Raw 문자열 API 금지 | PASS | guardrail fixed |
|
||||||
|
| R4 차단 | PASS | blocked in plan and design |
|
||||||
|
| 완료 정의 존재 | PASS | definition and release task present |
|
||||||
+2233
File diff suppressed because it is too large
Load Diff
+1497
File diff suppressed because it is too large
Load Diff
+16
-7
@@ -27,7 +27,7 @@ FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694b
|
|||||||
ARG RELEASE_VERSION
|
ARG RELEASE_VERSION
|
||||||
ARG GIT_SHA
|
ARG GIT_SHA
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build/src
|
||||||
|
|
||||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||||
@@ -38,6 +38,7 @@ WORKDIR /build
|
|||||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||||
COPY gradlew ./
|
COPY gradlew ./
|
||||||
COPY gradle/ gradle/
|
COPY gradle/ gradle/
|
||||||
|
COPY config/ ./config/
|
||||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||||
|
|
||||||
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
# Resolve every module configuration in STRICT mode (no --write-locks in a release build). This
|
||||||
@@ -48,14 +49,11 @@ RUN test -n "${RELEASE_VERSION}" \
|
|||||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||||
|
|
||||||
# Copy full source and build the JAR
|
# Copy full source and stage the executable JAR at Gradle's declared Docker output path.
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \
|
RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \
|
||||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||||
|
|
||||||
# Locate the produced JAR (avoids hardcoding the version string)
|
|
||||||
RUN cp $(ls app-bootstrap/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
|
|
||||||
|
|
||||||
# ---- Stage 2: runtime image -------------------------------------------------
|
# ---- Stage 2: runtime image -------------------------------------------------
|
||||||
# JRE-only slim image (D3: no full JDK in production image).
|
# JRE-only slim image (D3: no full JDK in production image).
|
||||||
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
|
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
|
||||||
@@ -114,9 +112,20 @@ RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap
|
|||||||
RUN groupadd --system --gid 1000 app \
|
RUN groupadd --system --gid 1000 app \
|
||||||
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
&& useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app
|
||||||
|
|
||||||
|
# ---- Fileserver storage root ------------------------------------------------
|
||||||
|
# Created in the image with the runtime user's ownership and 0750, so a fresh named volume
|
||||||
|
# mounted here inherits both. Without it the Fileserver platform's default root does not exist
|
||||||
|
# on a read-only root filesystem, and the capability fails on its first upload rather than at
|
||||||
|
# startup. This directory is a mount point, not a place to keep data in the image: an unmounted
|
||||||
|
# container writes into the container layer and loses everything on replacement.
|
||||||
|
RUN mkdir -p /var/lib/backend/files \
|
||||||
|
&& chown app:app /var/lib/backend/files \
|
||||||
|
&& chmod 0750 /var/lib/backend/files
|
||||||
|
VOLUME ["/var/lib/backend/files"]
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder --chown=app:app /build/app.jar app.jar
|
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/application.jar app.jar
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694b
|
|||||||
ARG RELEASE_VERSION
|
ARG RELEASE_VERSION
|
||||||
ARG GIT_SHA
|
ARG GIT_SHA
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build/src
|
||||||
|
|
||||||
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST,
|
||||||
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle
|
||||||
@@ -49,6 +49,7 @@ WORKDIR /build
|
|||||||
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.)
|
||||||
COPY gradlew ./
|
COPY gradlew ./
|
||||||
COPY gradle/ gradle/
|
COPY gradle/ gradle/
|
||||||
|
COPY config/ ./config/
|
||||||
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./
|
||||||
|
|
||||||
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
|
# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either).
|
||||||
@@ -57,14 +58,11 @@ RUN test -n "${RELEASE_VERSION}" \
|
|||||||
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
&& ./gradlew verifyDependencyLocks --no-daemon --quiet \
|
||||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||||
|
|
||||||
# Copy full source and build the sample JAR.
|
# Copy full source and stage the executable sample JAR at Gradle's declared Docker output path.
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN ./gradlew :sample-portfolio:bootJar --no-daemon -x test \
|
RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \
|
||||||
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
|
||||||
|
|
||||||
# Locate the produced JAR (avoids hardcoding the version string).
|
|
||||||
RUN cp $(ls sample-portfolio/build/libs/*.jar | grep -v plain | head -1) /build/app.jar
|
|
||||||
|
|
||||||
# ---- Stage 2: runtime image -------------------------------------------------
|
# ---- Stage 2: runtime image -------------------------------------------------
|
||||||
# JRE-only slim image (no full JDK in the demo image either).
|
# JRE-only slim image (no full JDK in the demo image either).
|
||||||
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
|
||||||
@@ -110,7 +108,7 @@ RUN groupadd --system --gid 1000 app \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder --chown=app:app /build/app.jar app.jar
|
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/application.jar app.jar
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
|
|||||||
자동 합성/바인딩하도록 얹는 얇은 계층이다.
|
자동 합성/바인딩하도록 얹는 얇은 계층이다.
|
||||||
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
|
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
|
||||||
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
|
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
|
||||||
|
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를
|
||||||
|
의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책,
|
||||||
|
GraphiQL/introspection 운영 설정을 함께 명시해야 한다.
|
||||||
|
|
||||||
## Allowed
|
## Allowed
|
||||||
|
|
||||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||||
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
|
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
|
||||||
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
|
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
|
||||||
|
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
|
||||||
|
|
||||||
## Forbidden
|
## Forbidden
|
||||||
|
|
||||||
@@ -43,18 +47,34 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
|
|||||||
`GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
|
`GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다.
|
||||||
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
|
비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md).
|
||||||
|
|
||||||
## Feature 기여 방법
|
## 향후 adopter 의 feature 기여 방법
|
||||||
|
|
||||||
- **스키마**: `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합쳐진다.
|
- **스키마**: adopter feature 가 `src/main/resources/graphql/*.graphqls` 를 두면
|
||||||
- **핸들러**: `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다.
|
`classpath:graphql/**` 병합으로 합칠 수 있다.
|
||||||
- **도메인 예외 매핑**: sample 이 자신의 `DataFetcherExceptionResolver` 를 추가해 도메인 예외를
|
- **핸들러**: adopter 가 `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동
|
||||||
`PortfolioErrorCode` 로 매핑한다(스켈레톤 resolver 보다 앞 순서). 스켈레톤은 `ApiErrorCarrier` 만 처리.
|
바인딩된다.
|
||||||
|
- **도메인 예외 매핑**: adopter 는 자신의 `DataFetcherExceptionResolver` 를 추가하거나
|
||||||
|
`ApiErrorCarrier` 를 사용해 안정적 코드로 매핑할 수 있다.
|
||||||
|
|
||||||
`sample-portfolio` 를 지워도 스켈레톤은 health 스키마만으로 부팅한다 (disposability).
|
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
|
||||||
|
health 스키마만 소유한다.
|
||||||
|
|
||||||
|
## 명시적 미구현 범위(P2)
|
||||||
|
|
||||||
|
- feature GraphQL schema/resolver
|
||||||
|
- query depth/cost 제한
|
||||||
|
- persisted operation
|
||||||
|
- DataLoader/batching
|
||||||
|
- subscription
|
||||||
|
|
||||||
|
이 범위는 production GraphQL 표면 채택 시 별도 설계와 qualification 을 요구한다.
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
./gradlew :adapter:inbound:graphql:test
|
./gradlew :adapter:inbound:graphql:test --console=plain
|
||||||
|
./gradlew :adapter:inbound:graphql:test \
|
||||||
|
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
|
||||||
|
--console=plain
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -19,15 +19,18 @@ Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하
|
|||||||
|
|
||||||
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리
|
||||||
|
|
||||||
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** Spring for GraphQL 이 두 축으로 자동 합성한다:
|
스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에
|
||||||
|
명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다:
|
||||||
|
|
||||||
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
|
- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의
|
||||||
`worklog.graphqls` 가 스켈레톤의 `skeleton.graphqls` 와 자동으로 합쳐진다.
|
향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다.
|
||||||
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping`
|
- **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping`
|
||||||
메서드를 바인딩한다. sample 의 `WorkLogGraphqlController` 가 스켈레톤을 수정하지 않고 등록된다.
|
메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수
|
||||||
|
있다.
|
||||||
|
|
||||||
`sample-portfolio` 를 지우면 스켈레톤은 여전히 health 스키마만으로 부팅한다(web 과 동일한
|
현재 `app-bootstrap` 과 `sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다.
|
||||||
disposability 보장).
|
즉 이 모듈은 **classpath opt-in** 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는
|
||||||
|
뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.
|
||||||
|
|
||||||
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
|
## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제
|
||||||
|
|
||||||
@@ -74,3 +77,16 @@ gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한
|
|||||||
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
|
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
|
||||||
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
|
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
|
||||||
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
||||||
|
|
||||||
|
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
|
||||||
|
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
|
||||||
|
비활성화, 오류 redaction 을 검증한다. 이 테스트 구성은 production 정책 bean 이 아니다. 실제
|
||||||
|
composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을 함께 제공하고
|
||||||
|
`spring.graphql.graphiql.enabled=false`,
|
||||||
|
`spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다.
|
||||||
|
|
||||||
|
## 아직 구현하지 않은 P2 범위
|
||||||
|
|
||||||
|
이 leaf 와 현재 sample 에는 feature GraphQL schema/resolver, query depth/cost 제한, persisted
|
||||||
|
operation, DataLoader/batching, subscription 이 구현되어 있지 않다. 이 항목들은 실제 GraphQL 제품
|
||||||
|
표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user