Compare commits

...
14 Commits
Author SHA1 Message Date
donghyeon-ka 60b6a319e7 refactor: 빌드 로직 개선, gradle 파일 경량화 2026-09-18 15:22:30 +09:00
donghyeon-ka ace8aaaef6 refactor: 빌드 최적화 및 ci 수정 2026-09-17 15:24:31 +09:00
DongHyeonka 944a1e348b chore: 문서 추가 2026-09-17 15:03:36 +09:00
DongHyeonkaandClaude Opus 5 ef947e5bb0 refactor(build,ci): 현재 상태 검증을 걷어내고 불변조건만 남기는 검증 표면 축소
외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를
다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다.
설계·판단 근거는 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md.

삭제
- .github/ci-gate-matrix.yml(1,025줄) + verify-gate-matrix.sh(568줄):
  Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제
- verify-gradle-wrapper.sh(799줄): workflow 바이트 해시 잠금.
  wrapper 검증은 gradle/actions/wrapper-validation(full SHA 핀)에 위임
- DeveloperExperienceContractTest 등의 CI YAML mutation 테스트:
  애플리케이션 test suite가 GitHub Actions YAML 파서를 검증하던 계층 역전
- 문서 drift 파서: verifyReadmeCommands, verifyRunbookReferences,
  verifyDocumentedLeafCount, verifyTestSourceSetRegistry
- 빈 레지스트리를 지키던 커스텀 YAML 파서: verifyTrivyignore,
  verifyQuarantineSunset, flaky-quarantine.yaml
- verifyConfigurationPropertiesProcessor, verifyOneTypePerFile:
  각각 ca.spring-config convention과 Checkstyle OneTopLevelClass가 대체
- 정상 입력으로도 성공할 수 없던 messaging always-fail task
- ModuleRegistry의 JSON 필드 집합 정확 일치, sample-portfolio negative guard

이동
- java/quality/spring 공통 설정을 configure(subprojects) 블록에서
  ca.java-conventions / ca.quality-conventions / ca.java-library /
  ca.spring-library convention plugin으로
- 아키텍처 검증을 ca.architecture로, JPA·messaging qualification을
  gradle/qualification/ 아래로, verifyEnvKeys를 :app-bootstrap 소유로

완화
- Git revision은 releaseCheck·아카이브 생성에서만 요구. 일반 빌드는 SNAPSHOT
- SpotBugs/FindSecBugs는 로컬 check에서 빼고 qualityCheck 레인으로

task 계층
- leaf check는 그 leaf만. architectureCheck / qualityCheck /
  configContractCheck / integrationCheck / ci / releaseCheck로 이름 분리

CI
- _reusable-gradle.yml 신규. checkout + wrapper validation + JDK/캐시 공통화
- fileserver-release.yml -> fileserver-certification.yml (CD가 아니라 certification)
- GitHub Actions = CI + artifact, Argo CD = CD 경계를 docs/ci-cd/boundary.md로 고정

순증감 +3,274 / -7,483.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:33:19 +09:00
DongHyeonkaandClaude Opus 5 d00c76241c docs: 감사 추정(−3,368줄)과 실제 결과(+1,652줄) 차이를 기록
빌드·CI 레이어는 줄지 않고 늘었다. 영역별 증감과 이유를 적어둔다.

줄어든 곳은 실제로 중복이던 곳이다(워크플로 30 → 19파일, setup 블록 59회 →
composite 1개, jpa-evidence −274줄). 늘어난 곳은 그동안 비어 있던 자리다
(게이트 매트릭스 커버리지 8/28 → 28/28 워크플로가 +634줄, convention 플러그인과
그 테스트가 +701줄).

추정이 빗나간 이유 셋: 감사 findings 17건이 실행 결과로 기각됐고(지울 수 있다고
본 코드에 소비자가 있었다), 작동하게 만드는 데 코드가 들고, convention 플러그인은
총량이 아니라 중복을 줄인다.

줄 수로 보고하면 틀린 보고가 되므로 그렇게 적어둔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:47:15 +09:00
DongHyeonkaandClaude Opus 5 40ee9f1e83 fix(build,ci): 정의만 되고 안 돌던 레인 13개를 배선하고 버전 11개를 카탈로그로
감사 remediation 의 마지막 항목 두 개.

## 아무 데서도 안 돌던 레인

등록 태스크 97개 중 어느 CI 경로로도 도달하지 않는 것이 20개였다.
있는 줄 알았는데 안 도는 상태 — 이번에 고친 `*CleanArchitectureTest` 와 같은
종류의 문제다. 각각 판단해서 처리했고, 삭제는 0건이다.

- stage 2(`integration-main.yml`, push:main + 03:00) 에 잡 3개 신설:
  mongo 컨테이너 레인 6개 / messaging 계약 증거 3개 / app-bootstrap integrationTest.
  컨테이너가 필요한 레인은 PR 에 두지 않는다 — PR 예산은 5분이고, 단계를 나눈
  이유가 이것이다
- stage 3(`release.yml`) 에 `grpc-stable-release-gate` 신설
  (inprocess/netty/fault) + `app-image-release` 의 needs 로 연결
- 수동 확정 3개: `grpcPerformanceTest`, `openapiCheckSnapshot`(드리프트 검사는
  이미 stage 1 의 `check` 안에 있고 이 태스크는 승인 지점),
  `sampleOffCompile`(stage 1 `sampleOffTest` 의 진부분집합).
  전용 레지스트리 대신 루트 README 에 적었다 — `verifyReadmeCommands` 가
  거기 적힌 태스크의 실재를 검증하므로, 문서가 곧 검사 대상이 된다
- 게이트 매트릭스 행 11개 신설. 잡↔행 양방향 대조 결과 68개 잡 전부 행이 있고
  행 없는 잡도, 어디서도 안 도는 잡도 없다

측정이 틀린 4건은 배선하지 않았다 — 이미 도달하고 있었다:
`jpaPlatformReleaseGate`(`jpaReleaseGate dependsOn`),
`generateJpaEvidenceManifests`(`verifyJpaCandidateEvidence` 경유),
`messagingCertificationTest`(`verifyMessagingCertificationEvidence` 경유),
`stageDockerJar`(호출자가 Gradle 이 아니라 `release.yml` 의 `docker build`).

## 버전 카탈로그 이관

카탈로그를 우회해 문자열로 박혀 있던 값 11개를 `gradle/libs.versions.toml` 로
옮겼다. plugin 5개는 `[plugins]` + `alias(...)`, 툴 3개는 `libs.versions.*.get()`.

`grpcVersion`/`protobufVersion`/`awsSdkVersion` 은 이관이 불가하다고 넘어온
항목이었으나, `ext.x` 를 접근자로 남기고 값만 카탈로그에서 읽으면 소비 파일 9개와
`ca.grpc-platform-module.gradle:28` 의 `findProperty` 계약이 그대로이고 해석
결과도 동일하다. **lockfile 재생성 0건.**

`commons-lang3` / `netty` 는 BOM 오버라이드라 그대로 둔다 — 오버라이드하는
이유가 주석과 분리되면 값만 남고 근거가 사라진다.

## 검증 (깨끗한 체크아웃, 커밋 전)

`verify-gate-matrix.sh` → 107 gates, 101 verified, drift 0 ·
`verify-gradle-wrapper.sh` PASS · 워크플로 YAML 21개 파싱 OK ·
`gradlew help` · `verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0** · `:domain-core:check` ·
`verifyDocumentationContracts` · `verifyDependencyLocks` · `verifyReadmeCommands`.

## 남은 문제

mongo 6레인 · `bootstrap-integration` · messaging 매니페스트 스키마 검증은
CI 에서 한 번도 돈 적이 없다. Docker 가 없으면 실패하도록 설계돼 있으므로
**첫 main push 와 03:00 run 이 빨간 것이 정상 시나리오**다. 로컬에서 Docker
레인을 돌려보지 않았고, `mongo-container-lanes` 의 timeout 90분은 실측이 아니라
추정치다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:46:34 +09:00
DongHyeonkaandClaude Opus 5 9bc2e75fe5 refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리
빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.

## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)

모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.

- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*

setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.

## 컨테이너 릴리스 신설

이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.

Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.

## 게이트 정리

- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
  목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
  `release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
  행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
  stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
  `OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
  정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
  `:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
  플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
  (태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
  (기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
  3,072 → 2,977줄

## 검사가 검사를 못 하고 있던 것들

- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
  "examples beside it are the tracked contract, never a real one" 이라고 적어둔
  규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
  추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
  이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
  만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
  정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
  추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
  규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
  모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
  거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
  만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
  그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
  로 읽히지 않게

## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)

`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.

## 남은 문제

- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
  ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
  억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
  게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
  않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
  cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
  `.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
  건드리지 않았다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:27:15 +09:00
DongHyeonkaandClaude Opus 5 1535481794 refactor(build,src): testkit 소스셋 이관과 빌드 게이트 정상화, 미추적 빌드 파일 추적
한 커밋인 이유: src/build.gradle 안에서 ca.testkit-publisher 플러그인 제거와
게이트 수정이 얽혀 있다. 플러그인 적용부만 빼면 web·websocket·persistence-jpa·
persistence-mongo·app-bootstrap 이 사라진 testkitPublisher() 와 *Testkit
컨피규레이션을 계속 참조해 설정 단계에서 빌드가 죽는다. 파일 단위로 나눌 수 없다.

1) testkit 소스셋 → Gradle 표준 java-test-fixtures 이관
   web, websocket, persistence-jpa, persistence-mongo, httpclient, graphql 과
   이들의 testkit 컨피규레이션을 소비하던 app-bootstrap.
   자체 제작 ca.testkit-publisher.gradle 77줄이 사라진다.

2) 실행되지 않거나 실패할 수 없던 빌드 게이트 정상화 (E등급)
   - strict-test-lane 의 실행 카운터가 skip 을 실행으로 세던 것 수정.
     전부 skip 인 레인은 이제 실패한다 (회귀 테스트 2건 추가)
   - public-path 스냅샷이 gitignore 된 src/.env 를 읽던 것을
     config/security.yml 의 바인딩 기본값으로 교체
   - verifyEnvKeys 가 build/ 산출물을 소스로 읽어 삭제된 키를 사용 중으로
     오판하던 것 수정 (입력 4,637 → 4,630 파일)
   - jpa-evidence 가 git 실패를 "워크트리 깨끗함"으로 읽던 것을 fail-closed 로
   - notification-evidence 의 Grade 열 탐지를 헤더 기준으로 교체 +
     표 부재 시 fail-closed
   - spring70CompatibilityTest 가 레인을 복제하며 잃은 fail-closed 복구
     (태스크명 유지 — 워크플로 3곳과 gate-matrix 린트 무손상)
   - 메시징 R2 스켈레톤 주변의 도달 불가 검증 45줄을 MSG-015 명시적 실패로 교체

3) git 에 없던 빌드 필수 파일 추적
   - src/gradle/libs.versions.toml — src/build.gradle 이 9곳에서 참조하는데
     추적되지 않아 깨끗한 체크아웃에서 설정이 실패했다
   - app-bootstrap config/*.yml 15개 — application.yml 이 전부 import 한다.
     하드코딩된 시크릿은 없고 값은 secret://environment/APP_* 참조다

4) 진행 중이던 구현 작업 반영 (redis/idempotency 구성, startup 검증,
   아키텍처 테스트 클래스, notification 콜백 레지스트리 등)

검증:
- 깨끗한 체크아웃에서 ./gradlew help 통과
- :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
  → 20개 클래스 174 tests, 실패 0, 스킵 0 (이전에는 0개 실행)

미해결: verifyOneTypePerFile 은 손대지 않았다(Checkstyle 로 교체 권고).
B/C/D 등급 100여 건과 CI 단계 분리는 별도 작업 —
docs/superpowers/plans/2026-09-16-ci-stage-separation.md 참고.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:53:25 +09:00
DongHyeonkaandClaude Opus 5 e34519113b fix(ci): 실행되지 않거나 실패할 수 없던 CI 게이트 정상화
가장 큰 것: 문서화된 아키텍처 게이트가 규칙을 하나도 실행하지 않았다.
워크플로 7곳과 CLAUDE.md 가 --tests '*CleanArchitectureTest' 를 지정했으나
그 이름의 클래스는 존재하지 않는다. 4곳은 매칭 0건으로 하드 실패하고,
3곳은 다른 필터와 병기돼 아키텍처 규칙 0개를 돌고 초록으로 통과했다.
필터를 패키지 글롭 dev.caskeleton.bootstrap.architecture.* 로 교체했다.
*ArchitectureTest 글롭은 20개 중 12개만 잡고 ArchRuleDiscoveryContractTest 등
8개를 놓치므로 쓰지 않았다.

그 외:
- ci-gate-matrix 의 release_blocking 이 강제되지 않아 trivy-fs 가 빨개도
  release-gate 가 초록이던 것을 실제 의존으로 연결
- build-logic TestKit 이 어떤 CI 에서도 돌지 않던 것을 ci-quality-gates 에 연결
- jpa-next-* 3개, object-storage, fileserver-pr 의 실패할 수 없거나
  트리거되지 않던 잡 정리
- 릴리스 태그 네임스페이스 분열로 v* 태그가 web·websocket 게이트를
  건너뛰던 것 수정
- 워크플로 SHA 잠금 28개 재생성 (verify-gradle-wrapper.sh)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:52:44 +09:00
DongHyeonkaandClaude Opus 5 2a8d34f85c docs: 빌드·CI 감사 계획과 진행 중이던 문서 정리
- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md).
  빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다.
- public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성.
  이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다.
- 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:52:37 +09:00
DongHyeonka 21234e38cd feat: grpc 기능 deep 구현 2026-08-31 07:28:39 +09:00
DongHyeonka a24ece9cf7 feat: web, websocket 어댑터 추가 구현 2026-08-28 17:01:27 +09:00
DongHyeonka 0137263441 refactor: 각 어댑터터별 리펙토링 진행 2026-08-24 18:26:40 +09:00
DongHyeonka e98b56eb03 feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링 2026-08-18 10:59:56 +09:00
2684 changed files with 221773 additions and 43138 deletions
@@ -0,0 +1,41 @@
name: Set up Java and Gradle
description: >-
Installs the repository's pinned Temurin JDK, then configures Gradle through the official
setup-gradle action — which validates every checked-in wrapper jar and manages the Gradle cache.
Every Gradle job used to carry the JDK block verbatim, so the JDK patch level lived in fifty-nine
places; every job also carried a separate three-line wrapper-validation step, so the pinned action
SHA lived in forty.
# Wrapper validation is INSIDE this action now.
#
# It could not be before, and the reason was not a GitHub limitation: .github/scripts/
# verify-gradle-wrapper.sh read every workflow job and required it to contain, literally and in this
# order, an `actions/checkout@` step, the exact three-field pinned wrapper-validation step, and then
# the Gradle invocation. That literalness was the whole guard — "this job validated the wrapper" had
# to be answerable from the workflow file alone — and it is what made the step uninlineable.
#
# That script is gone (it also byte-hashed all twelve workflow files, so a comment change needed a
# hash update, while an attacker with write access would simply have updated both). The guarantee it
# was protecting is now the official action's own: `gradle/actions/setup-gradle` validates all
# wrapper jars by default (`validate-wrappers`, default true), and the action is pinned to a full
# commit SHA here — which GitHub's own hardening guide calls the only immutable action reference.
#
# `actions/checkout` still cannot move here: a `./.github/actions/...` reference is resolved from the
# checked-out working copy, so this file does not exist until checkout has already run. A composite
# action cannot contain the step that makes itself readable.
#
# So a Gradle job is two lines — checkout, then this action.
runs:
using: composite
steps:
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
# Gradle's own caching, not setup-java's `cache: gradle`. The two cache the same directory with
# different keys, and running both is how a job restores one cache and saves the other.
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
with:
build-scan-publish: false
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
-289
View File
@@ -1,289 +0,0 @@
# Current repository CI controls. This file lists only mechanisms and jobs that exist in this
# checkout. Build/release supply-chain, image, signing, provenance, SBOM, and tag-release jobs are
# intentionally absent until their later bounded reconstruction.
#
# Fields:
# release_blocking: true, false, or conditional
# mechanism: gradle-custom-task, gradle-plugin-task, contract-test, workflow-job,
# or delegated-pending
# ref: task, plugin@task, repository-relative test path below src/, or workflow job id
# workflow/job: canonical workflow and job that execute or represent the control
# execution: check (through Gradle check), explicit (named in the job), or job
gates:
- id: format-lint
release_blocking: true
mechanism: gradle-plugin-task
ref: com.diffplug.spotless@spotlessCheck
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: unit-and-contract-tests
release_blocking: true
mechanism: gradle-plugin-task
ref: java@test
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: conditional-transport-qualification
release_blocking: true
mechanism: gradle-custom-task
ref: conditionalTransportQualification
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: clean-architecture-dependencies
release_blocking: true
mechanism: gradle-custom-task
ref: verifyCleanArchitectureDependencies
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: environment-contract
release_blocking: true
mechanism: gradle-custom-task
ref: verifyEnvKeys
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: one-type-per-file
release_blocking: true
mechanism: gradle-custom-task
ref: verifyOneTypePerFile
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: readme-command-drift
release_blocking: true
mechanism: gradle-custom-task
ref: verifyReadmeCommands
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: trivy-suppression-governance
release_blocking: true
mechanism: gradle-custom-task
ref: verifyTrivyignore
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: quarantine-sunset
release_blocking: true
mechanism: gradle-custom-task
ref: verifyQuarantineSunset
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: public-path-snapshot
release_blocking: true
mechanism: gradle-custom-task
ref: verifyPublicPathSnapshot
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: dependency-locks
release_blocking: true
mechanism: gradle-custom-task
ref: verifyDependencyLocks
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: architecture-contract-test
release_blocking: true
mechanism: contract-test
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: sample-off
release_blocking: true
mechanism: gradle-custom-task
ref: sampleOffTest
workflow: ci-quality-gates.yml
job: sample-off
execution: explicit
- id: gate-matrix-lint
release_blocking: true
mechanism: workflow-job
ref: gate-matrix-lint
workflow: ci-quality-gates.yml
job: gate-matrix-lint
execution: job
- id: redis-sdk
release_blocking: true
mechanism: workflow-job
ref: redis-sdk
workflow: ci-quality-gates.yml
job: redis-sdk
execution: job
- id: jpa-candidate-evidence
release_blocking: true
mechanism: workflow-job
ref: jpa-candidate-evidence
workflow: ci-quality-gates.yml
job: jpa-candidate-evidence
execution: job
- id: jpa-r2-evidence
release_blocking: conditional
mechanism: workflow-job
ref: jpa-r2-evidence
workflow: jpa-r2-evidence.yml
job: jpa-r2-evidence
execution: job
- id: quality-release-gate
release_blocking: true
mechanism: workflow-job
ref: release-gate
workflow: ci-quality-gates.yml
job: release-gate
execution: job
- id: flaky-quarantine
release_blocking: false
mechanism: workflow-job
ref: quarantine
workflow: ci-quality-gates.yml
job: quarantine
execution: job
- id: dependency-review
release_blocking: conditional
mechanism: workflow-job
ref: dependency-review
workflow: dependency-vulnerability.yml
job: dependency-review
execution: job
- id: dependency-submission
release_blocking: false
mechanism: workflow-job
ref: dependency-submission
workflow: dependency-vulnerability.yml
job: dependency-submission
execution: job
- id: filesystem-vulnerability-scan
release_blocking: true
mechanism: workflow-job
ref: trivy-fs
workflow: dependency-vulnerability.yml
job: trivy-fs
execution: job
- id: documentation-links
release_blocking: conditional
mechanism: workflow-job
ref: lychee
workflow: link-check.yml
job: lychee
execution: job
- id: object-storage-minio-managed-contract
release_blocking: true
mechanism: gradle-custom-task
ref: objectStorageMinioContractTest
workflow: object-storage-qualification.yml
job: minio-managed-contract
execution: explicit
- id: poster-image-migration
release_blocking: true
mechanism: gradle-custom-task
ref: posterImageMigrationTest
workflow: object-storage-qualification.yml
job: poster-image-v7-migration
execution: explicit
- id: object-storage-minio-managed-fault
release_blocking: conditional
mechanism: gradle-custom-task
ref: objectStorageMinioFaultTest
workflow: object-storage-qualification.yml
job: minio-managed-fault
execution: explicit
- id: object-storage-aws-protected-qualification
release_blocking: conditional
mechanism: delegated-pending
ref: approval-gate-b
workflow: object-storage-qualification.yml
job: aws-managed-common-subset
execution: job
- id: redis-sdk-support-matrix
release_blocking: true
mechanism: contract-test
ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
# Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that
# touches the Redis leaf runs the standalone lane, and the full supported-version x topology
# matrix runs nightly and on a release candidate. While it was dispatch-only, a release could
# claim topology evidence that nobody had produced for that commit.
- id: redis-sdk-topology-evidence
release_blocking: conditional
mechanism: workflow-job
ref: topology-evidence
workflow: redis-sdk-topology.yml
job: topology-evidence
execution: job
- id: httpclient-stable-contract
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientStableContractTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-security-suite
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientSecurityTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-fault-injection
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientFailureInjectionTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-performance-certification
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientPerformanceTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-spring62-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: spring62ApiSurfaceScan
workflow: httpclient-release.yml
job: release-gate
execution: explicit
# The 6.2 API-surface scan above proves the common packages compile against the older surface. It
# does not prove they run on it, and the two were being conflated: a lane called
# "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to
# say what it does, and the runtime claim is registered here as its own delegated-pending control
# so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring
# Framework 6.2 distribution resolved into a separate test runtime, which this repository's
# Boot 4.0 baseline does not carry.
- id: httpclient-spring62-runtime
release_blocking: conditional
mechanism: delegated-pending
ref: spring62-runtime-lane
workflow: httpclient-release.yml
job: release-gate
execution: job
- id: httpclient-spring70-compatibility
release_blocking: true
mechanism: gradle-custom-task
ref: spring70CompatibilityTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-documentation-drift
release_blocking: true
mechanism: workflow-job
ref: httpclient-documentation
workflow: httpclient-release.yml
job: httpclient-documentation
execution: job
- id: httpclient-event-loop-blocking
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientBlockHoundTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
+2 -2
View File
@@ -3,7 +3,7 @@
This policy is enforced by This policy is enforced by
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml), [`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
[`dependency-review-config.yml`](dependency-review-config.yml), [`dependency-review-config.yml`](dependency-review-config.yml),
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and [`../.trivyignore.yaml`](../.trivyignore.yaml), CODEOWNERS, and
[`../renovate.json`](../renovate.json). [`../renovate.json`](../renovate.json).
## Execution and platform boundary ## Execution and platform boundary
@@ -73,7 +73,7 @@ dependencies; stale mirrors can delay detection.
The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it
explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a
non-empty rationale, and a future expiry no more than 90 days away. `verifyTrivyignore` validates non-empty rationale, and a future expiry no more than 90 days away. A CODEOWNERS reviewer validates
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change. the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
Neither control substitutes for the other. Neither control substitutes for the other.
+2 -2
View File
@@ -4,8 +4,8 @@
## Verification ## Verification
- [ ] I ran the focused test for each changed leaf. - [ ] I ran focused `:<changed-leaf>:check` tasks for the modules I changed.
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`. - [ ] I ran `cd src && ./gradlew architectureCheck verifyPublicPathSnapshot verifyDependencyLocks` when the change touched repository structure, dependencies, or public paths.
- [ ] I did not add an unregistered production module dependency. - [ ] I did not add an unregistered production module dependency.
- [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification. - [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification.
- [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days. - [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days.
-365
View File
@@ -1,365 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
if (( $# > 1 )); then
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
exit 2
fi
if (( $# == 1 )); then
if [[ ! -d "$1" ]]; then
printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2
exit 2
fi
REPO_ROOT="$(cd -- "$1" && pwd -P)"
else
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2
exit 1
fi
fi
readonly REPO_ROOT
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it.
readonly EXPECTED_GATE_COUNT=38
if [[ ! -f "${MATRIX}" ]]; then
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
exit 1
fi
records="$(
awk '
function flush() {
if (id != "") {
printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, blocking, mechanism, ref, workflow, job, execution
}
}
/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/ {
flush()
id=$0
sub(/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/, "", id)
blocking=mechanism=ref=workflow=job=execution=""
next
}
/^[[:space:]]+release_blocking:[[:space:]]*/ {
blocking=$0
sub(/^[[:space:]]+release_blocking:[[:space:]]*/, "", blocking)
next
}
/^[[:space:]]+mechanism:[[:space:]]*/ {
mechanism=$0
sub(/^[[:space:]]+mechanism:[[:space:]]*/, "", mechanism)
next
}
/^[[:space:]]+ref:[[:space:]]*/ {
ref=$0
sub(/^[[:space:]]+ref:[[:space:]]*/, "", ref)
next
}
/^[[:space:]]+workflow:[[:space:]]*/ {
workflow=$0
sub(/^[[:space:]]+workflow:[[:space:]]*/, "", workflow)
next
}
/^[[:space:]]+job:[[:space:]]*/ {
job=$0
sub(/^[[:space:]]+job:[[:space:]]*/, "", job)
next
}
/^[[:space:]]+execution:[[:space:]]*/ {
execution=$0
sub(/^[[:space:]]+execution:[[:space:]]*/, "", execution)
next
}
END { flush() }
' "${MATRIX}"
)"
declare -A seen_ids=()
declare -a failures=()
total=0
verified=0
delegated=0
job_body() {
local workflow_file="$1"
local job_id="$2"
awk -v target="${job_id}" '
$0 ~ "^ " target ":[[:space:]]*$" { inside=1; print; next }
inside && $0 ~ "^ [A-Za-z0-9_-]+:[[:space:]]*$" { exit }
inside { print }
' "${workflow_file}"
}
gradle_command_has_safe_literal_grammar() {
local command="$1"
[[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]]
}
gradle_token_suppresses_execution() {
local token="$1"
case "${token}" in
'--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;;
*) return 1 ;;
esac
}
gradle_token_is_allowed_gate_argument() {
local token="$1"
case "${token}" in
'--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;;
esac
[[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]]
}
gradle_plugin_is_applied() {
local plugin_id="$1"
grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src"
}
gradle_custom_task_is_registered_in_build_file() {
local task_name="$1"
local build_file="$2"
if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then
return 0
fi
awk -v required_task="${task_name}" '
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
candidate=$0
sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate)
quote=substr(candidate, 1, 1)
if (quote != "\"" && quote != sprintf("%c", 39)) {
next
}
candidate=substr(candidate, 2)
closing_quote=index(candidate, quote)
if (closing_quote == 0) {
next
}
candidate=substr(candidate, 1, closing_quote - 1)
if (candidate == required_task) {
found=1
}
}
inside_registration && /\)[[:space:]]*$/ { inside_registration=0 }
END { exit found ? 0 : 1 }
' "${build_file}"
}
gradle_custom_task_is_registered() {
local task_name="$1"
local build_file
while IFS= read -r -d '' build_file; do
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
return 0
fi
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0)
return 1
}
gradle_token_matches_registered_task() {
local token="$1"
local required_task="$2"
local project_path build_file
if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then
return 0
fi
if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then
return 1
fi
project_path="${token%:"${required_task}"}"
project_path="${project_path#:}"
project_path="${project_path%:}"
build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle"
[[ -f "${build_file}" ]] \
&& gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}"
}
job_runs_gradle_task() {
local workflow_file="$1"
local job_id="$2"
local required_task="$3"
local command token
local found_task suppressed
local -a tokens=()
while IFS= read -r command; do
if ! gradle_command_has_safe_literal_grammar "${command}"; then
continue
fi
read -r -a tokens <<< "${command}"
if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then
continue
fi
found_task=0
suppressed=0
for token in "${tokens[@]:1}"; do
case "${token}" in
'&&'|'||'|';'|'|'|'#'*) break ;;
esac
if gradle_token_suppresses_execution "${token}"; then
suppressed=1
break
fi
if ! gradle_token_is_allowed_gate_argument "${token}"; then
suppressed=1
break
fi
if gradle_token_matches_registered_task "${token}" "${required_task}"; then
found_task=1
fi
done
if (( found_task == 1 && suppressed == 0 )); then
return 0
fi
done < <(
job_body "${workflow_file}" "${job_id}" | awk '
/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ {
command=$0
sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command)
if (command !~ /^(\||>)/) {
print command
}
}
'
)
return 1
}
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
[[ -z "${id}" ]] && continue
total=$((total + 1))
if [[ -n "${seen_ids[${id}]:-}" ]]; then
failures+=("duplicate gate id '${id}'")
fi
seen_ids["${id}"]=1
if [[ -z "${blocking}" || -z "${mechanism}" || -z "${ref}" || -z "${workflow}" \
|| -z "${job}" || -z "${execution}" ]]; then
failures+=("gate '${id}' has an empty required field")
continue
fi
if [[ ! "${blocking}" =~ ^(true|false|conditional)$ ]]; then
failures+=("gate '${id}' has invalid release_blocking '${blocking}'")
fi
if [[ ! "${workflow}" =~ ^[A-Za-z0-9._-]+\.ya?ml$ || ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then
failures+=("gate '${id}' has an unsafe workflow or job identifier")
continue
fi
workflow_file="${REPO_ROOT}/.github/workflows/${workflow}"
if [[ ! -f "${workflow_file}" ]]; then
failures+=("gate '${id}' references missing workflow '.github/workflows/${workflow}'")
continue
fi
if ! grep -Eqs -- "^[[:space:]]{2}${job}:[[:space:]]*$" "${workflow_file}"; then
failures+=("gate '${id}' references missing job '${job}' in '${workflow}'")
continue
fi
case "${mechanism}" in
gradle-custom-task)
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'")
continue
fi
if ! gradle_custom_task_is_registered "${ref}"; then
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
continue
fi
;;
gradle-plugin-task)
plugin="${ref%@*}"
task="${ref#*@}"
if [[ "${plugin}" == "${ref}" \
|| ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \
|| ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'")
continue
fi
if ! gradle_plugin_is_applied "${plugin}"; then
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
continue
fi
;;
contract-test)
if [[ "${ref}" == /* || "${ref}" == *".."* || ! -f "${REPO_ROOT}/src/${ref}" ]]; then
failures+=("gate '${id}' references missing or unsafe contract test 'src/${ref}'")
continue
fi
;;
workflow-job)
if [[ "${ref}" != "${job}" ]]; then
failures+=("gate '${id}' workflow-job ref '${ref}' must equal job '${job}'")
continue
fi
;;
delegated-pending)
delegated=$((delegated + 1))
printf "gate '%s': explicitly delegated-pending\n" "${id}"
continue
;;
*)
failures+=("gate '${id}' has unknown mechanism '${mechanism}'")
continue
;;
esac
case "${execution}" in
check)
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
failures+=("gate '${id}' expects Gradle check in job '${job}'")
continue
fi
if [[ "${mechanism}" == "gradle-custom-task" ]] \
&& ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \
--include='build.gradle'; then
failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check")
continue
fi
;;
explicit)
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
continue
fi
;;
job)
;;
*)
failures+=("gate '${id}' has unknown execution '${execution}'")
continue
;;
esac
verified=$((verified + 1))
done <<< "${records}"
if (( total != EXPECTED_GATE_COUNT )); then
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
fi
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
"${total}" "${verified}" "${delegated}"
if (( ${#failures[@]} > 0 )); then
printf '::error::gate-matrix-lint: %d drift(s) found\n' "${#failures[@]}" >&2
for failure in "${failures[@]}"; do
printf ' - %s\n' "${failure}" >&2
done
exit 1
fi
printf 'gate-matrix-lint: OK\n'
-747
View File
@@ -1,747 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
# find .github/workflows -mindepth 1 -maxdepth 1 \
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
# | LC_ALL=C sort -z | xargs -0 sha256sum
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
# merely to make this verifier pass.
readonly EXPECTED_WORKFLOW_LOCK=(
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml'
'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml'
'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml'
'b56b548a867b74eaeccb42e7df4f4e52cf7ce657ab27f91e2c8d7ea9944d64af .github/workflows/jpa-nightly.yml'
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'4748f2ba0a0b77dc1a858ebcfa7db6e41627d97843df5f0aa978bc2facccaad2 .github/workflows/jpa-release.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
readonly EXPECTED_WRAPPER_PROPERTIES=(
'distributionBase=GRADLE_USER_HOME'
'distributionPath=wrapper/dists'
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
'networkTimeout=10000'
'validateDistributionUrl=true'
'zipStoreBase=GRADLE_USER_HOME'
'zipStorePath=wrapper/dists'
)
fail() {
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
fail 'expected exactly one repository-root argument'
fi
readonly REPOSITORY_ROOT=$1
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
fi
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
workflow_lock_valid=1
actual_workflow_lock=()
while IFS= read -r -d '' locked_workflow; do
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
locked_workflow_sha256='<invalid-file-type>'
else
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
fi
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
done < <(
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
| LC_ALL=C sort -z
)
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
workflow_lock_entry_count=${#actual_workflow_lock[@]}
fi
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
workflow_lock_valid=0
fi
done
workflow_count=0
gradle_job_count=0
while IFS= read -r -d '' workflow; do
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
for (known_field in step_fields) {
delete step_fields[known_field]
}
}
function reset_job() {
job = ""
in_steps = 0
steps_count = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function grammar_error(message) {
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
invalid = 1
}
function workflow_grammar_error(message) {
printf "%s: %s\n", workflow, message > "/dev/stderr"
invalid = 1
}
function validate_job_shape() {
if (job != "" && steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
}
function is_allowed_step_field(field) {
return field == "name" \
|| field == "id" \
|| field == "uses" \
|| field == "run" \
|| field == "if" \
|| field == "shell" \
|| field == "with" \
|| field == "env" \
|| field == "working-directory" \
|| field == "continue-on-error" \
|| field == "timeout-minutes"
}
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
value = trim(value)
if (value == "" || index(value, "\\") != 0) {
grammar_error("has unsupported uses scalar")
return
}
first = substr(value, 1, 1)
single_quote = sprintf("%c", 39)
if (first == "\"" || first == single_quote) {
quote = first
closing = 0
for (index_value = 2; index_value <= length(value); index_value++) {
if (substr(value, index_value, 1) == quote) {
closing = index_value
break
}
}
if (closing == 0) {
grammar_error("has unsupported uses scalar")
return
}
suffix = substr(value, closing + 1)
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
grammar_error("has unsupported uses scalar")
return
}
action = substr(value, 2, closing - 2)
if (index(action, quote) != 0) {
grammar_error("has unsupported uses scalar")
return
}
} else {
action = value
sub(/[[:space:]]+#.*$/, "", action)
action = trim(action)
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
grammar_error("has unsupported uses scalar")
return
}
}
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
grammar_error("has unsupported uses scalar")
}
}
function validate_run_scalar(value, first) {
value = trim(value)
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
return
}
first = substr(value, 1, 1)
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
grammar_error("has unsupported run scalar")
}
}
function validate_step_field(content, field, value, separator) {
content = trim(content)
if (content ~ /^[{[]/) {
grammar_error("contains unsupported flow-style step syntax")
return
}
if (content ~ /^<</) {
grammar_error("contains a forbidden step merge key")
return
}
if (content ~ /^[*&!]/) {
grammar_error("contains unsupported step anchor, alias, or tag syntax")
return
}
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
grammar_error("contains unsupported step field syntax")
return
}
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
if (!is_allowed_step_field(field)) {
grammar_error("contains unsupported step field: " field)
return
}
if (field in step_fields) {
grammar_error("contains duplicate step field: " field)
return
}
step_fields[field] = 1
if (field == "uses") {
validate_uses_scalar(value)
} else if (field == "run") {
validate_run_scalar(value)
}
}
BEGIN {
in_jobs = 0
invalid = 0
jobs_count = 0
single_quote = sprintf("%c", 39)
reset_job()
}
/^jobs:/ {
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
jobs_count++
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
in_jobs = 1
next
}
/^"jobs":/ {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
run_block == 0 && /^<<:/ {
workflow_grammar_error("workflow contains a forbidden merge key")
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job_shape()
reset_job()
in_jobs = 0
}
in_jobs && /^ [^[:space:]#]/ {
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
grammar_error("job declaration must use a canonical block mapping")
next
}
validate_job_shape()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/ || line_indent > 8) {
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
if (raw != " steps:") {
grammar_error("steps container must use a canonical block sequence")
next
}
steps_count++
if (steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
in_steps = 0
reset_step()
}
if (raw ~ /^ *<<:/) {
grammar_error("contains a forbidden merge key")
next
}
if (in_steps != 0 && raw ~ /^ - /) {
reset_step()
step_active = 1
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
grammar_error("contains unsupported empty step syntax")
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
grammar_error("contains unsupported step-list syntax")
}
}
END {
validate_job_shape()
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
if (invalid) {
exit 1
}
}
' "${workflow}"; then
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
if ! grep -Fq -- './gradlew' "${workflow}" \
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
continue
fi
((workflow_count += 1))
if ! jobs_in_workflow=$(
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
step_kind = ""
step_name = ""
step_id = ""
step_uses = ""
step_uses_action = ""
step_if = ""
step_if_present = 0
step_continue_on_error = 0
step_gradle = 0
step_gradle_line = 0
step_unsupported_gradle = 0
step_field_count = 0
step_name_line = 0
step_id_line = 0
step_uses_line = 0
step_extra_field = ""
for (known_field in step_fields) {
delete step_fields[known_field]
delete step_field_raw[known_field]
}
}
function reset_job() {
job = ""
checkout_line = 0
validation_line = 0
gradle_line = 0
in_steps = 0
unsupported_gradle = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function has_gradle_reference(line) {
return index(line, "./gradlew") != 0 \
|| index(line, "gradle/actions/dependency-submission@") != 0
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function normalize_action(value, scalar, first, quote, closing, index_value) {
scalar = trim(value)
first = substr(scalar, 1, 1)
if (first == "\"" || first == single_quote) {
quote = first
closing = index(substr(scalar, 2), quote)
if (closing == 0) {
return ""
}
return substr(scalar, 2, closing - 1)
}
sub(/[[:space:]]+#.*$/, "", scalar)
return trim(scalar)
}
function record_gradle(line_number) {
step_gradle = 1
if (step_gradle_line == 0) {
step_gradle_line = line_number
}
if (gradle_line == 0) {
gradle_line = line_number
}
}
function record_uses(value, line_number, action) {
if (step_kind == "run") {
if (index(value, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "uses"
action = normalize_action(value)
step_uses = trim(value)
step_uses_action = action
step_uses_line = line_number
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
checkout_line = line_number
}
if (action == dependency_action) {
record_gradle(line_number)
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
record_gradle(line_number)
step_unsupported_gradle = 1
}
}
function record_run(value, line_number) {
if (step_kind == "uses") {
if (index(value, "./gradlew") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "run"
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
} else if (index(value, "./gradlew") != 0) {
record_gradle(line_number)
}
}
function record_step_field(content, line_number, separator, field, value) {
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
step_fields[field] = 1
step_field_raw[field] = trim(content)
step_field_count++
if (field == "name") {
step_name = trim(value)
step_name_line = line_number
} else if (field == "id") {
step_id = trim(value)
step_id_line = line_number
} else if (field == "uses") {
record_uses(value, line_number)
} else if (field == "run") {
record_run(trim(value), line_number)
} else if (field == "if") {
step_if_present = 1
step_if = trim(value)
} else if (field == "continue-on-error") {
step_continue_on_error = 1
}
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
step_extra_field = step_field_raw[field]
}
}
function validate_wrapper_step() {
if (step_uses_action != validation_reference) {
return
}
if (step_extra_field != "") {
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
invalid = 1
return
}
if (step_field_count != 3 \
|| step_name != "Validate Gradle wrapper" \
|| step_id != "gradle-wrapper-validation" \
|| step_uses != validation_action \
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
invalid = 1
return
}
if (validation_line == 0) {
validation_line = step_uses_line
}
}
function validate_gradle_step() {
if (step_gradle == 0 && step_unsupported_gradle == 0) {
return
}
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
unsupported_gradle = 1
}
if (step_if_present != 0 && step_if != guarded_gradle_if) {
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
invalid = 1
}
if (step_continue_on_error != 0) {
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
invalid = 1
}
}
function finalize_step() {
if (step_active == 0) {
return
}
validate_wrapper_step()
validate_gradle_step()
}
function start_step() {
finalize_step()
reset_step()
step_active = 1
}
function validate_job() {
finalize_step()
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
return
}
gradle_jobs++
if (unsupported_gradle != 0) {
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
invalid = 1
}
if (gradle_line == 0) {
return
} else if (checkout_line == 0) {
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (validation_line == 0) {
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
invalid = 1
}
}
BEGIN {
in_jobs = 0
invalid = 0
gradle_jobs = 0
single_quote = sprintf("%c", 39)
validation_reference = validation_action
sub(/[[:space:]]+#.*$/, "", validation_reference)
reset_job()
}
/^jobs:[[:space:]]*(#.*)?$/ {
in_jobs = 1
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job()
reset_job()
in_jobs = 0
}
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
validate_job()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/) {
next
}
if (line_indent > 8) {
if (index(raw, "./gradlew") != 0) {
record_gradle(NR)
}
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw == " steps:") {
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
finalize_step()
in_steps = 0
reset_step()
}
if (in_steps != 0 && raw ~ /^ - /) {
start_step()
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (has_gradle_reference(raw)) {
unsupported_gradle = 1
}
}
END {
validate_job()
print gradle_jobs
if (invalid) {
exit 1
}
}
' "${workflow}"
); then
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
((jobs_in_workflow > 0)) \
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
((gradle_job_count += jobs_in_workflow))
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
((workflow_lock_valid != 0)) \
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
printf 'gradle-wrapper-contract: PASS\n'
+63
View File
@@ -0,0 +1,63 @@
name: reusable-gradle
# One place that knows how a Gradle job starts.
#
# Every job in this repository opened with the same preamble: checkout, a three-line pinned
# wrapper-validation step, then the JDK/cache action. The wrapper step is gone (setup-gradle
# validates wrappers itself), and this workflow removes the rest of the repetition for the jobs whose
# only variation is the Gradle command they run.
#
# Jobs that need service containers, a matrix, artifact uploads or per-job env stay written out with
# `./.github/actions/setup-gradle-java`, because expressing those through `workflow_call` inputs
# means encoding YAML inside strings — which is how a "shared" workflow becomes less readable than
# the duplication it replaced.
on:
workflow_call:
inputs:
tasks:
description: The Gradle task list, whitespace-separated.
required: true
type: string
gradle-args:
description: Flags appended after the task list.
required: false
type: string
default: "--stacktrace"
working-directory:
description: Directory the wrapper is invoked from.
required: false
type: string
default: src
timeout-minutes:
required: false
type: number
default: 30
continue-on-error:
description: Run the job as an advisory signal rather than a gate.
required: false
type: boolean
default: false
permissions:
contents: read
jobs:
gradle:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
continue-on-error: ${{ inputs.continue-on-error }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run ${{ inputs.tasks }}
working-directory: ${{ inputs.working-directory }}
env:
GRADLE_TASKS: ${{ inputs.tasks }}
GRADLE_ARGS: ${{ inputs.gradle-args }}
run: |
set -euo pipefail
# Word-split on purpose: both inputs are task/flag lists. They come from this repository's
# own workflow files, never from a pull request.
# shellcheck disable=SC2086
./gradlew ${GRADLE_TASKS} ${GRADLE_ARGS}
+93 -100
View File
@@ -1,5 +1,14 @@
name: ci-quality-gates name: ci-quality-gates
# The pull-request gate. Everything here blocks a merge.
#
# The job list used to include `gate-matrix-lint`, which ran .github/scripts/verify-gate-matrix.sh
# against .github/ci-gate-matrix.yml: a 1,025-line register of all 107 CI controls, checked for
# consistency against the Gradle task graph and this workflow by a 568-line shell script, which was
# itself checked by contract tests in :app-bootstrap. Adding one check meant editing Gradle, a
# workflow, the matrix, the verifier's expectations and a Java test. The information was already in
# the task graph and the job graph; the matrix was a third copy that had to be kept equal to both.
on: on:
pull_request: pull_request:
push: push:
@@ -21,9 +30,6 @@ 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
@@ -36,103 +42,97 @@ jobs:
echo "::error::${snapshot} exists locally but is not committed." echo "::error::${snapshot} exists locally but is not committed."
exit 1 exit 1
fi fi
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 - uses: ./.github/actions/setup-gradle-java
with: # `ci`, not `check`. A leaf's `check` is that leaf's — compile, its tests, Spotless, Checkstyle
distribution: temurin # and Error Prone — and the repository-wide gates are named tasks of their own:
java-version: "21.0.11+10" # ci = every leaf check + architectureCheck + qualityCheck + configContractCheck + qualificationCheck
cache: gradle # so CI runs strictly more than it used to while `./gradlew :domain-core:check` runs strictly
cache-dependency-path: | # less.
src/**/*.gradle - name: Run the pull-request gate
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Check quality, public paths, and dependency locks
working-directory: src working-directory: src
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace run: ./gradlew :ci :verifyPublicPathSnapshot :verifyDependencyLocks --warning-mode=fail --stacktrace
# Named as its own step because nothing else runs it: `check` does not depend on
# graphqlStableTest, so the lane's required-class guard — the check that its module-boundary
# suite has not silently stopped being discovered — would protect nothing in CI.
- name: Qualify the GraphQL Stable lane
working-directory: src
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --stacktrace
- name: Qualify opt-in inbound transports without skips - name: Qualify opt-in inbound transports without skips
working-directory: src working-directory: src
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace run: ./gradlew :conditionalTransportQualification --stacktrace
sample-off: build-logic:
# Included-build tests are independent of the main project task graph. Running them as a
# separate blocking job keeps plugin TestKit work off the quality-gates critical path.
runs-on: ubuntu-latest 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 - uses: ./.github/actions/setup-gradle-java
id: gradle-wrapper-validation - name: Test the build-logic convention plugins
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify the application without the sample fixture
working-directory: src working-directory: src
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace run: ./gradlew -p build-logic test --stacktrace
gate-matrix-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Verify the gate matrix against the repository
run: bash .github/scripts/verify-gate-matrix.sh
redis-sdk: redis-sdk:
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit
# provenance, connection isolation, and the executor guard. There is no real-server lane yet.
#
# `verifyConfigurationPropertiesProcessor` used to be in this list. It is deleted: the parity it
# enforced — a leaf declares Spring's configuration processor exactly when it owns
# @ConfigurationProperties — is now what applying `ca.spring-config` means.
# `verifyEnvKeys` is no longer named here either; it belongs to :app-bootstrap and runs through
# `configContractCheck`, which the quality-gates job covers.
uses: ./.github/workflows/_reusable-gradle.yml
with:
tasks: ":shared-contract:edgeRateLimitContractTest"
optional-platforms:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
id: gradle-wrapper-validation - name: Verify the optional gRPC platform build
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 working-directory: src
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 run: ./gradlew -p optional-platforms ci --stacktrace
with:
distribution: temurin configuration-cache:
java-version: "21.0.11+10" runs-on: ubuntu-latest
cache: gradle steps:
cache-dependency-path: | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
src/**/*.gradle - uses: ./.github/actions/setup-gradle-java
src/**/gradle-wrapper.properties - name: Store configuration cache for the everyday core build
src/**/gradle.lockfile
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
# permit provenance, connection isolation, and the executor guard. There is no real-server
# lane yet — Tasks 10-17 add the contract suites that need one.
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:shared-contract:edgeRateLimitContractTest :domain-core:check
:adapter:outbound:cache-redis:check :application-core:check
verifyCleanArchitectureDependencies --configuration-cache
verifyEnvKeys --configuration-cache-problems=fail
verifyPublicPathSnapshot --stacktrace
verifyConfigurationPropertiesProcessor - name: Require configuration-cache reuse
--no-daemon --stacktrace working-directory: src
shell: bash
run: |
set -euo pipefail
output="$({ ./gradlew :domain-core:check :application-core:check \
--configuration-cache --configuration-cache-problems=fail --stacktrace; } 2>&1)"
printf '%s\n' "${output}"
grep -Fq 'Reusing configuration cache.' <<<"${output}" || {
echo '::error::Gradle did not reuse the configuration cache on the second identical build.'
exit 1
}
jpa-candidate-evidence: jpa-candidate-evidence:
runs-on: ubuntu-latest runs-on: ubuntu-latest
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 - uses: ./.github/actions/setup-gradle-java
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: Produce zero-skip JPA candidate manifests - name: Produce zero-skip JPA candidate manifests
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
--no-daemon
--stacktrace --stacktrace
- name: Retain content-addressed JPA candidate manifests - name: Retain content-addressed JPA candidate manifests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
@@ -142,53 +142,46 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check. # Advisory. The quarantine bucket runs so a flaky test is still executed and reported; it never
# blocks. The 14-day sunset registry that used to make an expired quarantine entry a build failure
# is gone — it was a 250-line YAML-and-Java parser guarding a registry with zero entries.
quarantine: quarantine:
runs-on: ubuntu-latest uses: ./.github/workflows/_reusable-gradle.yml
continue-on-error: true with:
steps: tasks: ":quarantineTest"
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 gradle-args: "--stacktrace"
- name: Validate Gradle wrapper continue-on-error: true
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run quarantined tests as an advisory signal
working-directory: src
run: ./gradlew quarantineTest --no-daemon
release-gate: release-gate:
needs: needs:
- quality-gates - quality-gates
- sample-off - build-logic
- gate-matrix-lint
- redis-sdk - redis-sdk
- jpa-candidate-evidence - jpa-candidate-evidence
- optional-platforms
- configuration-cache
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Require every current blocking job to succeed - name: Require every current blocking job to succeed
env: env:
QUALITY_RESULT: ${{ needs.quality-gates.result }} QUALITY_RESULT: ${{ needs.quality-gates.result }}
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }} BUILD_LOGIC_RESULT: ${{ needs.build-logic.result }}
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
REDIS_RESULT: ${{ needs.redis-sdk.result }} REDIS_RESULT: ${{ needs.redis-sdk.result }}
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }} JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
OPTIONAL_PLATFORMS_RESULT: ${{ needs.optional-platforms.result }}
CONFIGURATION_CACHE_RESULT: ${{ needs.configuration-cache.result }}
run: | run: |
set -euo pipefail set -euo pipefail
for result in \ for result in \
"${QUALITY_RESULT}" \ "${QUALITY_RESULT}" \
"${SAMPLE_OFF_RESULT}" \ "${BUILD_LOGIC_RESULT}" \
"${MATRIX_RESULT}" \
"${REDIS_RESULT}" \ "${REDIS_RESULT}" \
"${JPA_CANDIDATE_RESULT}"; do "${JPA_CANDIDATE_RESULT}" \
"${OPTIONAL_PLATFORMS_RESULT}" \
"${CONFIGURATION_CACHE_RESULT}"; do
if [[ "${result}" != "success" ]]; then if [[ "${result}" != "success" ]]; then
echo "::error::release-gate: required job result was ${result}" echo "::error::release-gate: required job result was ${result}"
exit 1 exit 1
+5 -13
View File
@@ -35,18 +35,7 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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: Submit the resolved Gradle dependency graph - name: Submit the resolved Gradle dependency graph
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4 uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
with: with:
@@ -179,7 +168,10 @@ jobs:
trivy-kev.json | sort -u > found-cves.txt trivy-kev.json | sort -u > found-cves.txt
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \ jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
kev.json | sort -u > kev-cves.txt kev.json | sort -u > kev-cves.txt
hits="$(comm -12 found-cves.txt kev-cves.txt || true)" # No `|| true`. comm exits non-zero only when it cannot read or order its inputs, and
# swallowing that would have turned an unreadable CVE list into an empty intersection and
# printed "no catalog match" — a KEV cross-check that passes because it never ran.
hits="$(comm -12 found-cves.txt kev-cves.txt)"
if [[ -n "${hits}" ]]; then if [[ -n "${hits}" ]]; then
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:" echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
printf '%s\n' "${hits}" printf '%s\n' "${hits}"
@@ -0,0 +1,122 @@
name: fileserver-certification
# The certification a release must clear. Its job list is deliberately the same shape as the support
# matrix: nothing may be advertised at a support level whose evidence job is absent here.
#
# Named "certification", not "release", and the name is the point. This workflow proves a storage
# topology, a support matrix and a telemetry redaction claim. It deploys nothing and holds no cluster
# credential. Calling it `fileserver-release.yml` read as if GitHub Actions released the fileserver,
# which is the CI/CD boundary this repository has now fixed in docs/ci-cd/boundary.md: GitHub Actions
# tests, scans and publishes artifacts; Argo CD deploys.
#
# It used to be workflow_dispatch only, which made that sentence false: the four jobs below are the
# only place the fileserver support matrix, the PVC manifest and the telemetry redaction proof are
# checked, and a release tag reached none of them unless somebody remembered to press a button.
#
# `v*` is the only release tag. The adapter-scoped `fileserver-v*` pattern is gone: this repository
# has one deployable unit (app-bootstrap), so an adapter-scoped tag could only ever run a subset of
# the release gates and call the result a release — the tag-namespace split that release.yml exists
# to end.
#
# These four jobs stay in their own file, and not in release.yml, for one mechanical reason:
# FileserverDocumentationCoverageTest reads job ids out of `.github/workflows/fileserver-*.yml` and
# requires every `fileserver-...` job docs/fileserver/support-matrix.md names to be defined in one
# of them. Renaming the file or moving these jobs needs that document changed in the same change.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
fileserver-full-verification:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the architecture-wide dependency and module verification
working-directory: src
run: >-
./gradlew
:verifyCleanArchitectureDependencies
--stacktrace
- name: Run the complete fileserver suite across every leaf
working-directory: src
run: >-
./gradlew
:application-core:check
:adapter:inbound:web:check
:adapter:outbound:fileserver:check
--stacktrace
fileserver-documentation-gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Prove every support claim maps to a job and every endpoint is documented
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
--stacktrace
fileserver-pvc-certification:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
# This job checks the manifest, and only the manifest. It deliberately does not apply anything
# to a cluster.
#
# There used to be a second step here that applied the job to a release cluster when
# secrets.FILESERVER_PVC_KUBECONFIG was set and `exit 0`-ed with a ::warning:: when it was
# not. With no secret configured — which is every fork of this template and was this
# repository — the step printed a warning and the job went green under the name
# "fileserver-pvc-certification", so a release read as ReadWriteOnce-certified against a
# cluster nothing had ever touched. It also wrote a `certified` output that no job, step or
# script in this repository read.
#
# The cluster result comes from an operator running infra/fileserver/kubernetes/
# pvc-certification-job.yaml against a real cluster and recording it in
# docs/fileserver/storage-certification.md. That is registered as
# docs/fileserver/storage-certification.md, and the absence of a cluster result is stated
# there rather than hidden behind a green check.
- name: Check the certification manifest still says what the claim depends on
run: |
set -euo pipefail
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
test -f "$manifest"
grep -q 'kind: PersistentVolumeClaim' "$manifest"
grep -q 'kind: Job' "$manifest"
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
# mode would certify a topology the support matrix says is uncertified.
grep -q 'ReadWriteOnce' "$manifest"
! grep -q 'ReadWriteMany' "$manifest"
fileserver-sensitive-telemetry-scan:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Prove telemetry carries no filename, path, or raw identifier
working-directory: src
run: >-
./gradlew
:application-core:test --tests '*FileserverObservabilityTest'
--stacktrace
+8 -52
View File
@@ -24,18 +24,7 @@ jobs:
FILESERVER_NFS_TESTS: "true" FILESERVER_NFS_TESTS: "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 - uses: ./.github/actions/setup-gradle-java
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 - name: Start the NFSv4 certification environment
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
- name: Run the network-filesystem ambiguity suite - name: Run the network-filesystem ambiguity suite
@@ -43,7 +32,7 @@ jobs:
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest' :adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
--no-daemon
--stacktrace --stacktrace
- name: Tear down the NFS environment - name: Tear down the NFS environment
if: always() if: always()
@@ -54,18 +43,7 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 - name: Run the crash matrix and reconciliation suites
working-directory: src working-directory: src
run: >- run: >-
@@ -73,7 +51,7 @@ jobs:
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest' :adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
:application-core:test --tests '*FileReconciliationServiceTest' :application-core:test --tests '*FileReconciliationServiceTest'
--rerun-tasks --rerun-tasks
--no-daemon
--stacktrace --stacktrace
fileserver-large-file-performance: fileserver-large-file-performance:
@@ -81,18 +59,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 - name: Run the large-file and slow-client suites under a constrained heap
working-directory: src working-directory: src
env: env:
@@ -102,7 +69,7 @@ jobs:
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest' :adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest' :adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
--rerun-tasks --rerun-tasks
--no-daemon
--stacktrace --stacktrace
fileserver-multi-instance-lease: fileserver-multi-instance-lease:
@@ -110,23 +77,12 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 - name: Prove no run commits bytes from a stale lease
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:application-core:test --tests '*MultiInstanceWriterLeaseTest' :application-core:test --tests '*MultiInstanceWriterLeaseTest'
--rerun-tasks --rerun-tasks
--no-daemon
--stacktrace --stacktrace
+17 -68
View File
@@ -23,9 +23,13 @@ on:
- 'docs/registries/env-keys.yaml' - 'docs/registries/env-keys.yaml'
- 'src/Dockerfile' - 'src/Dockerfile'
- 'docker-compose.yml' - 'docker-compose.yml'
- 'infra/nginx/**' - 'infra/fileserver/nginx/**'
- 'infra/k8s/**' - 'infra/fileserver/kubernetes/**'
- 'infra/fileserver/nfs/**'
- '.github/workflows/fileserver-pr.yml' - '.github/workflows/fileserver-pr.yml'
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- '.github/actions/setup-gradle-java/action.yml'
permissions: permissions:
contents: read contents: read
@@ -40,25 +44,14 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 - name: Run the fileserver application and architecture suites
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:application-core:test :application-core:test
:app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*Fileserver*' :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --tests '*Fileserver*'
--no-daemon
--stacktrace --stacktrace
fileserver-local-ext4-contract: fileserver-local-ext4-contract:
@@ -66,24 +59,13 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 - name: Certify the local content store against the shared contract
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:fileserver:test :adapter:outbound:fileserver:test
--no-daemon
--stacktrace --stacktrace
fileserver-http-contract: fileserver-http-contract:
@@ -91,24 +73,13 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 - name: Run the servlet and reactive transport contracts
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:inbound:web:test :adapter:inbound:web:test
--no-daemon
--stacktrace --stacktrace
fileserver-security-suite: fileserver-security-suite:
@@ -116,25 +87,14 @@ 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 - uses: ./.github/actions/setup-gradle-java
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 - name: Run the path, filename, range, and problem-detail hardening suite
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest' :adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest' :adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
--no-daemon
--stacktrace --stacktrace
fileserver-bounded-memory: fileserver-bounded-memory:
@@ -142,23 +102,12 @@ 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 - uses: ./.github/actions/setup-gradle-java
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 - name: Prove transfer cost does not scale with file size
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest' :adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
:adapter:inbound:web:test --tests '*DataBufferReleaseTest' :adapter:inbound:web:test --tests '*DataBufferReleaseTest'
--no-daemon
--stacktrace --stacktrace
-143
View File
@@ -1,143 +0,0 @@
name: fileserver-release
# The gate a release must clear. Its job list is deliberately the same shape as the support matrix:
# nothing may be advertised at a support level whose evidence job is absent here.
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
fileserver-full-verification:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the architecture-wide dependency and module verification
working-directory: src
run: >-
./gradlew
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
- name: Run the complete fileserver suite across every leaf
working-directory: src
run: >-
./gradlew
:application-core:check
:adapter:inbound:web:check
:adapter:outbound:fileserver:check
--no-daemon
--stacktrace
fileserver-documentation-gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Prove every support claim maps to a job and every endpoint is documented
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
--no-daemon
--stacktrace
fileserver-pvc-certification:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
# when nothing had been applied anywhere.
- name: Check the certification manifest still says what the claim depends on
run: |
set -euo pipefail
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
test -f "$manifest"
grep -q 'kind: PersistentVolumeClaim' "$manifest"
grep -q 'kind: Job' "$manifest"
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
# mode would certify a topology the support matrix says is uncertified.
grep -q 'ReadWriteOnce' "$manifest"
! grep -q 'ReadWriteMany' "$manifest"
- name: Certify the ReadWriteOnce claim on the release cluster
id: pvc-cluster-run
env:
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
run: |
set -euo pipefail
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
echo "::warning::no release cluster configured; PVC certification was NOT run."
echo "The support matrix records this profile as Limited for exactly this reason:"
echo "the cluster result is produced by an operator against a real cluster and read"
echo "from docs/fileserver/storage-certification.md, not by this job."
echo "certified=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
kubectl logs job/fileserver-pvc-certification
echo "certified=true" >> "$GITHUB_OUTPUT"
fileserver-sensitive-telemetry-scan:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Prove telemetry carries no filename, path, or raw identifier
working-directory: src
run: >-
./gradlew
:application-core:test --tests '*FileserverObservabilityTest'
--no-daemon
--stacktrace
-132
View File
@@ -1,132 +0,0 @@
name: httpclient-contract
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
# disappearing into an aggregate run.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/outbound/httpclient/**'
- 'src/app-bootstrap/src/**/httpclient/**'
- 'docs/httpclient/**'
- 'scripts/verify-httpclient-docs.py'
- '.github/workflows/httpclient-contract.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
httpclient-unit-and-boundaries:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the focused module suite and the architecture gate
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
httpclient-stable-contract:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
transport: [apache, jdk, reactor]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify one transport against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientStableContractTest
-Phttpclient.contract.transports=${{ matrix.transport }}
--no-daemon
--stacktrace
httpclient-security-and-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the SSRF, cardinality, and Spring compatibility lanes
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientSecurityTest
:adapter:outbound:httpclient:httpClientBlockHoundTest
:adapter:outbound:httpclient:spring62ApiSurfaceScan
:adapter:outbound:httpclient:spring70CompatibilityTest
--no-daemon
--stacktrace
httpclient-composition:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify composition and architecture in the bootstrap module
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest'
--no-daemon
--stacktrace
-94
View File
@@ -1,94 +0,0 @@
name: httpclient-nightly
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
# fails, it does not skip.
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *'
permissions:
contents: read
jobs:
httpclient-fault-injection:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Inject TCP faults against a real upstream
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientFailureInjectionTest
--no-daemon
--stacktrace
httpclient-performance:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify pool, streaming, retry, and rotation bounds
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientPerformanceTest
--no-daemon
--stacktrace
httpclient-http3-experimental:
runs-on: ubuntu-latest
timeout-minutes: 30
# Experimental by design (D-08): the result is reported, never used to block a merge.
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Exercise the experimental HTTP/3 opt-in
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
-Phttp3.tests.enabled=true
--no-daemon
--stacktrace
-70
View File
@@ -1,70 +0,0 @@
name: httpclient-release
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
# single-line `./gradlew <task>` step, because .github/scripts/verify-gate-matrix.sh reads these
# commands to prove the gate is actually executed — a folded or flag-laden command would make the
# declaration in .github/ci-gate-matrix.yml unverifiable.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Focused module tests
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
- name: Spring 6.2 API surface lane
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
- name: Spring 7.0 compatibility lane
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
- name: Stable cross-transport contract suite
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
- name: SSRF and cardinality suite
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
- name: Event-loop blocking suite
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
- name: Toxiproxy fault-injection suite
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
- name: Resource-bound performance certification
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
- name: Architecture dependency gate
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
+232
View File
@@ -0,0 +1,232 @@
name: integration-main
# Stage 2: is the merged state healthy.
#
# The question this stage answers is different from stage 1's. Stage 1 asks whether a diff is safe
# and blocks a merge; stage 2 asks whether main is healthy and does not — the merge has already
# happened. That difference is the point, and it is what lets a control exist without being an
# obstacle: a gate here still fails loudly, it just fails after the thing it is reporting on.
#
# Two kinds of work live here.
#
# 1. The lanes that need a machine that is not simultaneously compiling something else — load,
# abuse, graceful shutdown, TCP fault injection, resource bounds. They were web-nightly.yml and
# httpclient-nightly.yml, two module-shaped files whose only real difference was the cadence they
# shared. They now run on every push to main as well as nightly, which is strictly more often
# than before.
#
# 2. Lanes that were registered in Gradle and invoked by nothing. Ten Gradle tasks — six MongoDB
# container lanes, app-bootstrap's Testcontainers `integrationTest`, and the three messaging
# evidence tasks that `verifyMessagingContracts` reaches — existed, failed closed, and executed
# in no workflow. A lane nobody runs is not coverage; it is a file that looks like coverage. They
# are here rather than in stage 1 because every one of them either starts containers or re-runs
# suites the PR gate already covers, and the pull-request budget is minutes for the whole gate.
#
# What is deliberately NOT here: the web and WebSocket "Advanced capability" nightly lanes that used
# to exist as web-advanced-nightly.yml and websocket-advanced-nightly.yml. Both leaves' build files
# say it outright — "They also run inside `test`, deliberately ... excluding them from the PR gate to
# make this lane look meaningful would mean the PR gate stopped covering a fifth of the leaf" — so
# `webAdvancedTest` and `websocketAdvancedTest` select tagged tests that `:<leaf>:test` already runs,
# and `:<leaf>:test` runs inside the root `check` on every pull request and every push to main. The
# strict lanes themselves survive in release.yml, where their fail-on-nothing-discovered guard is
# worth a job.
on:
push:
branches: ["main"]
schedule:
# 03:00 UTC. Late enough that the day's merges are in, early enough that a failure is triaged
# before the next working day starts.
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
# The documentation-drift gates that used to run here are gone rather than demoted.
#
# They were four hand-written parsers: README shell blocks compared against the Gradle task graph,
# runbook identifiers compared against every declared Java type, a leaf count written in prose
# compared against the registry, and a Markdown table compared against the declared source sets.
# Each was a custom parser for a file format nobody controls, and each made a documentation edit a
# precondition for a build. A stale sentence is a defect, but it is not one a build can be failed
# for, and link-check.yml already answers the one documentation question with a stable machine
# answer: does this link resolve.
jobs:
# Load, abuse and graceful shutdown measure behaviour that degrades gradually rather than breaking
# outright — which is exactly the kind of regression a per-PR gate never catches.
web-load-abuse-and-shutdown:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the load, abuse and shutdown lanes on every container
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
:adapter:inbound:web:webJettyCompatTest
:adapter:inbound:web:webFluxContractTest
--stacktrace
- name: Publish the test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-integration-reports
path: src/adapter/inbound/web/build/reports/tests/
if-no-files-found: warn
# Needs a container runtime and real time (design §29). Separated from the per-PR gate rather than
# made optional inside it: a lane that cannot run here fails, it does not skip.
httpclient-fault-injection:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Inject TCP faults against a real upstream
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientFailureInjectionTest
--stacktrace
httpclient-performance:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
# A project property rather than a command-line flag, so the run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Certify pool, streaming, retry, and rotation bounds
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientPerformanceTest
--stacktrace
httpclient-http3-experimental:
runs-on: ubuntu-latest
timeout-minutes: 30
# Experimental by design (D-08): the result is reported, never used to block a merge. Registered
# advisory so that "this job cannot fail the
# build" is written down rather than inferred from a field two hundred lines into a workflow.
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Exercise the experimental HTTP/3 opt-in
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
-Phttp3.tests.enabled=true
--stacktrace
# The six Docker-backed MongoDB lanes. Until now they ran in no workflow at all: the leaf excludes
# every one of their tags from `test` (build.gradle "Docker-backed lanes are excluded from the
# default unit run"), `check` gains only the hermetic `mongoStableContractTest`, and the only thing
# that named them was scripts/verify-mongodb-platform.sh, which nothing in .github invokes. Six
# lanes that fail closed without Docker, and no machine with Docker was ever asked to run them.
#
# Stage 2 rather than stage 1 because each lane starts real MongoDB containers — mongo:8.0.16,
# mongo:7.0.28 and a Toxiproxy in front of a three-node replica set. That is minutes per lane, and
# the pull-request budget is minutes for the whole gate.
#
# One single-line `./gradlew <task>` step per lane, not one folded command running six, because
# These command lines name each lane explicitly so that a lane which stops being invoked is
# actually executed — a folded command would leave six matrix rows unverifiable. It also means a
# red replica-set lane does not hide the compatibility lane behind it.
mongo-container-lanes:
runs-on: ubuntu-latest
timeout-minutes: 90
env:
# Reuse would hand the failover lane a replica set another lane had already faulted.
TESTCONTAINERS_REUSE_ENABLE: "false"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Single-node replica set contract lane
working-directory: src
run: ./gradlew :adapter:outbound:persistence-mongo:mongoReplicaSetTest --stacktrace
- name: Run MongoDB integration lanes
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-mongo:mongoReplicaSetTest
:adapter:outbound:persistence-mongo:mongoFailoverTest
:adapter:outbound:persistence-mongo:mongoMigrationTest
:adapter:outbound:persistence-mongo:mongoCompatibilityTest
:adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest
:adapter:outbound:persistence-mongo:mongoPerformanceTest
--stacktrace
- name: Publish the MongoDB lane reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: mongo-lane-reports
path: src/adapter/outbound/persistence-mongo/build/reports/tests/
if-no-files-found: warn
# The messaging contract evidence DAG. `verifyMessagingContracts` is the root of a chain that ran
# nowhere: it depends on four production qualification tasks (application-core,
# shared-contract and two in adapter:outbound:messaging), each of which depends on
# `prepareMessagingContractEvidence`; it is finalizedBy
# `validateMessagingContractsEvidenceManifestSchema`; and it depends on
# `validateMessagingJsonSchemaV1EvidenceManifestSchema`, which depends on
# `verifyMessagingJsonSchemaV1`. Strict qualification tasks are registered outside `check` by
# design (ca.strict-qualification.gradle), so none of the seven was reachable from any workflow.
#
# The schema validators are the part that matters. They re-read the manifest bytes the run just
# wrote and validate them against config/messaging/evidence/build-evidence-manifest-v1.schema.json
# — a manifest that claims a qualification nobody executed is exactly the failure they exist to
# catch, and until now nothing executed them either.
#
# Stage 2 rather than stage 1: no containers, but it runs four qualification suites across three
# leaves plus two JavaExec validators, and the tests it re-runs are already inside the PR gate's
# `check`. What this job adds is the evidence manifest, which is a main-branch artifact.
messaging-contract-evidence:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Qualify the messaging contract, catalog, binding and schema evidence
working-directory: src
run: ./gradlew :verifyMessagingContracts --stacktrace
- name: Publish the messaging evidence manifest
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: messaging-contract-evidence
path: src/build/messaging-evidence/
if-no-files-found: warn
# app-bootstrap's Testcontainers lane. The leaf gave it a source set of its own precisely so that
# `./gradlew :app-bootstrap:test` would not require a Docker daemon — and the consequence nobody
# closed is that a source set outside `test` is also outside `check`, so the real-PostgreSQL
# outbox and idempotency contracts compiled on every build and executed on none.
bootstrap-integration:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
TESTCONTAINERS_REUSE_ENABLE: "false"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the real-PostgreSQL integration contracts
working-directory: src
run: ./gradlew :app-bootstrap:integrationTest --stacktrace
-64
View File
@@ -1,64 +0,0 @@
name: jpa-next-hibernate8
# Hibernate ORM 8 compatibility lane (experimental plan Task 8).
#
# Re-runs the contracts most likely to move between provider majors: collection fetch pagination,
# StatementInspector, Statistics, JSONB, batch, and StatelessSession. Differences are recorded, not
# accommodated — weakening the 7.x gate to make this lane green would delete the evidence that 7.x
# behaves as documented.
on:
workflow_dispatch:
schedule:
- cron: '0 5 * * 1'
permissions:
contents: read
jobs:
hibernate8-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 45
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: Report Hibernate ORM 8 compatibility
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest'
--no-daemon
--stacktrace
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=Hibernate 8"
echo "target-coordinate=org.hibernate.orm:hibernate-core:8.x"
echo "status=NOT_EXECUTABLE"
echo "reason=Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::Hibernate 8 compatibility is NOT_EXECUTABLE: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-hibernate-8
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
-64
View File
@@ -1,64 +0,0 @@
name: jpa-next-jpa4
# Jakarta Persistence 4.0 compatibility lane (experimental plan Task 7).
#
# Non-blocking by design: it reports whether the Stable public API still compiles and whether the
# selected mapping contracts still hold on JPA 4. It publishes nothing, and a red result here never
# changes a Stable contract — the 3.2 gate keeps asserting what 3.2 must do, because that is what
# deployments run.
on:
workflow_dispatch:
schedule:
- cron: '0 4 * * 1'
permissions:
contents: read
jobs:
jpa4-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 45
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: Report Jakarta Persistence 4.0 compatibility
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest'
--no-daemon
--stacktrace
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=Jakarta Persistence 4"
echo "target-coordinate=jakarta.persistence:jakarta.persistence-api:4.x"
echo "status=NOT_EXECUTABLE"
echo "reason=the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::Jakarta Persistence 4 compatibility is NOT_EXECUTABLE: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-jakarta-persistence-4
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
@@ -1,70 +0,0 @@
name: jpa-next-postgresql19
# PostgreSQL 19 compatibility lane (experimental plan Task 9).
#
# This lane is NOT_EXECUTABLE against its target.
#
# It runs the current runtime's policy and lane-definition tests; it does not resolve the target
# dependency or start a container of the target version. A green run therefore says "the target is
# absent from this build", which is not the same claim as "we are compatible with the target" — and
# the workflow's name reads as the second one. The status artifact says which it is.
#
# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic
# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what
# ExperimentalPromotionGate encodes.
on:
workflow_dispatch:
schedule:
- cron: '0 6 * * 1'
permissions:
contents: read
jobs:
postgresql19-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 60
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: Report PostgreSQL 19 compatibility
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest'
--no-daemon
--stacktrace
- name: Record what this lane did and did not execute
if: always()
run: |
mkdir -p compatibility-evidence
{
echo "target=PostgreSQL 19"
echo "target-coordinate=postgres:19-alpine"
echo "status=NOT_EXECUTABLE"
echo "reason=no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::PostgreSQL 19 compatibility is NOT_EXECUTABLE: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-postgresql-19
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
+75
View File
@@ -0,0 +1,75 @@
name: jpa-next
# Advisory compatibility probes for future JPA/Hibernate/PostgreSQL majors. These targets are not
# resolved by the current build, so the artifact records NOT_EXECUTABLE rather than implying that a
# green policy test is compatibility evidence.
on:
workflow_dispatch:
schedule:
- cron: '0 5 * * 1'
permissions:
contents: read
jobs:
compatibility:
strategy:
fail-fast: false
matrix:
include:
- id: jakarta-persistence-4
target: Jakarta Persistence 4
coordinate: jakarta.persistence:jakarta.persistence-api:4.x
test: "*CompatibilityLaneDefinitionTest"
reason: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it
- id: hibernate-8
target: Hibernate 8
coordinate: org.hibernate.orm:hibernate-core:8.x
test: "*HibernateCompatibilityPolicyTest"
reason: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it
- id: postgresql-19
target: PostgreSQL 19
coordinate: postgres:19-alpine
test: "*ExperimentalPromotionGateTest"
reason: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the current-runtime policy probe
id: compatibility-probe
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:test
--tests '${{ matrix.test }}'
--stacktrace
- name: Record what this lane did and did not execute
if: always()
env:
PROBE_OUTCOME: ${{ steps.compatibility-probe.outcome }}
TARGET: ${{ matrix.target }}
TARGET_COORDINATE: ${{ matrix.coordinate }}
REASON: ${{ matrix.reason }}
run: |
mkdir -p compatibility-evidence
{
echo "target=${TARGET}"
echo "target-coordinate=${TARGET_COORDINATE}"
echo "status=NOT_EXECUTABLE"
echo "probe-result=${PROBE_OUTCOME}"
echo "reason=${REASON}"
echo "what-ran=the current runtime's own policy and lane-definition tests"
echo "sha=${{ github.sha }}"
} > compatibility-evidence/status.properties
echo "::notice::${TARGET} compatibility is NOT_EXECUTABLE: ${REASON}"
- name: Upload the compatibility status
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: compatibility-status-${{ matrix.id }}
path: compatibility-evidence/status.properties
retention-days: 30
if-no-files-found: error
+15 -55
View File
@@ -30,25 +30,14 @@ jobs:
postgresql: ["16", "17", "18"] postgresql: ["16", "17", "18"]
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 platform against PostgreSQL ${{ matrix.postgresql }} - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformContractTest
-Pjpa.matrix.versions=${{ matrix.postgresql }} -Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace --stacktrace
jpa-failure-injection: jpa-failure-injection:
@@ -56,24 +45,13 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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: Reproduce deadlock, serialization, and commit-ambiguity scenarios - name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformFailureTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
--no-daemon
--stacktrace --stacktrace
jpa-query-plan-and-security: jpa-query-plan-and-security:
@@ -81,25 +59,14 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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 query plan and database security suites - name: Run the query plan and database security suites
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
:adapter:outbound:persistence-jpa:jpaPlatformSecurityTest :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
--no-daemon
--stacktrace --stacktrace
jpa-pool-pressure: jpa-pool-pressure:
@@ -107,24 +74,17 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
id: gradle-wrapper-validation - name: Verify pool saturation and REQUIRES_NEW connection behaviour
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: Measure pool saturation and REQUIRES_NEW pressure
working-directory: src working-directory: src
# Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a # A behaviour contract, not a measurement. This step used to switch assertions off with an
# noisy shared runner does not produce a red build that means nothing. # explicit property and call the result a certification, so the only threshold it ever
# asserted was that thresholds were not being asserted. What
# it checks now — that REQUIRES_NEW needs two connections per concurrent thread, that a
# saturated pool reports its pending count, that a caller waits rather than proceeding
# without a connection — is true on any runner, so there is nothing to switch off.
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
--no-daemon
--stacktrace --stacktrace
-114
View File
@@ -1,114 +0,0 @@
name: jpa-pr
# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly /
# jpa-release. A support level with no job behind it is a marketing claim.
#
# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour
# that differs across the matrix almost always differs at its ends, and the middle version is
# covered nightly. What it does not do is skip the container lane on a runner without Docker —
# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database
# nobody tested.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/outbound/persistence-jpa/**'
- 'src/app-bootstrap/src/**/jpa/**'
- 'src/config/architecture/modules.json'
- 'docs/jpa/**'
- 'docs/adr/ADR-JPA-*'
- 'infra/jpa/**'
- '.github/workflows/jpa-pr.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
jpa-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 JPA unit and architecture suites
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:test
:app-bootstrap:test --tests '*CleanArchitectureTest'
verifyCleanArchitectureDependencies
verifyOneTypePerFile
--no-daemon
--stacktrace
jpa-postgresql-contract:
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# 16 and 18 — the ends of the Stable matrix. 17 runs nightly.
postgresql: ["16", "18"]
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 platform against PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace
jpa-migration-smoke:
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 migration upgrade smoke scenarios
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
--no-daemon
--stacktrace
+2 -13
View File
@@ -26,25 +26,14 @@ 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 - uses: ./.github/actions/setup-gradle-java
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify the production-profile JPA R2 manifest DAG - name: Verify the production-profile JPA R2 manifest DAG
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
-PjpaEvidenceProfile=r2 -PjpaEvidenceProfile=r2
--no-daemon
--stacktrace --stacktrace
- name: Retain JPA R2 attempt manifests - name: Retain JPA R2 attempt manifests
if: always() if: always()
+18 -35
View File
@@ -1,8 +1,12 @@
name: jpa-release name: jpa-release
# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion # The release registry is the gate-task source: jpaReleaseQualification reads its blocking gates,
# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build # while JpaReleaseRenderingTest holds this file's matrix and promotion lists to the registry's Stable
# rather than quietly ceasing to be checked. # majors and verifyJpaReleaseGateTasks resolves each declared task against the real Gradle graph.
# CI therefore owns release scheduling, not a second JPA gate-task inventory.
#
# The matrix below is therefore not free to drift: editing it without editing the registry fails the
# unit lane.
on: on:
workflow_dispatch: workflow_dispatch:
@@ -38,25 +42,13 @@ jobs:
postgresql: ["16", "17", "18"] postgresql: ["16", "17", "18"]
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
id: gradle-wrapper-validation - name: Run the JPA database qualification set on PostgreSQL ${{ matrix.postgresql }}
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 full JPA release gate on PostgreSQL ${{ matrix.postgresql }}
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
jpaReleaseGate jpaReleaseQualification
-Pjpa.matrix.versions=${{ matrix.postgresql }} -Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace --stacktrace
- name: Record which major this evidence covers - name: Record which major this evidence covers
if: always() if: always()
@@ -67,7 +59,7 @@ jobs:
echo "sha=${{ github.sha }}" echo "sha=${{ github.sha }}"
echo "ref=${{ github.ref }}" echo "ref=${{ github.ref }}"
echo "postgresql-major=${{ matrix.postgresql }}" echo "postgresql-major=${{ matrix.postgresql }}"
echo "task=jpaReleaseGate" echo "task-set=jpa-database-qualification"
} > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties" } > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties"
- name: Upload the release evidence - name: Upload the release evidence
if: always() if: always()
@@ -121,25 +113,16 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify architecture boundaries and the support matrix - name: Verify architecture boundaries and the support matrix
working-directory: src working-directory: src
run: >- run: >-
./gradlew ./gradlew
verifyCleanArchitectureDependencies :verifyJpaReleaseGateTasks
verifyOneTypePerFile :verifyJpaReadinessRegistry
:app-bootstrap:test --tests '*CleanArchitectureTest' :verifyCleanArchitectureDependencies
checkstyleMain
:app-bootstrap:architectureTest
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest' :adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
--no-daemon
--stacktrace --stacktrace
@@ -0,0 +1,51 @@
# The messaging platform's broker certification lane.
#
# Separate from ci-quality-gates.yml because it needs a container runtime and several minutes of it.
# The lane deliberately carries no Docker guard: every other container suite in the messaging tree
# skips with a stated reason when Docker is absent, and a certification lane that skipped would
# report success for a broker nobody started — which is the exact claim the evidence exists to rule
# out.
#
# The job runs the evidence gate rather than the lane, and the gate depends on the lane. What it
# proves is not only that the scenarios pass but that the committed manifest
# (messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl) is what this
# run produced, so "certified against a live broker" cannot be restored by editing a file.
name: messaging-certification
on:
pull_request:
paths:
- "src/messaging/**"
- ".github/workflows/messaging-certification.yml"
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- ".github/actions/setup-gradle-java/action.yml"
schedule:
- cron: "41 4 * * 3"
workflow_dispatch:
permissions:
contents: read
env:
# Reuse would hand one scenario the broker another scenario had already faulted.
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
broker-certification:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Certify the Kafka adapter against a real broker
working-directory: src
# GITHUB_SHA is read by the lane and written into every evidence line, because "certified"
# is a claim about one source tree.
run: ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --stacktrace
- name: Publish the certification evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: messaging-broker-certification-evidence
path: src/messaging/messaging-kafka/build/messaging-certification/
if-no-files-found: warn
+29 -78
View File
@@ -31,6 +31,9 @@ on:
- 'docs/notification/**' - 'docs/notification/**'
- 'infra/notification/**' - 'infra/notification/**'
- '.github/workflows/notification-platform.yml' - '.github/workflows/notification-platform.yml'
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- '.github/actions/setup-gradle-java/action.yml'
push: push:
branches: [ main ] branches: [ main ]
schedule: schedule:
@@ -52,18 +55,7 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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: Compile and format check - name: Compile and format check
working-directory: src working-directory: src
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
@@ -89,19 +81,19 @@ jobs:
- name: Architecture gates - name: Architecture gates
working-directory: src working-directory: src
run: | run: |
./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew :verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain ./gradlew :app-bootstrap:architectureTest --console=plain
- name: Configuration surface - name: Configuration surface
working-directory: src working-directory: src
run: | run: |
./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain ./gradlew :app-bootstrap:verifyEnvKeys :verifyPublicPathSnapshot --console=plain
./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain ./gradlew :verifyNotificationApiSurface :verifyNotificationConfiguration --console=plain
# A support grade is a promise about production behaviour. This refuses one the pipeline # A support grade is a promise about production behaviour. This refuses one the pipeline
# cannot back — the check that would have caught five channels reading "Stable" while no # cannot back — the check that would have caught five channels reading "Stable" while no
# request had ever left the process. # request had ever left the process.
- name: Evidence manifest - name: Evidence manifest
working-directory: src working-directory: src
run: ./gradlew verifyNotificationEvidence --console=plain run: ./gradlew :verifyNotificationEvidence --console=plain
- name: Static analysis - name: Static analysis
working-directory: src working-directory: src
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
@@ -113,18 +105,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper - uses: ./.github/actions/setup-gradle-java
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
# This job is named for ambiguity, restart recovery and callback burst. It used to run a # This job is named for ambiguity, restart recovery and callback burst. It used to run a
# unit-test filter and then `test` — neither of which restarts anything or bursts anything — # unit-test filter and then `test` — neither of which restarts anything or bursts anything —
# so the job name was the only place those three properties existed. # so the job name was the only place those three properties existed.
@@ -137,9 +118,15 @@ jobs:
- name: Restart recovery and lease fencing (real PostgreSQL) - name: Restart recovery and lease fencing (real PostgreSQL)
working-directory: src working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
- name: Full suite - name: Notification regression suite
working-directory: src working-directory: src
run: ./gradlew test --console=plain run: >-
./gradlew
:application-core:test
:adapter:outbound:notification:test
:adapter:outbound:persistence-jpa:test
:adapter:inbound:web:test
--console=plain
# A filter that matches nothing passes. Each --tests filter above names a class that exists # A filter that matches nothing passes. Each --tests filter above names a class that exists
# today; if one is renamed the job must fail rather than quietly stop covering it. # today; if one is renamed the job must fail rather than quietly stop covering it.
- name: Every named suite actually ran - name: Every named suite actually ran
@@ -153,50 +140,14 @@ jobs:
fi fi
done done
provider-sandbox: # There is no provider-sandbox job. It ran only on workflow_dispatch and could not succeed by
name: provider sandbox smoke (secret-protected, non-blocking) # any path: with no credentials its first step exit 1-ed, and with credentials the only test it
if: github.event_name == 'workflow_dispatch' # ran was ProviderSandboxSmokeTest, whose body is an unconditional fail() saying a real sandbox
runs-on: ubuntu-latest # call is not implemented. Its credential check read secrets.NOTIFICATION_SANDBOX_CREDENTIALS,
timeout-minutes: 30 # which nothing in this repository consumes — the test reads NOTIFICATION_SANDBOX_ENABLED — so
environment: notification-provider-sandbox # any non-empty string satisfied it and was then dropped.
# Not a required check: an external outage must not block a merge. But not continue-on-error #
# either — a job that cannot fail produces no evidence, and this job's entire previous body was # The unimplemented state is still stated in two places that do not depend on a workflow:
# two echo statements, which is what let five channels be graded Stable on nothing. # ProviderSandboxSmokeTest itself, and the unsatisfied provider-wire-qualified claim in
steps: # docs/notification/evidence-manifest.json, which verifyNotificationEvidence enforces inside
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 # check. When a real sandbox call is implemented, the job comes back with it.
- 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: Refuse to report a pass with no credentials
env:
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: |
set -euo pipefail
if [ -z "${NOTIFICATION_SANDBOX_CREDENTIALS:-}" ]; then
echo "provider sandbox credentials are not configured for this environment." >&2
echo "The job stops here rather than reporting a green run that called nothing." >&2
exit 1
fi
- name: Smoke test against real provider sandboxes
working-directory: src
env:
NOTIFICATION_SANDBOX_ENABLED: 'true'
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: ./gradlew :adapter:outbound:notification:test --tests '*ProviderSandbox*' --console=plain
- name: Upload the wire evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: notification-provider-sandbox-evidence
path: src/adapter/outbound/notification/build/test-results/test/
if-no-files-found: error
retention-days: 90
@@ -11,6 +11,17 @@ on:
required: true required: true
default: false default: false
type: boolean type: boolean
# AwsS3DirectTransferQualificationTest requires a second, separate authority
# (OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED) before the direct-transfer lane may run, and
# the job never supplied it. objectStorageAwsQualificationTest is a strict qualification task
# that requires both of its classes, so the lane could not be run to a pass from any input:
# dispatching it always failed on the missing variable. The authority now exists as its own
# input rather than as a constant, which is what "separate" was supposed to mean.
run_protected_aws_direct_mutation:
description: Also authorize the direct-transfer mutation lane against the sandbox bucket
required: true
default: false
type: boolean
permissions: permissions:
contents: read contents: read
@@ -19,90 +30,41 @@ env:
TESTCONTAINERS_REUSE_ENABLE: "false" TESTCONTAINERS_REUSE_ENABLE: "false"
jobs: jobs:
poster-image-v7-migration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run non-skipping Poster image migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
minio-managed-contract: minio-managed-contract:
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 - uses: ./.github/actions/setup-gradle-java
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 exact-release MinIO managed contract - name: Run exact-release MinIO managed contract
working-directory: src working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --stacktrace
minio-managed-fault: minio-managed-fault:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
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 - uses: ./.github/actions/setup-gradle-java
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 digest-pinned MinIO and Toxiproxy fault contract - name: Run digest-pinned MinIO and Toxiproxy fault contract
working-directory: src working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --stacktrace
aws-managed-common-subset: aws-managed-common-subset:
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws if: >-
github.event_name == 'workflow_dispatch'
&& inputs.run_protected_aws
&& inputs.run_protected_aws_direct_mutation
environment: object-storage-aws-qualification environment: object-storage-aws-qualification
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true" OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED: ${{ inputs.run_protected_aws_direct_mutation }}
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }} OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }} OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
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 - uses: ./.github/actions/setup-gradle-java
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 protected AWS common-subset qualification - name: Run protected AWS common-subset qualification
working-directory: src working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --stacktrace
+289
View File
@@ -0,0 +1,289 @@
name: pr-adapters
# Stage 1, the adapter half: the lanes a pull request must clear that `ci-quality-gates.yml` cannot
# reach.
#
# It replaces web-pr.yml, websocket-pr.yml, httpclient-contract.yml and jpa-pr.yml, which were four
# files split by module rather than by stage. Splitting by module is what made the duplication
# invisible: each file opened with its own "unit and architecture" job running
# `:<leaf>:test verifyCleanArchitectureDependencies`, and all four of those were already inside the
# root `check` that ci-quality-gates.yml runs on every pull request with no path filter. Four jobs,
# four runners, four Gradle configurations, zero additional coverage. They are gone; what is left
# here is only what `check` does not run.
#
# What `check` does not run, and therefore what this file is for:
# * lanes with their own source set — a second servlet container, a real Nginx, Reactor Netty;
# * lanes selected by a tag that `test` excludes — the cross-stack parity recording comparison;
# * lanes parameterised per run — one PostgreSQL major per job, one HTTP transport per job.
# Each of those genuinely cannot run inside `check`, which is the test for whether a job belongs
# here at all.
#
# Path filtering is per job rather than per workflow. The four files it replaces each carried an
# `on.pull_request.paths` list, so the whole file was skipped or run as a unit; a change touching
# web and JPA started two workflows and a change touching neither still started none. Here one
# `changes` job computes the answer once from the pull request's own diff and every lane reads it.
# The filter is a plain `git diff` rather than a filter action: this repository pins every action by
# commit SHA and adding a third-party action to compute a boolean is a supply-chain decision, not a
# convenience.
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# One diff, read once. `workflow_dispatch` answers "everything changed", because a manual run is
# somebody asking for the lanes and there is no base ref to compare against.
changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
web: ${{ steps.filter.outputs.web }}
websocket: ${{ steps.filter.outputs.websocket }}
httpclient: ${{ steps.filter.outputs.httpclient }}
jpa: ${{ steps.filter.outputs.jpa }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
with:
# Both endpoints of the pull request's diff have to be present locally; the default
# shallow fetch has neither the base commit nor the merge base.
fetch-depth: 0
- name: Decide which adapter lanes this diff can affect
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" != 'pull_request' ]; then
changed='ALL'
else
if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
echo "::error::pull request diff endpoints are missing; refusing to report no lanes"
exit 1
fi
changed="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")"
fi
# Fail closed rather than reporting "nothing changed": an empty diff on a pull request
# means the comparison did not work, and a filter that answers false on a broken
# comparison silently turns every lane below off.
if [ "${changed}" != 'ALL' ] && [ -z "${changed}" ]; then
echo "::error::the pull request diff is empty; the comparison did not run"
exit 1
fi
printf 'changed files:\n%s\n' "${changed}"
emit() {
lane="$1"
shift
if [ "${changed}" = 'ALL' ]; then
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
printf 'lane %s: true (manual run)\n' "${lane}"
return 0
fi
for pattern in "$@"; do
if printf '%s\n' "${changed}" | grep -qE -- "${pattern}"; then
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
printf 'lane %s: true (%s)\n' "${lane}" "${pattern}"
return 0
fi
done
printf '%s=false\n' "${lane}" >> "${GITHUB_OUTPUT}"
printf 'lane %s: false\n' "${lane}"
}
# This workflow and the composite action every lane below uses are in every lane's path
# set: a change to either changes what the lanes do, and a gate that does not re-run when
# its own definition changes is a gate nobody has seen run in its current form.
common='^\.github/workflows/pr-adapters\.yml$|^\.github/actions/'
emit web \
'^src/adapter/inbound/web/' \
'^src/application-core/src/.*/operation/' \
'^src/application-core/src/.*/idempotency/' \
'^src/adapter/outbound/persistence-jpa/src/.*/operation/' \
'^docs/web/' \
"${common}"
emit websocket \
'^src/adapter/inbound/websocket/' \
'^docs/websocket/' \
"${common}"
emit httpclient \
'^src/adapter/outbound/httpclient/' \
'^src/app-bootstrap/src/.*/httpclient/' \
'^docs/httpclient/' \
'^scripts/verify-httpclient-docs\.py$' \
"${common}"
emit jpa \
'^src/adapter/outbound/persistence-jpa/' \
'^src/app-bootstrap/src/.*/jpa/' \
'^src/config/architecture/modules\.json$' \
'^docs/jpa/' \
'^docs/adr/ADR-JPA-' \
'^infra/jpa/' \
"${common}"
# The parity gate depends on all three recording lanes and fails when one is missing, so it runs
# them itself rather than trusting a previous job to have left the recordings behind. Its tag is
# excluded from `test`, which is why `check` cannot cover it.
web-cross-stack-parity:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Compare the wire contract across Tomcat, Jetty and Reactor Netty
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
--stacktrace
- name: Publish the parity recordings
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-contract-parity
path: src/adapter/inbound/web/build/web-contract-parity/
if-no-files-found: error
# Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy
# contract that quietly passes without a proxy has been certifying nothing since whenever the
# container runtime last broke.
web-nginx-proxy-contract:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the proxy, prefix and spoofing contract behind a real Nginx
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webNginxProxyTest
--stacktrace
websocket-container-matrix:
needs: changes
if: needs.changes.outputs.websocket == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the runtime contract on the second servlet container
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:websocketJettyTest
--stacktrace
# Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most
# common WebSocket deployment failure and it is invisible from either side alone.
websocket-nginx-contract:
needs: changes
if: needs.changes.outputs.websocket == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the upgrade and forwarded-header contract behind a real Nginx
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:websocketNginxTest
--stacktrace
# One transport per job, so a transport that stops satisfying the shared contract fails on its own
# row instead of disappearing into an aggregate run. `check` runs this lane once, unparameterised.
httpclient-stable-contract:
needs: changes
if: needs.changes.outputs.httpclient == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
transport: [apache, jdk, reactor]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Certify one transport against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientStableContractTest
-Phttpclient.contract.transports=${{ matrix.transport }}
--stacktrace
# Only the Spring 7.0 lane. httpClientSecurityTest, httpClientBlockHoundTest and
# spring62ApiSurfaceScan used to run here too; all three are `dependsOn` of this leaf's `check`
# (src/adapter/outbound/httpclient/build.gradle), so ci-quality-gates.yml already ran them on the
# same pull request. spring70CompatibilityTest is deliberately outside `check` and is what is left.
httpclient-security-and-compatibility:
needs: changes
if: needs.changes.outputs.httpclient == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the next-major Spring compatibility lane
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:spring70CompatibilityTest
--stacktrace
# 16 and 18 — the ends of the Stable matrix. 17 runs in the integration stage. What this does not
# do is skip the container lane on a runner without Docker: PostgreSqlContainerFactory throws,
# because a skipped contract reports success for a database nobody tested.
jpa-postgresql-contract:
needs: changes
if: needs.changes.outputs.jpa == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
postgresql: ["16", "18"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--stacktrace
jpa-migration-smoke:
needs: changes
if: needs.changes.outputs.jpa == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the migration upgrade smoke scenarios
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
--stacktrace
+16 -13
View File
@@ -34,6 +34,9 @@ on:
- "src/adapter/outbound/cache-redis/**" - "src/adapter/outbound/cache-redis/**"
- "infra/redis-sdk/**" - "infra/redis-sdk/**"
- ".github/workflows/redis-sdk-topology.yml" - ".github/workflows/redis-sdk-topology.yml"
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- ".github/actions/setup-gradle-java/action.yml"
schedule: schedule:
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers. # 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
- cron: "30 2 * * *" - cron: "30 2 * * *"
@@ -113,18 +116,7 @@ jobs:
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }} matrix: ${{ fromJson(needs.lanes.outputs.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 - uses: ./.github/actions/setup-gradle-java
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 - name: Start the topology
env: env:
REDIS_VERSION: ${{ matrix.redis_version }} REDIS_VERSION: ${{ matrix.redis_version }}
@@ -135,8 +127,19 @@ jobs:
set -euo pipefail set -euo pipefail
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be # The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
# reproduced once the tag moves. # reproduced once the tag moves.
#
# This used to end in `|| echo 'unresolved'`, which absorbed the failure that `set -e` was
# there to catch: the manifest below recorded `image_digest=unresolved`, the upload
# satisfied `if-no-files-found: error`, and the lane went green holding exactly the
# artifact this workflow's header calls "not evidence". Compose pulls the image in the
# step before this one, so RepoDigests is populated; if it is not, the run has nothing to
# certify and says so.
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \ digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
"redis:${{ matrix.redis_version }}" 2>/dev/null || echo 'unresolved')" "redis:${{ matrix.redis_version }}")"
if [[ -z "$digest" ]]; then
echo "::error::no repository digest for redis:${{ matrix.redis_version }}; this run cannot say which image produced its evidence"
exit 1
fi
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
- name: Run the topology contracts - name: Run the topology contracts
working-directory: src working-directory: src
+466
View File
@@ -0,0 +1,466 @@
name: release
# Stage 3: produce a deployable artifact.
#
# One workflow, because there is one deployable unit. `app-bootstrap` is the composition root and
# the only thing a cluster runs; the adapters are leaves of that artifact, not independently
# shippable services. Eight files used to answer a release tag — web-release, web-advanced-release,
# websocket-release, httpclient-release, container-release, and the three that still have to live
# apart (see below) — and between them they ran `verifyCleanArchitectureDependencies` six times and
# `:app-bootstrap:test` four times for one release, on separate runners, with no job in any of them
# able to wait on a job in another.
#
# Tag scheme: `v*` only. The adapter-scoped patterns (`web-v*`, `websocket-v*`, `fileserver-v*`) are
# gone. They were the namespace-split bug: tagging `v1.2.3` and tagging `web-v1.2.3` ran different
# sets of gates, so a release could choose which gate it cleared, and the adapter-scoped half could
# not build an image because there is no per-adapter image to build.
#
# Two release workflows still stand outside this file, both for a mechanical reason rather than a
# design one:
# * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL
# matrix and promotion list to src/config/jpa/release-registry.json.
# * fileserver-certification.yml — FileserverDocumentationCoverageTest requires every job id named
# in docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`.
# It is named "certification" rather than "release" on purpose: it certifies a storage topology
# and a support matrix, it deploys nothing, and the CI/CD boundary in docs/ci-cd/boundary.md
# says GitHub Actions does not deploy.
# Folding either one in needs its src-side test (and, for fileserver, the support document) changed
# in the same commit. Until then the image job below cannot wait on them — a stated gap.
#
# The image job DOES now wait on the evidence jobs in this file, which is new: while the image build
# lived in its own workflow it could publish while a sibling suite was still running or already red,
# because `needs:` does not reach across workflows.
on:
push:
tags:
- "v*"
permissions:
contents: read
# Never cancel a release in flight. A half-pushed manifest is worse than a slow one, and two runs
# for the same tag would race for the same registry tags.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# The architecture-wide verification, once. Each of the four release workflows this file replaces
# ran `verifyCleanArchitectureDependencies` on its own runner, and three of them also ran the
# bootstrap architecture suite; the answers were identical because the input was one commit.
architecture-and-surface:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Verify architecture boundaries and the published surfaces
working-directory: src
run: >-
./gradlew
:verifyCleanArchitectureDependencies
:verifyPublicPathSnapshot
:app-bootstrap:verifyEnvKeys
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
--stacktrace
# Every web lane that `check` cannot reach. webCrossStackParityTest depends on `test`,
# webJettyCompatTest and webFluxContractTest, so naming it runs all four — which is what
# web-advanced-release.yml spent a separate 90-minute job doing by naming the three by hand.
#
# webAdvancedTest is here rather than in a nightly of its own. Its tests run inside
# `:adapter:inbound:web:test` by design, so the lane adds exactly one thing: it fails closed when
# the `web-advanced` tag selects nothing. That is worth asserting at a release and is not worth a
# workflow file and a runner every night.
web-stable-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run every web lane, Stable and Advanced
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
:adapter:inbound:web:webNginxProxyTest
:adapter:inbound:web:webAdvancedTest
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-release-evidence
path: |
src/adapter/inbound/web/build/web-contract-parity/
src/adapter/inbound/web/build/reports/tests/
if-no-files-found: error
websocket-stable-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run every websocket lane, Stable and Advanced
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:test
:adapter:inbound:websocket:websocketJettyTest
:adapter:inbound:websocket:websocketNginxTest
:adapter:inbound:websocket:websocketTransportQualificationTest
:adapter:inbound:websocket:websocketAdvancedTest
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: websocket-release-evidence
path: src/adapter/inbound/websocket/build/reports/tests/
if-no-files-found: error
# The three gRPC certification lanes. Their tests already run on every pull request — the
# `grpc-inprocess`, `grpc-netty` and `grpc-fault` tags are NOT excluded from
# `:grpc:grpc-testkit:test` (only `grpc-performance` is), and that task runs inside the root
# `check`. So this job adds exactly what the web and WebSocket Advanced lanes above add: the lane
# fails closed when its tag selects nothing, which is the one thing a tag-filtered suite inside
# `test` cannot tell you. A renamed or deleted @Tag would otherwise leave the in-process,
# transport and fault evidence grades claiming coverage that stopped existing.
#
# Release rather than nightly, for the same reason web-stable-release-gate is: these lanes need no
# container and no fixed cadence — grpcNettyContractTest opens an ephemeral socket, not a broker —
# so the guard is worth asserting once per tag and is not worth a runner every night.
#
# grpcPerformanceTest is deliberately absent. The leaf excludes it from `test` and says why: "a
# measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks
# for it, by name." It is recorded as a manual entrypoint in the repository README instead.
grpc-stable-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the gRPC Stable certification lanes
working-directory: src
run: >-
./gradlew -p optional-platforms
:grpc:grpc-testkit:grpcInProcessContractTest
:grpc:grpc-testkit:grpcNettyContractTest
:grpc:grpc-testkit:grpcFaultTest
--stacktrace
- name: Publish the gRPC release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: grpc-release-evidence
path: src/grpc/grpc-testkit/build/reports/tests/
if-no-files-found: warn
# Each gate runs as its own single-line `./gradlew <task>` step so that a failure names the gate
# rather than a folded command. The architecture dependency gate that used to end this list is now
# architecture-and-surface above; it was the fourth copy of the same invocation.
httpclient-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the HTTP client release qualification graph
run: >-
./gradlew
:adapter:outbound:httpclient:test
:adapter:outbound:httpclient:spring62ApiSurfaceScan
:adapter:outbound:httpclient:spring70CompatibilityTest
:adapter:outbound:httpclient:httpClientStableContractTest
:adapter:outbound:httpclient:httpClientSecurityTest
:adapter:outbound:httpclient:httpClientBlockHoundTest
:adapter:outbound:httpclient:httpClientFailureInjectionTest
:adapter:outbound:httpclient:httpClientPerformanceTest
--stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
app-image-release:
needs:
- architecture-and-surface
- web-stable-release-gate
- websocket-stable-release-gate
- grpc-stable-release-gate
- httpclient-release-gate
- httpclient-documentation
# Job-level, because a job that declares `permissions:` replaces the workflow set entirely: this
# is the only job that writes anything anywhere, and `packages: write` stops at its boundary.
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
# The builder stage inside src/Dockerfile runs this repository's Gradle wrapper to produce the
# JAR that becomes the image. Validating the wrapper here checks the thing that is about to
# execute, before it executes, rather than after an image already exists.
# The tag is the release identity; everything below derives from it. A tag that does not parse
# stops the release here, rather than producing an image named after whatever ref happened to
# be checked out.
#
# GHCR rejects an uppercase path, and this repository's owner is mixed case — the naive
# `ghcr.io/${{ github.repository }}` fails at push time with a message about the manifest
# rather than about the case, so the lowercasing is explicit and the result is asserted.
- name: Resolve the release coordinates
env:
CONFIGURED_IMAGE_NAME: ${{ vars.APP_IMAGE_NAME }}
run: |
set -euo pipefail
readonly REGISTRY='ghcr.io'
if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then
echo "::error::container-release runs only for a release tag; ref type was ${GITHUB_REF_TYPE}"
exit 1
fi
release_tag="${GITHUB_REF_NAME}"
# Bare MAJOR.MINOR.PATCH, because src/build.gradle's release-version guard refuses a
# pre-release or build suffix and the image tag must be the same string the JAR reports.
if [[ ! "${release_tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::release tag must be vMAJOR.MINOR.PATCH with no suffix; got '${release_tag}'"
exit 1
fi
release_version="${BASH_REMATCH[1]}"
owner_path="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
image_name="${CONFIGURED_IMAGE_NAME:-${owner_path}/caskeleton}"
image_repository="${REGISTRY}/${image_name}"
if [[ "${image_repository}" != "${image_repository,,}" ]]; then
echo "::error::image repository must be lowercase; got '${image_repository}'"
exit 1
fi
if [[ "${image_repository}" =~ [[:space:]] || "${image_repository}" == *:* ]]; then
echo "::error::image repository must carry no tag and no whitespace; got '${image_repository}'"
exit 1
fi
{
printf 'REGISTRY=%s\n' "${REGISTRY}"
printf 'RELEASE_VERSION=%s\n' "${release_version}"
printf 'BUILD_VERSION=%s+%s\n' "${release_version}" "${GITHUB_SHA}"
printf 'IMAGE_REPOSITORY=%s\n' "${image_repository}"
printf 'IMAGE_VERSION_TAG=%s\n' "${release_version}"
printf 'IMAGE_REVISION_TAG=sha-%s\n' "${GITHUB_SHA}"
printf 'SOURCE_URL=%s/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}"
} >> "${GITHUB_ENV}"
printf 'container-release: %s -> %s:%s and %s:sha-%s\n' \
"${release_tag}" "${image_repository}" "${release_version}" \
"${image_repository}" "${GITHUB_SHA}"
# Byte-identical to the install in dependency-vulnerability.yml, deliberately: the same
# checksum-pinned binary at the same version scans the filesystem and the image, so the two
# gates cannot disagree because one of them silently moved to a newer database schema.
#
# This repository installs its scanner rather than calling a scanner action, which is why no
# third-party action appears in this workflow: a pinned tarball with an asserted SHA-256 is a
# supply-chain claim that can be checked offline, and an action pinned to a commit is not.
- name: Install pinned Trivy under RUNNER_TEMP
env:
TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }}
run: |
set -euo pipefail
readonly TRIVY_VERSION='0.71.2'
readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460'
readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc'
readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}"
case "${RUNNER_ARCH:-X64}" in
X64)
asset_arch='64bit'
expected_sha256="${TRIVY_SHA256_AMD64}"
;;
ARM64)
asset_arch='ARM64'
expected_sha256="${TRIVY_SHA256_ARM64}"
;;
*)
echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}"
exit 1
;;
esac
install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}"
archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz"
mkdir -p "${install_dir}"
curl --fail --show-error --silent --location --retry 3 \
--proto '=https' --tlsv1.2 \
"${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \
--output "${archive}"
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c -
tar -xzf "${archive}" -C "${install_dir}" trivy
chmod 0755 "${install_dir}/trivy"
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
# SOURCE_DATE_EPOCH is the commit time, not the wall clock, so the image metadata is a function
# of the commit rather than of when the runner happened to pick the job up. Verified locally,
# and worth stating exactly because it is easy to overclaim: BuildKit uses it for the image
# config `created` field and for every history timestamp — both came back as the commit time —
# and it does NOT rewrite file mtimes inside the layers. Those still carry the build time, so
# two builds of the same commit agree on metadata but their layer digests still differ.
# Byte-identical layers additionally need `--output type=image,rewrite-timestamp=true`, which
# needs the containerd image store; that is a runner-capability change, not a flag to add
# untested to the one job that publishes releases.
#
# The OCI `created` label comes from the same commit for the same reason: `date -u` there would
# have made every rebuild a different image for no reason anybody could see.
#
# Both base images are already digest-pinned inside src/Dockerfile, and so is the Dockerfile
# frontend in its `# syntax` directive, so nothing in this build resolves a floating tag.
- name: Build the release image
run: |
set -euo pipefail
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
export SOURCE_DATE_EPOCH
created="$(git log -1 --format=%cI)"
printf 'SOURCE_DATE_EPOCH=%s (%s)\n' "${SOURCE_DATE_EPOCH}" "${created}"
DOCKER_BUILDKIT=1 docker build \
--file src/Dockerfile \
--tag "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
--tag "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" \
--build-arg RELEASE_VERSION="${RELEASE_VERSION}" \
--build-arg BUILD_VERSION="${BUILD_VERSION}" \
--build-arg GIT_SHA="${GITHUB_SHA}" \
--build-arg SOURCE_URL="${SOURCE_URL}" \
--label org.opencontainers.image.created="${created}" \
src
docker image inspect \
--format 'built {{.Id}} ({{.Size}} bytes, {{len .RootFS.Layers}} layers)' \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
# Generated before the blocking scan, and uploaded before it too, so the inventory of what is
# in the image survives the run that refuses to publish it. An SBOM you only get on a green
# build is an SBOM you cannot use to answer "what was in the one that failed".
- name: Generate the image SBOM
run: |
set -euo pipefail
trivy image \
--format cyclonedx \
--scanners license \
--output image-sbom.cdx.json \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
test -s image-sbom.cdx.json
- name: Upload the image SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: container-release-sbom
path: image-sbom.cdx.json
if-no-files-found: error
retention-days: 90
# The same policy dependency-vulnerability.yml applies to the filesystem, applied to the thing
# that actually ships: CRITICAL and HIGH block, everything else is reported. The filesystem
# scan cannot see the base image's OS packages, which is most of an image's attack surface, so
# a green trivy-fs has never been evidence about the artifact.
#
# --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression
# source. Each entry carries a rationale and an expiry by policy, reviewed through CODEOWNERS
# (.github/dependency-vulnerability-policy.md); an inline --skip or a second ignore file would
# be a suppression nobody reviews.
#
# This step is the reason `docker push` is further down. A vulnerable image that was pushed and
# then reported is already pullable by everything that watches the tag.
- name: Block High and Critical vulnerabilities in the release image
run: |
set -euo pipefail
trivy image \
--scanners vuln,license \
--severity CRITICAL,HIGH \
--exit-code 1 \
--ignorefile .trivyignore.yaml \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
- name: Report Medium and Low vulnerabilities in the release image
run: |
set -euo pipefail
trivy image \
--scanners vuln,license \
--severity MEDIUM,LOW \
--exit-code 0 \
--ignorefile .trivyignore.yaml \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
- name: Sign in to the container registry
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
printf '%s' "${REGISTRY_TOKEN}" \
| docker login "${REGISTRY}" --username "${GITHUB_ACTOR}" --password-stdin
# Two tags, one digest. The semver tag is what a human reads and what a release note cites; the
# sha- tag is the one that can never be moved to different content, because the git SHA it
# names is the only commit that can produce it.
#
# Neither is what a manifest should pin. Both are mutable names in a registry: a later push can
# point `1.2.3` at something else, and nothing about a tag tells a cluster it did not. The
# digest recorded below is immutable by construction, and it is the field the GitOps repository
# pins — the tags exist so a person can find the digest, not so a cluster can resolve one.
- name: Push the release and revision tags
run: |
set -euo pipefail
docker push "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
docker push "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}"
# awk rather than `grep | head`, deliberately. Under `set -e` with `pipefail`, a grep that
# matches nothing exits 1 and kills the step right here — so the explicit check below,
# and its message, would never run and the failure would surface as a bare exit code.
# awk exits 0 whether or not it matched, which leaves the empty case for us to report.
pinned_reference="$(
docker image inspect \
--format '{{range .RepoDigests}}{{println .}}{{end}}' \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
| awk -v prefix="${IMAGE_REPOSITORY}@sha256:" \
'index($0, prefix) == 1 { print; exit }'
)"
if [[ -z "${pinned_reference}" ]]; then
echo "::error::no registry digest for ${IMAGE_REPOSITORY} after push"
exit 1
fi
printf 'PINNED_REFERENCE=%s\n' "${pinned_reference}" >> "${GITHUB_ENV}"
printf 'container-release: pushed %s\n' "${pinned_reference}"
# The handoff to the GitOps repository, in a form a person and a script can both read. It is
# written to the job summary as well as to an artifact because the summary is where somebody
# looks first and the artifact is what survives the ninety days a release audit asks about.
- name: Record the immutable image reference
run: |
set -euo pipefail
digest="${PINNED_REFERENCE#*@}"
{
printf 'release_tag: %s\n' "${GITHUB_REF_NAME}"
printf 'git_sha: %s\n' "${GITHUB_SHA}"
printf 'image_repository: %s\n' "${IMAGE_REPOSITORY}"
printf 'version_tag: %s\n' "${IMAGE_VERSION_TAG}"
printf 'revision_tag: %s\n' "${IMAGE_REVISION_TAG}"
printf 'digest: %s\n' "${digest}"
printf 'pinned_reference: %s\n' "${PINNED_REFERENCE}"
} > image-release.txt
{
printf '### container-release\n\n'
printf 'Pin this in the GitOps manifest as the container image:\n\n'
printf '```\n%s\n```\n\n' "${PINNED_REFERENCE}"
printf -- '- release tag: `%s`\n' "${GITHUB_REF_NAME}"
printf -- '- version tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_VERSION_TAG}"
printf -- '- revision tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_REVISION_TAG}"
} >> "${GITHUB_STEP_SUMMARY}"
cat image-release.txt
- name: Upload the immutable image reference
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: container-release-image-reference
path: image-release.txt
if-no-files-found: error
retention-days: 90
+9
View File
@@ -1,3 +1,12 @@
.vscode/ .vscode/
src/**/bin/ src/**/bin/
.claude/ .claude/
# Operator input, not a build input. The examples beside it are the tracked contract;
# verifyEnvKeys reads the registry, the profile YAMLs and .env.example, never a real one.
src/.env*
!src/.env.example
!src/.env.local.example
# Written per run by the runtime-smoke wrapper; never committed.
src/.env.lane
+15 -7
View File
@@ -1,13 +1,21 @@
# Structured Trivy suppression baseline. # Structured Trivy suppression baseline.
# #
# This repository-root file is the only CI suppression source. Every future entry must include: # This repository-root file is the only CI suppression source. Every Trivy invocation must name it
# id: advisory, license, misconfiguration, or secret identifier # with `--ignorefile .trivyignore.yaml`; ad-hoc ignore files and inline bypasses are not allowed.
# statement: non-empty accepted-risk or false-positive rationale
# expired_at: future YYYY-MM-DD no more than 90 days from review
# #
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate # Every entry must carry:
# reviewer control. Every Trivy invocation must also name this file with # id: advisory, license, misconfiguration, or secret identifier
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses. # statement: non-empty accepted-risk or false-positive rationale
# expired_at: future YYYY-MM-DD, no more than 90 days from review
#
# Enforced by review, not by a build task. `verifyTrivyignore` used to be a 105-line hand-written
# YAML parser in the root build — indentation tracking, inline-scalar handling, quote stripping — and
# what it guarded was this file, which has been empty since it was created. A suppression is added by
# a human and merged by a CODEOWNERS reviewer (.github/dependency-vulnerability-policy.md); that
# reviewer is the control, and a parser that has never seen an entry is not a second one.
#
# If this file ever carries entries and they start drifting, that is the moment to automate the
# check — against real entries, with a real YAML library. Not before.
vulnerabilities: [] vulnerabilities: []
licenses: [] licenses: []
+21 -9
View File
@@ -52,10 +52,19 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로, - `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 — membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
산문에 적힌 숫자는 leaf가 추가되는 순간 drift한다. `verifyDocumentedLeafCount` 강제한다. 산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 강제하는 태스크는 없다:
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping `verifyDocumentedLeafCount`는 삭제됐다. 문서에 적힌 수가 틀린 것은 결함이지만 빌드를 실패시킬
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의 사유는 아니고, 그 태스크는 모든 `CLAUDE.md``build.gradle`을 정규식으로 훑는 파서였다.
architecture-wide verification task - `src/settings.gradle`: 16줄. `ca.architecture-registry` 설정 플러그인이 registry를 읽어
project를 include/mapping 한다. registry가 project 목록이 될 수 없는 경우(중복 ID, 저장소 밖
경로, 없는 디렉터리)만 여기서 실패한다. 허용되지 않는 edge 같은 아키텍처 규칙은
`verifyCleanArchitectureDependencies`가 답한다 — settings에서 죽으면 실행할 수 있는 태스크가
하나도 없다.
- `src/build-logic/`: convention plugin. leaf는 `ca.java-library` / `ca.spring-library` /
`ca.platform-module` 중 자기 성격을 선언하고, 그 플러그인이 toolchain·락·정적분석·테스트
기본값을 준다. `ca.architecture`가 아키텍처 검증 태스크를 소유한다.
- `src/build.gradle`: 루트 라이프사이클(`ci`, `releaseCheck`, `qualityCheck`,
`configContractCheck`, `integrationCheck`)과 버전/리비전
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md` 작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`
함께 읽는다. focused test는 registry의 `gradle_path`에서 함께 읽는다. focused test는 registry의 `gradle_path`에서
@@ -174,14 +183,17 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
```bash ```bash
cd src cd src
./gradlew <owner-gradle-path>:test --console=plain ./gradlew <owner-gradle-path>:check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne
./gradlew test ./gradlew check # 모든 leaf의 check
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle) ./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성
./gradlew verifyCleanArchitectureDependencies ./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다)
./gradlew ci # PR 게이트 = 위 셋 + configContractCheck
./gradlew verifyPublicPathSnapshot ./gradlew verifyPublicPathSnapshot
./gradlew verifyEnvKeys ./gradlew :app-bootstrap:verifyEnvKeys
``` ```
leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다.
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test 소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다. 명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
+27 -8
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template. Repository guidance for the Java 21 + Spring Boot 4.0.8 Clean Architecture template.
## Prime Directive ## Prime Directive
@@ -45,8 +45,9 @@ count.
## Module families ## Module families
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes `src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules. `verifyDocumentedLeafCount` fails the families; the nearest `src/**/CLAUDE.md` owns local rules. No task enforces this any more:
build when a policy document states a leaf count that the registry does not agree with. `verifyDocumentedLeafCount` was deleted along with the other documentation-drift parsers. A stated
count that disagrees with the registry is a defect, not a build failure — so do not state one.
| Family | Responsibility | Stable dependency direction | | Family | Responsibility | Stable dependency direction |
| --- | --- | --- | | --- | --- | --- |
@@ -56,9 +57,20 @@ build when a policy document states a leaf count that the registry does not agre
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered | | `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge | | `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only | | `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
| `messaging:*` | Vendored messaging platform: a product with its own API, SPI, adapters and composition boundary, not a layer of this application | `messaging:*` only — it depends on no `domain-core`, `application-core`, or `shared-contract` type |
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency | | `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | | `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
The `messaging:*` family is the one entry that is not a Clean Architecture layer, and it is listed so
that the exception is stated rather than inferred from a directory. It is a vendored library — its
own `*-api` leaves are its ports, its broker leaves are its adapters, its starter is its composition
root — and the messaging module review (`docs/reviews/2026-08-14-messaging-module-code-review.md`
MSG-023 §6.2) chose that layout deliberately over folding it into `adapter:outbound:*`. This
application is supposed to reach it the way it reaches any library: through an application-owned port
satisfied by an anti-corruption bridge in `adapter:outbound:messaging`. That bridge does not exist
yet (MSG-015), so today the composition root wires the starter directly; `src/messaging/CLAUDE.md`
holds the detail.
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` 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.
@@ -94,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from
Architecture-wide commands: Architecture-wide commands:
```bash ```bash
./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew architectureCheck --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain ./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain
./gradlew verifyPublicPathSnapshot --console=plain ./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain ./gradlew :app-bootstrap:verifyEnvKeys --console=plain
``` ```
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone.
the controller's workflow authorization. Repository-wide questions have their own names: `architectureCheck` (dependency direction, runtime
membership, application-core purity, Git-carryable sources), `qualityCheck` (SpotBugs, FindSecBugs),
`configContractCheck` (the environment contract), `integrationCheck` (the declared strict test
lanes). `ci` is check + architectureCheck + qualityCheck + configContractCheck; `releaseCheck` adds
provenance, archive hygiene and the public-path snapshot.
Use public-path and env-key checks only when their surfaces changed. Full `test`, `check` or `ci`
requires the controller's workflow authorization.
## Advisory and reporting ## Advisory and reporting
+37 -2
View File
@@ -90,14 +90,20 @@ cd src
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다. 3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
4. 모듈 이름과 경계는 그대로 유지합니다. 4. 모듈 이름과 경계는 그대로 유지합니다.
검증은 sample-on과 sample-off를 모두 통과시킵니다. 검증은 먼저 composition root의 빠른 테스트와 sample-off 계약을 확인합니다. 루트에서
`./gradlew test`를 호출하면 등록된 모든 하위 프로젝트의 `test`를 실행하므로 일상적인 로컬
피드백 명령으로 사용하지 않습니다. 저장소 전체 qualification은 CI 또는 명시적인 `ci` task가
담당합니다.
```bash ```bash
cd src cd src
./gradlew test ./gradlew :app-bootstrap:test
./gradlew :app-bootstrap:sampleOffTest ./gradlew :app-bootstrap:sampleOffTest
./gradlew architectureCheck
``` ```
병합 전 저장소 전체 검증이 필요하면 `./gradlew ci`를 실행합니다.
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다. `sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
## 아키텍처 규칙과 검증 ## 아키텍처 규칙과 검증
@@ -122,6 +128,35 @@ cd src
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다. 두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
## 수동 전용 Gradle 태스크
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
적어 둔다.
여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의
```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는
Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의
함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다.
```bash
cd src
./gradlew :grpc:grpc-testkit:grpcPerformanceTest
./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange
./gradlew :app-bootstrap:sampleOffCompile
```
- `grpcPerformanceTest` — latency percentile·saturation·drain budget을 **측정**한다. 공유 CI
runner의 측정값은 흔들리고, 흔들리는 게이트는 결국 꺼진다. leaf `build.gradle`이 이 태스크의
태그를 `test`에서 제외하는 이유도 같다. 성능 회귀가 의심될 때 사람이 이름으로 부른다.
- `openapiCheckSnapshot` — 드리프트 검사 자체는 이미 자동으로 돈다. 이 태스크가 감싸는
`OpenApiDriftContractTest`는 `:sample-portfolio:test`의 일부이고, 그건 `check` 안이며 stage 1에서
실행된다. 이 태스크의 고유한 역할은 `-PapproveOpenApiChange`로 **커밋된 스냅샷을 다시 만드는 것**
— 의도된 API 변경을 사람이 승인하는 지점이다. 자동으로 돌리면 승인이 승인이 아니게 된다.
- `sampleOffCompile` — `sampleOffTest` 소스셋을 **컴파일만** 한다. CI가 돌리는
`:app-bootstrap:sampleOffTest`(stage 1, `ci-quality-gates.yml`의 `sample-off` 잡)는 같은 소스셋을
컴파일한 뒤 실행까지 하므로, CI에 따로 넣으면 진부분집합을 한 번 더 도는 것이다. 남겨 둔 이유는
sample 제거 작업 중 테스트를 기다리지 않고 컴파일만 빠르게 확인하는 로컬 루프가 실재하기 때문이다.
## 더 알아보기 ## 더 알아보기
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md) - 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
+41 -1
View File
@@ -18,11 +18,30 @@ services:
app: app:
# Relax read-only constraint for local development. # Relax read-only constraint for local development.
read_only: false read_only: false
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs # `!override`, not a plain empty list. An empty sequence merges with the base sequence rather
# than replacing it, so the base's /var/tmp/heap tmpfs survived and collided with the bind mount
# below — Compose refuses to have the same target twice and will not silently pick one. That is
# the right refusal: a heap dump written into a tmpfs dies with the container that produced it,
# which is the one moment somebody wants the file.
#
# `!override` needs Compose >= 2.24.4. Whether the collision is actually gone is checked in the
# merged model rather than assumed from this line.
tmpfs: !override []
# More memory for dev profiling / heap dumps. # More memory for dev profiling / heap dumps.
mem_limit: 1g mem_limit: 1g
memswap_limit: 1g memswap_limit: 1g
environment: environment:
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
# environment the application believes it is in, and the two drifting is how a dev stack ends
# up running local's settings.
SPRING_PROFILES_ACTIVE: "dev"
# The datasource address, owned here like the local and prod-smoke overlays own theirs. It was
# the only one of the three missing, and the gap was invisible while the qualification wrapper
# supplied a URL to every lane: the dev stack ran on a value that came from the test harness
# rather than from the file that describes the dev environment. With the wrapper no longer
# setting it — it was overriding prod's sslmode=verify-full URL — dev had none at all and
# Flyway was handed the literal string "${APP_DATASOURCE_URL}".
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
TZ: "UTC" TZ: "UTC"
LANG: "C.UTF-8" LANG: "C.UTF-8"
LC_ALL: "C.UTF-8" LC_ALL: "C.UTF-8"
@@ -53,9 +72,30 @@ services:
# Do not restart automatically so crash loops stay visible. # Do not restart automatically so crash loops stay visible.
restart: "no" restart: "no"
# Optional: mount heap dump directory to host for dev analysis. # Optional: mount heap dump directory to host for dev analysis.
# The base declares /var/tmp/heap as a tmpfs, which is right for an ephemeral runtime and wrong
# for dev: a heap dump written into a tmpfs dies with the container that produced it, which is
# the one moment somebody wants the file. Compose refuses to have both, and correctly — it will
# not silently pick one — so the tmpfs list is replaced rather than appended to.
#
# `!override` needs Compose >= 2.24.4. An empty sequence is not assumed to delete the base
# sequence by itself; scripts/verify-compose-profile-contracts.sh checks mount-target uniqueness
# in the merged model, which is what actually proves the collision is gone.
volumes: volumes:
- type: bind - type: bind
source: ./tmp/heap-dumps source: ./tmp/heap-dumps
target: /var/tmp/heap target: /var/tmp/heap
bind: bind:
create_host_path: true create_host_path: true
# The same network the shared infrastructure lives on. The local overlay joins it and the dev
# overlay did not, so a dev lane that started PostgreSQL beside the application put the two on
# different networks: `UnknownHostException: db`, from a container that was running and healthy
# a metre away. Compose puts a service with no `networks:` on `default`, which is a network of
# its own making — so the omission reads as a working stack until something has to resolve a
# name across it.
networks:
- caskeleton-infra
networks:
# Defined in docker-compose.infra.yml, where the services that share it live.
caskeleton-infra:
external: false
+503
View File
@@ -0,0 +1,503 @@
# =============================================================================
# Shared infrastructure, owned here and nowhere else.
#
# Environment overlays (local, dev, prod-smoke) describe how the application runs. This file
# describes what it runs against. Keeping the two apart is why `local` could stop meaning "the app
# plus a database" and start meaning "the app, with whichever services the lane asked for".
#
# Every service carries a Compose profile, so nothing here starts unless a lane names it. A profile
# selects services; it never implies a Spring profile. The lane definitions live in
# src/config/runtime/compose-profile-contracts.json, and scripts/verify-compose-profile-contracts.sh
# checks this file against them.
# =============================================================================
services:
# ---- PostgreSQL --------------------------------------------------------------
db:
profiles:
- local-jpa
- local-messaging-outbox
- local-notification-ingest
- local-notification-serving
- local-notification-handoff
- shared-infra
- prod-smoke
- all-adapters
image: postgres:16-alpine
environment:
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
TZ: "UTC"
volumes:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
ports:
- "127.0.0.1:5433:5432"
networks:
- caskeleton-infra
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ---- MongoDB -----------------------------------------------------------------
# A replica set of one. Single-node is still a replica set: transactions and change streams need
# one, and a standalone mongod that "works for reads" is a deployment that discovers the
# difference at the first transaction.
mongo:
profiles:
- local-mongo
- all-adapters
image: mongo:7
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
volumes:
- type: volume
source: caskeleton-mongo-data
target: /data/db
networks:
- caskeleton-infra
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
mongo-rs-init:
profiles:
- local-mongo
- all-adapters
image: mongo:7
depends_on:
mongo:
condition: service_healthy
# Idempotent: rs.initiate() on an already-initiated set returns an error this swallows, so the
# lane can be re-run against a surviving volume without a manual reset.
command:
- mongosh
- --host
- mongo
- --quiet
- --eval
- >-
try { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }
catch (e) { if (!/already initialized/i.test(e.message)) { throw e } }
networks:
- caskeleton-infra
restart: "no"
# ---- Kafka -------------------------------------------------------------------
kafka:
profiles:
- local-messaging
- local-messaging-outbox
- all-adapters
image: apache/kafka:3.8.0
environment:
KAFKA_NODE_ID: "1"
KAFKA_PROCESS_ROLES: "broker,controller"
KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092"
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1"
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1"
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1"
networks:
- caskeleton-infra
healthcheck:
test:
["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:9092"]
interval: 10s
timeout: 10s
retries: 12
start_period: 30s
# ---- Mailpit — the reference SMTP provider for notification serving ----------
mailpit:
profiles:
- local-notification-serving
- local-notification-handoff
- all-adapters
image: axllent/mailpit:v1.21
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
# MP_SMTP_AUTH_ALLOW_INSECURE is deliberately absent, and Mailpit refuses to start with both:
# "TLS cannot be required with --smtp-auth-allow-insecure". It existed to permit credentials
# over a plaintext connection, which is exactly what requiring STARTTLS removes the need for —
# any AUTH now happens inside the TLS session.
# STARTTLS, required. Not a hardening extra: SmtpProviderProperties.TlsMode has two members and
# neither is plaintext, so the platform cannot describe an unencrypted relay at all. A lane that
# wanted a plaintext Mailpit would be asking for a transport the type refuses to express, and
# the honest way to satisfy it is to give the relay a certificate.
MP_SMTP_TLS_CERT: /run/mailpit-tls/server.crt
MP_SMTP_TLS_KEY: /run/mailpit-tls/server.key
MP_SMTP_REQUIRE_STARTTLS: "true"
volumes:
# Generated per run by the qualification wrapper for the host name `mailpit`, and removed on
# teardown, exactly like the PostgreSQL lane certificate. A committed test certificate is a
# private key in Git.
- type: bind
source: ./infra/mailpit/tls
target: /run/mailpit-tls
read_only: true
networks:
- caskeleton-infra
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
# ---- MinIO -------------------------------------------------------------------
minio:
profiles:
- shared-infra
- prod-smoke
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
command: ["server", "/data"]
environment:
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
volumes:
- type: volume
source: caskeleton-minio-data
target: /data
networks:
- caskeleton-infra
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
# Bucket and policy bootstrap. Not a substitute for the round trip: creating a bucket proves the
# server accepts an admin command, not that an object survives being written and read back.
minio-init:
profiles:
- shared-infra
- prod-smoke
image: minio/mc:RELEASE.2024-09-16T17-43-14Z
depends_on:
minio:
condition: service_healthy
entrypoint: ["/bin/sh", "/opt/minio/bucket-bootstrap.sh"]
environment:
MINIO_ENDPOINT: "http://minio:9000"
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}"
volumes:
- type: bind
source: ./infra/minio/init
target: /opt/minio
read_only: true
networks:
- caskeleton-infra
restart: "no"
# ---- Keycloak ----------------------------------------------------------------
keycloak:
profiles:
- local-graphql
- local-notification-ingest
- local-notification-serving
- local-notification-handoff
- shared-infra
- prod-smoke
- all-adapters
image: quay.io/keycloak/keycloak:26.0
# The wrapper reads the client secret from a mounted file and execs kc.sh. The realm artifact
# carries only a ${...} reference, so no secret value is in Git, in the rendered config, or on a
# command line.
entrypoint: ["/bin/bash", "/opt/keycloak-entrypoint/entrypoint.sh"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: "${KEYCLOAK_ADMIN:-admin}"
KC_BOOTSTRAP_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}"
KC_HEALTH_ENABLED: "true"
volumes:
- type: bind
source: ./infra/keycloak/entrypoint.sh
target: /opt/keycloak-entrypoint/entrypoint.sh
read_only: true
- type: bind
source: ./infra/keycloak/realms
target: /opt/keycloak/data/import
read_only: true
secrets:
- keycloak-graphql-smoke-client-secret
networks:
- caskeleton-infra
healthcheck:
test:
- "CMD-SHELL"
- "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
# ---- Capability schema streams ------------------------------------------------
# Two pre-start one-shots, in this order, because a capability stream is an operator sequence
# rather than a property.
#
# Install: each stream under db/migration/jpa keeps its own Flyway history table — they all declare
# a V1, so one Flyway pointed at all of them fails outright — and each registers itself
# INSTALLED_INACTIVE.
#
# Promote: an operator sanctions the installed schema, and the application refuses to start until
# that has happened. That is the fail-closed half of the same design, so it cannot be folded into
# the install step without making "the tables exist" and "this is sanctioned" the same event.
#
# They are also two images because they must be: flyway/flyway ships no psql, so the promotion
# could not have run in the migration container at all.
#
# Both run before `up`, not with the smoke clients after it — the application is what they are a
# precondition for. The lane contract's preStartServices carries that ordering.
db-migrate-capabilities:
profiles:
- local-notification-ingest
- local-notification-serving
- local-notification-handoff
- all-adapters
image: flyway/flyway:11.1.0
depends_on:
db:
condition: service_healthy
entrypoint: ["/bin/sh", "/opt/capability-streams/apply-capability-streams.sh"]
environment:
PGHOST: "db"
PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
PGDATABASE: "${POSTGRES_DB:-ca_skeleton}"
CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}"
volumes:
- type: bind
source: ./infra/postgres/apply-capability-streams.sh
target: /opt/capability-streams/apply-capability-streams.sh
read_only: true
# The whole migration tree, not just db/migration/jpa: the application's own postgresql stream
# has to be installed first, or the capability tables arrive in a schema whose flyway_schema_history
# does not exist yet and the application refuses to start — which is its baseline-on-migrate: false
# policy working as designed.
- type: bind
source: ./src/adapter/outbound/persistence-jpa/src/main/resources/db/migration
target: /flyway/sql
read_only: true
networks:
- caskeleton-infra
restart: "no"
db-promote-capabilities:
profiles:
- local-notification-ingest
- local-notification-serving
- local-notification-handoff
- all-adapters
image: postgres:16-alpine
depends_on:
db:
condition: service_healthy
entrypoint: ["/bin/sh", "/opt/capability-streams/promote-capability-streams.sh"]
environment:
PGHOST: "db"
PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
PGDATABASE: "${POSTGRES_DB:-ca_skeleton}"
CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}"
volumes:
- type: bind
source: ./infra/postgres/promote-capability-streams.sh
target: /opt/capability-streams/promote-capability-streams.sh
read_only: true
networks:
- caskeleton-infra
restart: "no"
# The GraphQL transport, as a request. auth-smoke proves a token can be obtained and that public
# health answers; this proves /graphql is guarded and that an authenticated query executes.
graphql-smoke:
profiles:
- local-graphql
- all-adapters
image: curlimages/curl:8.10.1
depends_on:
keycloak:
condition: service_healthy
# uid 0 for the mounted 0600 client secret, same as auth-smoke.
user: "0:0"
entrypoint: ["/bin/sh", "/opt/graphql-smoke/graphql-smoke.sh"]
environment:
APP_BASE_URL: "http://app:8080"
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
# Spring for GraphQL serves its own endpoint through a router function rather than an
# annotated controller, so the presentation base-path prefix does not apply to it.
GRAPHQL_PATH: "${GRAPHQL_PATH:-/graphql}"
volumes:
- type: bind
source: ./infra/graphql/smoke
target: /opt/graphql-smoke
read_only: true
secrets:
- keycloak-graphql-smoke-client-secret
networks:
- caskeleton-infra
restart: "no"
# ---- One-shot smoke clients --------------------------------------------------
# Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or
# non-zero one fails its lane rather than being treated as "not applicable".
auth-smoke:
profiles:
- local-graphql
- shared-infra
- prod-smoke
- all-adapters
image: curlimages/curl:8.10.1
depends_on:
keycloak:
condition: service_healthy
# The client secret is written on the host at mode 0600 by the qualification wrapper and mounted
# in. The Keycloak image happens to run as the same uid the wrapper writes as; this image runs as
# uid 100, so it read "Permission denied" and the lane failed on the smoke client rather than on
# anything it was checking. Compose ignores the secret's uid/gid/mode options outside swarm, so
# the container reads it as root instead. The two alternatives are both worse: loosening the host
# file to world-readable leaves a credential readable by every process on the machine, and passing
# the value as an environment variable puts it in `docker compose config` output and in ps.
user: "0:0"
entrypoint: ["/bin/sh", "/opt/auth-smoke/auth-smoke.sh"]
environment:
# The same issuer URL the application is given. A token obtained from one URL and validated
# against another proves nothing, and localhost means a different host inside each container.
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
APP_BASE_URL: "http://app:8080"
# Supplied per runtime, because the same endpoint has two addresses: application-local.yml
# pins presentation.api-base-path to /api and the shipped default is /v1. The qualification
# wrapper exports the value that matches the lane's Spring runtime.
APP_HEALTH_PATH: "${APP_HEALTH_PATH:-/v1/healthcheck}"
volumes:
- type: bind
source: ./infra/keycloak/smoke
target: /opt/auth-smoke
read_only: true
secrets:
- keycloak-graphql-smoke-client-secret
networks:
- caskeleton-infra
restart: "no"
# The server image, not the mc client image: minio/mc ships no sed, grep or cmp, and the round-trip
# client needs a digest tool. See infra/minio/smoke/object-storage-smoke.sh for how that went
# unnoticed. The lane already pulls this image for the server itself.
object-storage-smoke:
profiles:
- shared-infra
- prod-smoke
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
depends_on:
minio-init:
condition: service_completed_successfully
entrypoint: ["/bin/sh", "/opt/minio-smoke/object-storage-smoke.sh"]
environment:
MINIO_ENDPOINT: "http://minio:9000"
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}"
volumes:
- type: bind
source: ./infra/minio/smoke
target: /opt/minio-smoke
read_only: true
networks:
- caskeleton-infra
restart: "no"
notification-smoke:
profiles:
- local-notification-ingest
- local-notification-serving
- local-notification-handoff
- all-adapters
image: curlimages/curl:8.10.1
depends_on:
keycloak:
condition: service_healthy
# uid 0 for the same reason auth-smoke uses it: the mounted client secret is mode 0600 on the
# host and this image otherwise runs as uid 100, which reads "Permission denied". The lane then
# fails on the smoke client rather than on anything it was checking.
user: "0:0"
entrypoint: ["/bin/sh", "/opt/notification-smoke/notification-smoke.sh"]
environment:
APP_BASE_URL: "http://app:8080"
MAILPIT_BASE_URL: "http://mailpit:8025"
# ingest | serving | handoff-verify — which phase of the lane this invocation is.
#
# No default, deliberately. It defaulted to `ingest`, and local-notification-serving therefore
# ran the ingest assertions — "accepted, and nothing was delivered" — against an application in
# SERVING mode. The lane passed while testing the opposite of what it is named for, and would
# have kept passing for as long as the check happened to run before the dispatch worker. An
# unset value now renders empty and the client refuses it.
# `:-` and not a value: an explicit empty default keeps Compose from warning about an unset
# variable on every lane that never runs this client, while still rendering empty so the
# client refuses it.
NOTIFICATION_SMOKE_PHASE: "${NOTIFICATION_SMOKE_PHASE:-}"
# Submission and template publication are authenticated like every other non-public path, so
# this client obtains a token the same way auth-smoke does — client credentials against the
# same issuer URL the application validates against.
APP_BASE_PATH: "${APP_BASE_PATH:-/api}"
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
volumes:
- type: bind
source: ./infra/notification/smoke
target: /opt/notification-smoke
read_only: true
# The handoff lane runs this client twice in one project and the second run needs the request
# id the first accepted, so the state lives in a named volume that outlives a `run --rm`
# container and is removed with the project by the teardown's --volumes.
#
# Its own path, not a subdirectory of the script mount above: a volume nested inside a
# read-only bind cannot be created, because the runtime has to mkdir the mountpoint in a
# filesystem it was just told is read-only.
- type: volume
source: caskeleton-notification-smoke-state
target: /opt/notification-smoke-state
secrets:
- keycloak-graphql-smoke-client-secret
networks:
- caskeleton-infra
restart: "no"
networks:
caskeleton-infra:
driver: bridge
volumes:
caskeleton-notification-smoke-state:
driver: local
caskeleton-db-data:
driver: local
caskeleton-mongo-data:
driver: local
caskeleton-minio-data:
driver: local
secrets:
# Written per run at mode 0600 by the qualification wrapper and removed on teardown. The realm
# artifact references it by name; the value never reaches Git, a rendered config, a command line,
# or an evidence file.
keycloak-graphql-smoke-client-secret:
file: ./infra/keycloak/secrets/graphql-smoke-client-secret
+19 -49
View File
@@ -5,8 +5,11 @@
# docker compose -f docker-compose.yml -f docker-compose.local.yml up # docker compose -f docker-compose.yml -f docker-compose.local.yml up
# #
# Local intent: # Local intent:
# - Starts a local PostgreSQL database for integration testing without Testcontainers. # - Wires the app environment to point at the shared `db` service, which lives in
# - Wires the app environment to point at the local DB. # docker-compose.infra.yml and starts only for lanes whose Compose profile names it.
# - Declares no depends_on: a depends_on aimed at a profiled service makes every lane that does
# not enable that profile fail to render at all, and ordering is the runtime-smoke wrapper's
# job — it knows which services a lane actually starts.
# - Keeps read-only filesystem and memory limits from the base compose. # - Keeps read-only filesystem and memory limits from the base compose.
# - Publishes the DB on the loopback interface only, so a host-side run # - Publishes the DB on the loopback interface only, so a host-side run
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the # (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
@@ -15,10 +18,19 @@
services: services:
app: app:
# Optional, because src/.env is operator input and a fresh clone does not have one. Before this
# was marked optional, untracking that file made `docker compose config` fail outright on a
# clone — the environment override that exists for convenience became a hard prerequisite for
# rendering the stack at all. The tracked contract is src/.env.example; copy it.
env_file: env_file:
- ./src/.env - path: ./src/.env
required: false
# Wire the app to the local Postgres service on the internal network. # Wire the app to the local Postgres service on the internal network.
environment: environment:
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
# environment the application believes it is in, and the two drifting is how a dev stack ends
# up running local's settings.
SPRING_PROFILES_ACTIVE: "local"
TZ: "UTC" TZ: "UTC"
LANG: "C.UTF-8" LANG: "C.UTF-8"
LC_ALL: "C.UTF-8" LC_ALL: "C.UTF-8"
@@ -30,9 +42,6 @@ services:
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}" APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}" APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}" APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
depends_on:
db:
condition: service_healthy
healthcheck: healthcheck:
test: test:
- "CMD" - "CMD"
@@ -46,49 +55,10 @@ services:
start_period: 20s start_period: 20s
retries: 12 retries: 12
networks: networks:
- caskeleton-local - caskeleton-infra
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
TZ: "UTC"
# Persist data between restarts; remove the volume to start fresh.
volumes:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
# The containerised app reaches this over the internal network and needs no host port. A
# host-side run does: src/.env is the dotenv source bootRun reads, and its committed
# APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
# that default named an address nothing in the repository provisioned, so every bootRun died in
# the startup migration phase with a connection refusal.
#
# Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
# nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
# host keeps its conventional port.
ports:
- "127.0.0.1:5433:5432"
networks:
- caskeleton-local
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks: networks:
caskeleton-local: # Defined in docker-compose.infra.yml, where the services that share it live.
driver: bridge caskeleton-infra:
external: false
volumes:
caskeleton-db-data:
driver: local
+37
View File
@@ -0,0 +1,37 @@
# =============================================================================
# prod-smoke — a production-shaped runtime, for evidence, on a laptop.
#
# Not "production Compose". What it is for is proving that the prod profile's fail-closed validators
# can be satisfied at all: TLS on the JDBC URL, a schema Flyway owns, JSON logging, secret
# references rather than values. A prod lane that only ever gets as far as `config` proves the file
# parses, which was never the thing in doubt.
#
# The credentials here are generated per run by the lane wrapper. Nothing local is reused: a
# prod-smoke that borrows the local MinIO password is a prod-smoke that tests the local setup.
# =============================================================================
services:
app:
environment:
# Explicit, not inherited. A Compose profile selects services and says nothing about which
# environment the application believes it is in.
SPRING_PROFILES_ACTIVE: "prod"
TZ: "UTC"
# verify-full, which is the point: PostgreSqlTransportSecurityValidator refuses anything less,
# and that refusal is the behaviour this lane exists to satisfy rather than bypass.
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}?sslmode=verify-full&sslrootcert=/run/secrets/postgres-ca"
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
# The password is deliberately absent here. An `environment:` entry beats `env_file:`, so
# declaring it as "${APP_DATASOURCE_PASSWORD:-}" read the host shell rather than the lane's
# generated file and injected an empty string — which the prod env validator then refused, for
# the right reason, about a value the lane had actually supplied.
APP_DATASOURCE_DDL_AUTO: "validate"
APP_LOG_JSON_ENABLED: "true"
APP_SECURITY_JWT_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
APP_SECURITY_JWT_AUDIENCE: "ca-skeleton-api"
networks:
- caskeleton-infra
networks:
caskeleton-infra:
external: false
+50
View File
@@ -0,0 +1,50 @@
# =============================================================================
# Database transport security, for the lanes whose runtime requires it.
#
# The prod runtime connects with `sslmode=verify-full` and an explicit `sslrootcert`. That is not a
# lane setting to relax: a prod smoke test against a database with TLS disabled is a smoke test of a
# configuration production never runs, and the one failure mode it would hide — the certificate
# chain or the host name not checking out — is the one that only ever appears in production.
#
# So the lane brings a real certificate instead. The qualification wrapper generates a CA and a
# server certificate for the host name `db` per run, at mode 0600, and removes both on teardown; the
# realm-secret pattern, applied to a keypair. Nothing here is committed: infra/postgres/tls holds
# only a .gitignore.
#
# `verify-full` is deliberate rather than `verify-ca`. `verify-ca` proves the certificate was issued
# by the expected authority and says nothing about who presented it, so it does not detect a
# redirected connection — which is most of what transport security is for.
# =============================================================================
services:
db:
# Runs as root just long enough to install the key where postgres can read it, then hands over
# to the official entrypoint. See infra/postgres/entrypoint.sh for why a bind mount cannot do it.
entrypoint: ["/bin/sh", "/opt/postgres-entrypoint/entrypoint.sh"]
command:
- "postgres"
- "-c"
- "ssl=on"
- "-c"
- "ssl_cert_file=/etc/postgresql-tls/server.crt"
- "-c"
- "ssl_key_file=/etc/postgresql-tls/server.key"
volumes:
- type: bind
source: ./infra/postgres/entrypoint.sh
target: /opt/postgres-entrypoint/entrypoint.sh
read_only: true
- type: bind
source: ./infra/postgres/tls
target: /opt/postgres-tls
read_only: true
app:
# The certificate authority the JDBC URL names in `sslrootcert`. A public certificate, so it
# carries no mode problem — the private half never leaves the database container's filesystem.
secrets:
- postgres-ca
secrets:
postgres-ca:
file: ./infra/postgres/tls/ca.crt
+8
View File
@@ -34,6 +34,14 @@ services:
GIT_SHA: "${GIT_SHA:-0000000}" GIT_SHA: "${GIT_SHA:-0000000}"
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}" SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000} image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
# Generated per run by scripts/run-compose-runtime-smoke.sh and removed on teardown. Seven values
# have no inline default on purpose — the datasource address and credential, the application
# name, and the JWT issuer and audience — so a lane has to supply them, and a lane that borrowed
# the developer's own src/.env would be reproducible only on that developer's machine. Optional,
# so an ordinary `docker compose up` is unaffected.
env_file:
- path: ./src/.env.lane
required: false
ports: ports:
- "${APP_SERVER_PORT:-8080}:8080" - "${APP_SERVER_PORT:-8080}:8080"
- "9001:9001" - "9001:9001"
+54
View File
@@ -0,0 +1,54 @@
# docs
저장소의 모든 문서는 이 디렉터리 아래에 있다. 어떤 문서를 어디에 두는지가 유일한 규칙이고,
파일 목록은 디렉터리를 직접 읽는다. 개수를 여기에 적으면 다음 문서가 추가되는 순간 틀린 글이 된다.
## 어댑터별 운영 문서
각 어댑터의 지원 범위, 설정, 보안, 운영, 마이그레이션 문서다. 코드와 함께 갱신되어야 하는 문서이고,
`docs/httpclient/``scripts/verify-httpclient-docs.py` 가 코드에서 뽑은 이름과 대조한다.
| 디렉터리 | 대상 |
| --- | --- |
| `fileserver/` | 파일 서버 어댑터 |
| `httpclient/` | HTTP 클라이언트 플랫폼 |
| `jpa/` | JPA·PostgreSQL 영속성 |
| `messaging/` | 메시징 어댑터 |
| `mongodb/` | MongoDB 문서 영속성 (`advanced/`, `runbooks/` 포함) |
| `notification/` | 알림 전달 플랫폼 (`adr/` 포함) |
| `redis/` | Redis 캐시·세션 |
## 횡단 문서
| 디렉터리 | 대상 |
| --- | --- |
| `adr/` | 아키텍처 결정 기록 |
| `architecture/` | 공개 API 표면 스냅숏 |
| `evidence/` | 작업 단계별 증거·체크포인트 |
| `registries/` | env 키·에러 코드·메트릭·헤더 등 레지스트리 SSOT |
| `reviews/` | 모듈 코드 리뷰 결과 |
| `runbooks/` | 장애 코드별 대응 런북 (`template.md` 기준) |
| `security/` | 공개 경로 스냅숏 |
## 설계와 계획
| 디렉터리 | 대상 |
| --- | --- |
| `superpowers/specs/` | 설계서. `YYYY-MM-DD-<주제>-design.md` |
| `superpowers/plans/` | 구현·확장 계획서. `YYYY-MM-DD-<주제>-plan.md` |
| `superpowers/packages/` | 외부에서 납품된 설계 패키지의 README와 정적 검증 결과 |
`superpowers/packages/<어댑터>/` 는 설계서가 처음 전달됐을 때의 안내와 `VALIDATION.md` 검증 이력을
남긴 기록 보관소다. 설계서·계획서 본문은 전부 `specs/``plans/` 에 있으므로 이 디렉터리에서
문서를 찾을 필요는 없다. 각 README 상단의 보존 안내가 무엇이 옮겨졌고 무엇이 제거됐는지 밝힌다.
계획서 본문에는 당시 계획한 경로와 명령이 그대로 남아 있다. 그중 일부는 실제 구현에서 다른 위치로
조정됐고, 저장소에 어떻게 대응시켰는지는 각 어댑터의 `repository-adaptation.md` 또는
`module-mapping.md` 가 기록한다. 계획서를 사후에 고치지 않는 이유는 그렇게 하면 계획의 기록이 아니라
결과를 계획처럼 보이게 만든 글이 되기 때문이다.
## 여기에 없는 것
- 실행되는 검증 스크립트는 문서가 아니다. `scripts/``.github/scripts/` 에 있다.
- 모듈 레지스트리·Gradle 정책은 `src/config/architecture/modules.json``src/build.gradle` 이 소유한다.
- 각 모듈의 지역 규칙은 해당 모듈의 `src/**/CLAUDE.md` 가 소유한다.
@@ -0,0 +1,119 @@
# ADR-BUILD-001: `java-test-fixtures` is the standard for shared test code
- Status: Accepted
- Date: 2026-09-07
- Scope: every leaf that publishes or consumes shared test code
- Source: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` BOOT-015
## Context
Two conventions do the same job in this repository.
`ca.testkit-publisher` — a convention plugin — gives a leaf a `testkit` source set, wires its output
onto the lanes that leaf names, and optionally publishes it as a consumable configuration. Five
leaves use it: `persistence-jpa` (published as `jpaTestkit`), `web` (`webTestkit`), `websocket`
(`websocketTestkit`), `persistence-mongo` and `httpclient` (both unpublished).
`java-test-fixtures` — Gradle's own plugin — gives a leaf a `testFixtures` source set, puts it on
`test`'s classpath automatically, and always publishes it as a variant consumers reach with
`testFixtures(project(':x'))`. One leaf uses it: `graphql`, which additionally fails its build when a
fixture is written outside `src/testFixtures/java`.
Two conventions for one purpose is the defect. A contributor adding shared test code has to know
which leaf they are in before they know where the file goes, and the two answers are not
interchangeable: a consumer of the first writes `project(path: ':x', configuration: 'jpaTestkit')`
and has to know the configuration's name, while a consumer of the second writes
`testFixtures(project(':x'))` and does not.
## Decision
**`java-test-fixtures` is the standard.** New shared test code goes in `src/testFixtures/java`, and a
consumer depends on it with `testFixtures(project(':x'))`.
Three reasons, in order of weight:
1. **The consumer side describes itself.** `testFixtures(project(':adapter:inbound:web'))` says what
it is. `project(path: ':adapter:inbound:web', configuration: 'webTestkit')` says where to look,
and only after the reader has learned that `webTestkit` is a testkit rather than a lane.
2. **The enforcement already exists and is copyable.** `graphql`'s build fails when a fixture is
declared in the wrong place. The same guard applies unchanged to any leaf that adopts the plugin.
3. **It is one fewer local concept.** A convention plugin that reimplements a Gradle plugin has to be
maintained against it.
## What the local plugin does better, and how it is replaced
This is worth writing down, because the review that prompted this ADR recommended the migration
before reading `ca.testkit-publisher`, and the plugin turns out to encode two deliberate decisions
rather than being an oversight.
**Publishing is opt-in.** `persistence-mongo` and `httpclient` have a testkit and publish nothing;
`persistence-jpa` publishes. The plugin's own comment names this as "a real difference in what each
leaf offers rather than an oversight to normalise away". `java-test-fixtures` always creates the
variant, so the distinction is lost — a leaf that never meant to offer its fixtures will offer them.
> Replacement: none at the build level. The distinction moves to review: the fixtures of a leaf that
> nobody consumes are simply unconsumed. This is a real, accepted loss.
**Lane consumption is declared.** `persistence-jpa` says `consumedBy 'test', 'postgresqlIntegrationTest'`.
`java-test-fixtures` puts fixtures on `test` only, so every other lane needs the output added
explicitly.
> Replacement: `strictTestLanes`' existing `compilesAgainst` expresses this unchanged — a lane
> declares `compilesAgainst 'main', 'testFixtures'`. The first draft of this ADR assumed the DSL
> would need a change, because `sourceSet(name)` creates what it is given and `testFixtures` already
> exists. The `persistence-mongo` migration showed otherwise: `compilesAgainst` only *looks a source
> set up*, so naming a plugin-created one works as-is. What the leaf drops is the
> `sourceSet('testkit')` declaration, not the lane's.
## Migration: done, and what it cost
Five leaves, eleven lanes, two published testkits, all migrated leaf by leaf with the suite run
between each. `ca.testkit-publisher` is deleted.
The order was chosen so a mistake would be cheap: unpublished leaves first, published ones last with
their consumer in the same step.
1. `persistence-mongo` — one leaf, two lanes, no cross-module consumer; the proof the path works.
What it took, per leaf:
- `apply plugin: 'java-test-fixtures'` at the top of the leaf build file;
- `git mv src/testkit src/testFixtures`;
- drop `sourceSet('testkit')` and the whole `testkitPublisher` block; keep every other lane's
`compilesAgainst`, renaming `'testkit'` to `'testFixtures'`;
- rename `testkitImplementation` to `testFixturesImplementation`, **and add what the old source
set was inheriting silently**. This is the one non-mechanical step: `testkit*` extended
`testImplementation`, so the fixtures saw every test library the leaf declared. Mongo's needed
four more lines (AssertJ, BSON, Spring Data commons, Toxiproxy) — none of which the leaf had
ever stated the fixtures depended on;
- regenerate the leaf's lock state.
2. `httpclient`, then `websocket` — unpublished as well, more lanes.
3. `web` and `persistence-jpa` with `app-bootstrap`'s two consumer declarations, which became
`testImplementation(testFixtures(project(':…')))`.
4. `ca.testkit-publisher` deleted, along with its `plugins {}` entry and its application in the root
build.
### Two things the migration broke, and what they taught
Both were caught by tests that exist to catch exactly this, which is the argument for having them.
**ArchUnit corpora went wrong in opposite directions.** `httpclient`'s boundary rules *excluded*
`build/classes/java/testkit`; after the move the fixtures arrived as a `…-test-fixtures.jar` on the
same classpath, so the exclusion missed them and 258 fixture-to-fixture calls were reported as
production depending on the testkit. `persistence-jpa`'s rules *included* only
`build/classes/java/main`; applying `java-test-fixtures` makes the module's own test classpath carry
the module as a **jar** rather than as a class directory, so its corpus became empty. The second is
the dangerous one — an empty corpus makes every `noClasses()` rule pass — and it surfaced only
because that suite asserts its corpus is non-empty before asserting anything about it.
**Fixtures had invisible dependencies.** `testkit*` configurations extended `testImplementation`, so
the fixtures compiled against every test library their leaf declared without ever naming one. Making
them explicit took roughly thirty `testFixturesImplementation` lines across the five leaves —
Micrometer, Spring Web, Netty, logback, Jackson, JUnit, AssertJ, Spring Data. None of them were
wrong; none of them were stated.
## Consequences
- `docs/testing/TESTING_STRATEGY.md` §5 records the standard; this ADR records why and at what cost.
- Until step 5, two conventions remain visible. The strategy document says so explicitly, so a
contributor reading it is not left to infer which one is current.
- The opt-in-publishing distinction is given up. If it later proves load-bearing — a leaf whose
fixtures genuinely must not be reachable — the answer is a separate module, not a third convention.
@@ -0,0 +1,70 @@
# ADR-GQL-001 — GraphQL context stays inbound; object authorization moves to application-core; the persisted-operation store stays an inbound SPI
- Status: Accepted
- Date: 2026-08-24
- Review: `docs/reviews/2026-08-14-graphql-module-code-review.md` GQL-026
## Context
The GraphQL leaf's own documentation described three things crossing its boundary: a
`GraphQlRequestContext` with a deadline propagated into application, JPA, Mongo and the HTTP client;
object authorization decided inside the transport; and a persisted-operation registry implemented by
an external durable store.
Two of those invert the dependency direction. If `application-core` or an outbound adapter
implements a type that lives in `adapter:inbound:graphql`, the registry edge that says inbound
depends on application is satisfied while the real compile-time dependency runs the other way.
The third is a business rule in the wrong layer: whether an actor may see an object is a decision
about the domain, and GraphQL is one of four transports this skeleton ships.
## Decision
Three different answers, because the three problems are not the same problem.
**GraphQL context stays inbound-local.** It is mapped explicitly onto application command fields —
actor, tenant, deadline — rather than travelling as a type. Nothing outside the leaf references
`GraphQlRequestContext`, and the boundary test is that grep returns nothing outside it.
**Object authorization moves to `application-core`.** `ObjectAccessPolicy`, `ObjectAccessRequest`
and `ObjectAccessDecision` are transport-neutral and live with the other application policies;
`ApplicationObjectAuthorization` in the GraphQL leaf is the bridge that calls them. This is the one
of the three that was a real layering defect, and it is fixed rather than documented.
**The persisted-operation store stays an inbound-owned SPI.** `GraphQlPersistedOperationRegistry`
remains in `advanced/persisted`, and no leaf outside GraphQL implements it.
## Consequences
The third decision is the one that needs defending, because it leaves the reported risk in place
rather than removing it.
The risk is conditional: the direction inverts only when something outside the leaf implements the
interface. Nothing does. The template ships an in-memory registry and no durable one, because it
ships no persisted-operation store at all.
The alternative was to introduce a generic operational key-value store port owned by a neutral
contract holder, with the GraphQL adapter owning only the key and value mapping. That port would
have exactly one interface, zero implementations and one speculative consumer — a new abstraction
whose shape is guessed from a requirement nobody has stated. This repository has spent a full
remediation pass deleting controls that existed and were reached by nothing, and inventing a port
for a store that does not exist is how the next one of those gets written.
So the decision is to leave the SPI where it is and to move it when a durable store is actually
built. Moving it then is a rename across one leaf and one new adapter, which is cheaper than
carrying a wrong abstraction until then. What must not happen in the meantime is an outbound leaf
implementing the inbound interface, because that is the moment the direction actually inverts, and
it would happen in a commit whose diff looks like an implementation rather than a layering change.
The composition root wires these and owns no business or storage policy of its own.
## Enforcement
`verifyCleanArchitectureDependencies` and `modules.json` hold the leaf's edges to
`domain-core`, `application-core` and `shared-contract`. `ObjectAccessPolicyTest` covers the
application-side policy and `ApplicationObjectAuthorizationTest` the bridge.
The condition this ADR turns on — that nothing outside the GraphQL leaf implements the
persisted-operation SPI — is a claim about the whole repository, so it is checked at the
composition root rather than inside the leaf, next to the other GraphQL boundary rules in
`app-bootstrap`'s architecture suite.
@@ -0,0 +1,55 @@
# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json`
## Context
The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and
composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under
`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against
Spring Boot 4.1.
None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that
owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this:
their last Global Constraint says that when the repository structure differs, file paths are remapped
and the public contracts, invariants and test meanings are not changed.
Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as
packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as
a family the way `messaging:*` is registered.
## Decision
Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under
`src/grpc-advanced/`.
The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already
describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters
and composition boundary, not a layer of this application", and the gRPC platform is the same shape
for the same reason — the application is meant to reach it the way it reaches a library, through an
application-owned port. The four platforms that became packages are all layers of this application;
this one is not.
The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan
requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle
path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note:
`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently.
## Consequences
**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in
one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit
decision moves it.
**The advanced boundary is checked twice.** Once by the registry at build time, and once by
`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by
something the registry never sees.
**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot
be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose
lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps
the same rule inside the code, so a report cannot cite a contract run as transport evidence.
**Codegen is not wired.** See ADR-GRPC-002.
@@ -0,0 +1,62 @@
# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen`
## Context
Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single
Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release.
Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And
`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes
spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror`
— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set
from five quality gates.
There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh`
disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality
baseline of a leaf, taken for one task, and outside what this work was asked to change.
`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md`
forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module
should own its schema.
## Decision
Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no
protoc run and no Buf CLI invocation.
`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the
`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package
disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked
against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs
against the committed schema in its own test, so the shipped `.proto` files are live rather than
decorative.
`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages
a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal
generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher`
carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a
publish that breaks a consumer or republishes a released version with different bytes.
The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it
reaches the same verdict.
## Consequences
**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which
schema changes are refused, which consumer breaks block a release, who owns generation — is a
build-checkable rule here. What is missing is the protoc invocation and the Buf binary.
**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the
owner, the managed version source, the build-directory output paths and the disjoint package policy
that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin
block, not a redesign.
**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the
in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream
sequencing without generated stubs. Those contracts are properties of the platform and the transport,
not of any message shape, so the substitution costs nothing — and the lanes run today rather than
after codegen lands.
@@ -0,0 +1,50 @@
# ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-core-api`, `:grpc:grpc-policy`, `:grpc:grpc-testkit`
## Context
A failed RPC produces a status code, and a status code is not an answer to the question the caller
actually has. `DEADLINE_EXCEEDED` on a mutation does not say whether the mutation happened;
`UNAVAILABLE` after the request was sent does not say the server never saw it; response headers
arriving does not say a transaction committed.
Every one of those is a place where a plausible inference produces a duplicate write or a lost one,
and none of them is visible in a test that only exercises the happy path.
## Decision
Model what happened as three independent axes, and refuse the inferences between them.
`GrpcTransportEvidence` records what the client observed on the wire, and distinguishes `NOT_SENT`
the client watched its own send fail — from `UNOBSERVED`, which is every other case where nothing is
known. `GrpcBusinessEvidence` records what the application confirmed, with `COMMIT_UNKNOWN` as a real
state rather than a placeholder. `GrpcStreamEvidence` is a sealed hierarchy whose non-empty cases all
carry a position, because "partial" without a last sequence can be neither resumed nor reconciled.
`GrpcExecutionEvidence` holds all three and rejects combinations nobody could have observed: a unary
call with stream evidence, or a request the client watched fail to send that nonetheless carries
business evidence. Promoting response headers to a confirmed commit is possible only by editing
`withResponseHeadersSeen`, which is one method rather than a plausible line in an interceptor.
`GrpcCompletionOutcome.forMutation` derives what a caller may conclude, and defaults
`DEADLINE_EXCEEDED` and post-send `UNAVAILABLE` on a mutation to `COMPLETION_UNKNOWN`.
The same types are used by the failure model and by the observation convention, so an incident has
one account of a call rather than two.
## Consequences
**A whole class of retry bug becomes unrepresentable.** `GrpcRetryEligibility` reads all three axes
plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status
alone is not an input.
**The fault lane has something to check.** `GrpcTransportEvidenceClassifier` turns a client's
observations into evidence and refuses to infer `NOT_SENT` from an unobserved state — and the lane
exercises it against a real connection dropped mid-call, not against a mock.
**Callers must handle a third outcome.** `COMPLETION_UNKNOWN` is not a failure and not a success, and
a caller that treats it as either is wrong. `GrpcOperationStatusQuery` and `GrpcCompletionReconciler`
exist so that resolving it is a supported path rather than an exercise for the caller.
@@ -0,0 +1,52 @@
# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api`
## Context
Three layers can retry a gRPC call: the application, the channel's service config, and a service
mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and
the load arrives exactly when the dependency is already failing.
Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a
retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the
two afterwards.
## Decision
**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a
decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the
owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method
names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently,
and the method then runs with channel defaults.
**Retry eligibility reads the method, the evidence and the status together.**
`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream
delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into
"resolve the completion first" rather than a retry.
**A keyed mutation is retryable only with both a caller key and a durable ledger.**
`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable
idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read
against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation,
because a read-then-insert implementation has a window exactly as wide as the race it closes and
passes every test that does not run two attempts concurrently.
The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing:
without the caller, one tenant's key suppresses another's write; without the method, a key reused
across operations makes the second a replay of the first.
## Consequences
**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no
retries when everything is failing, which is the behaviour that lets a dependency recover.
**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no
transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and
reintroduce the window where the write is durable and the claim is not.
**A key reused for a different request is a caller error, not a duplicate.** The stored request
fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's
answer.
@@ -0,0 +1,50 @@
# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-policy`
## Context
`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is
not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as
a valid one it should never have received.
Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer
that falls behind either terminates the stream or silently loses messages, and the second leaves a
client with a stream that appears fine and is missing changes. And a reconnect either continues from
a position the server can still replay, or skips whatever is no longer there.
## Decision
**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any
thread and hands them to the transport only from `flush`, which is synchronized. `write` returning
`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon
as bytes are handed over, so no method here can honestly report delivery.
**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension.
`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies
only on its queue bound produces as fast as it can allocate.
**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for
feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect
dropped messages: the sequence numbers it sees are the ones it was sent.
**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token
whose caller and filter fingerprints match the current request, refuses one whose snapshot version
moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken`
carries a key id so the signing key can rotate without invalidating every outstanding token.
## Consequences
**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id,
generation, sequence, snapshot version and resume token, because resume, gap detection and drain all
need a position and a generation.
**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max
duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair
produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams.
**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and
not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse
it.
@@ -0,0 +1,68 @@
# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances
- Status: accepted
- Date: 2026-08-31
- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client`
## Context
A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and
it does not do what the dashboard says it does.
The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual
address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate
across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every
request from that client goes to the same pod for the life of the connection. Nothing fails. The
configuration says `round_robin`, the metrics show requests spread across clients rather than pods,
and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to
check.
The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out
of many.
Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without
removing its own retry policy has two retriers whose effects multiply.
## Decision
**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and
`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd`
and `eureka` by name, with a message saying they are Advanced capabilities with their own control
plane and promotion gate rather than unknown schemes.
**The pairing is checked against the resolved address count, not against intent.**
`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers
whether the policy distributes anything over that many endpoints. A `round_robin` profile over one
address is a reported violation whose message says it describes spreading that is not happening.
**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the
retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and
`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process.
**A profile that carries long-lived streams must state a reconnect budget and a readiness drain
grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and
scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying
long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops
serving before its clients have finished reconnecting elsewhere.
**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS,
because a channel that resolved once at startup keeps sending to addresses that stopped existing an
hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout
finished.
## Consequences
**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything
over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape,
the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile
and still have put retries in two places, so merging them would let one answer hide the other.
**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can
be wrong. It is still better than the alternative, which is not comparing anything: a wrong
declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked.
`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the
check where they are not, rather than guessing and failing on a name that cannot be resolved yet.
**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its
capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()`
returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001.
@@ -0,0 +1,55 @@
# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc-advanced:*`
## Context
The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring
with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage
modes, its own security boundary and its own version skew. Hedging duplicates production traffic.
Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the
most consequential.
## Decision
**One flag per capability**, under `ca-skeleton.grpc.advanced.<capability>.enabled`, all off by
default.
**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate
production approval, because the flag says somebody wanted the feature and the approval says somebody
accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED`
is withdrawn.
`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable,
production unapproved — because the remedy differs in each case.
**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per
capability, so no promotion can drag another along;
`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested
property rather than a claim. Two thresholds: seven days of soak plus complete evidence for
`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the
capability's dependencies and its failure modes.
**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that
gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin
needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which
is worse than not having one.
## Consequences
**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so
`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one
schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds,
platform evidence types preserved — are checkable and are checked; only the compile lane is missing.
**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not
conditional on the report, because letting a status record also authorise use means a schema moves
onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no
consumer migration and no ADR.
**xDS is not part of the Stable support statement.** It works, behind its flag and its approval;
`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen
quietly.
@@ -0,0 +1,67 @@
# ADR-JPA-006 — `audit` is the canonical technical audit model; `auditing` stays a frozen candidate
- Status: Accepted
- Date: 2026-08-24
- Review: `docs/reviews/2026-08-14-jpa-module-code-review.md` JPA-022
## Context
Two complete technical-audit mechanisms live in this leaf and they disagree about the schema.
`audit/AuditableEntity` stamps `created_at`/`created_by`/`updated_at`/`updated_by` with an actor
column of length 256, captured through explicit `initializeAudit`/`applyModification` calls and an
`AuditContextPort`. `auditing/AuditMetadata` is a Spring Data embeddable that stamps
`created_*`/`modified_*` with an actor column of length 64, captured by `@CreatedDate` and friends
through an `AuditorAware`.
Only the first is real: it is what the sample entities extend and what the migrations were written
for. `JpaAuditingConfiguration` is not a Spring `@Configuration`, and nothing in production
constructs any of the three `auditing` types.
The review asked for one canonical model with a migration or activation decision. The failure mode
it was protecting against is specific: an author of a new entity picks whichever package they find
first, and column names, actor lengths and capture lifecycles then diverge per table.
## Decision
`audit/AuditableEntity` is canonical. `auditing` stays in the tree as a candidate and is excluded
from the Stable capability report.
The candidate is not deleted and not promoted. Deleting it would discard a working Spring Data
integration that a deployment preferring declarative auditing would want. Promoting it would mean
either renaming `modified_*` to `updated_*` and widening the actor column — a schema migration of
every audited table to gain nothing a caller asked for — or moving the sample entities onto
`modified_*`, which is the same migration in the other direction.
Neither is worth doing now. What the divergence actually needed was not consolidation but a rule
that an entity cannot straddle the two, and that rule is cheaper than either migration.
## Consequences
Two audit mechanisms remain readable in one leaf, and a reader has to be told which one is live.
That cost is paid in this document, in the package javadoc and in a test whose name says so.
Two failure modes stay silent unless they are asserted, so both are:
- The candidate acquires a stereotype and starts stamping in every deployment that has this module
on the classpath, including the ones whose tables have no `modified_*` columns — where the result
is a failed startup rather than a feature.
- Somebody "harmonises" the two by editing one side's column names, at which point the schema a
deployed table was migrated for and the schema its entity expects diverge with no migration
between them.
If the candidate is ever promoted, it is promoted atomically: forward migration, sample conversion,
`AuditContextPort → AuditorAware` and `Clock → DateTimeProvider` bridges land together, and this
ADR is superseded rather than amended.
Bulk and native updates stamp nothing under either mechanism. That is a property of JPA, not of the
choice made here, so it is enforced separately rather than assumed away.
## Enforcement
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism` and
`bulkUpdatesOfAuditedEntitiesStampAudit`, run against the real production graph by
`JpaProductionArchitectureTest` at the composition root — not against fixtures, which is how the
earlier version of this rule pack passed while applying to nothing. `AuditingCandidateStatusTest`
asserts the candidate carries no composing stereotype and that the two column sets stay distinct.
`JpaAuditMechanismRuleTest` exercises the rules' own negative cases.
+1 -1
View File
@@ -2,7 +2,7 @@
- **Status:** Accepted - **Status:** Accepted
- **Date:** 2026-08-13 - **Date:** 2026-08-13
- **Design source:** `mongodb-superpowers-package/.../2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6 - **Design source:** `docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
## Context ## Context
@@ -74,6 +74,7 @@ where encryption and sharding are both expensive to reverse.
## Verification ## Verification
```bash `scripts/verify-mongodb-advanced.sh` enforced this ADR until it was removed on 2026-08-15. The
bash scripts/verify-mongodb-advanced.sh promotion evidence categories this ADR requires are therefore no longer checked by any automated
``` gate; they are a review obligation until one is rebuilt. See `docs/mongodb/repository-adaptation.md`
§5 for the Gradle lanes the script wrapped.
@@ -0,0 +1,69 @@
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.stream.**`
## Context
Advanced Tasks 613 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
change.** A stream that ends because a dependency failed and one that ends because it finished are
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
cut off mid-flight.
The second fact is that a resume path invites the web module to remember things. It must not: the
messaging platform already owns durable event history, and a second copy would have its own
retention, its own eviction and its own opinion about ordering.
## Decision
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
where a rising rate of mid-stream failures would otherwise hide.
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
which no client parses and every proxy caches as a success.
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
repeated or regressing position. A client deduplicating on position would silently drop the second
item.
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
consumer that reads slowly for an hour.
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
retries immediately, and if every socket is cut at once, every client retries at once.
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
the oldest retained position, because that delivers a stream with a hole the client cannot see.
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
visible in either half on its own.
## Consequences
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
wrong, and no server change can fix that for it.
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
## Alternatives considered
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
unparseable and the 200 is cached.
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
contiguous from where the replay started, so nothing in the data says events are missing.
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
invisibly.
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
server's heap.
@@ -0,0 +1,66 @@
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.virtualthread`, `advanced.blockingbridge`
## Context
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
for WebFlux.
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
side effect of the pool size.
Switching to virtual threads deletes that limit without deleting anything that depended on it.
## Decision
**An explicit admission limit is required when virtual threads are enabled.**
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
had it been refused. The load that used to be shed at the front door is shed at the back, after the
cost of accepting it.
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
all database-bound, and that should be a choice rather than an accident.
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
dependencies starve behind them.
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
a deadlock a thread dump does not obviously show.
## Consequences
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
will not let it be one.
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
the client than the same request accepted and timed out thirty seconds later behind a full pool.
An operator seeing 503s climb should read them as the limit working.
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
It is invisible from throughput, which is why a load test that only measures throughput would pass
with the guard removed.
## Alternatives considered
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
sized to what the dependencies can serve, not to what the web tier can accept.
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
thread pool with extra steps.
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
until the pool is the thing consuming the heap.
@@ -0,0 +1,55 @@
# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.openapi`
## Context
Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot.
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
document looks fine. The value of an API description is entirely in what consumes it, and a document
in a version a client generator does not fully understand produces a client that compiles and is
wrong — which is worse than no document at all.
## Decision
**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and
`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting.
**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a
contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped —
silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the
snapshot hash before and after and makes a difference a promotion blocker.
**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies
style rules and accepts documents a parser rejects; a generator produces client code, and this is
where an unsupported construct surfaces — not as an error but as a method with the wrong signature;
a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a
statement anybody can make. "This document is read correctly by these four tools at these versions"
is.
**Promotion requires an accepted ADR regardless of how green the matrix is.**
`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide
whether the consumer population is ready.
**Streaming description differences are reported separately.** They are the substantive difference
between the two versions for this application, and folding them into a pass/fail hides what
changed.
## Consequences
- The 3.2 document is published as an artifact of the experimental workflow, never of the release
workflow.
- A client generator that only understands 3.1 is unaffected, which is the point.
- Adopting 3.2 later is a documented decision with a named consumer matrix behind it.
## Alternatives considered
- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the
constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences.
- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client
that compiles and misbehaves.
- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the
failure the others miss.
@@ -0,0 +1,56 @@
# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries
- Status: accepted
- Date: 2026-08-25
- Scope: `:adapter:inbound:websocket`
## Context
The realtime connection platform design models itself as eighteen Gradle modules under
`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies.
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the
leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and
HARD-STOP #5 forbids doing it implicitly.
Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same
situation and resolved it the same way.
## Decision
The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule`
declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest`
scans the production tree and fails when the declaration and the tree disagree in either direction.
Three deviations from the design's module map were forced by the check and are recorded in
`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec
moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here;
and the `budget -> core` edge was inverted because `budget` imports nothing from `core`.
## Consequences
**The boundary is enforced, not documented.** Six violations were caught during implementation that
a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed
one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one
package, ownership depends on iteration order and one module's rules silently apply to nothing. A
guard against it is now part of the boundary test.
**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not
`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have
imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list.
**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so
splitting one out later is a registry edit rather than an archaeology exercise.
**The design's own rules were kept where they cost something.** `core` names no framework, so the
same decisions serve both runtimes and are testable without a server; no Java class name reaches the
wire; the payload is an encoded string rather than a map; and handlers are given no way to write,
which is what makes ordering and backpressure guarantees rather than conventions.
## Alternatives considered
**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry
for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary
test provides the property the modules were for.
**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim.
The six violations found during implementation are the argument against it.
+60
View File
@@ -0,0 +1,60 @@
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:websocket``advanced.resume`, `advanced.cluster`, `advanced.presence`
## Context
Advanced Tasks 28 add three things that all look like state and are not: a resume token that says
where a client got to, a cluster index that says which node holds a session, and a presence summary
derived from that index.
Each is a statement about the past. The resume token was minted before the disconnect; the index
entry was written by a node that may since have died; presence is a read of the index and inherits
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
fact, because each reads as one at the call site.
## Decision
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
resynchronise because the client believes it is complete.
**Cluster index entries carry an observation time and are checked against it on every read.**
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
the session.
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
`compute`, so two consumer threads cannot both deliver the same position.
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
that the index went dark and the connections are fine.
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
can move the platform's belief about who is present. Presence answers "show a green dot".
## Consequences
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
implement one; there is no mode in which the platform silently pretends.
- Every read of the cluster index needs a clock. This is deliberate friction.
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
object. A rolling deploy has two versions of the code reading the same envelope.
## Alternatives considered
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
that retries certainly does. Position is the property the ordering actually has.
- **A single `online` boolean.** Rejected for the partition case above.
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
@@ -0,0 +1,74 @@
# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:websocket``advanced.stomp`, `advanced.stomp.rabbit`
## Context
Advanced Tasks 913 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker
relay and cross-node user destinations.
This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on
`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context
do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the
broker that results is whichever ran last. Nothing errors and nothing logs.
STOMP also brings a destination model that is a free string from the client. Without a catalog, the
set of reachable destinations is whatever the broker accepts, which for the simple broker is every
string.
## Decision
**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the
one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and
folding it into `advanced` would relax that module's purity for every capability in it.
**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the
relay opens a TCP connection to somebody else's broker and makes every delivery depend on it.
Different blast radius, different decision, different module.
**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation,
destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate
declarations, because reading a feed and publishing into it are different rights.
**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy`
returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule
reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for.
**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels
are enabled, when both brokers are, or when the adapter is enabled with no broker behind it.
**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and
`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the
protocol layer, which knows nothing about whether the work succeeded.
**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed
claiming cluster support or durable acks, and refuses activation outside local/test — in a
multi-node deployment it does not error, it delivers to whichever fraction of users is on the
publishing node.
**Unresolved user destinations are broadcast once and then dead-lettered.**
`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did
not. Without that, every node rebroadcasts every unresolvable message on receipt.
## Consequences
- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that
runs both; the exclusivity check makes that explicit at startup rather than at 3am.
- A deployment must write its own catalog. There is deliberately no default: an empty one refuses
every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose.
- The relay's cost is one broker connection per authenticated session plus one system connection.
`brokerConnectionsFor` exists so this is computed before the first outage.
- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user
destination contains a user identifier by construction.
## Alternatives considered
- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable
module naming an Advanced one. Making the legacy channel profile-driven would have required that
edge.
- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational
decision and deserves to be refusable on its own.
- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the
default and the catalog becomes documentation.
+24 -7
View File
@@ -5,7 +5,7 @@
# split into capability artifacts. # split into capability artifacts.
# Update only after review with: # Update only after review with:
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange # ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# types: 391 # types: 408
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
@@ -84,7 +84,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.persisted.OperationalStoreGraphQ
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseFailure dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseException
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario
@@ -100,6 +100,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
@@ -110,8 +111,16 @@ dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseRe
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
@@ -134,10 +143,11 @@ dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscription
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolError dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolException
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId
@@ -152,14 +162,18 @@ dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlActivationEnvironmentPostProcessor
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlDeploymentMode
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlOffAutoConfigurationImportFilter
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformEnvironment
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformProperties
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformSettings
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRetiredSafetyAxis
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval
@@ -175,6 +189,7 @@ dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
dev.caskeleton.adapter.inbound.graphql.context.ActorRef dev.caskeleton.adapter.inbound.graphql.context.ActorRef
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
dev.caskeleton.adapter.inbound.graphql.context.TenantContext dev.caskeleton.adapter.inbound.graphql.context.TenantContext
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
@@ -195,8 +210,8 @@ dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseNodeCounter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext
@@ -333,7 +348,7 @@ dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseFailure dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseException
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter
@@ -354,6 +369,7 @@ dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
@@ -383,6 +399,7 @@ dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
dev.caskeleton.adapter.inbound.graphql.security.ApplicationObjectAuthorization
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
+346
View File
@@ -0,0 +1,346 @@
# JPA persistence leaf public API surface — every public top-level type in src/main/java.
# A public type in a single-jar leaf is reachable from every adopter's code, so
# additions are reviewed rather than discovered. `api` is the intended external
# surface; the rest is implementation that has not been moved under an internal
# root yet.
# Update only after review with:
# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange
# types: 338
dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName
dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport
dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability
dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel
dev.caskeleton.adapter.outbound.persistence.api.error.CheckConstraintViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.ConnectionUnavailableException
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails
dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException
dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException
dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory
dev.caskeleton.adapter.outbound.persistence.api.error.ForeignKeyViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.JpaEntityNotFoundException
dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext
dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException
dev.caskeleton.adapter.outbound.persistence.api.error.NotNullConstraintViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException
dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException
dev.caskeleton.adapter.outbound.persistence.api.error.QueryTimeoutException
dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException
dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException
dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver
dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionTimeoutException
dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException
dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator
dev.caskeleton.adapter.outbound.persistence.api.query.CursorCodec
dev.caskeleton.adapter.outbound.persistence.api.query.CursorPayloadCodec
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice
dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation
dev.caskeleton.adapter.outbound.persistence.api.query.QueryName
dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation
dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope
dev.caskeleton.adapter.outbound.persistence.api.query.SignedJsonCursorCodec
dev.caskeleton.adapter.outbound.persistence.api.query.SortDirection
dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel
dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaTransactionExecutor
dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryEventListener
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile
dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort
dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity
dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort
dev.caskeleton.adapter.outbound.persistence.auditing.AuditMetadata
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditingConfiguration
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditorProvider
dev.caskeleton.adapter.outbound.persistence.cache.CacheConcurrencyStrategy
dev.caskeleton.adapter.outbound.persistence.cache.CacheRegionCatalog
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheGuard
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCachePolicy
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheSettings
dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig
dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig
dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings
dev.caskeleton.adapter.outbound.persistence.envers.EntityRevision
dev.caskeleton.adapter.outbound.persistence.envers.EnversConfigurationGuard
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryPolicy
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryReader
dev.caskeleton.adapter.outbound.persistence.envers.EnversRevisionMetadata
dev.caskeleton.adapter.outbound.persistence.envers.HibernateEnversHistoryReader
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceLifecycle
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceRegistry
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantEntityManagerFactoryRegistry
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget
dev.caskeleton.adapter.outbound.persistence.experimental.next.CompatibilityLane
dev.caskeleton.adapter.outbound.persistence.experimental.next.ExperimentalPromotionGate
dev.caskeleton.adapter.outbound.persistence.experimental.next.HibernateCompatibilityPolicy
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionDecision
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionEvidence
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaRoutingDecision
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget
dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsAdminBypassToken
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsPolicyVerifier
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaMultiTenantConnectionProvider
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry
dev.caskeleton.adapter.outbound.persistence.experimental.schema.TenantMigrationStatus
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantAwareRepositoryGuard
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantEntityListenerGuard
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId
dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverSchemaActivation
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.VerificationResultEntity
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2IdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2LocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.h2.H2OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig
dev.caskeleton.adapter.outbound.persistence.h2.H2SqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot
dev.caskeleton.adapter.outbound.persistence.hibernate.JdbcBatchCounter
dev.caskeleton.adapter.outbound.persistence.hibernate.NamedStatementInspector
dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.BatchExecutionResult
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateBatchConfigurationGuard
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateJpaBatchExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfile
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfileRegistry
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.AffectedRowsExpectation
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlResult
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkOperationName
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.HibernateBulkDmlExecutor
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.HibernateStatelessSessionRunner
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessRowCapExceededException
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessSessionRunner
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkName
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkResult
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyReaper
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyRecordJpaRepository
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjectStore
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter
dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity
dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper
dev.caskeleton.adapter.outbound.persistence.liveevent.JpaLiveEventReplayAdapter
dev.caskeleton.adapter.outbound.persistence.liveevent.LiveEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity
dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter
dev.caskeleton.adapter.outbound.persistence.lock.LockSettings
dev.caskeleton.adapter.outbound.persistence.migration.ConcurrentIndexMigrationInspector
dev.caskeleton.adapter.outbound.persistence.migration.FailedConcurrentIndexRecovery
dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy
dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate
dev.caskeleton.adapter.outbound.persistence.migration.MigrationResource
dev.caskeleton.adapter.outbound.persistence.migration.NonTransactionalMigrationPolicy
dev.caskeleton.adapter.outbound.persistence.migration.SchemaManagementMode
dev.caskeleton.adapter.outbound.persistence.migration.SchemaVersionSnapshot
dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaStream
dev.caskeleton.adapter.outbound.persistence.notification.configuration.NotificationJpaPersistenceFacade
dev.caskeleton.adapter.outbound.persistence.notification.crypto.DirectAeadNotificationPayloadCrypto
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCiphertext
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCryptoException
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationHmacDigester
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialHandle
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialProvider
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcNotificationServingState
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcReconciliationJobStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaAdminOperationStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaContactPointStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaDeliveryAttemptStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationRequestStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationSideEffectStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaPolicyStores
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaProviderEventLedger
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientDeliveryStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientLeaseStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaSuppressionStore
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaTemplateRegistry
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRecordMapper
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientClaimSql
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.TenantBoundRepositoryGuard
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxCommitEventPublisher
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemEntity
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemJpaRepository
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxOutboxRecordFactory
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.JpaNotificationInbox
dev.caskeleton.adapter.outbound.persistence.observation.JpaMetricTags
dev.caskeleton.adapter.outbound.persistence.observation.JpaRetryObservation
dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservation
dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality
dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation
dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationJpaRepository
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationStoreAdapter
dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter
dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.postgresql.array.PostgreSqlArraySupport
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintViolationTranslator
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.BoundedCopyInputStream
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyAdminCapability
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyFormat
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyLimits
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyOperationName
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyResult
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.PostgreSqlCopyLoader
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredCopyStatement
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredPostgreSqlCopyLoader
dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlState
dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore
dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocument
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocumentCodec
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonPathName
dev.caskeleton.adapter.outbound.persistence.postgresql.json.PostgreSqlJsonQuerySupport
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.LockWaitObservation
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockOptions
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlWorkClaimExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaim
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaimExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueDefinition
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueName
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlPollingDeliveryAdapter
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRange
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeCodec
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeJdbcType
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PostgreSqlRangeQuerySupport
dev.caskeleton.adapter.outbound.persistence.postgresql.write.NativeWriteName
dev.caskeleton.adapter.outbound.persistence.postgresql.write.PostgreSqlUpsertExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredPostgreSqlUpsertExecutor
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredUpsertStatement
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertConflictTarget
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertResult
dev.caskeleton.adapter.outbound.persistence.postgresql.write.WriteDisposition
dev.caskeleton.adapter.outbound.persistence.querydsl.PredicatePolicy
dev.caskeleton.adapter.outbound.persistence.querydsl.QueryPage
dev.caskeleton.adapter.outbound.persistence.querydsl.QuerydslJpaSupport
dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport
dev.caskeleton.adapter.outbound.persistence.security.DatabaseRolePolicy
dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier
dev.caskeleton.adapter.outbound.persistence.security.SearchPathPolicy
dev.caskeleton.adapter.outbound.persistence.springdata.EntityGraphCatalog
dev.caskeleton.adapter.outbound.persistence.springdata.EntityManagerAccess
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanApplier
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanName
dev.caskeleton.adapter.outbound.persistence.springdata.JpaKeysetQuerySupport
dev.caskeleton.adapter.outbound.persistence.springdata.JpaRepositoryFragmentSupport
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamExecutor
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamScope
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetPredicateBuilder
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetSliceAssembler
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetTerm
dev.caskeleton.adapter.outbound.persistence.springdata.RegisteredQuery
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortField
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortMapper
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortRegistry
dev.caskeleton.adapter.outbound.persistence.springdata.ScrollPolicy
dev.caskeleton.adapter.outbound.persistence.springdata.SpecificationPolicy
dev.caskeleton.adapter.outbound.persistence.transaction.BackoffCalculator
dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecord
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecorder
dev.caskeleton.adapter.outbound.persistence.transaction.DefaultJpaRetryPolicy
dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts
dev.caskeleton.adapter.outbound.persistence.transaction.EvidenceAwareJpaTransactionManager
dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionConfig
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionSettings
dev.caskeleton.adapter.outbound.persistence.transaction.OptimisticConflictTranslator
dev.caskeleton.adapter.outbound.persistence.transaction.PersistenceFailureTranslatorChain
dev.caskeleton.adapter.outbound.persistence.transaction.RetryBudget
dev.caskeleton.adapter.outbound.persistence.transaction.RetrySleeper
dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor
dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort
dev.caskeleton.adapter.outbound.persistence.transaction.ThreadRetrySleeper
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionDefinitionMapper
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceContext
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceFrame
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceScope
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionProfileRegistry
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionStartBudget
dev.caskeleton.adapter.outbound.persistence.transaction.UnknownOperation
+11 -6
View File
@@ -5,10 +5,11 @@
# root yet. # root yet.
# Update only after review with: # Update only after review with:
# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange # ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
# types: 341 # types: 346
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceProperties dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings
dev.caskeleton.adapter.outbound.mongo.MongoRootAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint
@@ -16,7 +17,7 @@ dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedProperties dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedSettings
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge
@@ -143,10 +144,8 @@ dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGenerationRegistr
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformProperties dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseEvidence
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseGate
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
@@ -157,6 +156,10 @@ dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.ReactiveMongoChangeStreamConsumer
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.SpringReactiveChangeStreamSource
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
@@ -166,6 +169,7 @@ dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeHistoryLo
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery
dev.caskeleton.adapter.outbound.mongo.client.MongoClientSettingsFactory
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView
@@ -197,6 +201,7 @@ dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
+73
View File
@@ -0,0 +1,73 @@
# CI/CD 경계 — GitHub Actions는 CI, Argo CD는 CD
## 결론
GitHub Actions는 **검증하고 아티팩트를 만든다**. Argo CD는 **배포한다**. 두 역할은 겹치지 않는다.
GitHub Actions 워크플로는 `kubectl apply`, `helm upgrade`, `argocd app sync` 중 어느 것도 하지
않는다. 그러므로 CI에는 클러스터 자격증명(kubeconfig, 서비스 계정 토큰)이 들어가지 않는다.
## 흐름
```text
git push / tag
GitHub Actions ─────────────── CI ───────────────┐
• 테스트 · 정적분석 · 아키텍처 검증 │
• 컨테이너 이미지 빌드 │
• 취약점 스캔 (Trivy) │
• SBOM 생성 │
• 레지스트리에 이미지 push │
│ │
│ 이미지 태그(다이제스트)를 manifest에 기록 │
▼ │
GitOps 저장소 (배포 희망 상태) ──────────────────┘
│ Argo CD가 watch
Argo CD ──────────────────── CD ───────────────
│ auto-sync
Kubernetes
```
용어 한 줄 풀이:
- **GitOps 저장소** — 클러스터에 무엇이 떠 있어야 하는지를 적어 둔 Git 저장소. 애플리케이션 소스와
분리한다.
- **manifest** — Kubernetes에 넣을 YAML(Deployment, Service 등).
- **auto-sync** — Argo CD가 GitOps 저장소의 변경을 스스로 감지해 클러스터에 반영하는 모드. 이걸 쓰면
CI가 Argo CD API 서버에 접근할 필요가 없다.
## 왜 이렇게 나누나
1. **자격증명 반경.** CI가 배포하면 CI 러너가 프로덕션 클러스터에 대한 쓰기 권한을 갖는다. 포크된
PR, 서드파티 액션, 캐시 오염이 모두 그 권한에 닿는다. auto-sync를 쓰면 그 권한은 클러스터 안의
Argo CD에만 있고, CI는 Git에 커밋만 한다.
2. **현재 상태의 소유자가 하나.** 클러스터에 무엇이 떠 있는지는 GitOps 저장소가 답한다. CI가 직접
apply 하면 답이 두 개가 된다 — Git에 적힌 것과 실제로 떠 있는 것.
3. **롤백이 revert.** 배포를 되돌리는 것이 `git revert`가 된다.
## 이 저장소의 현재 위치
| 항목 | 상태 |
| --- | --- |
| 이미지 빌드/스캔/push | `release.yml`이 수행 |
| SBOM | `release.yml`이 생성 |
| 이미지 서명 · provenance attestation | **없음.** 추가 대상 |
| GitOps 저장소 | **없음.** 별도 저장소로 만들 예정 |
| Argo CD Application 정의 | **없음.** GitOps 저장소에 둘 예정 |
| CI에서의 클러스터 접근 | 없음 — 유일했던 `kubectl apply`는 제거됨 |
`fileserver-certification.yml`은 예외처럼 보이지만 아니다. PVC 매니페스트가 여전히 ReadWriteOnce를
선언하는지 **파일만** 확인하고, 클러스터에는 아무것도 적용하지 않는다. 실제 클러스터에서의 인증은
운영자가 `infra/fileserver/kubernetes/pvc-certification-job.yaml`을 직접 실행하고
`docs/fileserver/storage-certification.md`에 기록한다. 이름을 `fileserver-release.yml`에서 바꾼 이유가
이것이다 — 이 워크플로는 릴리스하지 않는다.
## 규칙
- 워크플로에 클러스터 자격증명 secret을 추가하지 않는다.
- 배포 대상이 바뀌면 GitOps 저장소의 manifest를 바꾼다. 워크플로를 바꾸지 않는다.
- CI가 만드는 것은 **불변 다이제스트로 지정된 이미지**다. `latest` 태그로 배포하지 않는다.
+50
View File
@@ -0,0 +1,50 @@
# Template maintainer와 Template consumer의 검증은 다르다
## 결론
이 저장소에는 성격이 다른 두 종류의 검증이 섞여 있다.
1. **스켈레톤을 만드는 사람**에게 필요한 검증 — sample 모듈이 정말 제거 가능한가, optional 모듈
조합이 모두 빌드되는가, 레지스트리가 확장 가능한가.
2. **스켈레톤을 가져다 서비스를 만드는 사람**에게 필요한 검증 — 내 애플리케이션의 테스트,
아키텍처 방향, 보안, 릴리스.
파생 프로젝트가 1번을 그대로 물려받으면, 자기 서비스와 아무 상관 없는 게이트를 평생 유지하게 된다.
이 문서는 어느 쪽이 어느 쪽인지 적어 둔다.
## Template 전용 (파생 프로젝트는 삭제해도 된다)
| 대상 | 무엇을 지키는가 |
| --- | --- |
| `:app-bootstrap:sampleOffTest`, `ci-quality-gates.yml``sample-off` job | sample 픽스처를 지워도 애플리케이션이 빌드·부팅되는가 |
| `sample-portfolio` leaf 전체 | 참조 구현 |
| `Dockerfile.sample`, `docker-compose.*` 중 sample 관련 | 위와 동일 |
| `docs/superpowers/**` | 이 템플릿을 만든 과정의 설계/계획 기록 |
| `gradle/qualification/**` | 이 템플릿이 벤더링한 플랫폼(JPA, messaging)의 인증 체계 |
| `*-certification.yml`, `*-qualification.yml`, `jpa-next-*.yml` | 템플릿이 광고하는 지원 매트릭스의 근거 |
## Consumer 필수 (파생 프로젝트가 유지해야 한다)
| 대상 | 무엇을 지키는가 |
| --- | --- |
| `architectureCheck` | Clean Architecture 의존 방향. 이 템플릿의 존재 이유 |
| 각 leaf의 `check` | 컴파일 · 단위 테스트 · 포맷 · 스타일 · Error Prone |
| `qualityCheck` | SpotBugs / FindSecBugs |
| `configContractCheck` | 환경변수 계약 |
| `verifyDependencyLocks` | 재현 가능한 의존성 해석 |
| `dependency-vulnerability.yml` | dependency-review + Trivy |
| `ci-quality-gates.yml` | PR 게이트 |
| `release.yml` | 이미지 · SBOM 생산 |
| action의 full SHA 핀 | 공급망 |
## 파생 프로젝트가 할 일
1. Template 전용 표의 항목을 삭제한다. 삭제는 대부분 파일 삭제 + `config/architecture/modules.json`
에서 leaf 항목 제거로 끝난다 — 레지스트리가 leaf 목록의 SSOT이고, 개수를 따로 적어 둔 곳은 없다.
2. `docs/ci-cd/boundary.md`의 경계를 그대로 유지한 채 자기 GitOps 저장소를 연결한다.
3. `.trivyignore.yaml`과 CODEOWNERS는 그대로 쓴다.
## 아직 하지 않은 것
Template CI와 Generated Application CI를 **물리적으로** 분리하지는 않았다(생성기 없음). 지금은 이
문서가 그 경계다. 생성기를 만든다면, 위 표의 "Template 전용" 열이 생성기가 벗겨 내야 할 목록이다.
@@ -0,0 +1,62 @@
# gRPC advanced capability support matrix
Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it.
`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade`
carries the same values.
All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`.
## Grades
| Grade | May start | Production needs a separate approval |
| --- | --- | --- |
| `ADVANCED_STABLE` | Yes | No |
| `EXPERIMENTAL` | Yes | Yes |
| `WATCH` | No | — |
| `DISABLED` | No | — |
## Capabilities
| Capability | Flag | Grade | Real infrastructure its evidence needs |
| --- | --- | --- | --- |
| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — |
| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — |
| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — |
| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — |
| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — |
| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — |
| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — |
| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — |
| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane |
| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy |
| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container |
| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — |
| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — |
| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain |
| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — |
## What the grades mean here, concretely
**Grade is a statement about the contract, not about a deployment.** Every capability's contract is
implemented and tested in this repository. What no capability has is evidence from a real deployment:
`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted.
**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they
need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that
runs without its infrastructure passes and establishes nothing.
**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so
`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The
four contract requirements — one schema source shared with Java, coroutine cancellation propagated,
Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable
without a toolchain and are checked. The compile lane is not.
## Promotion thresholds
| To | Soak | Also required |
| --- | --- | --- |
| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test |
| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review |
`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one
capability changes no other's grade.
+75
View File
@@ -0,0 +1,75 @@
# gRPC platform support matrix
What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and
what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement
anyone can act on.
`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and
`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one.
## Lanes
| Lane | Grade | Failure blocks a release |
| --- | --- | --- |
| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes |
| proto3 with explicit `optional` | Certified | Yes |
| `grpc-netty-shaded` | Certified | Yes |
| `grpc-netty` (unshaded) | Compatibility | No |
| Upstream gRPC Java version override | Compatibility | No |
| Protobuf Edition 2024 | Watch | No |
| Protobuf Edition 2026 | Watch | No |
## Runtime baseline
| | |
| --- | --- |
| Java | 21 |
| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) |
| io.grpc | `ext.grpcVersion` in `src/build.gradle` |
| Protobuf | `ext.protobufVersion` in `src/build.gradle` |
| Stable transport | Netty (shaded) |
| Stable RPC shapes | Unary, Server Streaming |
| Stable resolvers | Static, DNS, Unix domain socket |
| Stable load balancing | `pick_first`, `round_robin` |
## Evidence grades
A capability may only be advertised on evidence of a grade that can establish it.
`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a
claim backed by the wrong lane.
| Grade | Lane | Establishes |
| --- | --- | --- |
| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation |
| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown |
| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier |
| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget |
In-process results are never transport evidence. The in-process transport does not negotiate TLS,
does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has
tested the adapter and not the transport.
## What is not supported
| | Where it lives |
| --- | --- |
| Client streaming, bidirectional streaming | `grpc-advanced-streaming` |
| Manual flow control | `grpc-advanced-streaming` |
| Hedging | `grpc-advanced-resilience` |
| Custom name resolver, custom load balancer | `grpc-advanced-resilience` |
| xDS | `grpc-advanced-resilience` |
| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` |
| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` |
## Current release status
Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the
platform is build-only: it compiles, its lanes run, and no deployed artifact carries it.
Two release gate inputs are outstanding and are the work between here and a release:
- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate
that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns
it into a regression gate.
- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and
the consumer-compile fixture are governed as policy rather than produced from a compiled schema.
+1 -1
View File
@@ -12,7 +12,7 @@
# #
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in # Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
# src/.env: it is the only key with a deployment-independent value, and it is the only one the # src/.env: it is the only key with a deployment-independent value, and it is the only one the
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly # three-way :app-bootstrap:verifyEnvKeys gate can express. Everything below is per deployment and is set directly
# in the environment — templating an indexed client in application.yml would materialise a nameless # in the environment — templating an indexed client in application.yml would materialise a nameless
# client in every deployment, which the settings' aggregate validation refuses. # client in every deployment, which the settings' aggregate validation refuses.
# #
+3 -3
View File
@@ -1,7 +1,7 @@
# HTTP Client Platform — Repository Adaptation Contract # HTTP Client Platform — Repository Adaptation Contract
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md` **Design source:** `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md` **Plan source:** `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
The design package states its own adaptation rule: The design package states its own adaptation rule:
@@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
| Design assumption | Repository reality | Adaptation | | Design assumption | Repository reality | Adaptation |
|---|---|---| |---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. | | Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. | | Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. | | `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. | | Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar | | Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
+6 -9
View File
@@ -1,11 +1,8 @@
# JPA Relational Persistence Platform — Repository Adaptation Contract # JPA Relational Persistence Platform — Repository Adaptation Contract
**Design source:** `jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` **Design source:** `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
(copied to `docs/superpowers/specs/`) **Stable plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
**Stable plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md` **Experimental plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
(copied to `docs/superpowers/plans/`)
**Experimental plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
(copied to `docs/superpowers/plans/`)
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
structure are explicit implementation *assumptions* made because the real Backend Skeleton structure are explicit implementation *assumptions* made because the real Backend Skeleton
@@ -88,7 +85,7 @@ in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|---|---| |---|---|
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` | | `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks | | `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
| `performanceTest` | `src/jpaPlatformPerformanceTest`machine-dependent bounds, never part of `check` | | `performanceTest` | `src/jpaPlatformPerformanceTest`pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. |
Docker-dependent lanes fail closed rather than skipping, matching the existing Docker-dependent lanes fail closed rather than skipping, matching the existing
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf. `PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
@@ -98,13 +95,13 @@ Docker-dependent lanes fail closed rather than skipping, matching the existing
| Plan assumption | Repository reality | Adaptation | | Plan assumption | Repository reality | Adaptation |
|---|---|---| |---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. | | Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). | | Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. | | Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. | | PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. | | `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. | | `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. | | `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. | | release blocking aggregate | `.github/workflows/jpa-release.yml` | CI names the blocking JPA lanes directly; Gradle only defines how each lane runs. |
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. | | Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. | | Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". | | Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
+37 -1
View File
@@ -10,6 +10,20 @@ major changed nothing so long as the string survived somewhere in the document.
declares a support level per major as a field, each gate names the Gradle task that produces its declares a support level per major as a field, each gate names the Gradle task that produces its
evidence, and this document describes what the registry says. evidence, and this document describes what the registry says.
Being a rendering used to be a claim rather than a mechanism: the tables below were still typed by
hand, so a major demoted in the registry stayed Stable here and kept its full release job.
`JpaReleaseRenderingTest` now compares the database table, the gate table and `jpa-release.yml`'s
matrix and promotion lists to the registry, and `verifyJpaReleaseGateTasks` resolves every gate's
task against the real Gradle task graph. Edit the registry; these tables follow, or the build fails.
Two renderings stayed outside that comparison until they were added to it. `jpa-nightly.yml` runs
its own matrix and nothing checked it, so a demotion corrected the release lane and left the nightly
lane certifying the major. And an Experimental major's "compatibility lane only" named no file: the
lane existed, but the registry, this document and the release workflow could each be read end to end
without establishing that, so a reader looking for it concluded there was none. An Experimental major
now has to be recorded as the target of a lane in `.github/workflows`, and a Stable lane may not run
it.
## Database ## Database
| Database | Support | Evidence | | Database | Support | Evidence |
@@ -17,7 +31,7 @@ evidence, and this document describes what the registry says.
| PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) | | PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR | | PostgreSQL 19 | Experimental | [`jpa-next-postgresql19.yml`](../../.github/workflows/jpa-next-postgresql19.yml) — `NOT_EXECUTABLE`: no `postgres:19-alpine` is published, so no container of that major has been started; promotion requires an ADR |
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour | | H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
Each major gets its **own release job**, because for a while it did not. The release lane passed Each major gets its **own release job**, because for a while it did not. The release lane passed
@@ -82,6 +96,8 @@ the difference visible instead of asserting a constant against itself. See
| PostgreSQL `COPY` | Admin (J4) | | PostgreSQL `COPY` | Admin (J4) |
| Hibernate second-level cache | Advanced | | Hibernate second-level cache | Advanced |
| Hibernate Envers | Advanced | | Hibernate Envers | Advanced |
| Technical auditing — `audit/AuditableEntity` | Stable (canonical) |
| Technical auditing — `auditing/AuditMetadata` | Candidate, not composed |
| Multi-tenancy (column, RLS, schema, database) | Experimental | | Multi-tenancy (column, RLS, schema, database) | Experimental |
| Consistency-aware read replica | Experimental | | Consistency-aware read replica | Experimental |
@@ -98,6 +114,26 @@ Each row is a way the platform could pass its tests and still be wrong in produc
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects | | `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
| `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory | | `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
### The two audit mechanisms
`audit/AuditableEntity` is the canonical one: `created_*`/`updated_*`, a 256-character actor,
stamped explicitly by the repository adapter. It is what the sample entities extend and what the
migrations were written for.
`auditing/AuditMetadata` is a second, complete mechanism with different column names
(`modified_*`), a different actor length (64) and a different capture lifecycle (Spring Data
listeners). Nothing embeds it and nothing composes `JpaAuditingConfiguration`, which is why it is
listed as a candidate rather than as a capability: promoting it means choosing between reshaping it
to the canonical columns and writing a forward migration for the new ones, and that choice has not
been made. Until it is, an entity picks one mechanism or none — enforced on the production graph by
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`.
Neither mechanism reaches a bulk or native update. Both stamp on an ordinary save — one in the
adapter, one on a managed entity's lifecycle — so a statement that goes straight to the database
leaves the audit columns showing the previous save. A bulk update of an audited entity must
therefore set the audit column in the statement, which
`JpaAuditMechanismRule.bulkUpdatesOfAuditedEntitiesStampAudit` checks over the production graph.
## Explicitly unsupported ## Explicitly unsupported
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call - Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
+167 -91
View File
@@ -7,60 +7,99 @@
> either of the old prefixes now fails startup with a message naming the key — see > either of the old prefixes now fails startup with a message naming the key — see
> `MessagingPrefixMigrationValidator`. > `MessagingPrefixMigrationValidator`.
> **이 페이지는 실행된다.** 아래 YAML 블록은 `MessagingConfigurationBindingTest`가 이 파일에서 직접
> 읽어 컨텍스트에 올린다. 문서가 설명하는 모양이 곧 바인딩되는 모양이라는 뜻이고, 문서를 고치면서
> 코드를 고치지 않으면 테스트가 깨진다. 이전 판은 destination·broker·security 세 섹션을 설명했지만
> 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지
> 못했다 (MSG-008).
## Application publish bridge identity
Application의 canonical integration event를 platform publish pipeline으로 보낼 때는
`app.messaging.producer-id`를 명시한다. 같은 값의 환경변수 이름은
`APP_MESSAGING_PRODUCER_ID`다. 이 값은 host/pod 이름이 아니라 배포와 무관하게 유지되는 논리적
producing-service identity다.
값이 없으면 `IntegrationEventPublishPort` bridge 자체를 만들지 않는다. `spring.application.name`이나
현재 process 이름으로 추론하지 않는다. 기존 legacy `OutboxEvent`/realtime 경로는 별도 cutover가
끝날 때까지 `app.messaging.broker` 경로를 유지한다.
## Outbox canonical transport-only cutover
`APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED` / `ca-skeleton.outbox.canonical-transport-enabled`
기존 `outbox_event` writer/claim/status authority를 유지한 채 canonical row의 **transport만** platform
publish path로 보내는 compatibility gate다. 기본값은 `false`이며 `POLLING_V2`를 활성화하지 않는다.
`true`일 때는 `OutboxAppendPort``ValidatedIntegrationEvent`의 exact envelope bytes와 canonical
metadata를 기존 outbox row에 저장하고, claim된 canonical row는 `IntegrationEventPublishPort`로 간다.
legacy row는 계속 `MessageBroker`를 사용한다. 따라서 mixed-row compatibility 기간에는 relay가 켜져
있다면 `app.messaging.broker`도 계속 필요하며, canonical path를 위해 `IntegrationEventPublishPort`
추가로 필요하다. legacy backlog가 0이라는 별도 증거 없이 broker 요구를 제거하지 않는다.
## Destination profile ## Destination profile
```yaml ```yaml
app: app:
messaging: messaging:
destinations: destinations:
order-events: order-events:
broker: kafka-primary broker: kafka-primary
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
# | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY # | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY
tier: M1 # M1 | M2 | M3 tier: M1 # M1 | M2 | M3
physical: physical:
topic: order.events.v1 topic: order.events.v1
schema: schema:
codec: application/json codec: application/json
compatibility: BACKWARD_TRANSITIVE compatibility: BACKWARD_TRANSITIVE
message-types: [order.created] message-types: [order.created]
guarantees: guarantees:
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
ordering: KEY # NONE | DESTINATION | PARTITION | KEY ordering: KEY # NONE | DESTINATION | PARTITION | KEY
external-side-effect: INBOX_TRANSACTIONAL external-side-effect: INBOX_TRANSACTIONAL
producer: producer:
confirmation: REPLICATION_OR_PERSISTENCE_ACK confirmation: REPLICATION_OR_PERSISTENCE_ACK
timeout: 5s timeout: 5s
mandatory-routing: true mandatory-routing: true
idempotent: true idempotent: true
consumer: consumer:
group: order-projection group: order-projection
concurrency: 6 concurrency: 1 # DESTINATION 순서를 요구하면 1이어야 한다
max-in-flight-per-ordering-unit: 1 max-in-flight-per-ordering-unit: 1
prefetch: 16 prefetch: 16
handler-timeout: 30s handler-timeout: 30s
manual-settlement: false manual-settlement: false
retry: retry:
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
# | RETRY_DESTINATION | BROKER_DELAYED # | RETRY_DESTINATION | BROKER_DELAYED
max-attempts: 3 max-attempts: 3
initial-delay: 200ms initial-delay: 200ms
max-delay: 2s max-delay: 2s
multiplier: 2.0 multiplier: 2.0
jitter: true jitter: true
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
dlq: dlq:
destination: order-events-dlq destination: order-events-dlq
max-redrive-count: 1 max-redrive-count: 1
payload: payload:
max-bytes: 1048576 max-bytes: 1048576
claim-check-threshold-bytes: 1048576 claim-check-threshold-bytes: 1048576
key-resolver-configured: true key-resolver-configured: true
production: true production: false
topology-auto-create: false topology-auto-create: false
order-events-dlq:
broker: kafka-primary
kind: WORK_QUEUE
physical:
topic: order.events.v1.dlt
schema:
message-types: [order.created]
``` ```
`dlq.destination`이 가리키는 destination도 선언되어야 한다. 선언되지 않은 이름은 부팅 실패이며,
메시지가 갈 곳 없는 DLQ 설정이 조용히 통과하지 않는다. `retry.destination``dlq.destination`
섞여 만드는 순환(A의 retry가 B로, B의 dlq가 A로)도 하나의 그래프로 검사되어 경로와 함께 거절된다.
## 기본값 ## 기본값
| 설정 | 기본값 | 근거 | | 설정 | 기본값 | 근거 |
@@ -81,26 +120,36 @@ app:
| Outbox polling | 500ms | | | Outbox polling | 500ms | |
| metric dimension 상한 | 200 | cardinality 폭발 방지 | | metric dimension 상한 | 200 | cardinality 폭발 방지 |
`schema.codec``application/json`, `schema.compatibility``BACKWARD_TRANSITIVE`,
`guarantees.delivery``AT_LEAST_ONCE`, `retry.mode``NONE`이 기본값이다. 자동 retry가 기본으로
꺼져 있는 이유는 순서를 흐트러뜨리거나 비멱등 side effect를 두 번 실행하는 retry가 눈에 보이는
실패보다 나쁘기 때문이다.
## Broker profile ## Broker profile
브로커는 `app.messaging.brokers` 아래에 한 번만 기술한다. `type`이 어느 계열의 설정이 적용되는지
결정하며, 다른 계열의 키(Kafka 항목의 `prefetch` 같은)는 무시되지 않고 부팅 실패로 거절된다 —
무시하면 그 줄을 쓴 사람은 무언가가 적용됐다고 믿게 된다.
### Kafka ### Kafka
```yaml ```yaml
app: app:
messaging: messaging:
brokers: brokers:
kafka-primary: kafka-primary:
type: kafka type: kafka
stable: true stable: true
production: true production: false
bootstrap-servers: [broker-1:9093, broker-2:9093] bootstrap-servers: [broker-1:9093, broker-2:9093]
enable-idempotence: true # stable에서 필수 enable-idempotence: true # stable에서 필수
acks: all # stable에서 필수 acks: all # stable에서 필수
max-in-flight-requests-per-connection: 5 # 최대 5 max-in-flight-requests-per-connection: 5 # 최대 5
delivery-timeout: 30s delivery-timeout: 30s
enable-auto-commit: false # 항상 금지 enable-auto-commit: false # 항상 금지
tls-enabled: true # production 필수 consumer-group: order-projection
authentication-enabled: true # production 필수 tls-enabled: false # production이면 필수
authentication-enabled: false # production이면 필수
``` ```
### RabbitMQ ### RabbitMQ
@@ -108,40 +157,52 @@ app:
```yaml ```yaml
app: app:
messaging: messaging:
brokers: brokers:
rabbit-primary: rabbit-primary:
type: rabbitmq type: rabbitmq
stable: true stable: true
production: true production: false
addresses: [rabbit-1:5671] addresses: [rabbit-1:5671]
publisher-confirms: true # stable에서 필수 publisher-confirms: true # stable에서 필수
publisher-returns: true # stable에서 필수 publisher-returns: true # stable에서 필수
mandatory: true # stable에서 필수 mandatory: true # stable에서 필수
confirm-timeout: 5s confirm-timeout: 5s
auto-ack: false # 항상 금지 auto-ack: false # 항상 금지
prefetch: 16 prefetch: 16
quorum-queues: true # durable work queue 필수 quorum-queues: true # durable work queue 필수
tls-enabled: true tls-enabled: false
authentication-enabled: true authentication-enabled: false
``` ```
`production: true`인 브로커는 `tls-enabled``authentication-enabled`가 모두 참이어야 하고,
그렇지 않으면 `KafkaProfileValidator` / `RabbitProfileValidator`가 부팅을 거절한다. 위 예시가
`production: false`인 것은 이 페이지가 그대로 실행되는 fixture이기 때문이며, 실 배포는 셋 다 참이다.
## 보안 ## 보안
```yaml ```yaml
app: app:
messaging: messaging:
security: security:
kafka-primary: kafka-primary:
producer: { type: SASL_SCRAM, credential-id: kafka-producer } producer: { type: SASL_SCRAM, credential-id: kafka-producer }
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer } consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
# admin은 application runtime에 설정하지 않는다 # admin은 application runtime에 설정하지 않는다
hostname-verification: true hostname-verification: true
access: access:
publishable: [order-events] publishable: [order-events]
consumable: [] consumable: []
administrable: [] administrable: []
``` ```
키는 `app.messaging.brokers`에 선언된 브로커 이름과 같아야 한다. `tls-enabled``production`
브로커 쪽에만 있고 여기에 중복되지 않는다 — 하나의 브로커가 두 곳에서 기술되면 두 값이 어긋나는
날이 오고, 어느 쪽이 이기는지는 아무도 모른다.
`credential-id`는 이름일 뿐이고 자격 증명 자체가 아니다. 실제 재료는 `CredentialProvider`
연결 시점에 해석하므로, 설정 덤프나 힙 덤프에서 나오는 것은 이름뿐이다. producer와 consumer는
서로 다른 `credential-id`를 써야 하며, 같으면 부팅에 실패한다.
## Experimental / Optional ## Experimental / Optional
기본값은 전부 `false`다. 기본값은 전부 `false`다.
@@ -149,12 +210,12 @@ app:
```yaml ```yaml
app: app:
messaging: messaging:
experimental: experimental:
kafka-share: false kafka-share: false
pulsar: false pulsar: false
nats: false nats: false
bridge: bridge:
spring-cloud-stream: false spring-cloud-stream: false
``` ```
## Backpressure ## Backpressure
@@ -162,9 +223,24 @@ app:
```yaml ```yaml
app: app:
messaging: messaging:
backpressure: backpressure:
global-limit: 512 global-limit: 512
per-destination-limit: 64 # global-limit 이하여야 한다 per-destination-limit: 64 # global-limit 이하여야 한다
``` ```
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다. `per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
## 바인딩되지 않는 키
섹션은 바인딩되는데 그 안의 키 하나가 오타인 경우는 접두사 오타와 달리 조용하다 — 섹션은 붙고,
플랫폼은 뜨고, 바꾸러 온 그 설정만 적용되지 않는다. `MessagingConfigurationKeyValidator`
`app.messaging.destinations|brokers|security` 아래의 모든 키를 settings 레코드에서 파생한 목록과
대조하고, 없는 키는 그 키 이름을 담아 부팅을 거절한다.
허용 키 목록은 이 문서가 아니라 레코드에서 나온다. 문서에 목록을 적으면 필드가 추가된 날 그
목록이 틀리고, 오타를 잡으라고 만든 검사가 정상 필드를 거절하게 된다.
환경변수(`APP_MESSAGING_...`)는 이 검사의 대상이 아니다. `APP_MESSAGING_DESTINATIONS_ORDER_EVENTS_
CONSUMER_PREFETCH`에서 entry 이름과 leaf를 가르는 밑줄은 둘 안에 있는 밑줄과 구별되지 않으므로,
되돌려 쪼개려면 추측해야 한다. 여기서의 추측은 정상 배포를 거절하는 쪽으로 틀리며, 그것은 배포
매니페스트에 손으로 적어야 하는 변수에서 오타 하나를 놓치는 것보다 나쁘다.
+1 -1
View File
@@ -5,7 +5,7 @@
```bash ```bash
./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyRuntimeModuleMembership --console=plain ./gradlew verifyRuntimeModuleMembership --console=plain
./gradlew verifyOneTypePerFile --console=plain ./gradlew checkstyleMain --console=plain
``` ```
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다. destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
+23 -1
View File
@@ -3,6 +3,12 @@
플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다. 플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다.
여기 없는 조합은 지원되지 않는다. 여기 없는 조합은 지원되지 않는다.
> **등급은 증거를 따른다.** `CompatibilityMatrix.Entry.hasLiveBrokerCertification()`은 선언된
> boolean이 아니라 `CertifiedEvidence`가 가진 레인 증거에서 파생된다. RabbitMQ가 Stable에서 내려온
> 이유가 이것이다 — 어댑터는 공유 contract 7개를 통과하고 `RabbitBrokerIT`가 실 컨테이너에서 정상
> 경로를 돌리지만, 이 저장소의 Stable 기준인 **장애 시나리오 증거**가 하나도 없다. 레인이 생겨
> 증거를 내면 등급은 코드 수정 없이 따라 올라간다.
> **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은 > **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은
> Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와 > Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와
> 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로 > 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로
@@ -25,7 +31,7 @@
| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 | | 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 |
|---|---|---|---|---| |---|---|---|---|---|
| Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental | | Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental |
| RabbitMQ | Stable | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | stream 및 특수 plugin 미지원 | | RabbitMQ | Experimental | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | 장애 시나리오 레인 미실행 — 증거 없음. stream 및 특수 plugin 미지원 |
| Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 | | Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 |
| NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 | | NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 |
| Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 | | Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 |
@@ -86,11 +92,15 @@ Kafka와 RabbitMQ가 동일한 7개 테스트를 변경 없이 통과한다. 결
|---|---| |---|---|
| `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit | | `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit |
| `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) | | `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) |
| `KafkaBrokerCertificationIT` | 인증 레인. Toxiproxy를 broker 앞에 두고 connection cut / confirm 유실 / 지연 / settlement 유실을 각각 주입하고, 통과한 시나리오마다 `BrokerCertificationEvidence` 한 줄을 manifest에 쓴다 |
| `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 | | `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 |
| `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 | | `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 |
| `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 | | `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 |
Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다. Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다.
`KafkaBrokerCertificationIT`만 예외다 — 인증 레인은 가드를 달지 않고 Docker가 없으면 실패한다. skip하는
레인은 아무도 켜지 않은 브로커에 대해 성공을 보고하기 때문이다. 그래서 이 레인은 `test`에서 태그로
제외되고 `messagingCertificationTest`로만 실행된다.
### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`) ### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`)
@@ -109,6 +119,18 @@ Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목
커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라 커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라
*무엇을 실제로 돌렸는지*의 기록이다. *무엇을 실제로 돌렸는지*의 기록이다.
**증거의 출처.** `CertifiedEvidence`는 더 이상 손으로 쓴 목록이 아니라
`messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl`을 읽는다. 그 파일은
`messagingCertificationTest` 레인이 실제 Kafka 컨테이너에 장애를 주입하며 만들어낸 출력이고,
`verifyMessagingCertificationEvidence`가 커밋된 manifest와 이번 실행의 출력을 대조해 다르면 빌드를
실패시킨다. 즉 **manifest를 손으로 고치면 게이트가 깨지고, 레인을 돌리면 manifest가 다시 쓰인다.**
오늘 Kafka가 가진 증거는 `connection-cut-after-write` · `confirm-timeout` · `high-latency` ·
`settlement-lost` 네 개다. `connection-refused`는 남은 gap이며 그 이유가 있다 — Kafka producer는 연결
존재 여부를 알기 전에 레코드를 버퍼에 넣으므로, 연결 거부는 전송에 대해 아무것도 증명하지 못하는
delivery timeout으로 나타난다. 이를 `REJECTED`로 보고하는 것은 이 플랫폼이 금지한 추측이므로,
시나리오는 `CertifiedEvidence.knownGaps`가 이름으로 들고 있는 미커버 항목으로 남는다.
### 실 브로커가 실제로 잡아낸 결함 ### 실 브로커가 실제로 잡아낸 결함
이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한 이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한
+1 -1
View File
@@ -11,7 +11,7 @@
| Topology | A real sharded cluster. A replica set cannot exercise routing. | | Topology | A real sharded cluster. A replica set cannot exercise routing. |
| Server | MongoDB 7.0 or 8.0. | | Server | MongoDB 7.0 or 8.0. |
| Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. | | Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. |
| Gate | `mongoShardedTest` lane with `MongoShardingContractSuite`. | | Gate | **Not promoted.** No `mongoShardedTest` lane is registered, and a sharded cluster is not an environment this repository stands up. Listed under `experimental_contracts` in `src/config/mongodb/release-contracts.json`; promoting it needs the lane, its required class, and protected-environment evidence to exist first. |
## Shard key ## Shard key
+5 -1
View File
@@ -1,6 +1,10 @@
# Advanced capability sign-off # Advanced capability sign-off
`scripts/verify-mongodb-advanced.sh` treats a file in this directory as the evidence that a review > **2026-08-15:** `scripts/verify-mongodb-advanced.sh` was removed, so nothing reads this directory
> automatically any more. The files below are still the record that a review happened, but a missing
> one no longer fails anything — a human has to check for it during promotion.
`scripts/verify-mongodb-advanced.sh` treated a file in this directory as the evidence that a review
happened: happened:
- `security.md` — per-capability privilege review, naming the roles granted and by whom. - `security.md` — per-capability privilege review, naming the roles granted and by whom.
+20 -8
View File
@@ -1,8 +1,8 @@
# MongoDB Document Persistence Platform — Repository Adaptation Contract # MongoDB Document Persistence Platform — Repository Adaptation Contract
**Design source:** `mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md` **Design source:** `docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md`
**Stable plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md` **Stable plan:** `docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md`
**Advanced plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md` **Advanced plan:** `docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md`
The design package declares its own module root (`modules/mongodb`) and root package The design package declares its own module root (`modules/mongodb`) and root package
(`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the (`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the
@@ -91,12 +91,12 @@ otherwise. Being on the classpath is not being enabled.
|---|---|---| |---|---|---|
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. | | Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. | | `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. | | Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.8 / Spring Data MongoDB 5.0.x | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. | | `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. | | `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. | | Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. | | Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. | | `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. The two gate scripts were later removed (2026-08-15); see §5. |
## 4. What is unchanged from the design ## 4. What is unchanged from the design
@@ -124,9 +124,21 @@ otherwise. Being on the classpath is not being enabled.
## 5. Verification ## 5. Verification
The two release-gate scripts (`scripts/verify-mongodb-platform.sh` and
`scripts/verify-mongodb-advanced.sh`) were removed on 2026-08-15. They wrapped the Gradle lanes below
and added two things Gradle does not do on its own: a lane that executed zero tests was reported as a
failure rather than counted as a pass, and a `promotion.json` recording the commit, server image and
contract-manifest hash. Neither exists until something replaces it, so a green run of the commands
below is weaker evidence than the gate was.
From `src/`:
```bash ```bash
bash scripts/verify-mongodb-platform.sh # Stable gate ./gradlew :adapter:outbound:persistence-mongo:check --console=plain
bash scripts/verify-mongodb-advanced.sh # Advanced gate (opt-in lanes) ./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
``` ```
Both scripts run from the repository root and delegate to `src/gradlew`. The container-backed lanes the gate ran behind `MONGODB_DOCKER=1``mongoCompatibilityTest`,
`mongoMigrationTest`, `mongoSecurityIntegrationTest`, `mongoReplicaSetTest`, `mongoFailoverTest`,
`mongoPerformanceTest` — are now invoked by name or not at all.
@@ -26,8 +26,14 @@ dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.Notification
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSecretRequirements
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpProviderConfig
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpSettings
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.SmtpProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.callback.MapProviderCallbackAdapterRegistry
dev.caskeleton.adapter.outbound.notification.platform.callback.MapProviderEventProjectorRegistry
dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog
@@ -53,9 +59,11 @@ dev.caskeleton.adapter.outbound.notification.platform.dispatch.SingleTenantConte
dev.caskeleton.adapter.outbound.notification.platform.dispatch.UuidV7Generator dev.caskeleton.adapter.outbound.notification.platform.dispatch.UuidV7Generator
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationAudit dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationAudit
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationMetrics dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationMetrics
dev.caskeleton.adapter.outbound.notification.platform.observation.MicrometerNotificationMetrics
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds
dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments
dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults
dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier
@@ -89,6 +97,7 @@ dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesRequestMap
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier
@@ -122,6 +131,7 @@ dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorContextBrid
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmNotificationPayloadProtection
dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration
dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher
dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager
@@ -137,6 +147,7 @@ dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTempl
dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine
dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotPolicy
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine
dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId
@@ -384,6 +395,7 @@ dev.caskeleton.application.notification.platform.callback.ProviderEventProjector
dev.caskeleton.application.notification.platform.callback.ProviderEventRecord dev.caskeleton.application.notification.platform.callback.ProviderEventRecord
dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId
dev.caskeleton.application.notification.platform.callback.ProviderEventSource dev.caskeleton.application.notification.platform.callback.ProviderEventSource
dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash
dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector
dev.caskeleton.application.notification.platform.callback.SuppressionFacts dev.caskeleton.application.notification.platform.callback.SuppressionFacts
dev.caskeleton.application.notification.platform.callback.VerifiedCallback dev.caskeleton.application.notification.platform.callback.VerifiedCallback
@@ -400,6 +412,7 @@ dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationTo
dev.caskeleton.application.notification.platform.contact.MobilePushTarget dev.caskeleton.application.notification.platform.contact.MobilePushTarget
dev.caskeleton.application.notification.platform.contact.PhoneNumber dev.caskeleton.application.notification.platform.contact.PhoneNumber
dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue
dev.caskeleton.application.notification.platform.dispatch.AcceptNotificationApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase
@@ -430,6 +443,7 @@ dev.caskeleton.application.notification.platform.dispatch.PolicyRoutePlanner
dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort
dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort
dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort
dev.caskeleton.application.notification.platform.dispatch.PublishNotificationTemplateApplicationUseCase
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort
dev.caskeleton.application.notification.platform.dispatch.RecipientLease dev.caskeleton.application.notification.platform.dispatch.RecipientLease
@@ -499,12 +513,16 @@ dev.caskeleton.application.notification.platform.policy.SuppressionReason
dev.caskeleton.application.notification.platform.policy.SuppressionScope dev.caskeleton.application.notification.platform.policy.SuppressionScope
dev.caskeleton.application.notification.platform.policy.SuppressionSource dev.caskeleton.application.notification.platform.policy.SuppressionSource
dev.caskeleton.application.notification.platform.policy.SuppressionStorePort dev.caskeleton.application.notification.platform.policy.SuppressionStorePort
dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand
dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand
dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery
dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase
dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand
dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand
@@ -539,6 +557,8 @@ dev.caskeleton.application.notification.platform.push.ReceiptKind
dev.caskeleton.application.notification.platform.push.ReceiptResult dev.caskeleton.application.notification.platform.push.ReceiptResult
dev.caskeleton.application.notification.platform.security.AccessContext dev.caskeleton.application.notification.platform.security.AccessContext
dev.caskeleton.application.notification.platform.security.ContactPointProtector dev.caskeleton.application.notification.platform.security.ContactPointProtector
dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection
dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException
dev.caskeleton.application.notification.platform.security.NotificationRedactor dev.caskeleton.application.notification.platform.security.NotificationRedactor
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext
+116
View File
@@ -0,0 +1,116 @@
# Notification payload at rest — threat model and decision (NTF-INT-007)
Wave 2 Task D6 offers two branches and requires that one be chosen and implemented fully:
- **(a) encryption** — a codec/port, ciphertext envelope, key id, rotation and history, row migration,
and a decryption-failure contract;
- **(b) restriction** — this document plus a static restriction proving the variable types cannot
carry sensitive values.
The plan recommends (b) **"if and only if the variable types can genuinely be restricted to
non-sensitive values"**. They cannot. This document records why, what that leaves, and when the
remaining branch lands.
## What is stored, and where
`CanonicalNotificationPlanWriter.request(...)` puts `encoded.variablesPayload()` into
`NotificationRequestRecord` verbatim. `JpaNotificationRequestStore` writes that record to
`notification_request.variables_payload` with no transformation. There is no encryption anywhere on
this path.
## Why the restriction branch is unavailable
Template variables are a closed algebra — `NotificationVariable` permits `TextValue`, `NumberValue`,
`BooleanValue`, `NullValue`, `ListValue`, `ObjectValue` — which is a real improvement over the
`Map<String, Object>` it replaced. But `TextValue` holds an arbitrary UTF-8 string of up to 8 KiB,
and that is not an oversight to be tightened: **the variables are the recipient-specific content of
the message**. A password-reset code, an order total, a delivery address, a patient's appointment
time — those are what a notification is for.
A restriction to "non-sensitive values" would therefore be one of two things, and both are worse
than the problem:
- **unenforceable** — a comment saying callers should not put sensitive data in a field designed to
carry the message's content, which is a policy no type checks and no reviewer can see violated;
- **enforced and useless** — a type that refuses free text, which does not restrict the capability so
much as delete it.
The precondition on the plan's recommendation is false. Branch (b) is not available.
> **Status: branch (a) implemented at the storage boundary.** `NotificationPayloadProtection` is the
> application-owned port, `AesGcmNotificationPayloadProtection` the AES-GCM implementation, and
> `NotificationRecordMapper` applies it — as a **required** constructor argument, so a composition
> cannot assemble the notification stores while leaving the payload in plaintext. What remains before
> the facade can be imported is the row migration for any deployment that already has plaintext rows,
> and the `local-notification-*` lanes. The analysis below is kept as written, because it is what the
> decision rests on.
## Decision: branch (a), landing with the persistence wiring
Encryption is therefore the required branch. Its scope is unchanged from the plan: a codec behind an
application port, a ciphertext envelope carrying its key id, key rotation with history so an old row
stays readable, a migration for existing rows, and an explicit contract for what a decryption failure
does to a request.
**It lands in the change unit that makes the write path reachable, and not before.** The reason is a
fact the spec did not have: `NotificationJpaPersistenceFacade`, which assembles
`JpaNotificationRequestStore`, is imported by nothing. The composition root's component scan excludes
the persistence package by design, and no configuration imports the facade — so the notification
capability has **no JPA persistence at all**, and no deployment currently writes this payload
anywhere. The defect is real in the code and latent in the runtime.
Designing key rotation and a row migration for rows that no deployment produces would be building the
migration before the table. Worse, it would settle the envelope's shape before the store that has to
read it is wired, which is the order that produces an envelope the store cannot use.
**One correction, learned by trying it.** This section said the envelope "lands with the wiring".
Wiring the facade first — to register the SMTP assembler — made
`NotificationPayloadAtRestContractTest` fail on the case asserting the write path is reachable from
no composition, which is exactly what that case is for. The wave forbids connecting wiring over a
known security finding on a runtime path, so the wiring was reverted and the envelope built first.
The honest ordering is **envelope before or with the wiring, never after**, and the contract test now
enforces it by failing on the wiring alone.
### The envelope, and why it has a key id
```
byte version always 1
byte keyIdLength 1..255 UTF-8 bytes
byte[] keyId
byte[12] nonce
byte[] ciphertext + GCM tag
```
The key id is the reason there is a format at all. This repository's callback protection stores nonce
and ciphertext and nothing else, so the day the active key changes, every row written under the
previous one becomes unreadable and nothing in the row can say which key it needed — that is not a
rotation story with a gap in it, it is the absence of one. `SecretMaterialProvider` already exposes
`keyById`, so reading the id back and asking for that specific key makes rotation a change of default
rather than a data migration. The version byte costs one byte and is what allows the format to change
at all.
The header is passed as **AAD**, not merely prefixed: without that, the key id is attacker-editable
and an envelope could be redirected at a key of the attacker's choosing.
A failed decryption throws `NotificationPayloadUnreadableException` rather than returning empty. A
caller handed an empty payload renders every variable as nothing and sends "Hello , your code is " to
a real person — the failure delivered instead of reported. All three causes (unknown key, wrong key,
modified ciphertext) collapse into one message, because telling them apart tells an attacker which of
the three they achieved.
## What must not be done instead
**Requiring `PAYLOAD_ENCRYPTION` in `INGEST_ONLY` is not a fix.** That secret is consumed by exactly
one thing — `AesGcmCallbackPayloadProtection`, which protects raw callback bodies — and by nothing on
the accept path. Demanding it would make a deployment supply a key that protects nothing while the
payload it appears to be about stays in plaintext. The repository already has one defect of that
exact shape: `backend.graphql.cursor.key-ids`, which production refuses to start without and which no
code signs a cursor with (GQL-INT-003). Adding a second would make the pattern a habit.
## Consequence
Notification is **not promoted to Stable**, per the index's scope boundaries, until branch (a) is
complete. The three notification Compose lanes stay non-blocking. `NotificationPayloadAtRestContractTest`
holds every fact this decision rests on, so the decision expires automatically if any of them stops
being true — in particular, the assertion that no encryption sits on the accept path fails the moment
somebody adds one, which is the change this document is waiting for.
+71 -1
View File
@@ -15,7 +15,7 @@ when this page, the YAML tree and `docs/registries/env-keys.yaml` disagree.
| Property | Environment variable | Default | Meaning | | Property | Environment variable | Default | Meaning |
|---|---|---|---| |---|---|---|---|
| `enabled` | `APP_NOTIFICATION_PLATFORM_ENABLED` | `false` | Binds nothing at all while false: no runtime, no schema check, no scheduler thread, no secret required | | `enabled` | `APP_NOTIFICATION_PLATFORM_ENABLED` | `false` | Binds nothing at all while false: no runtime, no schema check, no scheduler thread, no secret required |
| `mode` | `APP_NOTIFICATION_PLATFORM_MODE` | `SERVING` | `SERVING` refuses to start without a working provider; `ACCEPT_ONLY` stores requests and does not dispatch | | `mode` | `APP_NOTIFICATION_PLATFORM_MODE` | `SERVING` | `SERVING` refuses to start without a working provider; `INGEST_ONLY` stores requests and does not dispatch |
## Dispatch ## Dispatch
@@ -41,6 +41,12 @@ still waiting on gets claimed by a second worker, and the recipient receives the
| `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean | | `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean |
| `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 | | `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 |
| `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive | | `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive |
| `callbacks.trusted-proxies` | `APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES` | *(empty)* | CSV of peer addresses |
여러 provider가 요청 URL에 서명하므로, 그 URL을 잘못 재구성하면 정상 webhook이 전부 서명 실패가 된다.
`trusted-proxies`가 비어 있으면 forwarded 헤더를 **믿지 않고** 컨테이너가 관측한 값을 쓴다. 무조건 믿으면
아무 호출자나 자기 서명이 검증될 URL을 고를 수 있어 서명 자체가 무의미해진다. 로드밸런서 뒤에 있는 배포는
그 peer를 명시한다.
65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM 65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM
nonce and tag. A larger configured value would pass every check above the database and fail the nonce and tag. A larger configured value would pass every check above the database and fail the
@@ -69,6 +75,43 @@ them, because the keys are deployment-chosen; supply them as YAML or as
A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit. A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit.
Sender identity and credential profile are separate concerns. Sender identity and credential profile are separate concerns.
## SMTP relay
The one provider profile the template ships, off. A deployment that wants the common case sets
`APP_NOTIFICATION_PLATFORM_SMTP_ENABLED=true` and the relay address; one that wants a different
profile id or a second family declares it in its own YAML instead.
The profile and the relay are separate tables below because they answer different questions. The
profile says *which* provider serves EMAIL and under what limits; the relay says *what the transport
is*. Host, port and credentials are not here at all — they stay `spring.mail.*`, because Spring
already owns them and a second spelling would be a second thing to keep in step.
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `providers.smtp.enabled` | `APP_NOTIFICATION_PLATFORM_SMTP_ENABLED` | `false` | boolean |
| `providers.smtp.primary-for-channel` | `APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY` | `true` | boolean; exactly one primary per channel |
| `providers.smtp.environment` | `APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT` | `local` | required when enabled |
| `providers.smtp.credential-profile` | `APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE` | `default` | resolved through `SecretMaterialProvider`, never inline material |
| `providers.smtp.timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT` | `10s` | positive, finite |
| `providers.smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY` | `4` | positive |
| `providers.smtp.rate-per-second` | `APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND` | `10` | positive |
| Property | Environment variable | Default | Bound |
|---|---|---|---|
| `smtp.tls-mode` | `APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE` | `STARTTLS_REQUIRED` | `STARTTLS_REQUIRED` or `IMPLICIT_TLS` |
| `smtp.sender-identity` | `APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY` | `no-reply@example.invalid` | address |
| `smtp.connect-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT` | `5s` | positive, finite |
| `smtp.read-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT` | `10s` | positive, finite |
| `smtp.write-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT` | `10s` | positive, finite |
| `smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY` | `4` | positive |
The TLS mode enum has no plaintext member. An unencrypted relay is refused by construction rather
than by a validator somebody has to remember to run.
The default sender is an RFC 2606 reserved domain that resolves nowhere, so a deployment that forgot
to set one produces a traceable bounce instead of mail apparently sent from an address it does not
own.
## Startup failures ## Startup failures
Startup fails rather than degrading when: Startup fails rather than degrading when:
@@ -91,6 +134,33 @@ All key material arrives through `SecretMaterialProvider`. Nothing is read from
committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
distinct, and the encryption key must be exactly 256 bits. distinct, and the encryption key must be exactly 256 bits.
Eight purposes, eight keys. Each is base64 of at least 32 bytes and each must differ from every
other; the platform decodes them at startup and refuses to boot if one is blank, short or shared. A
blank value used to be skipped, which meant the platform started without the key and found out on
the first contact point — in production, on a recipient's notification.
Every default below is **unset**, deliberately. Supply the values out of band, per environment. Do
not write one into this table, into `application.yml`, into an `.env` file that is tracked, or into
any example: a value that appears in the repository is a value that has been disclosed.
| Purpose | Key material | Active key id |
|---|---|---|
| Contact point encryption | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID` |
| Contact point lookup HMAC | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID` |
| Callback signing | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID` |
| Callback fingerprint HMAC | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID` |
| Provider credential encryption | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID` |
| Provider request lookup HMAC | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID` |
| Payload encryption | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID` |
| Web Push VAPID signing | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID` |
A key id is not secret — an id identifies key material without revealing it — but it is required,
and it has no default on purpose. A constant id makes a rotation indistinguishable from the key it
replaced, so nothing could decrypt what was written before it. Change the id in the same deployment
that changes the material, and keep the superseded key readable under its old id until the data it
wrote has been re-encrypted. The rotation sequence is in
[at-rest-threat-model.md](at-rest-threat-model.md).
## Readiness ## Readiness
The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN
+2 -2
View File
@@ -1,8 +1,8 @@
# Notification Delivery Platform — module mapping # Notification Delivery Platform — module mapping
> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md` > Source design: `docs/superpowers/specs/2026-08-10-notification-platform-design.md`
> >
> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md` > Source plan: `docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md`
## Why a mapping exists ## Why a mapping exists
+639 -13
View File
@@ -20,12 +20,15 @@ env_keys:
# === Profile / Identity (feature-env-driven-runtime-configuration) === # === Profile / Identity (feature-env-driven-runtime-configuration) ===
- name: SPRING_PROFILES_ACTIVE - name: SPRING_PROFILES_ACTIVE
# source: feature-env-driven-runtime-configuration D6 (2026-06-06) # source: feature-env-driven-runtime-configuration D6 (2026-06-06), amended by
# Profile selector is Spring-native and sole (APP_PROFILE was dropped). Unset # five-adapter-runtime-remediation §7.1. Profile selector is Spring-native and sole
# -> local fallback in application.yml for early Boot profile binding. # (APP_PROFILE was dropped). Exactly one value, not a CSV list: two environments cannot both
type: csv_list # have their safety rules apply, and whichever lost did so silently.
default: local type: enum
allowed_values: [local, dev, staging, prod, sample] # No default. A profile that is guessed is a deployment nobody chose: a jar started
# with none used to become local, which before persistence was gated also meant an
# in-memory database that loses every write on restart.
allowed_values: [local, dev, prod]
classification: public-config classification: public-config
required: true required: true
reload_policy: restart-only reload_policy: restart-only
@@ -389,14 +392,19 @@ env_keys:
- name: APP_DATASOURCE_CONNECTION_TIMEOUT - name: APP_DATASOURCE_CONNECTION_TIMEOUT
# source: feature-env-driven-runtime-configuration "datasource/pool env" # source: feature-env-driven-runtime-configuration "datasource/pool env"
# + feature-persistence-failure-baseline "Hikari Alert Threshold: pool wait p99 > 100ms" # + feature-persistence-failure-baseline "Hikari Alert Threshold: pool wait p99 > 100ms"
type: duration # unit: milliseconds. It feeds spring.datasource.hikari.connection-timeout, which binds onto
default: 5s # HikariConfig#setConnectionTimeout(long) — a duration shorthand such as "5s" does not bind and
# fails the boot. This row said `duration` / `5s`, application.yml copied that default, and
# every prod and dev deployment refused to start; five-adapter-runtime-remediation Wave 2 found
# it in the prod-smoke lane. Corrected to what the property actually accepts.
type: integer
default: 5000
allowed_values: null allowed_values: null
classification: public-config classification: public-config
required: false required: false
reload_policy: restart-only reload_policy: restart-only
owner_branch: feature-env-driven-runtime-configuration owner_branch: feature-env-driven-runtime-configuration
validation: spring_duration_shorthand validation: positive_integer_milliseconds
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: env-contract:connection-timeout-set required_test: env-contract:connection-timeout-set
@@ -1664,9 +1672,12 @@ env_keys:
required_test: idempotency-contract:ttl-applied required_test: idempotency-contract:ttl-applied
- name: APP_IDEMPOTENCY_PROVIDER - name: APP_IDEMPOTENCY_PROVIDER
# postgresql selects the owner-safe V2 store on the primary data source. It had no value here
# while the store, its schema stream and its integration suite all existed, so the capability
# could only be reached by constructing it in a test.
type: enum type: enum
default: jdbc default: jdbc
allowed_values: [disabled, jdbc, redis] allowed_values: [disabled, jdbc, redis, postgresql]
classification: public-config classification: public-config
required: false required: false
reload_policy: restart-only reload_policy: restart-only
@@ -1886,7 +1897,7 @@ env_keys:
# Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED # Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED
# is true. They are deliberately absent from application.yml and src/.env: putting # is true. They are deliberately absent from application.yml and src/.env: putting
# them there would make a Redis-free deployment carry Redis configuration, which is # them there would make a Redis-free deployment carry Redis configuration, which is
# the defect the conditional composition root removes. verifyEnvKeys checks them # the defect the conditional composition root removes. :app-bootstrap:verifyEnvKeys checks them
# against spring-configuration-metadata.json instead. # against spring-configuration-metadata.json instead.
- name: APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED - name: APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED
@@ -3314,6 +3325,21 @@ env_keys:
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: adapter-contract:messaging-broker-selection required_test: adapter-contract:messaging-broker-selection
- name: APP_MESSAGING_PRODUCER_ID
# source: canonical messaging platform bridge 2026-09-18
# Explicit logical producing-service identity for IntegrationEventPublishPort.
# Blank/absent = canonical platform bridge is not exposed; identity is never inferred.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-integration-adapter-templates
validation: none
compatibility_impact: additive
required_test: adapter-contract:messaging-platform-producer-id
- name: APP_MESSAGING_KAFKA_BROKERS - name: APP_MESSAGING_KAFKA_BROKERS
# source: feature-domain-event-outbox-contract — "Kafka는 optional integration adapter" # source: feature-domain-event-outbox-contract — "Kafka는 optional integration adapter"
# (broker 활성화 시 endpoint 필요) # (broker 활성화 시 endpoint 필요)
@@ -4250,6 +4276,133 @@ env_keys:
# default. Every key carries an inline default so a deployment that leaves the platform off # default. Every key carries an inline default so a deployment that leaves the platform off
# supplies nothing. Reference: docs/notification/configuration.md. # supplies nothing. Reference: docs/notification/configuration.md.
- name: APP_PERSISTENCE_JPA_ENABLED
# source: five-adapter-runtime-remediation §5.1 — master switch for relational persistence.
# false means no DataSource, no entity scan, no repositories, no Hibernate, no Flyway and no DB
# health contributor; the old app.jpa-platform.enabled gated three add-on beans while reading
# like this one and defaulting to on.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:persistence-jpa-disabled-safe
- name: APP_PERSISTENCE_MONGO_ENABLED
# source: five-adapter-runtime-remediation §5.1 — master switch for MongoDB persistence.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:persistence-mongo-disabled-safe
- name: APP_PERSISTENCE_MONGO_ACTIVE_PROFILE
# source: five-adapter-runtime-remediation §5.1 — selects exactly one Mongo profile. The runtime
# builds one sync client and one pool; a profile present in the map but not selected has neither
# its secret resolved nor a client created.
type: string
default: ""
classification: public-config
required: false
required_when: APP_PERSISTENCE_MONGO_ENABLED=true
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: nonblank-when-required
compatibility_impact: behavior-change
required_test: adapter-contract:persistence-mongo-active-profile
- name: APP_MESSAGING_ENABLED
# source: five-adapter-runtime-remediation §5.1 — master switch for broker publication.
# APP_MESSAGING_BROKER selects which transport and is no longer the de-facto switch.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:messaging-disabled-safe
- name: APP_GRAPHQL_ENABLED
# source: five-adapter-runtime-remediation §5.1 — master switch for the GraphQL transport.
# false publishes no /graphql route, including the one Spring GraphQL would publish by itself.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:graphql-disabled-safe
- name: APP_GRAPHQL_DEPLOYMENT_MODE
# source: five-adapter-runtime-remediation §5.1 / GQL-INT-002 — replaces backend.graphql.production
# and backend.graphql.environment, which defaulted to production=false with
# environment=PRODUCTION_PUBLIC and let anonymous-principal and allow-by-default authorization
# read one axis while the other claimed production.
type: enum
default: ""
allowed_values: [LOCAL, DEV, PRODUCTION_INTERNAL, PRODUCTION_PUBLIC]
classification: public-config
required: false
required_when: APP_GRAPHQL_ENABLED=true
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: enum
compatibility_impact: behavior-change
required_test: adapter-contract:graphql-deployment-mode
- name: APP_OUTBOX_ENABLED
# source: five-adapter-runtime-remediation §6.1 JPA-INT-004 — the outbox capability switch.
# relay-enabled below only starts the scheduler; conflating the two meant a relay-off deployment
# still assembled outbox metrics over a store port a database-less runtime does not have.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:outbox-capability-disabled-safe
- name: APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED
# source: MSG-015 transport-only cutover 2026-09-18
# Enables canonical outbox rows/platform transport without switching publication authority.
type: boolean
default: false
allowed_values:
- "true"
- "false"
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-integration-adapter-templates
validation: boolean
compatibility_impact: additive
required_test: app-bootstrap:outbox-canonical-transport-gate
- name: APP_OUTBOX_RELAY_ENABLED
# source: five-adapter-runtime-remediation §6.3 MSG-INT-001 — starts the relay scheduler.
# Requires APP_OUTBOX_ENABLED, APP_PERSISTENCE_JPA_ENABLED and APP_MESSAGING_ENABLED with a
# broker; the shipped default was true beside a blank broker, which refused every startup.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-five-adapter-activation
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:outbox-relay-dependency
- name: APP_NOTIFICATION_PLATFORM_ENABLED - name: APP_NOTIFICATION_PLATFORM_ENABLED
# source: NTF-025 — master switch for the notification delivery platform; false binds nothing at all # source: NTF-025 — master switch for the notification delivery platform; false binds nothing at all
type: boolean type: boolean
@@ -4263,11 +4416,469 @@ env_keys:
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: adapter-contract:notification-platform-disabled-safe required_test: adapter-contract:notification-platform-disabled-safe
- name: APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY
# source: NTF-INT-007 — Encrypts recipient contact points at rest — addresses and phone numbers.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.contact-encryption-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY
# source: NTF-INT-007 — Blind index over contact points, so a lookup never needs the plaintext.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.contact-lookup-hmac-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY
# source: NTF-INT-007 — Signs the callback URLs a provider posts delivery outcomes back to.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.callback-signing-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY
# source: NTF-INT-007 — Encrypts stored provider credentials, which are themselves secrets.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.provider-credential-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY
# source: NTF-INT-007 — Encrypts notification variables and retained callback bodies at rest.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.payload-encryption-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY
# source: NTF-INT-007 — Signs Web Push requests; the browser push service rejects anything else.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.vapid-signing-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY
# source: NTF-INT-007 — Keyed hash of provider request ids, which are provider-side identifiers.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.provider-request-lookup-hmac-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY
# source: NTF-INT-007 — Keyed fingerprint of callback bodies, used to detect replays.
# Full row owned by secrets-classification.yaml. Bound by
# ca-skeleton.notification.platform.secrets.callback-fingerprint-hmac-key; the platform decodes it at startup and
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: base64_at_least_32_bytes_and_distinct_per_purpose
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the CONTACT_ENCRYPTION key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.CONTACT_ENCRYPTION.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the CONTACT_LOOKUP_HMAC key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.CONTACT_LOOKUP_HMAC.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the CALLBACK_SIGNING key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.CALLBACK_SIGNING.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the PROVIDER_CREDENTIAL key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.PROVIDER_CREDENTIAL.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the PAYLOAD_ENCRYPTION key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.PAYLOAD_ENCRYPTION.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the VAPID_SIGNING key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.VAPID_SIGNING.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the PROVIDER_REQUEST_LOOKUP_HMAC key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.PROVIDER_REQUEST_LOOKUP_HMAC.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID
# source: NTF-INT-007 — the id written into every envelope the CALLBACK_FINGERPRINT_HMAC key produces.
# An identifier, not key material, so it is public-config; the material itself is the
# APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY row above. Bound into
# ca-skeleton.notification.platform.secrets.active-key-ids.CALLBACK_FINGERPRINT_HMAC.
type: string
default: null
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank_when_platform_enabled
compatibility_impact: behavior-change
required_test: adapter-contract:notification-secret-material-required
- name: APP_NOTIFICATION_PLATFORM_SMTP_ENABLED
# source: NTF-INT-001 — master switch of the shipped SMTP provider profile; false means assembly skips it entirely.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY
# source: NTF-INT-001 — whether this profile is the primary route for EMAIL; exactly one primary per channel.
type: boolean
default: true
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: boolean
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT
# source: NTF-INT-001 — the profile's declared environment, carried on every dispatch record.
type: string
default: local
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE
# source: NTF-INT-001 — the credential profile the relay's credentials are resolved through.
type: string
default: default
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: non_blank
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT
# source: NTF-INT-001 — per-attempt provider timeout for this profile.
type: duration
default: 10s
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: duration_spring_shorthand
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY
# source: NTF-INT-001 — how many attempts this profile may have in flight.
type: int
default: 4
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_int
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND
# source: NTF-INT-001 — the profile's attempt rate limit.
type: int
default: 10
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_int
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE
# source: NTF-INT-001 — transport security of the SMTP session; the type has no plaintext member.
type: enum
default: STARTTLS_REQUIRED
allowed_values: [STARTTLS_REQUIRED, IMPLICIT_TLS]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: enum_in_allowed_values
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY
# source: NTF-INT-001 — the envelope sender every message is sent as.
type: string
default: no-reply@example.invalid
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: email_address
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT
# source: NTF-INT-001 — how long a connection attempt to the relay may take.
type: duration
default: 5s
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: duration_spring_shorthand
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT
# source: NTF-INT-001 — how long a relay reply may take.
type: duration
default: 10s
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: duration_spring_shorthand
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT
# source: NTF-INT-001 — how long a write to the relay may take.
type: duration
default: 10s
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: duration_spring_shorthand
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY
# source: NTF-INT-001 — size of the bounded executor SMTP sends run on.
type: int
default: 4
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: positive_int
compatibility_impact: behavior-change
required_test: adapter-contract:notification-smtp-provider-assembled
- name: APP_OPENAPI_DOCS_ENABLED
# source: five-adapter-runtime-remediation §9 — whether /v3/api-docs is served; application-prod.yml pins it false. Stated rather than defaulted because
# SpringDoc warns on every startup until a deployment decides, and a warning on every start is
# one nobody reads.
type: boolean
default: true
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: main
validation: boolean
compatibility_impact: behavior-change
required_test: env-contract:openapi-exposure-decided
- name: APP_OPENAPI_UI_ENABLED
# source: five-adapter-runtime-remediation §9 — whether the Swagger UI is served; application-prod.yml pins it false. Stated rather than defaulted because
# SpringDoc warns on every startup until a deployment decides, and a warning on every start is
# one nobody reads.
type: boolean
default: true
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: main
validation: boolean
compatibility_impact: behavior-change
required_test: env-contract:openapi-exposure-decided
- name: APP_NOTIFICATION_PLATFORM_MODE - name: APP_NOTIFICATION_PLATFORM_MODE
# source: NTF-025 — SERVING refuses to start without a working provider; ACCEPT_ONLY stores and does not dispatch # source: NTF-025 — SERVING refuses to start without a working provider; INGEST_ONLY stores and does not dispatch.
# The constant is INGEST_ONLY. This row said ACCEPT_ONLY, a name NotificationPlatformMode has
# never had, so an operator following the registry got a binding failure naming a value the
# documentation does not mention. NotificationModeSsotTest derives the list below from the enum.
type: enum type: enum
default: SERVING default: SERVING
allowed_values: [SERVING, ACCEPT_ONLY] allowed_values: [SERVING, INGEST_ONLY]
classification: public-config classification: public-config
required: false required: false
reload_policy: restart-only reload_policy: restart-only
@@ -4393,6 +5004,21 @@ env_keys:
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-body-bound required_test: adapter-contract:notification-callback-body-bound
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES
# source: NTF-001 — peers whose forwarded headers may be believed when reconstructing the URL a
# provider signed. Empty means the resolver uses what the container observed; honouring
# forwarded headers unconditionally would let any caller pick the URL its signature is checked
# against, which defeats the signature.
type: csv
default: ""
allowed_values: null
classification: security-relevant
required: false
reload_policy: restart-only
owner_branch: worktree-notification-platform
validation: none
compatibility_impact: behavior-change
required_test: adapter-contract:notification-callback-url-resolution
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW - name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW
# source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay # source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay
type: duration type: duration
+128
View File
@@ -226,6 +226,134 @@ secrets:
# === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) === # === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) ===
- name: APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY
# Encrypts recipient contact points at rest — addresses and phone numbers.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-contact-encryption-no-leak
- name: APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY
# Blind index over contact points, so a lookup never needs the plaintext.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-contact-lookup-hmac-no-leak
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY
# Signs the callback URLs a provider posts delivery outcomes back to.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-callback-signing-no-leak
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY
# Encrypts stored provider credentials, which are themselves secrets.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-provider-credential-no-leak
- name: APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY
# Encrypts notification variables and retained callback bodies at rest.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-payload-encryption-no-leak
- name: APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY
# Signs Web Push requests; the browser push service rejects anything else.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-vapid-signing-no-leak
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY
# Keyed hash of provider request ids, which are provider-side identifiers.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-provider-request-lookup-hmac-no-leak
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY
# Keyed fingerprint of callback bodies, used to detect replays.
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
# the distinction at startup rather than trusting the deployment to have noticed.
classification: secret
source: secret-manager
rotation_policy: dual-read-restart-only
prod_default: null
required_when: ca-skeleton.notification.platform.enabled=true
dev_sentinel_prefix: __LOCAL_DEV_
owner_branch: worktree-notification-platform
masking_rule: full
compatibility_impact: behavior-change
required_test: secrets-contract:notification-callback-fingerprint-hmac-no-leak
- name: APP_NOTIFICATION_SLACK_WEBHOOK_URL - name: APP_NOTIFICATION_SLACK_WEBHOOK_URL
# source: feature-integration-adapter-templates 2026-05-22 # source: feature-integration-adapter-templates 2026-05-22
# "Slack | disabled optional module | notification failure policy" # "Slack | disabled optional module | notification failure policy"
@@ -0,0 +1,101 @@
# P1 remediation status — the five module reviews
**Reviews:** `docs/reviews/2026-08-14-{jpa,graphql,messaging,mongodb,notification}-module-code-review.md`
**Baseline:** the P0 pass was already complete when this pass began; this file records the P1 pass,
which is complete — all 31 findings closed, two of them by establishing that the review's own
accepted outcome was already met rather than by writing code.
**Verified at:** repo-wide `test`, `spotlessCheck`, `verifyCleanArchitectureDependencies`,
`verifyEnvKeys`, `verifyPublicPathSnapshot` and the root `CleanArchitectureTest` all green.
This file exists because the status was previously carried only in conversation and had to be
reconstructed. A finding's row is the claim; the evidence column is where the claim is falsifiable.
## The recurring defect
Nearly every P1 in these five reviews is one shape: **a control that exists, passes its own tests,
and is reached by nothing.** Not a wrong algorithm — an unreachable one. The tests passed because
they constructed the class directly; the capability was absent because no configuration could.
Examples closed in this pass: the Mongo typed-update path (`MongoBulkExecutor` and
`MongoAtomicOperationsTemplate` were constructed by nothing), the entire Mongo change-stream
capability (no production code opened a stream at all), `GraphQlBatchLoaderRegistrar.register` (no
caller, so the wrong-key refusal never ran on an executing query), `PublishOptions.timeout()` (read
by nobody on the real publish path), `markExhausted` (no caller, so a row that spent its budget
stayed `AMBIGUOUS` forever), `OutboxMessagePublishPort.publishForOutcome` (no caller, so every
ambiguous publish collapsed into an exception), the outbox relay itself (no bean ran a pass), and
`BackpressureController` (a limiter the publish path never consulted, reporting `globalInFlight: 0`
under any load).
The lesson worth keeping: **a passing unit test is not evidence a capability exists.** The
reachability question — what constructs this, and on which request path — has to be asked
separately, and several of the tests added in this pass exist only to ask it.
## Status
| Finding | Verdict | Evidence |
| --- | --- | --- |
| JPA-005 | closed already | roll-up in `NotificationRequestStatusPolicy`; port is tenant-scoped; ArchUnit `PERSISTENCE_DOES_NOT_DEPEND_ON_APPLICATION_SERVICES` |
| JPA-006 | closed already | `PersistenceJpaRootAutoConfiguration` is in `AutoConfiguration.imports` and imports the real runtime config |
| JPA-007 | closed under item 6 | the review offers two accepted outcomes: full integration, or the interim state under item 6's two conditions. Both hold and were verified in code — `FullTransactionRetryCoordinator:89-93` resolves the policy per call from the calling profile, and `DefaultJpaRetryPolicy:61-64` returns on `!failure.retryable()` before consulting the category allowlist |
| JPA-008 | closed | nothing registered a `VendorFailureTranslator`, so every executor ran `withoutCatalogs()` and a 40001 never reached the retry classifier |
| JPA-026 | closed | a rehydrated callback event had no matcher once `attempt_id` was null; hash fallback + write-back added |
| JPA-028 | closed | the reaper query had no caller, uploads had no terminal state, cleanup decided from a lease it had read rather than claiming |
| JPA-029 | closed already | tuple cutoff implemented; the signal is documented best-effort by decision |
| GQL-004 | not a defect | every evidence bullet false at HEAD; no WebFlux dependency exists, transport disagreement fails startup |
| GQL-011 | closed | unkeyed truncated SHA-256 over actor/tenant replaced with a keyed, rotating HMAC; no default key |
| GQL-015 | closed | schema extensions were invisible to the comparator, so a field removed by `extend type` produced no change at all |
| GQL-016 | closed | the batch executor returned the loader's map verbatim, so the wrong-key refusal and missing-key policy never ran |
| GQL-017 | closed | the blocking bridge was opt-in and null by default, so a reactive runtime ran blocking chunks on the event loop |
| MNG-010 | closed | the guardrail was a `Set<String>` asserted against itself; now an ArchUnit rule over the real production graph at the composition root |
| MNG-012 | closed | a failed abort or close on a committed transaction was discarded by a closing brace |
| MNG-018 | closed | TLS and auth were asserted against a settings object; four TLS cases now run against real servers |
| MNG-024 | closed | the reactive binder carried read preference and write concern only, so reactive writes skipped auditing and callbacks |
| MNG-026 | closed | both typed-update paths were unreachable, and the bulk executor could be built with no policy at all |
| MNG-028 | closed | no production code opened a change stream; the consumer now owns load → resume → stream → project → checkpoint |
| MSG-006 | closed | `markExhausted` had no caller and the scheduler's backoff was never written; a relay worker now runs passes |
| MSG-008 | closed | validators were beans nothing injected, and the documented configuration bound nowhere; destination/broker/security sections now bind under `app.messaging` and the reference document is executed by a test |
| MSG-010 | closed | `BackpressureController` deleted as an unreachable duplicate; its one unique capability moved into the gate that is called |
| MSG-012 | closed | header values accepted CR/LF/NUL, identifiers were bounded in chars not bytes, `traceparent` was any string, denylists matched exact spellings only |
| MSG-014 | closed | `hasLiveBrokerCertification` is derived from recorded evidence rather than declared, and the evidence is now a manifest a fault lane wrote against a real broker rather than a hand-authored list |
| MSG-015 | semantic half closed | the outcome-aware publish path is wired; the anti-corruption bridge needs a `modules.json` edge and is an architecture decision |
| MSG-016 | closed | no reserved name existed for tenant, so every consumed message was rebuilt with none; the canonical metadata is now columns, and the CDC event key moved off `destination`, which had put every message on a topic onto one partition |
| MSG-017 | closed | the timeout is an absolute deadline; contradictory `PublishResult` combinations are unrepresentable; `brokerHints` removed |
| NTF-015 | closed | webhook signing was one shared secret for every subscription; SES silently dropped attachments and now sends them as raw MIME |
| NTF-016 | closed | retired keys were forced to one purpose so a provider-credential drain failed; required purposes now follow enabled capabilities |
| NTF-019 | closed already | split inbound ports carry capabilities; four ArchUnit rules with negative fixtures close the gate |
## Found while closing, not in any review
`SmtpMimeMessageFactory` handed JavaMail the resolver's one-shot stream. JavaMail reads an
attachment twice — once to choose the part's transfer encoding, once to write it — so the second
read returned nothing and the message went out announcing a filename and carrying no bytes, with
the attempt recorded as accepted. Every existing test asserted on the outcome of the send rather
than on what was sent, which is why a bug that emptied every attachment on the one provider family
this platform can actually assemble survived a full review pass.
`SmtpAttachmentBodyTest` now reads the attachment back off the serialised message the way a
receiving client would. It was confirmed to fail against the original code and pass against the
fix, because a regression test nobody has watched fail is a regression test of unknown shape.
## Observations that are not open P1 items
Both were checked against the reviews rather than assumed, because "looks unfinished" and "is an open
finding" are different claims.
**The GraphQL cursor key gate.** `GraphQlPlatformStartupValidator` refuses to start a production
deployment without `backend.graphql.cursor.key-ids`, and nothing signs a cursor with it:
`GraphQlConnectionAssembler` and `HmacGraphQlCursorCodec` have no consumer anywhere in this
repository, because the template ships no paginating resolver. This is not GQL-010, which is about
the codec's framing, rotation and scope and is implemented — versioned framing, the codec choosing
the active key rather than the caller, v1 decode kept only for migration, tenant scope bound. It
belongs to the `modelled` grading the leaf's own `GraphQlPolicyRequestPathTest` already documents in
as many words. Declaring beans for it would create the unreachable-control defect this pass exists
to close, and the present behaviour fails closed, which is the safe direction. Left as it is, on
purpose.
## Product work, not remediation
**Notification provider transports.** Only SMTP has a `ProviderRuntimeAssembler`.
`NotificationProviderAssembly` refuses to start a profile whose family has no assembler, naming the
transport as a seam rather than an implementation — which is the honest fail-closed behaviour, not a
defect. Building SES, Twilio, FCM, APNs and WebPush transports is product work.
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# Messaging R2 자격(qualification) — 미구현
추적: MSG-015
## 상태
**구현되지 않았다.** R2 자격을 주장할 수 있는 근거가 없다.
- qualification producer 없음
- 대응하는 Test 태스크 없음
- 공통 스키마 validator 없음
따라서 `config/messaging/readiness-cards.yaml`의 카드는 `verifyMessagingContracts`
`verifyMessagingJsonSchemaV1` 두 개를 제외하면 모두 `maturity: not-implemented`다.
## 왜 Gradle 태스크를 미리 만들어 두지 않는가
2026-09 이전에는 루트 빌드가 아래 아홉 개 태스크 이름을 미리 등록해 두고, 그 본문이 **입력과 무관하게
무조건 예외를 던졌다**.
```text
verifyMessagingPollingOutboxR2 verifyMessagingTargetBinding
verifyMessagingKafkaProducerR2 verifyMessagingDeploymentCutover
verifyMessagingSecurityR2 verifyMessagingCleanupTargetBinding
verifyMessagingReleaseProfile verifyMessagingFinalR2Profile
verifyMessagingTargetBindingPreflight
```
의도는 "fail-closed"였지만 결과는 다음과 같았다.
- `./gradlew tasks`에 게이트처럼 보이는 이름 아홉 개가 나타난다.
- `dependsOn`으로 걸 수 있다. 거는 순간 그 레인은 영원히 빨간불이다.
- 정상적인 입력으로도 성공할 수 없으므로 "검증"이 아니다.
즉 TODO를 Gradle 태스크 API로 표현한 것이었다. 미구현 사실을 기록하는 자리는 이 문서이고, 태스크는
**실제로 통과할 수 있게 된 시점에** 그 producer와 함께 추가한다.
## 구현 시 추가할 것
1. 각 시나리오를 실제로 실행하는 Test 태스크.
2. 그 실행 결과(JUnit XML)에서 payload-free manifest를 만드는 producer.
3. `config/messaging/evidence/build-evidence-manifest-v1.schema.json`으로 그 manifest 바이트를
검증하는 finalizer.
4. 위 셋이 모두 생긴 다음에 `verifyMessaging<Scenario>R2` 태스크 등록.
`gradle/qualification/messaging-qualification.gradle``verifyMessagingJsonSchemaV1`이 그 네 단계를
모두 갖춘 예시다.
@@ -0,0 +1,98 @@
# Runbook: gRPC advanced capabilities
Scope: the `:grpc-advanced:*` family. Everything here is off by default and stays off until a
deployment names it. Nothing in this family ships in a runtime composition today.
Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`. `GrpcAdvancedCapability` owns the list of
capability names; `GrpcAdvancedSupportMatrix` owns their current grades.
---
## A capability refuses to start
`GrpcAdvancedModuleGuard` gives three different refusals, and the remedy differs:
| Message contains | Meaning | Remedy |
| --- | --- | --- |
| "its feature flag is not set" | Nobody enabled it | Set the property named in the message |
| "tracked rather than implemented" | Grade is `WATCH` | Nothing to do here; the capability is not implemented |
| "uncharacterised failure modes" | Grade is `EXPERIMENTAL` and this is production | Record a production approval, or run it outside production |
The refusal message always names the property key, so the first case is a configuration line rather
than a support question.
---
## xDS: the control plane went away
**What you are seeing.** The control plane is unreachable and clients are still routing.
**What it means.** `GrpcXdsFailurePolicy` serves the last-known-good snapshot, up to its staleness
bound.
**What to do.**
1. Check the snapshot's age. `SERVE_LAST_KNOWN_GOOD` is the healthy degraded state.
2. `STALE_BEYOND_BOUND` means the snapshot is older than the policy allows and is no longer trusted.
Beyond that bound, a decommissioned backend would otherwise keep receiving traffic indefinitely.
3. `NO_SNAPSHOT_YET` on a starting instance means it never reached the control plane. It fails after
the initial fetch timeout rather than starting with no routing.
**Do not** add application-level retry policy while xDS is enabled. `GrpcXdsStartupGuard` refuses it,
because retry defined in two places has a winner that depends on resolution order rather than on a
decision.
---
## gRPC-Web: a browser call hangs and then fails with no status
**Almost always the proxy.** A gRPC status arrives as a trailer, and a browser cannot read a trailer
the proxy did not expose. Check that the proxy's CORS `expose_headers` includes `grpc-status` and
`grpc-message`; `GrpcWebProxyContract.violations` reports exactly this, and the reference
configuration in `envoy/envoy.yaml` shows it in place.
**If the method is client- or bidirectional-streaming**, it cannot work over gRPC-Web at all — a
browser has no way to send a stream of messages. `GrpcWebCompatibilityGate` reports such a method
before it is exposed.
---
## Servlet: a transport setting appears to be ignored
It is ignored. The container owns the socket, so keepalive tuning, maximum connection age and
flow-control window tuning belong to it. `GrpcServletStartupValidator` refuses those settings at
startup rather than accepting and dropping them, because a setting that is silently ignored sends the
investigation somewhere else.
A Servlet run never substitutes for Netty certification.
---
## Hedging: backend load doubled
**Expected, within a bound.** Hedging trades duplicate load for tail latency.
`GrpcHedgingResult` records both `duplicateBackendCalls` and `cancelledLoserAttempts`; a dashboard
showing only the latency improvement makes the trade look free.
**What to check.** `GrpcHedgingBudget` caps hedges as a fraction of completed calls. If duplicate
load is above that fraction, the budget is not being consumed — which means something is issuing
hedges outside the coordinator.
**Hedging is refused** for anything but a read-only unary method. A hedged mutation runs twice by
design, and an idempotency key does not help: the second attempt duplicates a success in progress
rather than retrying a failure.
---
## Promoting a capability
`GrpcAdvancedPromotionGate.evaluate` names every missing item. Promotion to `ADVANCED_STABLE` needs
compatibility evidence, a security review, fault evidence, performance evidence, an ADR, a runbook, a
real-environment test and seven days of soak. A Stable default needs thirty.
Promotions are independent: promoting one capability changes no other's grade, and
`GrpcAdvancedSupportMatrix.apply` refuses a decision made against a different matrix state.
Before citing a suite as evidence, check `GrpcAdvancedInfrastructureTestkit.missingInfrastructure`.
A suite that ran without the proxy, the container, the control plane or the toolchain it needs passed
and established nothing.
+142
View File
@@ -0,0 +1,142 @@
# Runbook: gRPC platform operations
Scope: the `:grpc:*` family. All of it is build-only today — every leaf's `runtime_memberships` is
empty — so nothing here fires in production yet. It is written now because the states it covers are
the ones an on-call cannot work out from first principles at three in the morning, and shipping the
behaviour before the runbook means the first person to meet one is doing that.
Configuration lives under `ca-skeleton.grpc.platform.*` and is bound by `GrpcPlatformProperties`.
The platform does not start unless `ca-skeleton.grpc.platform.enabled=true`.
---
## COMPLETION_UNKNOWN on a mutation
**What you are seeing.** A client received `DEADLINE_EXCEEDED`, `UNAVAILABLE` or `INTERNAL` on a
state-changing call, and the response trailer `completion-outcome` reads `COMPLETION_UNKNOWN`.
**What it means.** The server may have committed. This is not a failure and not a success; the status
code cannot distinguish them, which is why the outcome is carried separately.
**What not to do.** Do not re-issue the call. Do not tell the caller it failed. Both are wrong half
the time, and which half is not knowable from the status.
**What to do.**
1. Take the `error-execution-id` from the trailers. It is the only link between what the client saw
and what the server did.
2. If the method is `IDEMPOTENCY_KEY_REQUIRED`, query the operation ledger with the caller
fingerprint, the full method name and the caller's key. `GrpcOperationStatusQuery` returns one of
`IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND` or `UNKNOWN`.
3. `COMMITTED` means return the stored outcome reference, not a freshly computed answer — the resource
may have changed since, and a new answer would describe the state at reconciliation time rather
than the state the caller's own call produced.
4. `NOT_FOUND` means the operation never started and is safe to re-issue. `FAILED_TERMINAL` means the
same.
5. `UNKNOWN` means the ledger could not be consulted. Nothing may be concluded. The case is queued by
`GrpcCompletionReconciler` and retried later.
6. If the method is not keyed, there is no ledger row. Resolve it against the business resource, or
escalate to the service owner. This is the case the keyed profile exists to avoid.
**Escalate when** the reconciler's pending list grows across passes. That means the ledger is
unreachable rather than slow.
---
## A stream ended with FULL_RESYNC_REQUIRED
**What you are seeing.** A client's resume was refused and it was told to resynchronise.
**What it means.** The server can no longer replay from the client's cursor. Either the snapshot
version moved, or the cursor predates retained history.
**What to do.** Nothing on the server. The client is expected to discard its position and start a new
stream from a fresh snapshot. A client that instead retries the same token will keep receiving the
same answer.
**Escalate when** it is happening to many clients at once. That usually means history retention was
reduced, or snapshots are rotating faster than clients reconnect.
---
## A stream ended with SLOW_CONSUMER
**What you are seeing.** Streams terminating with `SLOW_CONSUMER`, and
`grpc.stream.flow_control_stalls` rising.
**What it means.** The consumer could not keep up with the bounded queue. The stream was terminated
rather than silently dropping messages, because a client cannot detect drops — the sequence numbers
it sees are the ones it was sent.
**What to do.**
1. Check whether the consumer is slow or the producer is fast. `grpc.stream.messages` against
`grpc.stream.lifetime` tells you the rate.
2. If the consumer is slow, the fix is on the consumer. Raising the queue bound moves the failure
later and makes it larger.
3. A resume is not available after this ending: the messages that overflowed the queue are gone, so
continuing from the last delivered sequence would silently skip them. The client resynchronises.
---
## RESOURCE_EXHAUSTED under load
**What you are seeing.** Calls refused with `RESOURCE_EXHAUSTED` and `GrpcAdmissionController`
reporting rejections.
**What it means.** The server is at its concurrency and queue bounds and is shedding rather than
queueing. This is the designed behaviour: accepting work whose callers have already given up spends
capacity on nothing.
**What to do.**
1. Read `grpc.rpc.duration` and `grpc.rpc.queue_wait` separately. Queue time rising with duration flat
means the bottleneck is admission, not the work.
2. Check which saturation counter is moving — executor, channel or flow control. They look identical
in a latency graph and have different fixes.
3. Raising `ca-skeleton.grpc.platform.executor-queue-capacity` defers the problem; it does not remove
it. `GrpcExecutorProfile` refuses a queue above ten thousand for that reason.
---
## A rollout is producing errors at every deploy
**What you are seeing.** A burst of `UNAVAILABLE` or `CANCELLED` each time an instance goes away.
**What it means.** The drain sequence is not completing, or is running out of order.
**What to do.**
1. `GrpcDrainResult` records what each drain achieved: completed and cancelled unary calls, signalled
and cancelled streams, and which phases ran. A drain that routinely force-cancels is the cause.
2. The order matters. Readiness flips first and nothing is refused during that window, because there
is a gap between an instance reporting unready and routing acting on it. Refusing during that gap
turns a clean rollout into a burst of errors at every deploy.
3. For long streams, check the Kubernetes profile's `streamReconnectBudget` and
`readinessDrainGrace`. A stream is pinned to one pod for its whole life, so every rollout ends it;
a profile with long streams and no reconnect budget has not decided what clients do next.
---
## Verifying a deployment's configuration
`GrpcPlatformSnapshotService` produces a secret-free snapshot for a caller on the admin network
holding an admin role. Both gates are required.
`GrpcPlatformSnapshotService.driftAgainstRelease` compares a running snapshot with the release
manifest and reports schema version, method policy hash and per-channel profile differences. An
instance running a configuration the release did not ship is behind a whole class of incidents that
are otherwise diagnosed by reading logs.
The snapshot carries hashes and names only. A field whose name looks like a credential is refused at
construction rather than redacted.
---
## Things that are deliberately off
- **Reflection in production.** `GrpcReflectionMode.defaultFor` returns `DISABLED` for `STAGE` and
`PROD`. Reflection publishes the whole schema to anyone who can open a connection.
- **Every advanced capability.** See `docs/runbooks/grpc-advanced-capabilities.md`.
- **The platform itself.** `ca-skeleton.grpc.platform.enabled` defaults to false, and every `:grpc:*`
leaf is build-only in the registry.
+3 -1
View File
@@ -25,7 +25,9 @@ status: stub
### Step 1 — 확인 ### Step 1 — 확인
1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출 1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출
2. broker 상태 확인: `APP_MESSAGING_BROKER`(공백이면 messaging 비활성)과 broker endpoint 가용성 2. broker 상태 확인: `APP_MESSAGING_BROKER` 값과 broker endpoint 가용성. 이 키는 활성화 스위치가
아니라 **선택자**다 — messaging을 끄는 것은 `APP_MESSAGING_ENABLED=false`이고, 이 값을 비운다고
messaging이 꺼지지는 않는다.
- `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다** - `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다**
(`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부 (`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부
`AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다. `AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다.
+4 -2
View File
@@ -1,4 +1,6 @@
# 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: ca-skeleton.security.public-paths default in app-bootstrap/src/main/resources/config/security.yml
# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own SECURITY_PUBLIC_PATHS
# overrides it at run time and is outside this snapshot.
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange # Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
/api/healthcheck /v1/healthcheck
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More