Compare commits

...
31 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
DongHyeonka 2f5d2fc219 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가 2026-08-15 13:01:58 +09:00
DongHyeonkaandClaude Opus 5 ac874e49e6 fix(notification): close the two pre-existing gates that broke check
`./gradlew check` failed on main before this change. Both failures came from the
notification platform and neither was reachable from the graphql merge.

- `application-core:checkstyleMain` — ProviderSubmissionResult switches over
  AttemptConfirmation and covers all three constants, so the switch is exhaustive,
  but checkstyle's MissingSwitchDefault does not model exhaustive arrow switches.
  Add the repository's existing idiom (`default -> throw new IllegalStateException`),
  the same shape SwitchNotificationWriterOwnershipCommand and DefaultCleanupService
  already use. The branch stays unreachable; it exists to satisfy the linter and to
  fail loudly if the enum ever grows a constant.

- `adapter:inbound:web:spotbugsMain` — SPRING_CSRF_PROTECTION_DISABLED x2 on
  CallbackMvcSecurityConfiguration. Provider callbacks are inbound webhooks: an
  external provider POSTs to /internal/notification/callbacks/**, so it can never
  carry a CSRF token, and the chain is SESSION-STATELESS with no ambient cookie auth
  for CSRF to protect. Authenticity comes from the provider signature the callback
  package verifies (failure -> CallbackValidationException -> 400), not from
  permitAll(). Register it in the spotbugs exclude filter scoped to that exact class,
  matching the existing narrow-exception style; every other CSRF disable stays
  reportable.

Verified on the merged tree by replaying the CI jobs locally:
- quality-gates: `check verifyPublicPathSnapshot verifyDependencyLocks
  --warning-mode=fail --no-daemon` -> BUILD SUCCESSFUL (13m47s)
- sample-off, redis-sdk, jpa-candidate-evidence, gate-matrix-lint,
  conditionalTransportQualification (graphql 8 / grpc 15 / websocket 5 /
  composition 1, zero skips)
- notification-platform architecture gate (CleanArchitectureTest +
  NotificationArchitectureTest)
- runtime: `:app-bootstrap:bootRun` against the compose-local database started and
  served GET /api/healthcheck 200; actuator/health and /api/worklogs answered 401,
  so the authentication boundary is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:39:39 +09:00
DongHyeonkaandClaude Opus 5 c3043e530a docs(graphql): 43-leaf 레지스트리 사실에 맞게 sub-package 근거를 정정
main 통합으로 messaging 플랫폼이 24개 leaf 를 modules.json 에 등록한 것이 드러났다
(현재 총 43개 leaf). 따라서 "레지스트리는 정확히 19개로 고정되어 있고 확장하면 게이트가
깨진다"는 기존 서술은 사실과 다르다.

- 레지스트리는 확장 가능하며, 자매 플랫폼 messaging 은 정반대 패턴(leaf 등록)을 택했다.
- graphql 의 sub-package 매핑은 "레지스트리가 닫혀서"가 아니라 "GraphQL 표면은 하나의
  인바운드 전송 경계이고 그 내부 분할을 레포 전역 SSOT 까지 올리지 않는다"는 선택으로
  다시 서술한다.
- 두 패턴이 공존하므로 통일 여부는 미결 아키텍처 결정으로 명시한다. 모듈 레코드는 그대로
  leaf 명세로 승격 가능한 형태라 분해 비용은 낮게 유지된다.

루트 CLAUDE.md 도 여전히 "exactly 19 leaf identities" 라고 적혀 있으나 이는 messaging 머지에서
비롯된 선행 불일치이므로 이 커밋 범위 밖으로 두고 보고한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:20:07 +09:00
DongHyeonka 5c3c0e3de9 Merge branch 'main' into worktree-graphql-platform 2026-08-14 15:41:45 +09:00
DongHyeonkaandClaude Opus 5 b074c1494e feat(graphql): GraphQL API 실행 플랫폼 구현 (Stable 48 + Advanced 19 Task)
설계 문서(specs/2026-08-12-graphql-api-execution-platform-design.md)와 두 실행 계획서에
선언된 create path 전량을 adapter-inbound-graphql leaf 안에 구현한다.

- 계획서 main 클래스 322개 전량, Task별 테스트 클래스 67개(Stable 48 + Advanced 19) 전량.
- 설계서의 Stable 16 + Advanced 12 "Gradle 모듈"은 modules.json 이 19개 leaf 정체성을
  소유하므로 bounded sub-package 로 매핑한다(선례: httpclient leaf). 모듈 경계는 문서가
  아니라 GraphQlStableModule/GraphQlAdvancedModule 값 선언 + GraphQlModuleBoundaryTest 의
  실제 소스 스캔으로 기계 검증한다.
- architecture/ 규칙은 리플렉션 + 단순명 매칭으로 구현한다. 인바운드 어댑터가 자신이
  금지하는 jakarta.persistence/spring-tx 에 의존해야 검사할 수 있다면 본말전도이기 때문.
- 부분 실패는 HTTP 200 + partial data, 요청 실패는 4xx. GraphQL over HTTP 초안 status 294 는
  의도적으로 미채택(초안 변경이 클라이언트를 깨뜨리므로).
- 요청 단위 DB 트랜잭션을 열지 않는다. 커서는 HMAC 서명된 버전 있는 keyset(상수 시간 비교).
- DataLoader 는 요청 스코프, 캐시 키는 actor/tenant sha256 지문으로 격리.
- Advanced capability 는 전부 기본 비활성. EXPERIMENTAL 등급은 명시 승인 없이 production
  활성화가 거부된다.
- spring-webflux 는 compileOnly(runtimeClasspath 제외) — MVC 배치가 WebFlux 런타임을
  물려받지 않도록. lockfile 이 스코프 제한을 고정.
- graphqlPerformanceTest 는 성능 태그가 0개면 실패한다. failOnNoDiscoveredTests 는 태그
  필터로 0건이 된 경우를 잡지 못해(Gradle 9.0.0 실측) 결과 검사를 추가했다. 증거 부재를
  통과로 위장하지 않기 위한 fail-closed.

검증: graphqlStableTest 404 / graphqlContractTest 9 / graphqlAdvancedTest 141 tests,
:adapter:inbound:graphql:check, verifyCleanArchitectureDependencies,
CleanArchitectureTest, verifyConfigurationPropertiesProcessor, verifyEnvKeys,
verifyPublicPathSnapshot 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:41:38 +09:00
DongHyeonka 71c0d2122f Merge branch 'main' into worktree-messaging-platform
# Conflicts:
#	src/config/spotbugs/exclude.xml
2026-08-14 15:15:37 +09:00
DongHyeonka d646c2f12f feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
messaging-superpowers-package 설계서/계획서 기반 구현.
registry를 19 → 43 leaf로 확장하고 src/messaging 아래 24개 leaf를 등록.

- core-api: M1 publish/consume + M2 batch·delayed·pause-resume
- policy/transport-spi: 재시도 결정, DLQ orchestration, admission control, lifecycle
- kafka·rabbit(Stable): contiguous commit, confirm/return 상관, 배치, 보안 설정
- pulsar·nats(Experimental): 기본 비활성, live 인증 없음을 코드로 기록
- outbox/inbox/claim-check: 트랜잭션 결합, lease, 무결성 검증
- admin: plan → approve → execute를 타입으로 강제
- 문서 9종, infra compose 7종, JMH 벤치마크 3종

검증: 아키텍처 게이트 3종 통과, 24개 leaf 전부 check 통과,
messaging 테스트 604개 통과/0 실패.

미완: 계획서가 요구한 실 브로커 IT 40개 중 7개만 작성.
Rabbit 13 / Outbox 6 / Inbox 4 / NATS·Pulsar·Share 5 / testkit 2 /
starter·admin 3, 그리고 TLS·ACL 2개가 남음.
2026-08-14 14:55:38 +09:00
DongHyeonka 539e3eb58b Merge branch 'main' into worktree-jpa-persistence-platform 2026-08-14 14:24:57 +09:00
DongHyeonkaandClaude Opus 5 59a392ee96 build: exclude the deliberate System.gc in the notification gauge test
`spotbugsTest` and `spotbugsSampleOffTest` on :app-bootstrap fail on DM_GC in
NotificationObservationTest, which predates this branch — the file arrives from
701ba67 and this branch never touched it. The merge only surfaces it.

The call is intentional and cannot be removed without removing the assertion:
Micrometer holds gauge referents weakly, so a gauge whose source object is
collected reports NaN from then on, and provoking a collection is the only way
to show the notification metrics do not have that defect.

Scoped to the one method by class and method name, per the filter's own rule
that entries be narrow — a System.gc() anywhere else stays reportable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:23:14 +09:00
DongHyeonkaandClaude Opus 5 c1ee1d9dd9 fix(notification): close the static-analysis findings on the merged tree
Checkstyle and SpotBugs run in the module's `check` task, not in `test`, so
these only surfaced once the platform was verified against the merged tree.

- MissingSwitchDefault on the admin runtime-state switch. The enum is
  exhaustive, so the default is unreachable today; it throws rather than
  falling through, so a state added later fails loudly instead of silently
  leaving the runtime in whatever state it already had.
- ConstantName on the two audit loggers: the checkstyle pattern allows
  `log`, `logger` or UPPER_SNAKE, and this class needs two named sinks.
- DMI_RANDOM_USED_ONLY_ONCE in three Web Push fixtures. A fresh SecureRandom
  per call re-seeds from the OS every time, which on a constrained CI runner
  can block on entropy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:14:06 +09:00
DongHyeonka ae85f23dd3 Merge branch 'main' into worktree-jpa-persistence-platform 2026-08-14 14:06:21 +09:00
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00
DongHyeonka 92744c57de merge: integrate the notification delivery platform 2026-08-14 14:00:07 +09:00
DongHyeonkaandClaude Opus 5 701ba67456 feat(notification): implement the notification delivery platform
Maps the 31-module plan onto the registry's 19 leaves as packages; the two
edges the registry forbids (provider->httpclient, inbox->messaging) are
replaced by application-owned ports. See docs/notification/module-mapping.md.

Acceptance is not delivery: ProviderSubmissionResult refuses to carry a
delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks
automatic retry and fallback until reconciliation resolves it.

Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature +
reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292),
SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate
HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception.

Dispatch commits the attempt row, calls the provider with no transaction open,
then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:57:27 +09:00
DongHyeonkaandClaude Opus 5 99a51e5a16 merge: integrate the MongoDB document persistence platform
Brings in the mongodb-superpowers-package implementation (Stable Tasks 1-50,
Advanced Tasks 1-15) as packages inside the registered leaf
:adapter:outbound:persistence-mongo, with the design's module dependency table
enforced by ArchUnit.

Shared build files are untouched by this branch: src/build.gradle,
src/settings.gradle, config/architecture/modules.json and
app-bootstrap/build.gradle are all unchanged, so this merge does not move the
19-leaf registry and does not collide with the other platform branches still in
flight.

Verified before merging: scripts/verify-mongodb-platform.sh reports 9 lanes,
0 skipped, 0 failed, every evidence category produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:46:19 +09:00
DongHyeonkaandClaude Opus 5 d57d2f62a0 feat(mongodb): implement the MongoDB document persistence platform
Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and
Advanced Tasks 1-15.

The design assumes 19 Stable + 12 Advanced Gradle projects under
modules/mongodb*. This repository's fail-closed registry declares exactly 19
leaf identities, so those modules become package boundaries inside the
registered leaf :adapter:outbound:persistence-mongo, with the design's module
dependency table enforced by ten ArchUnit rules. The mapping and every
deviation are recorded in docs/mongodb/repository-adaptation.md.

Contract highlights, all enforced by tests rather than convention:

- Transaction body retry and commit retry are separate loops. A new session per
  body attempt; commit-only retry on an unknown commit. The body is never
  replayed after a commit ambiguity, so a failover cannot become a duplicate.
- MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and
  failure, and MongoFailureContext records only the design-permitted fields.
- Failure classification reads server error labels before numeric codes.
- BSON representations come from a pinned manifest, never a library default,
  and a golden type-signature gate fails on any drift.
- Index and validator changes go through the manifest and the admin plane;
  metadata ownership gates every drop.
- Every Advanced capability refuses construction unless its flag is enabled.

Verified against real servers, not only unit tests. Running the lanes for the
first time exposed four defects that a green `check` had hidden:

- Four release lanes passed while executing zero tests; the gate now counts
  executed tests per lane and fails on zero.
- The "single replica set" fixture was a standalone, because Testcontainers 2.x
  needs withReplicaSet(); its test only asserted a connection string.
- The three-node fixture was three independent clusters, so no election could
  occur, and awaitNewPrimary() compared against the post-stop primary.
- The migration lease checked modifiedCount, so a same-millisecond refresh read
  as a lost lease.

scripts/verify-mongodb-platform.sh now reports:
  9 lanes, 0 skipped, 0 failed, every evidence category produced.

scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology
evidence (real sharded cluster, real KMS, real target deployment) is
unobtainable here, so it is named rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:41:00 +09:00
DongHyeonka 3b5aee50e3 feat: 설계 문서 추가 2026-08-13 21:09:52 +09:00
4839 changed files with 471482 additions and 27755 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
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
[`dependency-review-config.yml`](dependency-review-config.yml),
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and
[`../.trivyignore.yaml`](../.trivyignore.yaml), CODEOWNERS, and
[`../renovate.json`](../renovate.json).
## Execution and platform boundary
@@ -73,7 +73,7 @@ dependencies; stale mirrors can delay detection.
The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it
explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a
non-empty rationale, and a future expiry no more than 90 days away. `verifyTrivyignore` validates
non-empty rationale, and a future expiry no more than 90 days away. A CODEOWNERS reviewer validates
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
Neither control substitutes for the other.
+2 -2
View File
@@ -4,8 +4,8 @@
## Verification
- [ ] I ran the focused test for each changed leaf.
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`.
- [ ] I ran focused `:<changed-leaf>:check` tasks for the modules I changed.
- [ ] I ran `cd src && ./gradlew architectureCheck verifyPublicPathSnapshot verifyDependencyLocks` when the change touched repository structure, dependencies, or public paths.
- [ ] I did not add an unregistered production module dependency.
- [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification.
- [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days.
-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'
-740
View File
@@ -1,740 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
# find .github/workflows -mindepth 1 -maxdepth 1 \
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
# | LC_ALL=C sort -z | xargs -0 sha256sum
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
# merely to make this verifier pass.
readonly EXPECTED_WORKFLOW_LOCK=(
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
readonly EXPECTED_WRAPPER_PROPERTIES=(
'distributionBase=GRADLE_USER_HOME'
'distributionPath=wrapper/dists'
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
'networkTimeout=10000'
'validateDistributionUrl=true'
'zipStoreBase=GRADLE_USER_HOME'
'zipStorePath=wrapper/dists'
)
fail() {
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
fail 'expected exactly one repository-root argument'
fi
readonly REPOSITORY_ROOT=$1
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
fi
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
workflow_lock_valid=1
actual_workflow_lock=()
while IFS= read -r -d '' locked_workflow; do
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
locked_workflow_sha256='<invalid-file-type>'
else
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
fi
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
done < <(
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
| LC_ALL=C sort -z
)
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
workflow_lock_entry_count=${#actual_workflow_lock[@]}
fi
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
workflow_lock_valid=0
fi
done
workflow_count=0
gradle_job_count=0
while IFS= read -r -d '' workflow; do
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
for (known_field in step_fields) {
delete step_fields[known_field]
}
}
function reset_job() {
job = ""
in_steps = 0
steps_count = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function grammar_error(message) {
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
invalid = 1
}
function workflow_grammar_error(message) {
printf "%s: %s\n", workflow, message > "/dev/stderr"
invalid = 1
}
function validate_job_shape() {
if (job != "" && steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
}
function is_allowed_step_field(field) {
return field == "name" \
|| field == "id" \
|| field == "uses" \
|| field == "run" \
|| field == "if" \
|| field == "shell" \
|| field == "with" \
|| field == "env" \
|| field == "working-directory" \
|| field == "continue-on-error" \
|| field == "timeout-minutes"
}
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
value = trim(value)
if (value == "" || index(value, "\\") != 0) {
grammar_error("has unsupported uses scalar")
return
}
first = substr(value, 1, 1)
single_quote = sprintf("%c", 39)
if (first == "\"" || first == single_quote) {
quote = first
closing = 0
for (index_value = 2; index_value <= length(value); index_value++) {
if (substr(value, index_value, 1) == quote) {
closing = index_value
break
}
}
if (closing == 0) {
grammar_error("has unsupported uses scalar")
return
}
suffix = substr(value, closing + 1)
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
grammar_error("has unsupported uses scalar")
return
}
action = substr(value, 2, closing - 2)
if (index(action, quote) != 0) {
grammar_error("has unsupported uses scalar")
return
}
} else {
action = value
sub(/[[:space:]]+#.*$/, "", action)
action = trim(action)
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
grammar_error("has unsupported uses scalar")
return
}
}
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
grammar_error("has unsupported uses scalar")
}
}
function validate_run_scalar(value, first) {
value = trim(value)
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
return
}
first = substr(value, 1, 1)
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
grammar_error("has unsupported run scalar")
}
}
function validate_step_field(content, field, value, separator) {
content = trim(content)
if (content ~ /^[{[]/) {
grammar_error("contains unsupported flow-style step syntax")
return
}
if (content ~ /^<</) {
grammar_error("contains a forbidden step merge key")
return
}
if (content ~ /^[*&!]/) {
grammar_error("contains unsupported step anchor, alias, or tag syntax")
return
}
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
grammar_error("contains unsupported step field syntax")
return
}
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
if (!is_allowed_step_field(field)) {
grammar_error("contains unsupported step field: " field)
return
}
if (field in step_fields) {
grammar_error("contains duplicate step field: " field)
return
}
step_fields[field] = 1
if (field == "uses") {
validate_uses_scalar(value)
} else if (field == "run") {
validate_run_scalar(value)
}
}
BEGIN {
in_jobs = 0
invalid = 0
jobs_count = 0
single_quote = sprintf("%c", 39)
reset_job()
}
/^jobs:/ {
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
jobs_count++
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
in_jobs = 1
next
}
/^"jobs":/ {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
run_block == 0 && /^<<:/ {
workflow_grammar_error("workflow contains a forbidden merge key")
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job_shape()
reset_job()
in_jobs = 0
}
in_jobs && /^ [^[:space:]#]/ {
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
grammar_error("job declaration must use a canonical block mapping")
next
}
validate_job_shape()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/ || line_indent > 8) {
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
if (raw != " steps:") {
grammar_error("steps container must use a canonical block sequence")
next
}
steps_count++
if (steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
in_steps = 0
reset_step()
}
if (raw ~ /^ *<<:/) {
grammar_error("contains a forbidden merge key")
next
}
if (in_steps != 0 && raw ~ /^ - /) {
reset_step()
step_active = 1
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
grammar_error("contains unsupported empty step syntax")
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
grammar_error("contains unsupported step-list syntax")
}
}
END {
validate_job_shape()
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
if (invalid) {
exit 1
}
}
' "${workflow}"; then
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
if ! grep -Fq -- './gradlew' "${workflow}" \
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
continue
fi
((workflow_count += 1))
if ! jobs_in_workflow=$(
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
step_kind = ""
step_name = ""
step_id = ""
step_uses = ""
step_uses_action = ""
step_if = ""
step_if_present = 0
step_continue_on_error = 0
step_gradle = 0
step_gradle_line = 0
step_unsupported_gradle = 0
step_field_count = 0
step_name_line = 0
step_id_line = 0
step_uses_line = 0
step_extra_field = ""
for (known_field in step_fields) {
delete step_fields[known_field]
delete step_field_raw[known_field]
}
}
function reset_job() {
job = ""
checkout_line = 0
validation_line = 0
gradle_line = 0
in_steps = 0
unsupported_gradle = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function has_gradle_reference(line) {
return index(line, "./gradlew") != 0 \
|| index(line, "gradle/actions/dependency-submission@") != 0
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function normalize_action(value, scalar, first, quote, closing, index_value) {
scalar = trim(value)
first = substr(scalar, 1, 1)
if (first == "\"" || first == single_quote) {
quote = first
closing = index(substr(scalar, 2), quote)
if (closing == 0) {
return ""
}
return substr(scalar, 2, closing - 1)
}
sub(/[[:space:]]+#.*$/, "", scalar)
return trim(scalar)
}
function record_gradle(line_number) {
step_gradle = 1
if (step_gradle_line == 0) {
step_gradle_line = line_number
}
if (gradle_line == 0) {
gradle_line = line_number
}
}
function record_uses(value, line_number, action) {
if (step_kind == "run") {
if (index(value, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "uses"
action = normalize_action(value)
step_uses = trim(value)
step_uses_action = action
step_uses_line = line_number
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
checkout_line = line_number
}
if (action == dependency_action) {
record_gradle(line_number)
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
record_gradle(line_number)
step_unsupported_gradle = 1
}
}
function record_run(value, line_number) {
if (step_kind == "uses") {
if (index(value, "./gradlew") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "run"
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
} else if (index(value, "./gradlew") != 0) {
record_gradle(line_number)
}
}
function record_step_field(content, line_number, separator, field, value) {
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
step_fields[field] = 1
step_field_raw[field] = trim(content)
step_field_count++
if (field == "name") {
step_name = trim(value)
step_name_line = line_number
} else if (field == "id") {
step_id = trim(value)
step_id_line = line_number
} else if (field == "uses") {
record_uses(value, line_number)
} else if (field == "run") {
record_run(trim(value), line_number)
} else if (field == "if") {
step_if_present = 1
step_if = trim(value)
} else if (field == "continue-on-error") {
step_continue_on_error = 1
}
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
step_extra_field = step_field_raw[field]
}
}
function validate_wrapper_step() {
if (step_uses_action != validation_reference) {
return
}
if (step_extra_field != "") {
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
invalid = 1
return
}
if (step_field_count != 3 \
|| step_name != "Validate Gradle wrapper" \
|| step_id != "gradle-wrapper-validation" \
|| step_uses != validation_action \
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
invalid = 1
return
}
if (validation_line == 0) {
validation_line = step_uses_line
}
}
function validate_gradle_step() {
if (step_gradle == 0 && step_unsupported_gradle == 0) {
return
}
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
unsupported_gradle = 1
}
if (step_if_present != 0 && step_if != guarded_gradle_if) {
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
invalid = 1
}
if (step_continue_on_error != 0) {
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
invalid = 1
}
}
function finalize_step() {
if (step_active == 0) {
return
}
validate_wrapper_step()
validate_gradle_step()
}
function start_step() {
finalize_step()
reset_step()
step_active = 1
}
function validate_job() {
finalize_step()
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
return
}
gradle_jobs++
if (unsupported_gradle != 0) {
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
invalid = 1
}
if (gradle_line == 0) {
return
} else if (checkout_line == 0) {
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (validation_line == 0) {
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
invalid = 1
}
}
BEGIN {
in_jobs = 0
invalid = 0
gradle_jobs = 0
single_quote = sprintf("%c", 39)
validation_reference = validation_action
sub(/[[:space:]]+#.*$/, "", validation_reference)
reset_job()
}
/^jobs:[[:space:]]*(#.*)?$/ {
in_jobs = 1
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job()
reset_job()
in_jobs = 0
}
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
validate_job()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/) {
next
}
if (line_indent > 8) {
if (index(raw, "./gradlew") != 0) {
record_gradle(NR)
}
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw == " steps:") {
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
finalize_step()
in_steps = 0
reset_step()
}
if (in_steps != 0 && raw ~ /^ - /) {
start_step()
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (has_gradle_reference(raw)) {
unsupported_gradle = 1
}
}
END {
validate_job()
print gradle_jobs
if (invalid) {
exit 1
}
}
' "${workflow}"
); then
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
((jobs_in_workflow > 0)) \
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
((gradle_job_count += jobs_in_workflow))
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
((workflow_lock_valid != 0)) \
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
printf 'gradle-wrapper-contract: PASS\n'
+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
# The pull-request gate. Everything here blocks a merge.
#
# The job list used to include `gate-matrix-lint`, which ran .github/scripts/verify-gate-matrix.sh
# against .github/ci-gate-matrix.yml: a 1,025-line register of all 107 CI controls, checked for
# consistency against the Gradle task graph and this workflow by a 568-line shell script, which was
# itself checked by contract tests in :app-bootstrap. Adding one check meant editing Gradle, a
# workflow, the matrix, the verifier's expectations and a Java test. The information was already in
# the task graph and the job graph; the matrix was a third copy that had to be kept equal to both.
on:
pull_request:
push:
@@ -21,9 +30,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- name: Require the committed public-path security baseline
run: |
set -euo pipefail
@@ -36,103 +42,97 @@ jobs:
echo "::error::${snapshot} exists locally but is not committed."
exit 1
fi
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Check quality, public paths, and dependency locks
- uses: ./.github/actions/setup-gradle-java
# `ci`, not `check`. A leaf's `check` is that leaf's — compile, its tests, Spotless, Checkstyle
# and Error Prone — and the repository-wide gates are named tasks of their own:
# ci = every leaf check + architectureCheck + qualityCheck + configContractCheck + qualificationCheck
# so CI runs strictly more than it used to while `./gradlew :domain-core:check` runs strictly
# less.
- name: Run the pull-request gate
working-directory: src
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
run: ./gradlew :ci :verifyPublicPathSnapshot :verifyDependencyLocks --warning-mode=fail --stacktrace
# Named as its own step because nothing else runs it: `check` does not depend on
# graphqlStableTest, so the lane's required-class guard — the check that its module-boundary
# suite has not silently stopped being discovered — would protect nothing in CI.
- name: Qualify the GraphQL Stable lane
working-directory: src
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --stacktrace
- name: Qualify opt-in inbound transports without skips
working-directory: src
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
run: ./gradlew :conditionalTransportQualification --stacktrace
sample-off:
build-logic:
# Included-build tests are independent of the main project task graph. Running them as a
# separate blocking job keeps plugin TestKit work off the quality-gates critical path.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify the application without the sample fixture
- uses: ./.github/actions/setup-gradle-java
- name: Test the build-logic convention plugins
working-directory: src
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
gate-matrix-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Verify the gate matrix against the repository
run: bash .github/scripts/verify-gate-matrix.sh
run: ./gradlew -p build-logic test --stacktrace
redis-sdk:
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit
# provenance, connection isolation, and the executor guard. There is no real-server lane yet.
#
# `verifyConfigurationPropertiesProcessor` used to be in this list. It is deleted: the parity it
# enforced — a leaf declares Spring's configuration processor exactly when it owns
# @ConfigurationProperties — is now what applying `ca.spring-config` means.
# `verifyEnvKeys` is no longer named here either; it belongs to :app-bootstrap and runs through
# `configContractCheck`, which the quality-gates job covers.
uses: ./.github/workflows/_reusable-gradle.yml
with:
tasks: ":shared-contract:edgeRateLimitContractTest"
optional-platforms:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
# permit provenance, connection isolation, and the executor guard. There is no real-server
# lane yet — Tasks 10-17 add the contract suites that need one.
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
- uses: ./.github/actions/setup-gradle-java
- name: Verify the optional gRPC platform build
working-directory: src
run: ./gradlew -p optional-platforms ci --stacktrace
configuration-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Store configuration cache for the everyday core build
working-directory: src
run: >-
./gradlew
:shared-contract:edgeRateLimitContractTest
:adapter:outbound:cache-redis:check
verifyCleanArchitectureDependencies
verifyEnvKeys
verifyPublicPathSnapshot
verifyConfigurationPropertiesProcessor
--no-daemon --stacktrace
:domain-core:check
:application-core:check
--configuration-cache
--configuration-cache-problems=fail
--stacktrace
- name: Require configuration-cache reuse
working-directory: src
shell: bash
run: |
set -euo pipefail
output="$({ ./gradlew :domain-core:check :application-core:check \
--configuration-cache --configuration-cache-problems=fail --stacktrace; } 2>&1)"
printf '%s\n' "${output}"
grep -Fq 'Reusing configuration cache.' <<<"${output}" || {
echo '::error::Gradle did not reuse the configuration cache on the second identical build.'
exit 1
}
jpa-candidate-evidence:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Produce zero-skip JPA candidate manifests
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
--no-daemon
--stacktrace
- name: Retain content-addressed JPA candidate manifests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
@@ -142,53 +142,46 @@ jobs:
if-no-files-found: error
retention-days: 14
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
# Advisory. The quarantine bucket runs so a flaky test is still executed and reported; it never
# blocks. The 14-day sunset registry that used to make an expired quarantine entry a build failure
# is gone — it was a 250-line YAML-and-Java parser guarding a registry with zero entries.
quarantine:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run quarantined tests as an advisory signal
working-directory: src
run: ./gradlew quarantineTest --no-daemon
uses: ./.github/workflows/_reusable-gradle.yml
with:
tasks: ":quarantineTest"
gradle-args: "--stacktrace"
continue-on-error: true
release-gate:
needs:
- quality-gates
- sample-off
- gate-matrix-lint
- build-logic
- redis-sdk
- jpa-candidate-evidence
- optional-platforms
- configuration-cache
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require every current blocking job to succeed
env:
QUALITY_RESULT: ${{ needs.quality-gates.result }}
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
BUILD_LOGIC_RESULT: ${{ needs.build-logic.result }}
REDIS_RESULT: ${{ needs.redis-sdk.result }}
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
OPTIONAL_PLATFORMS_RESULT: ${{ needs.optional-platforms.result }}
CONFIGURATION_CACHE_RESULT: ${{ needs.configuration-cache.result }}
run: |
set -euo pipefail
for result in \
"${QUALITY_RESULT}" \
"${SAMPLE_OFF_RESULT}" \
"${MATRIX_RESULT}" \
"${BUILD_LOGIC_RESULT}" \
"${REDIS_RESULT}" \
"${JPA_CANDIDATE_RESULT}"; do
"${JPA_CANDIDATE_RESULT}" \
"${OPTIONAL_PLATFORMS_RESULT}" \
"${CONFIGURATION_CACHE_RESULT}"; do
if [[ "${result}" != "success" ]]; then
echo "::error::release-gate: required job result was ${result}"
exit 1
+5 -13
View File
@@ -35,18 +35,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Submit the resolved Gradle dependency graph
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
with:
@@ -179,7 +168,10 @@ jobs:
trivy-kev.json | sort -u > found-cves.txt
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
kev.json | sort -u > kev-cves.txt
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
# No `|| true`. comm exits non-zero only when it cannot read or order its inputs, and
# swallowing that would have turned an unreadable CVE list into an empty intersection and
# printed "no catalog match" — a KEV cross-check that passes because it never ran.
hits="$(comm -12 found-cves.txt kev-cves.txt)"
if [[ -n "${hits}" ]]; then
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
printf '%s\n' "${hits}"
@@ -0,0 +1,122 @@
name: fileserver-certification
# The certification a release must clear. Its job list is deliberately the same shape as the support
# matrix: nothing may be advertised at a support level whose evidence job is absent here.
#
# Named "certification", not "release", and the name is the point. This workflow proves a storage
# topology, a support matrix and a telemetry redaction claim. It deploys nothing and holds no cluster
# credential. Calling it `fileserver-release.yml` read as if GitHub Actions released the fileserver,
# which is the CI/CD boundary this repository has now fixed in docs/ci-cd/boundary.md: GitHub Actions
# tests, scans and publishes artifacts; Argo CD deploys.
#
# It used to be workflow_dispatch only, which made that sentence false: the four jobs below are the
# only place the fileserver support matrix, the PVC manifest and the telemetry redaction proof are
# checked, and a release tag reached none of them unless somebody remembered to press a button.
#
# `v*` is the only release tag. The adapter-scoped `fileserver-v*` pattern is gone: this repository
# has one deployable unit (app-bootstrap), so an adapter-scoped tag could only ever run a subset of
# the release gates and call the result a release — the tag-namespace split that release.yml exists
# to end.
#
# These four jobs stay in their own file, and not in release.yml, for one mechanical reason:
# FileserverDocumentationCoverageTest reads job ids out of `.github/workflows/fileserver-*.yml` and
# requires every `fileserver-...` job docs/fileserver/support-matrix.md names to be defined in one
# of them. Renaming the file or moving these jobs needs that document changed in the same change.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
fileserver-full-verification:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the architecture-wide dependency and module verification
working-directory: src
run: >-
./gradlew
:verifyCleanArchitectureDependencies
--stacktrace
- name: Run the complete fileserver suite across every leaf
working-directory: src
run: >-
./gradlew
:application-core:check
:adapter:inbound:web:check
:adapter:outbound:fileserver:check
--stacktrace
fileserver-documentation-gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Prove every support claim maps to a job and every endpoint is documented
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
--stacktrace
fileserver-pvc-certification:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
# This job checks the manifest, and only the manifest. It deliberately does not apply anything
# to a cluster.
#
# There used to be a second step here that applied the job to a release cluster when
# secrets.FILESERVER_PVC_KUBECONFIG was set and `exit 0`-ed with a ::warning:: when it was
# not. With no secret configured — which is every fork of this template and was this
# repository — the step printed a warning and the job went green under the name
# "fileserver-pvc-certification", so a release read as ReadWriteOnce-certified against a
# cluster nothing had ever touched. It also wrote a `certified` output that no job, step or
# script in this repository read.
#
# The cluster result comes from an operator running infra/fileserver/kubernetes/
# pvc-certification-job.yaml against a real cluster and recording it in
# docs/fileserver/storage-certification.md. That is registered as
# docs/fileserver/storage-certification.md, and the absence of a cluster result is stated
# there rather than hidden behind a green check.
- name: Check the certification manifest still says what the claim depends on
run: |
set -euo pipefail
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
test -f "$manifest"
grep -q 'kind: PersistentVolumeClaim' "$manifest"
grep -q 'kind: Job' "$manifest"
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
# mode would certify a topology the support matrix says is uncertified.
grep -q 'ReadWriteOnce' "$manifest"
! grep -q 'ReadWriteMany' "$manifest"
fileserver-sensitive-telemetry-scan:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Prove telemetry carries no filename, path, or raw identifier
working-directory: src
run: >-
./gradlew
:application-core:test --tests '*FileserverObservabilityTest'
--stacktrace
+8 -52
View File
@@ -24,18 +24,7 @@ jobs:
FILESERVER_NFS_TESTS: "true"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Start the NFSv4 certification environment
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
- name: Run the network-filesystem ambiguity suite
@@ -43,7 +32,7 @@ jobs:
run: >-
./gradlew
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
--no-daemon
--stacktrace
- name: Tear down the NFS environment
if: always()
@@ -54,18 +43,7 @@ jobs:
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the crash matrix and reconciliation suites
working-directory: src
run: >-
@@ -73,7 +51,7 @@ jobs:
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
:application-core:test --tests '*FileReconciliationServiceTest'
--rerun-tasks
--no-daemon
--stacktrace
fileserver-large-file-performance:
@@ -81,18 +59,7 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the large-file and slow-client suites under a constrained heap
working-directory: src
env:
@@ -102,7 +69,7 @@ jobs:
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
--rerun-tasks
--no-daemon
--stacktrace
fileserver-multi-instance-lease:
@@ -110,23 +77,12 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Prove no run commits bytes from a stale lease
working-directory: src
run: >-
./gradlew
:application-core:test --tests '*MultiInstanceWriterLeaseTest'
--rerun-tasks
--no-daemon
--stacktrace
+17 -68
View File
@@ -23,9 +23,13 @@ on:
- 'docs/registries/env-keys.yaml'
- 'src/Dockerfile'
- 'docker-compose.yml'
- 'infra/nginx/**'
- 'infra/k8s/**'
- 'infra/fileserver/nginx/**'
- 'infra/fileserver/kubernetes/**'
- 'infra/fileserver/nfs/**'
- '.github/workflows/fileserver-pr.yml'
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- '.github/actions/setup-gradle-java/action.yml'
permissions:
contents: read
@@ -40,25 +44,14 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the fileserver application and architecture suites
working-directory: src
run: >-
./gradlew
:application-core:test
:app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*Fileserver*'
--no-daemon
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --tests '*Fileserver*'
--stacktrace
fileserver-local-ext4-contract:
@@ -66,24 +59,13 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Certify the local content store against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:fileserver:test
--no-daemon
--stacktrace
fileserver-http-contract:
@@ -91,24 +73,13 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the servlet and reactive transport contracts
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
--no-daemon
--stacktrace
fileserver-security-suite:
@@ -116,25 +87,14 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the path, filename, range, and problem-detail hardening suite
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
--no-daemon
--stacktrace
fileserver-bounded-memory:
@@ -142,23 +102,12 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Prove transfer cost does not scale with file size
working-directory: src
run: >-
./gradlew
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
--no-daemon
--stacktrace
-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
+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
+90
View File
@@ -0,0 +1,90 @@
name: jpa-nightly
# The suites that are too slow, too Docker-heavy, or too machine-dependent for a PR, and the middle
# of the PostgreSQL matrix.
#
# The failure-injection lane is the one that matters most and is easiest to lose: it is the only
# place the commit-ambiguity scenarios run, and they are the only evidence that a lost commit
# acknowledgement produces completion-unknown rather than a retry.
on:
workflow_dispatch:
schedule:
# 02:30 UTC daily.
- cron: '30 2 * * *'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
jpa-full-matrix:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
postgresql: ["16", "17", "18"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--stacktrace
jpa-failure-injection:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformFailureTest
--stacktrace
jpa-query-plan-and-security:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the query plan and database security suites
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
:adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
--stacktrace
jpa-pool-pressure:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Verify pool saturation and REQUIRES_NEW connection behaviour
working-directory: src
# A behaviour contract, not a measurement. This step used to switch assertions off with an
# explicit property and call the result a certification, so the only threshold it ever
# asserted was that thresholds were not being asserted. What
# it checks now — that REQUIRES_NEW needs two connections per concurrent thread, that a
# saturated pool reports its pending count, that a caller waits rather than proceeding
# without a connection — is true on any runner, so there is nothing to switch off.
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
--stacktrace
+2 -13
View File
@@ -26,25 +26,14 @@ jobs:
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Verify the production-profile JPA R2 manifest DAG
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
-PjpaEvidenceProfile=r2
--no-daemon
--stacktrace
- name: Retain JPA R2 attempt manifests
if: always()
+128
View File
@@ -0,0 +1,128 @@
name: jpa-release
# The release registry is the gate-task source: jpaReleaseQualification reads its blocking gates,
# while JpaReleaseRenderingTest holds this file's matrix and promotion lists to the registry's Stable
# majors and verifyJpaReleaseGateTasks resolves each declared task against the real Gradle graph.
# CI therefore owns release scheduling, not a second JPA gate-task inventory.
#
# The matrix below is therefore not free to drift: editing it without editing the registry fails the
# unit lane.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# One job per PostgreSQL major, because one job for three majors was one job for one major.
#
# `-Pjpa.matrix.versions=16,17,18` reached JpaPlatformContractSupport.start(), which started
# selectedVersions().get(0) — so twenty-eight integration classes ran against PG16 and nothing
# ran against 17 or 18, while docs/jpa/support-matrix.md recorded all three as "full contract
# suite, release lane". A JSONB mapping, a Hibernate dialect difference or a Flyway upgrade that
# only breaks on 18 shipped with a green release.
#
# start() now fails closed on a multi-version selection, so the fan-out is not optional: the
# matrix is the only way the three majors get covered, and removing a major from it removes the
# evidence rather than quietly reusing another major's.
jpa-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
postgresql: ["16", "17", "18"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Run the JPA database qualification set on PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
jpaReleaseQualification
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--stacktrace
- name: Record which major this evidence covers
if: always()
working-directory: src
run: |
mkdir -p build/jpa-release-evidence
{
echo "sha=${{ github.sha }}"
echo "ref=${{ github.ref }}"
echo "postgresql-major=${{ matrix.postgresql }}"
echo "task-set=jpa-database-qualification"
} > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties"
- name: Upload the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: jpa-release-evidence-pg${{ matrix.postgresql }}
path: |
src/build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties
src/adapter/outbound/persistence-jpa/build/test-results/**/*.xml
retention-days: 30
if-no-files-found: error
# The promotion decision. Three majors' evidence, and all three must come from this SHA — an
# aggregate that accepted a re-run artifact from another commit would promote a release on
# evidence produced by different code.
jpa-release-promotion:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: jpa-release-gate
steps:
- name: Download every major's evidence
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0
with:
pattern: jpa-release-evidence-pg*
path: evidence
- name: Require all three majors, all from this SHA
run: |
set -euo pipefail
missing=0
for major in 16 17 18; do
manifest=$(find evidence -name "manifest-${major}.properties" -print -quit)
if [ -z "${manifest}" ]; then
echo "::error::no release evidence for PostgreSQL ${major}"
missing=1
continue
fi
sha=$(sed -n 's/^sha=//p' "${manifest}")
if [ "${sha}" != "${{ github.sha }}" ]; then
echo "::error::PostgreSQL ${major} evidence is from ${sha}, not ${{ github.sha }}"
missing=1
fi
done
if [ "${missing}" -ne 0 ]; then
echo "::error::the release gate covers three PostgreSQL majors; promotion needs all three"
exit 1
fi
echo "PostgreSQL 16, 17 and 18 evidence all present and all from ${{ github.sha }}."
jpa-architecture-and-docs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Verify architecture boundaries and the support matrix
working-directory: src
run: >-
./gradlew
:verifyJpaReleaseGateTasks
:verifyJpaReadinessRegistry
:verifyCleanArchitectureDependencies
checkstyleMain
:app-bootstrap:architectureTest
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
--stacktrace
@@ -0,0 +1,51 @@
# The messaging platform's broker certification lane.
#
# Separate from ci-quality-gates.yml because it needs a container runtime and several minutes of it.
# The lane deliberately carries no Docker guard: every other container suite in the messaging tree
# skips with a stated reason when Docker is absent, and a certification lane that skipped would
# report success for a broker nobody started — which is the exact claim the evidence exists to rule
# out.
#
# The job runs the evidence gate rather than the lane, and the gate depends on the lane. What it
# proves is not only that the scenarios pass but that the committed manifest
# (messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl) is what this
# run produced, so "certified against a live broker" cannot be restored by editing a file.
name: messaging-certification
on:
pull_request:
paths:
- "src/messaging/**"
- ".github/workflows/messaging-certification.yml"
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- ".github/actions/setup-gradle-java/action.yml"
schedule:
- cron: "41 4 * * 3"
workflow_dispatch:
permissions:
contents: read
env:
# Reuse would hand one scenario the broker another scenario had already faulted.
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
broker-certification:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Certify the Kafka adapter against a real broker
working-directory: src
# GITHUB_SHA is read by the lane and written into every evidence line, because "certified"
# is a claim about one source tree.
run: ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --stacktrace
- name: Publish the certification evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: messaging-broker-certification-evidence
path: src/messaging/messaging-kafka/build/messaging-certification/
if-no-files-found: warn
+153
View File
@@ -0,0 +1,153 @@
name: notification-platform
# Verification tiers for the Notification Delivery Platform.
#
# The PR tier is deliberately free of any external provider. A gate that depends on a third-party
# sandbox fails for reasons that have nothing to do with the change under review, and a gate people
# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where
# a failure is an environment signal rather than a merge blocker.
#
# Every job that invokes Gradle validates the wrapper first with the repository's pinned action;
# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a
# compromised wrapper from turning any workflow run into arbitrary code execution.
on:
pull_request:
paths:
# The filter used to stop at the four notification source trees, so a change to the
# composition root, the settings binding, the schema migrations, or the evidence manifest
# ran none of this — and those are exactly the surfaces that decide whether the platform
# assembles, binds and migrates at all.
- 'src/application-core/src/**/notification/**'
- 'src/adapter/outbound/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/jpa/notification-platform/**'
- 'src/adapter/inbound/web/src/**/notification/**'
- 'src/app-bootstrap/src/**/notification/**'
- 'src/app-bootstrap/src/main/resources/application*.yml'
- 'src/gradle/notification-*.gradle'
- 'src/config/architecture/modules.json'
- 'src/.env'
- 'docs/notification/**'
- 'infra/notification/**'
- '.github/workflows/notification-platform.yml'
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- '.github/actions/setup-gradle-java/action.yml'
push:
branches: [ main ]
schedule:
# Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier.
- cron: '0 17 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: notification-platform-${{ github.ref }}
cancel-in-progress: true
jobs:
pr:
name: contract (Java 21, no external provider)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
- name: Compile and format check
working-directory: src
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
- name: Application contracts
working-directory: src
run: ./gradlew :application-core:test --console=plain
- name: Provider contract suite
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --console=plain
- name: Persistence and web
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain
# The PR tier never touched a database, so every claim about migrations, claim atomicity and
# lease fencing rested on a fake. Docker is available on this runner; the lane fails closed
# when the container cannot start, because a skipped contract reports success for a database
# nobody tested.
- name: Notification schema and claim contracts (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest --console=plain
- name: Notification migration upgrade (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest --console=plain
- name: Architecture gates
working-directory: src
run: |
./gradlew :verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:architectureTest --console=plain
- name: Configuration surface
working-directory: src
run: |
./gradlew :app-bootstrap:verifyEnvKeys :verifyPublicPathSnapshot --console=plain
./gradlew :verifyNotificationApiSurface :verifyNotificationConfiguration --console=plain
# A support grade is a promise about production behaviour. This refuses one the pipeline
# cannot back — the check that would have caught five channels reading "Stable" while no
# request had ever left the process.
- name: Evidence manifest
working-directory: src
run: ./gradlew :verifyNotificationEvidence --console=plain
- name: Static analysis
working-directory: src
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
nightly-chaos:
name: chaos (ambiguity, restart recovery, callback burst)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: ./.github/actions/setup-gradle-java
# This job is named for ambiguity, restart recovery and callback burst. It used to run a
# unit-test filter and then `test` — neither of which restarts anything or bursts anything —
# so the job name was the only place those three properties existed.
- name: Ambiguity and fault harness
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
- name: Concurrency and rotation races
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ConcurrencyTest' --tests '*ProviderRuntimeStateTest' --console=plain
- name: Restart recovery and lease fencing (real PostgreSQL)
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
- name: Notification regression suite
working-directory: src
run: >-
./gradlew
:application-core:test
:adapter:outbound:notification:test
:adapter:outbound:persistence-jpa:test
:adapter:inbound:web:test
--console=plain
# A filter that matches nothing passes. Each --tests filter above names a class that exists
# today; if one is renamed the job must fail rather than quietly stop covering it.
- name: Every named suite actually ran
working-directory: src
run: |
set -euo pipefail
for suite in ChaosSecurity CrossProviderContractSuite ConcurrencyTest ProviderRuntimeStateTest; do
if ! find . -path '*/build/test-results/*' -name "*${suite}*.xml" | grep -q .; then
echo "no test results for ${suite}: the filter matched nothing and the job passed vacuously" >&2
exit 1
fi
done
# There is no provider-sandbox job. It ran only on workflow_dispatch and could not succeed by
# any path: with no credentials its first step exit 1-ed, and with credentials the only test it
# ran was ProviderSandboxSmokeTest, whose body is an unconditional fail() saying a real sandbox
# call is not implemented. Its credential check read secrets.NOTIFICATION_SANDBOX_CREDENTIALS,
# which nothing in this repository consumes — the test reads NOTIFICATION_SANDBOX_ENABLED — so
# any non-empty string satisfied it and was then dropped.
#
# The unimplemented state is still stated in two places that do not depend on a workflow:
# ProviderSandboxSmokeTest itself, and the unsatisfied provider-wire-qualified claim in
# docs/notification/evidence-manifest.json, which verifyNotificationEvidence enforces inside
# check. When a real sandbox call is implemented, the job comes back with it.
@@ -11,6 +11,17 @@ on:
required: true
default: false
type: boolean
# AwsS3DirectTransferQualificationTest requires a second, separate authority
# (OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED) before the direct-transfer lane may run, and
# the job never supplied it. objectStorageAwsQualificationTest is a strict qualification task
# that requires both of its classes, so the lane could not be run to a pass from any input:
# dispatching it always failed on the missing variable. The authority now exists as its own
# input rather than as a constant, which is what "separate" was supposed to mean.
run_protected_aws_direct_mutation:
description: Also authorize the direct-transfer mutation lane against the sandbox bucket
required: true
default: false
type: boolean
permissions:
contents: read
@@ -19,90 +30,41 @@ env:
TESTCONTAINERS_REUSE_ENABLE: "false"
jobs:
poster-image-v7-migration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run non-skipping Poster image migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
minio-managed-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run exact-release MinIO managed contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --stacktrace
minio-managed-fault:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run digest-pinned MinIO and Toxiproxy fault contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --stacktrace
aws-managed-common-subset:
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
if: >-
github.event_name == 'workflow_dispatch'
&& inputs.run_protected_aws
&& inputs.run_protected_aws_direct_mutation
environment: object-storage-aws-qualification
runs-on: ubuntu-latest
env:
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED: ${{ inputs.run_protected_aws_direct_mutation }}
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run protected AWS common-subset qualification
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --stacktrace
+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/**"
- "infra/redis-sdk/**"
- ".github/workflows/redis-sdk-topology.yml"
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- ".github/actions/setup-gradle-java/action.yml"
schedule:
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
- cron: "30 2 * * *"
@@ -113,18 +116,7 @@ jobs:
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Start the topology
env:
REDIS_VERSION: ${{ matrix.redis_version }}
@@ -135,8 +127,19 @@ jobs:
set -euo pipefail
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
# reproduced once the tag moves.
#
# This used to end in `|| echo 'unresolved'`, which absorbed the failure that `set -e` was
# there to catch: the manifest below recorded `image_digest=unresolved`, the upload
# satisfied `if-no-files-found: error`, and the lane went green holding exactly the
# artifact this workflow's header calls "not evidence". Compose pulls the image in the
# step before this one, so RepoDigests is populated; if it is not, the run has nothing to
# certify and says so.
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
"redis:${{ matrix.redis_version }}" 2>/dev/null || echo 'unresolved')"
"redis:${{ matrix.redis_version }}")"
if [[ -z "$digest" ]]; then
echo "::error::no repository digest for redis:${{ matrix.redis_version }}; this run cannot say which image produced its evidence"
exit 1
fi
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
- name: Run the topology contracts
working-directory: src
+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
+10
View File
@@ -1,2 +1,12 @@
.vscode/
src/**/bin/
.claude/
# Operator input, not a build input. The examples beside it are the tracked contract;
# verifyEnvKeys reads the registry, the profile YAMLs and .env.example, never a real one.
src/.env*
!src/.env.example
!src/.env.local.example
# Written per run by the runtime-smoke wrapper; never committed.
src/.env.lane
+15 -7
View File
@@ -1,13 +1,21 @@
# Structured Trivy suppression baseline.
#
# This repository-root file is the only CI suppression source. Every future entry must include:
# id: advisory, license, misconfiguration, or secret identifier
# statement: non-empty accepted-risk or false-positive rationale
# expired_at: future YYYY-MM-DD no more than 90 days from review
# This repository-root file is the only CI suppression source. Every Trivy invocation must name it
# with `--ignorefile .trivyignore.yaml`; ad-hoc ignore files and inline bypasses are not allowed.
#
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate
# reviewer control. Every Trivy invocation must also name this file with
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
# Every entry must carry:
# id: advisory, license, misconfiguration, or secret identifier
# statement: non-empty accepted-risk or false-positive rationale
# expired_at: future YYYY-MM-DD, no more than 90 days from review
#
# Enforced by review, not by a build task. `verifyTrivyignore` used to be a 105-line hand-written
# YAML parser in the root build — indentation tracking, inline-scalar handling, quote stripping — and
# what it guarded was this file, which has been empty since it was created. A suppression is added by
# a human and merged by a CODEOWNERS reviewer (.github/dependency-vulnerability-policy.md); that
# reviewer is the control, and a parser that has never seen an entry is not a second one.
#
# If this file ever carries entries and they start drifting, that is the moment to automate the
# check — against real entries, with a real YAML library. Not before.
vulnerabilities: []
licenses: []
+25 -12
View File
@@ -49,12 +49,22 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
## Gradle 정책 권위
- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로,
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
membership
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
architecture-wide verification task
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 이걸 강제하는 태스크는 없다:
`verifyDocumentedLeafCount`는 삭제됐다. 문서에 적힌 수가 틀린 것은 결함이지만 빌드를 실패시킬
사유는 아니고, 그 태스크는 모든 `CLAUDE.md``build.gradle`을 정규식으로 훑는 파서였다.
- `src/settings.gradle`: 16줄. `ca.architecture-registry` 설정 플러그인이 registry를 읽어
project를 include/mapping 한다. registry가 project 목록이 될 수 없는 경우(중복 ID, 저장소 밖
경로, 없는 디렉터리)만 여기서 실패한다. 허용되지 않는 edge 같은 아키텍처 규칙은
`verifyCleanArchitectureDependencies`가 답한다 — settings에서 죽으면 실행할 수 있는 태스크가
하나도 없다.
- `src/build-logic/`: convention plugin. leaf는 `ca.java-library` / `ca.spring-library` /
`ca.platform-module` 중 자기 성격을 선언하고, 그 플러그인이 toolchain·락·정적분석·테스트
기본값을 준다. `ca.architecture`가 아키텍처 검증 태스크를 소유한다.
- `src/build.gradle`: 루트 라이프사이클(`ci`, `releaseCheck`, `qualityCheck`,
`configContractCheck`, `integrationCheck`)과 버전/리비전
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`
함께 읽는다. focused test는 registry의 `gradle_path`에서
@@ -102,7 +112,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit
## 모듈 책임
19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
모든 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은
`src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서
파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운
`src/**/CLAUDE.md`를 함께 읽는다.
@@ -173,16 +183,19 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
```bash
cd src
./gradlew <owner-gradle-path>:test --console=plain
./gradlew test
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
./gradlew verifyCleanArchitectureDependencies
./gradlew <owner-gradle-path>:check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne
./gradlew check # 모든 leaf의 check
./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성
./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다)
./gradlew ci # PR 게이트 = 위 셋 + configContractCheck
./gradlew verifyPublicPathSnapshot
./gradlew verifyEnvKeys
./gradlew :app-bootstrap:verifyEnvKeys
```
leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다.
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다.
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
## 설정과 런타임
+31 -10
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template.
Repository guidance for the Java 21 + Spring Boot 4.0.8 Clean Architecture template.
## Prime Directive
@@ -21,9 +21,10 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must
## Gradle policy authorities
- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source
- `src/config/architecture/modules.json`: every registered leaf identity, repository-relative source
paths, Gradle paths, allowed production project dependency edges, and the exact runtime
memberships of both composition roots.
memberships of both composition roots. The registry owns the leaf list and its size; no document
restates the count, because a number written in prose drifts the moment a leaf is added.
- `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping.
- `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide
verification tasks.
@@ -43,8 +44,10 @@ count.
## Module families
`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules.
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
families; the nearest `src/**/CLAUDE.md` owns local rules. No task enforces this any more:
`verifyDocumentedLeafCount` was deleted along with the other documentation-drift parsers. A stated
count that disagrees with the registry is a defect, not a build failure — so do not state one.
| Family | Responsibility | Stable dependency direction |
| --- | --- | --- |
@@ -54,9 +57,20 @@ families; the nearest `src/**/CLAUDE.md` owns local rules.
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
| `messaging:*` | Vendored messaging platform: a product with its own API, SPI, adapters and composition boundary, not a layer of this application | `messaging:*` only — it depends on no `domain-core`, `application-core`, or `shared-contract` type |
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
The `messaging:*` family is the one entry that is not a Clean Architecture layer, and it is listed so
that the exception is stated rather than inferred from a directory. It is a vendored library — its
own `*-api` leaves are its ports, its broker leaves are its adapters, its starter is its composition
root — and the messaging module review (`docs/reviews/2026-08-14-messaging-module-code-review.md`
MSG-023 §6.2) chose that layout deliberately over folding it into `adapter:outbound:*`. This
application is supposed to reach it the way it reaches any library: through an application-owned port
satisfied by an anti-corruption bridge in `adapter:outbound:messaging`. That bridge does not exist
yet (MSG-015), so today the composition root wires the starter directly; `src/messaging/CLAUDE.md`
holds the detail.
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
@@ -92,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from
Architecture-wide commands:
```bash
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew architectureCheck --console=plain
./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain
./gradlew :app-bootstrap:verifyEnvKeys --console=plain
```
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires
the controller's workflow authorization.
A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone.
Repository-wide questions have their own names: `architectureCheck` (dependency direction, runtime
membership, application-core purity, Git-carryable sources), `qualityCheck` (SpotBugs, FindSecBugs),
`configContractCheck` (the environment contract), `integrationCheck` (the declared strict test
lanes). `ci` is check + architectureCheck + qualityCheck + configContractCheck; `releaseCheck` adds
provenance, archive hygiene and the public-path snapshot.
Use public-path and env-key checks only when their surfaces changed. Full `test`, `check` or `ci`
requires the controller's workflow authorization.
## Advisory and reporting
+37 -2
View File
@@ -90,14 +90,20 @@ cd src
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
4. 모듈 이름과 경계는 그대로 유지합니다.
검증은 sample-on과 sample-off를 모두 통과시킵니다.
검증은 먼저 composition root의 빠른 테스트와 sample-off 계약을 확인합니다. 루트에서
`./gradlew test`를 호출하면 등록된 모든 하위 프로젝트의 `test`를 실행하므로 일상적인 로컬
피드백 명령으로 사용하지 않습니다. 저장소 전체 qualification은 CI 또는 명시적인 `ci` task가
담당합니다.
```bash
cd src
./gradlew test
./gradlew :app-bootstrap:test
./gradlew :app-bootstrap:sampleOffTest
./gradlew architectureCheck
```
병합 전 저장소 전체 검증이 필요하면 `./gradlew ci`를 실행합니다.
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
## 아키텍처 규칙과 검증
@@ -122,6 +128,35 @@ cd src
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
## 수동 전용 Gradle 태스크
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
적어 둔다.
여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의
```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는
Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의
함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다.
```bash
cd src
./gradlew :grpc:grpc-testkit:grpcPerformanceTest
./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange
./gradlew :app-bootstrap:sampleOffCompile
```
- `grpcPerformanceTest` — latency percentile·saturation·drain budget을 **측정**한다. 공유 CI
runner의 측정값은 흔들리고, 흔들리는 게이트는 결국 꺼진다. leaf `build.gradle`이 이 태스크의
태그를 `test`에서 제외하는 이유도 같다. 성능 회귀가 의심될 때 사람이 이름으로 부른다.
- `openapiCheckSnapshot` — 드리프트 검사 자체는 이미 자동으로 돈다. 이 태스크가 감싸는
`OpenApiDriftContractTest`는 `:sample-portfolio:test`의 일부이고, 그건 `check` 안이며 stage 1에서
실행된다. 이 태스크의 고유한 역할은 `-PapproveOpenApiChange`로 **커밋된 스냅샷을 다시 만드는 것**
— 의도된 API 변경을 사람이 승인하는 지점이다. 자동으로 돌리면 승인이 승인이 아니게 된다.
- `sampleOffCompile` — `sampleOffTest` 소스셋을 **컴파일만** 한다. CI가 돌리는
`:app-bootstrap:sampleOffTest`(stage 1, `ci-quality-gates.yml`의 `sample-off` 잡)는 같은 소스셋을
컴파일한 뒤 실행까지 하므로, CI에 따로 넣으면 진부분집합을 한 번 더 도는 것이다. 남겨 둔 이유는
sample 제거 작업 중 테스트를 기다리지 않고 컴파일만 빠르게 확인하는 로컬 루프가 실재하기 때문이다.
## 더 알아보기
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
+41 -1
View File
@@ -18,11 +18,30 @@ services:
app:
# Relax read-only constraint for local development.
read_only: false
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs
# `!override`, not a plain empty list. An empty sequence merges with the base sequence rather
# than replacing it, so the base's /var/tmp/heap tmpfs survived and collided with the bind mount
# below — Compose refuses to have the same target twice and will not silently pick one. That is
# the right refusal: a heap dump written into a tmpfs dies with the container that produced it,
# which is the one moment somebody wants the file.
#
# `!override` needs Compose >= 2.24.4. Whether the collision is actually gone is checked in the
# merged model rather than assumed from this line.
tmpfs: !override []
# More memory for dev profiling / heap dumps.
mem_limit: 1g
memswap_limit: 1g
environment:
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
# environment the application believes it is in, and the two drifting is how a dev stack ends
# up running local's settings.
SPRING_PROFILES_ACTIVE: "dev"
# The datasource address, owned here like the local and prod-smoke overlays own theirs. It was
# the only one of the three missing, and the gap was invisible while the qualification wrapper
# supplied a URL to every lane: the dev stack ran on a value that came from the test harness
# rather than from the file that describes the dev environment. With the wrapper no longer
# setting it — it was overriding prod's sslmode=verify-full URL — dev had none at all and
# Flyway was handed the literal string "${APP_DATASOURCE_URL}".
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
@@ -53,9 +72,30 @@ services:
# Do not restart automatically so crash loops stay visible.
restart: "no"
# Optional: mount heap dump directory to host for dev analysis.
# The base declares /var/tmp/heap as a tmpfs, which is right for an ephemeral runtime and wrong
# for dev: a heap dump written into a tmpfs dies with the container that produced it, which is
# the one moment somebody wants the file. Compose refuses to have both, and correctly — it will
# not silently pick one — so the tmpfs list is replaced rather than appended to.
#
# `!override` needs Compose >= 2.24.4. An empty sequence is not assumed to delete the base
# sequence by itself; scripts/verify-compose-profile-contracts.sh checks mount-target uniqueness
# in the merged model, which is what actually proves the collision is gone.
volumes:
- type: bind
source: ./tmp/heap-dumps
target: /var/tmp/heap
bind:
create_host_path: true
# The same network the shared infrastructure lives on. The local overlay joins it and the dev
# overlay did not, so a dev lane that started PostgreSQL beside the application put the two on
# different networks: `UnknownHostException: db`, from a container that was running and healthy
# a metre away. Compose puts a service with no `networks:` on `default`, which is a network of
# its own making — so the omission reads as a working stack until something has to resolve a
# name across it.
networks:
- caskeleton-infra
networks:
# Defined in docker-compose.infra.yml, where the services that share it live.
caskeleton-infra:
external: false
+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
#
# Local intent:
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
# - Wires the app environment to point at the local DB.
# - Wires the app environment to point at the shared `db` service, which lives in
# docker-compose.infra.yml and starts only for lanes whose Compose profile names it.
# - Declares no depends_on: a depends_on aimed at a profiled service makes every lane that does
# not enable that profile fail to render at all, and ordering is the runtime-smoke wrapper's
# job — it knows which services a lane actually starts.
# - Keeps read-only filesystem and memory limits from the base compose.
# - Publishes the DB on the loopback interface only, so a host-side run
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
@@ -15,10 +18,19 @@
services:
app:
# Optional, because src/.env is operator input and a fresh clone does not have one. Before this
# was marked optional, untracking that file made `docker compose config` fail outright on a
# clone — the environment override that exists for convenience became a hard prerequisite for
# rendering the stack at all. The tracked contract is src/.env.example; copy it.
env_file:
- ./src/.env
- path: ./src/.env
required: false
# Wire the app to the local Postgres service on the internal network.
environment:
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
# environment the application believes it is in, and the two drifting is how a dev stack ends
# up running local's settings.
SPRING_PROFILES_ACTIVE: "local"
TZ: "UTC"
LANG: "C.UTF-8"
LC_ALL: "C.UTF-8"
@@ -30,9 +42,6 @@ services:
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
depends_on:
db:
condition: service_healthy
healthcheck:
test:
- "CMD"
@@ -46,49 +55,10 @@ services:
start_period: 20s
retries: 12
networks:
- caskeleton-local
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
TZ: "UTC"
# Persist data between restarts; remove the volume to start fresh.
volumes:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
# The containerised app reaches this over the internal network and needs no host port. A
# host-side run does: src/.env is the dotenv source bootRun reads, and its committed
# APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
# that default named an address nothing in the repository provisioned, so every bootRun died in
# the startup migration phase with a connection refusal.
#
# Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
# nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
# host keeps its conventional port.
ports:
- "127.0.0.1:5433:5432"
networks:
- caskeleton-local
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
- caskeleton-infra
networks:
caskeleton-local:
driver: bridge
# Defined in docker-compose.infra.yml, where the services that share it live.
caskeleton-infra:
external: false
volumes:
caskeleton-db-data:
driver: local
+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}"
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
# Generated per run by scripts/run-compose-runtime-smoke.sh and removed on teardown. Seven values
# have no inline default on purpose — the datasource address and credential, the application
# name, and the JWT issuer and audience — so a lane has to supply them, and a lane that borrowed
# the developer's own src/.env would be reproducible only on that developer's machine. Optional,
# so an ordinary `docker compose up` is unaffected.
env_file:
- path: ./src/.env.lane
required: false
ports:
- "${APP_SERVER_PORT:-8080}:8080"
- "9001:9001"
+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,35 @@
# ADR-JPA-001 — The domain owns the persistence model
- Status: Accepted
- Date: 2026-08-11
- Design: §10.1, §23.3
## Context
A persistence platform can either own the repository abstraction — a `GenericRepository<T, ID>`
every aggregate inherits — or provide only the pieces domains assemble themselves.
## Decision
The domain owns entities, embeddables, repositories, queries, index requirements, and lock,
soft-delete, and audit policy. The platform provides no generic CRUD repository and no base
repository. `JpaRepositoryFragmentSupport` exists, has no `save`, `findById`, `findAll`, or
`delete`, and is enforced not to acquire them.
## Consequences
A generic base repository has one property that looks like a benefit and is not: every aggregate
gets the same operations. That means each aggregate is offered operations that may be wrong for it —
a `delete` on an append-only ledger, a `findAll` on a table that will never be small — and, worse,
one aggregate's later requirement changes the shared base and therefore changes behaviour for
aggregates nobody reviewed.
Spring Data already implements CRUD. Re-implementing it adds a layer whose only function is to be
harder to opt out of.
The cost is a small amount of repetition: each domain declares the repository interface it needs.
That repetition is the thing that makes each aggregate's persistence surface reviewable.
## Enforcement
`JpaArchitectureRules.noGenericRepository()`; `JpaRepositoryFragmentSupportTest`.
@@ -0,0 +1,37 @@
# ADR-JPA-002 — Retry re-runs the whole use case
- Status: Accepted
- Date: 2026-08-11
- Design: §19.2
## Context
Optimistic conflicts, deadlocks, and serialization failures are recoverable. The question is what
unit gets retried: the failed statement, the transaction, or the use case.
## Decision
The whole use case, in a new transaction with a new Persistence Context.
`FullTransactionRetryCoordinator` re-enters `JpaTransactionExecutor` for every attempt, and the
retry advice is ordered outside Spring's transaction advice so each attempt begins a new
transaction.
## Consequences
Statement-level retry is wrong for exactly the failures being retried. An optimistic conflict means
the state the attempt computed against is no longer the committed state; re-issuing the same
statement computes the same wrong answer against a version that has moved on. The domain rules have
to run again over reloaded data, which means the whole use case.
Reusing the Persistence Context would be equally wrong: the second attempt would read the first
attempt's stale entities out of the first-level cache. And with the advice ordering inverted, the
retry loop would run inside one transaction that has already been marked rollback-only, so the
second attempt fails immediately without executing anything.
The cost is that a retryable use case must be safe to run from scratch — no irreversible external
effect before the commit. `IrreversibleSideEffectContext` lets a use case declare when that does not
hold, and the policy then refuses to retry it whatever budget remains.
## Enforcement
`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`.
@@ -0,0 +1,41 @@
# ADR-JPA-003 — Completion unknown is never retried
- Status: Accepted
- Date: 2026-08-11
- Design: §17
## Context
A connection can break while a commit is in flight. The server may have committed; the
acknowledgement may simply have been lost. The driver cannot tell the two apart.
## Decision
`TransactionCompletionUnknownException` is never retried, automatically or otherwise. It is
produced only by a failure observed while the transaction phase is `COMMITTING`, and only for
SQLSTATE `40003`, a connection-class (`08*`) state, or a transport break. Recovery is
domain-specific reconciliation through `TransactionCompletionResolver`.
## Consequences
Retrying a possibly-committed write is the most damaging thing this platform could do: a duplicate
payment, a duplicate order, a double decrement. There is no budget or backoff that makes it safe,
because the failure is epistemic rather than transient.
The invariant is enforced at the type level rather than by policy alone. `JpaFailureContext` refuses
to construct a retryable completion-unknown context, and the exception rebuilds its context through
the safe factory whatever it is handed. A future policy bug therefore cannot produce an unsafe
retry — the value it would need does not exist.
The rule is deliberately narrow in the other direction too. Classifying every connection failure as
completion-unknown would push ordinary pool exhaustion and server restarts into the reconciliation
queue, which trains operators to clear that queue without reading it — and then the one entry that
mattered gets cleared with the rest.
The cost is that the domain must supply the resolver. The platform cannot: only the domain knows
which idempotency record, business row, or outbox entry proves the write happened.
## Enforcement
`JpaFailureContextTest`; `DefaultJpaRetryPolicyTest`; `CommitFailureClassifierTest`; release gate
`completion-unknown-no-retry`.
@@ -0,0 +1,37 @@
# ADR-JPA-004 — Flyway is the schema source of truth
- Status: Accepted
- Date: 2026-08-11
- Design: §31
## Context
Hibernate can create and alter schema from the entity mapping. Flyway can apply versioned scripts.
Both cannot own the schema.
## Decision
Flyway owns every schema change. Hibernate validates and never mutates: `ddl-auto` is `validate` or
`none`, enforced at startup. The runtime database credential holds no DDL privilege, so the rule is
enforced by the server as well as by configuration.
## Consequences
`ddl-auto=update` fails in a specific and expensive way: it adds but never drops or narrows, so the
result is a schema that is neither the previous one nor the one the mappings describe — produced
silently, by whichever instance started first, with no record of what it did.
Two credentials rather than one is what makes this more than a convention. A configuration rule can
be overridden by a property; a role without `CREATE` cannot be overridden by anything the
application does.
Validation fails closed and never repairs. `repair` rewrites the schema history to match the scripts
on disk, which resolves a checksum mismatch by deleting the evidence of which change is missing.
The cost is that a schema change requires a migration script and a deployment step. That is the
intended cost: it makes schema change reviewable and reversible.
## Enforcement
`JpaDangerousConfigurationGuard`; `FlywaySchemaPolicy`; `FlywayValidationGate`;
`PostgreSqlRuntimeRoleVerifier`; release gates `flyway-validate` and `runtime-role-no-ddl`.
@@ -0,0 +1,38 @@
# ADR-JPA-005 — Contracts run against real PostgreSQL
- Status: Accepted
- Date: 2026-08-11
- Design: §40
## Context
An in-memory database makes tests fast and hermetic. A container makes them slow and requires
Docker.
## Decision
Every persistence contract runs against real PostgreSQL 16, 17, and 18 in containers. H2 remains a
local-development convenience and never satisfies a contract. The lanes fail closed when Docker is
absent rather than skipping.
## Consequences
The behaviours these contracts verify either do not exist in H2 or differ there: SQLSTATE values for
the same violation, `FOR UPDATE SKIP LOCKED` semantics, JSONB operators, range types, concurrent
index builds, `search_path` privileges, and the generated SQL for a paged collection fetch. A green
H2 run is evidence that the code compiles and runs — not that any of the above holds.
Three versions rather than one because the platform claims three. A contract suite that ran only on
16 would make "Stable on 17 and 18" an assumption.
Skipping on missing Docker is the failure mode this decision most wants to avoid: a skipped contract
reports success, and CI eventually inherits that silence. `PostgreSqlContainerFactory.assertDockerAvailable()`
throws instead.
The cost is that the contract lanes need Docker and take minutes. The unit lane stays hermetic and
fast, and is where most tests live; the container lanes verify the things only a real server can
answer.
## Enforcement
`PostgreSqlVersion.stable()`; `PostgreSqlContainerFactory`; release gate `postgresql-contract`.
@@ -0,0 +1,67 @@
# ADR-JPA-006 — `audit` is the canonical technical audit model; `auditing` stays a frozen candidate
- Status: Accepted
- Date: 2026-08-24
- Review: `docs/reviews/2026-08-14-jpa-module-code-review.md` JPA-022
## Context
Two complete technical-audit mechanisms live in this leaf and they disagree about the schema.
`audit/AuditableEntity` stamps `created_at`/`created_by`/`updated_at`/`updated_by` with an actor
column of length 256, captured through explicit `initializeAudit`/`applyModification` calls and an
`AuditContextPort`. `auditing/AuditMetadata` is a Spring Data embeddable that stamps
`created_*`/`modified_*` with an actor column of length 64, captured by `@CreatedDate` and friends
through an `AuditorAware`.
Only the first is real: it is what the sample entities extend and what the migrations were written
for. `JpaAuditingConfiguration` is not a Spring `@Configuration`, and nothing in production
constructs any of the three `auditing` types.
The review asked for one canonical model with a migration or activation decision. The failure mode
it was protecting against is specific: an author of a new entity picks whichever package they find
first, and column names, actor lengths and capture lifecycles then diverge per table.
## Decision
`audit/AuditableEntity` is canonical. `auditing` stays in the tree as a candidate and is excluded
from the Stable capability report.
The candidate is not deleted and not promoted. Deleting it would discard a working Spring Data
integration that a deployment preferring declarative auditing would want. Promoting it would mean
either renaming `modified_*` to `updated_*` and widening the actor column — a schema migration of
every audited table to gain nothing a caller asked for — or moving the sample entities onto
`modified_*`, which is the same migration in the other direction.
Neither is worth doing now. What the divergence actually needed was not consolidation but a rule
that an entity cannot straddle the two, and that rule is cheaper than either migration.
## Consequences
Two audit mechanisms remain readable in one leaf, and a reader has to be told which one is live.
That cost is paid in this document, in the package javadoc and in a test whose name says so.
Two failure modes stay silent unless they are asserted, so both are:
- The candidate acquires a stereotype and starts stamping in every deployment that has this module
on the classpath, including the ones whose tables have no `modified_*` columns — where the result
is a failed startup rather than a feature.
- Somebody "harmonises" the two by editing one side's column names, at which point the schema a
deployed table was migrated for and the schema its entity expects diverge with no migration
between them.
If the candidate is ever promoted, it is promoted atomically: forward migration, sample conversion,
`AuditContextPort → AuditorAware` and `Clock → DateTimeProvider` bridges land together, and this
ADR is superseded rather than amended.
Bulk and native updates stamp nothing under either mechanism. That is a property of JPA, not of the
choice made here, so it is enforced separately rather than assumed away.
## Enforcement
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism` and
`bulkUpdatesOfAuditedEntitiesStampAudit`, run against the real production graph by
`JpaProductionArchitectureTest` at the composition root — not against fixtures, which is how the
earlier version of this rule pack passed while applying to nothing. `AuditingCandidateStatusTest`
asserts the candidate carries no composing stereotype and that the two column sets stay distinct.
`JpaAuditMechanismRuleTest` exercises the rules' own negative cases.
@@ -0,0 +1,63 @@
# ADR-MONGO-001 — MongoDB platform boundary
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** `docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
## Context
Two failure modes are common when a team wraps MongoDB.
The first is flattening: a shared `CommonMongoRepository<T, ID>` and a generic CRUD facade, which
forces every collection to share an id strategy, a consistency profile and a query surface. MongoDB's
single-document atomicity, aggregation model and change streams stop being reachable, and the first
collection that needs something different gets a cast or a leaky generic.
The second is unrestricted exposure: the driver and `runCommand` available everywhere. Then any
service can drop a collection, run an unbounded pipeline, or issue an admin command from a request
thread, and no review catches it because there is nothing structural to catch.
## Decision
The domain owns its documents; the platform owns the cross-cutting decisions. Four exposure planes:
| Plane | Contents | Client |
|---|---|---|
| D1 Standard document persistence | Spring Data repositories, typed queries, mapping manifest, atomic update primitives, optimistic revision | Stable API V1, `apiStrict=true` |
| D2 Advanced document operations | `MongoTemplate`, transactions/sessions, bulk, aggregation, keyset cursors, change streams | Stable API V1, `apiStrict=true` |
| D3 Explicit Mongo capability | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations | Separate capability client |
| D4 Admin plane | Collection, validator, index, migration, shard, repair | Separate admin client and credential |
Specifically:
1. **No `CommonMongoRepository<T, ID>`.** Each aggregate declares its own repository.
2. **D1/D2 run on Stable API V1 with `apiStrict=true`,** so a command outside the versioned API fails
at development time instead of on the next server upgrade.
3. **D3 is not a raw-client escape.** Every call passes a fixed admission order: capability registered
→ database profile → collection allowlist → operation name → timeout → consistency profile →
result limit → trace → redaction → command category → admin-command refusal → execute.
4. **D4 is a separate client with a separate credential.** No application-plane path reaches it;
`PolicyAwareMongoNativeGateway` refuses admin-category commands regardless of capability.
5. **Advanced and Experimental capabilities are opt-in modules**, never transitive dependencies of the
Stable surface.
## Consequences
**Positive.** MongoDB's semantics stay reachable. Misuse is refused structurally rather than reviewed
for. A server upgrade cannot silently change D1/D2 behaviour. Admin operations have their own audit
trail and credential.
**Negative.** Every operation needs a registered name and profile, so a new query is a small amount of
configuration rather than zero. A genuinely new capability requires a registration before it can be
used. Both are deliberate: the cost is paid once per operation, at review time.
**Rejected alternative — "expose the driver, rely on code review."** Review does not scale to every
query in every service, and the operations that matter (unbounded pipeline, `dropCollection`,
unanchored regex on user input) look unremarkable in a diff.
## Repository adaptation
The design assumes 19 Gradle modules under `modules/mongodb/`. This repository's fail-closed registry
declares exactly 19 leaf identities, so the modules became package boundaries inside
`:adapter:outbound:persistence-mongo`, enforced by ArchUnit. See
[docs/mongodb/repository-adaptation.md](../mongodb/repository-adaptation.md).
@@ -0,0 +1,58 @@
# ADR-MONGO-002 — BSON representation is a pinned manifest
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §10, decision D-06
## Context
How a Java value is represented in BSON is a data contract, but nothing in the default toolchain
treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have
changed across versions. A `BigDecimal` can land as a `Double`, a `String` or a `Decimal128`; a `UUID`
can land as `Binary` subtype 3 or subtype 4; an `Instant` can land as a `Date` or a `String`.
The consequences are asymmetric. A representation change is invisible in a value-equality test —
`12.30` looks like `12.30` whether it is a double or a `Decimal128` — but once a collection holds
production data, changing it is a full migration. And the UUID case is worse than a migration: legacy
Java representation byte-swaps two halves of the UUID, so a document written under one representation
and read under the other yields a *different, valid-looking* UUID. Nothing errors. You get the wrong
record.
## Decision
`MongoTypeRepresentationManifest` pins the representation for every type the platform maps, and
`MongoMappingConfiguration` builds the Spring Data converters from it. Nothing relies on a library
default.
| Java | BSON | Rationale |
|---|---|---|
| `UUID` | `Binary` subtype 4 (`STANDARD`) | Subtype 3 byte-swaps; cross-representation reads are silently wrong. |
| `BigDecimal` | `Decimal128` | A double cannot represent `12.30`; money compared as a double is eventually wrong by a cent. |
| `BigInteger` | `Decimal128`, or declared `String` when out of range | 34 significant digits; out of range fails on write instead of rounding. |
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | One instant, one representation. |
| `LocalDate` | declared per field | A calendar day is not an instant. |
| `LocalDateTime` | **refused** | No offset: the stored value depends on the writing JVM's default zone. |
| `enum` | `String` name | Ordinals renumber when someone inserts a constant. |
Type metadata follows `MongoTypeMetadataPolicy``NONE`, `ALIAS` or `CLASS_NAME`. A
`@LongLivedMongoDocument` type may not use `CLASS_NAME`: writing a FQCN into a million documents makes
a package rename a data migration.
The manifest is enforced by a golden gate. `MongoBsonSnapshot` canonicalises a stored document,
preserving BSON types and keeping missing distinct from null, and
`MongoBsonSnapshotAssert.hasTypeSignature(...)` fails on any representation change. The registry
pins `UuidCodec(STANDARD)` explicitly rather than inheriting a default, since inheriting the default
is the exact drift the gate exists to catch.
## Consequences
**Positive.** A library upgrade cannot move a representation without failing a test. Money is exact.
UUIDs read back as themselves. Class moves stay refactors.
**Negative.** Every representation-affecting change requires updating a snapshot *and* writing a
migration. A new mapped type needs a manifest entry before it can be used. This is the intended
friction: the alternative is discovering the change in production.
**Rejected alternative — "snapshot the JSON."** JSON destroys exactly the distinctions the gate
protects: `Decimal128` and `String` both render as text, `Binary` UUID and `ObjectId` both render as
hex, and missing and null both disappear.
@@ -0,0 +1,68 @@
# ADR-MONGO-003 — Transaction body retry and commit retry are separate loops
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §14–§16, decisions D-07 through D-10
## Context
MongoDB reports two transaction failures that look similar and must be handled in opposite ways.
`TransientTransactionError` means the transaction definitively did not commit. The correct response is
to run the whole thing again.
`UnknownTransactionCommitResult` means the commit **may already have applied** — typically because the
primary changed while the commit was in flight. The correct response is to retry *the commit*, which
is a no-op if it already succeeded.
The common implementation wraps everything in one retry loop. That loop replays the body after an
unknown commit, and if the commit did apply, the body applies twice. In a payment or notification path
that is a duplicate charge or a duplicate message, produced by the error handler.
The related trap is session reuse: retrying on the same session after an abort carries the aborted
transaction's state into the retry.
## Decision
`MongoTransactionRetryCoordinator` implements two loops with different scopes.
```
for each body attempt within the budget:
open a NEW session
run the body
TransientTransactionError -> abort, continue to next body attempt
commitWithRetry(session):
UnknownTransactionCommitResult -> retry the COMMIT ONLY, same session
```
Rules that follow, all of them load-bearing:
1. **A new session per body attempt.** No aborted state leaks into a retry.
2. **The body is never replayed after a commit ambiguity.** `MongoRetryScope.COMMIT_ONLY` is a
distinct value from `BODY` precisely so this cannot be collapsed by accident.
3. **One budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with
jittered backoff, so a struggling primary is not retried into the ground by every instance at once.
4. **An exhausted commit retry surfaces `TRANSACTION_COMMIT_UNKNOWN`,** never a generic failure. An
ambiguous outcome reported as a failure invites the caller to retry — the one thing that must not
happen. See [docs/mongodb/runbooks/unknown-commit.md](../mongodb/runbooks/unknown-commit.md).
5. **Transaction bodies write a deterministic marker** so `MongoCommitReconciler` can establish what
actually happened. A transaction that cannot be reconciled has no recovery path.
6. **Classification reads labels before codes.** Server error labels are the authoritative statement
about retryability; error codes vary by version.
Surrounding decisions that reduce how often this path is reached at all: single-document atomic
operations are preferred over transactions (D-09), partial changes use update operators rather than
`save()` (D-07), and whole-document replacement requires an optimistic revision (D-08).
## Consequences
**Positive.** A commit ambiguity cannot become a duplicate effect. The ambiguity reaches the caller as
an ambiguity. The retry budget is bounded in both attempts and time.
**Negative.** Callers must handle a third outcome beyond success and failure. Transaction bodies must
write a marker they would not otherwise need. Both costs are small compared with reconciling
duplicated financial effects after the fact.
**Rejected alternative — "one retry loop, at-least-once everywhere."** It requires every transaction
body to be fully idempotent, which is a much stronger and much less checkable property than writing
one marker, and it is silently violated the first time someone adds a non-idempotent step.
@@ -0,0 +1,62 @@
# ADR-MONGO-004 — Index and schema changes belong to the admin plane
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §21–§25, decisions D-11, D-13
## Context
Spring Data can create indexes automatically from annotations. On a laptop this is convenient. On a
collection with a hundred million documents, an index build is a capacity event: it consumes CPU, IO
and memory on the primary for minutes to hours, and it starts because a pod restarted.
Worse, it starts N times when N pods restart, and there is no approval step, no ordering relative to
the code that needs the index, and no record afterwards of what was created.
Schema validators have the same shape with a sharper edge: tightening a validator on a collection with
existing data rejects writes to documents that were legal when they were written.
TTL has a third shape. It looks like a scheduler and is not one: the TTL monitor runs about once a
minute and deletes in batches, so an expired document routinely remains readable for minutes or hours.
## Decision
**Indexes and validators are declared in a manifest and applied by the admin plane (D4).** Automatic
index creation in production is disabled.
1. `MongoManifestRegistry` holds the declared indexes (`MongoIndexManifest`) and validator
(`MongoSchemaManifest`) per collection. The manifest is the source of truth, reviewed in a pull
request.
2. `MongoIndexDiffEngine` compares manifest against observed state and reports missing, extra and
*changed* indexes. Changed ones are reported rather than re-issued: MongoDB will not silently
rebuild an index whose definition moved.
3. `MongoIndexApplyPolicy` sets what an environment may do — `APPLY` (local), `APPLY_WITH_DIFF`
(staging), `DIFF_WITH_APPROVED_APPLY` (production), `REPORT_ONLY` (audit).
4. **Ownership gates every drop.** `MongoMetadataOwnership` distinguishes `APPLICATION_MANAGED` from
`SEARCH_MANAGED`, `ENCRYPTION_MANAGED` and `EXTERNAL`. Only application-managed objects are
droppable on drift. A diff engine without ownership eventually proposes dropping
`enxcol_.customers.esc`, and "the drift tool cleaned it up" is a very bad incident summary.
5. **Retirement is staged.** `MongoIndexRetirementState` moves an index declared → hidden →
observed-unused → droppable, one deployment per transition. Hiding is instantly reversible;
dropping is a rebuild.
6. **Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
contract on 7.0 or 8.0 and is refused. Tightening goes `warn`+`MODERATE` → confirm zero warnings →
`error`+`STRICT`, in two deployments.
7. **TTL is physical cleanup only** (D-13). `MongoExpirationAccessPolicy` states the rule: a
document's presence is not authorization and its absence is not a deadline. Access control checks
the expiry field; scheduling uses a scheduler.
8. **Migrations are checksummed, locked, precondition-checked and resumable.**
`MongoMigrationRunner` fails hard when an applied id's checksum changed — two environments running
different code under one id is worse than a failed deploy.
## Consequences
**Positive.** Index builds are scheduled by people who know the capacity. Rollback is possible at
every step. Drift is visible without being dangerous. Nothing drops what it does not own.
**Negative.** Adding an index is a manifest change plus an apply, not an annotation. Local development
uses `APPLY` so the friction is confined to environments where it is warranted.
**Rejected alternative — "auto-create with a feature flag."** The flag is either on in production,
which is the problem, or off, in which case the manifest is the real mechanism and the annotation is a
second, divergent source of truth.
@@ -0,0 +1,80 @@
# ADR-MONGO-ADV-001 — Advanced capability promotion
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §2 (D-15), §3.2–§3.3; Advanced expansion plan Task 15
## Context
Sharding, time series, CSFLE, Queryable Encryption, search, vector search and multi-tenancy each work
in a demo within an afternoon. What they do not do is behave the same way in production, and the
differences are not discovered by functional tests:
- Sharding changes which queries are efficient. A query that misses the shard key becomes
scatter-gather, which passes every test on a one-shard cluster.
- Encryption's failure modes are KMS failure modes — wrong key, revoked permission, mid-rotation —
none of which occur against a local key provider.
- Search and vector search can be functionally correct and useless: the index returns results, and
the results are not relevant. Recall is not visible in a pass/fail assertion.
- Database-per-tenant works until the tenant count crosses what the connection and file-handle
budget supports, which is an operational property, not a code property.
The failure mode this ADR prevents is a capability marked "done" on the strength of a green test that
never touched the environment where it will run.
## Decision
Every Advanced and Experimental capability is an **opt-in module behind its own flag**, and promotion
requires evidence, not confidence.
### Enablement
`MongoAdvancedCapabilityFlags` gates construction of every Advanced entry point. A disabled capability
does not produce a runtime warning — the type refuses to be constructed, naming the property that
enables it (`MongoAdvancedCapabilityFlags.propertyFor(capability)`). Being on the classpath is not
being enabled, and `stableNeverDependsOnAdvanced` (ArchUnit) keeps the Stable surface free of them.
### Promotion evidence
`MongoAdvancedPromotionGate.verify(evidence)` requires every category:
| Category | Means |
|---|---|
| `stable-platform` | The Stable release gate passed on the same revision. |
| `actual-topology` | The capability ran on the real topology — a real sharded cluster, the real KMS, the actual target deployment. Atlas Local is a pull-request convenience and explicitly not release evidence (`MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL`). |
| `security` | Privileges reviewed; the capability's admin role is separate from the application role. |
| `migration` | A documented path in and, where the capability is irreversible, an explicit statement that there is no path back. |
| `failure` | Negative cases fail closed: wrong key, missing permission, rotation, non-ready index, unrouted query. |
| `runbook` | A runbook exists for the capability's characteristic incident. |
### Additional per-capability requirements
- **Search / vector search:** relevance and performance evidence, not functional success alone.
`MongoVectorSearchBenchmarkGate` requires recall alongside latency and index size; a gate that
measures only latency certifies a fast wrong answer.
- **Database-per-tenant and reshard orchestration remain Experimental** until operational scale
evidence exists. Both are correct in the small and unbounded in the large.
- **Reshard requires an explicit `ReshardApproval`** — a named approver and a stated window. It
rewrites the collection.
### Promotion does not change the dependency boundary
A capability promoted to Stable **remains an opt-in module** unless a later starter ADR changes the
dependency boundary. Promotion is a statement about evidence, not an invitation to add a transitive
dependency to every service.
## Consequences
**Positive.** No capability reaches production on the strength of a container-only test. The evidence
list is the same for every capability, so promotion is reviewable rather than negotiated.
**Negative.** Promotion requires access to real infrastructure — a sharded cluster, a real KMS, the
target deployment. That is the cost of the guarantee: the alternative is finding out in production,
where encryption and sharding are both expensive to reverse.
## Verification
`scripts/verify-mongodb-advanced.sh` enforced this ADR until it was removed on 2026-08-15. The
promotion evidence categories this ADR requires are therefore no longer checked by any automated
gate; they are a review obligation until one is rebuilt. See `docs/mongodb/repository-adaptation.md`
§5 for the Gradle lanes the script wrapped.
@@ -0,0 +1,69 @@
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
- Status: Accepted
- Date: 2026-08-25
- Scope: `adapter:inbound:web``advanced.stream.**`
## Context
Advanced Tasks 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.
+416
View File
@@ -0,0 +1,416 @@
# GraphQL leaf public API surface — every public top-level type in src/main/java.
# A public type in a single-jar leaf is reachable from every adopter's code, so
# additions are reviewed rather than discovered. `api` and `spi` are the intended
# external surface; the rest are candidates to become internal when this leaf is
# split into capability artifacts.
# Update only after review with:
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# types: 408
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminDeniedException
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminPort
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminService
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAudit
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationBlockCommand
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalGate
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationRemovalRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationUsage
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityGrade
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedDependencyRules
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags
dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDataLoaderPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedDispatchConfigurer
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlChainedLoaderMetrics
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderCycleDetector
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyCycleException
dev.caskeleton.adapter.inbound.graphql.advanced.chaining.GraphQlDataLoaderDependencyGraph
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlClientOperationGenerator
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenBoundaryException
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlCodegenProfile
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedCompatibilityGate
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlGeneratedSourceBoundary
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlOperationValidator
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlScalarMapping
dev.caskeleton.adapter.inbound.graphql.advanced.codegen.GraphQlTransportTypeGenerator
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionGate
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationCompositionResult
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationDeploymentOrder
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationLatencyBudget
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationReleaseRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlFederationUsageReport
dev.caskeleton.adapter.inbound.graphql.advanced.composition.GraphQlSubgraphContract
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationBatchResolver
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationCapability
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityKey
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityResolver
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationProperties
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationRepresentationException
dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationSchemaFactory
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpDraftCompatibilityReport
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCachePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetCsrfPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetOperationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetProfile
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.get.GraphQlHttpGetRequestParser
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCancellation
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGate
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryCapability
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryProfile
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalDeliveryRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalPatch
dev.caskeleton.adapter.inbound.graphql.advanced.incremental.GraphQlIncrementalTransportPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationConflictException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationInterceptor
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationLookup
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationNotFoundException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRecordMapping
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRequest
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedPreparsedBridge
dev.caskeleton.adapter.inbound.graphql.advanced.persisted.OperationalStoreGraphQlPersistedOperationRegistry
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseException
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorization
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayAuthorizationException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayGapException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayHistoryLostException
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplayPosition
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlReplaySource
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSnapshotLiveHandoff
dev.caskeleton.adapter.inbound.graphql.advanced.replay.GraphQlSubscriptionCursor
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlSubscriptionAuthorizationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationException
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptor
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseReason
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseTermination
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSlowConsumerPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionCancellation
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionContext
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDispatcher
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainCoordinator
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainPhase
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionDrainingException
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionEvent
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicy
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionLease
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionMetrics
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionOrderingProfile
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionSource
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionState
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolException
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName
dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlAsyncReturnShape
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerContractException
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerTransactionRule
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlInputTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlActivationEnvironmentPostProcessor
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlDeploymentMode
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlOffAutoConfigurationImportFilter
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformSettings
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRetiredSafetyAxis
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityImpact
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityPolicy
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityReport
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlDeprecationGate
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalDecision
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlRemovalRequest
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaChange
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
dev.caskeleton.adapter.inbound.graphql.context.ActorRef
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
dev.caskeleton.adapter.inbound.graphql.context.TenantContext
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlFieldCostDescriptor
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserLimits
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserOptionsFactory
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlParserRejectedException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResolverWeight
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseByteLimiter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseNodeCounter
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitException
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchErrorPolicy
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchLoadException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchObservation
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicy
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchPolicyRegistry
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResult
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchResultMapper
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchValue
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderRequestRegistry
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyException
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlMissingKeyPolicy
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode
dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext
dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver
dev.caskeleton.adapter.inbound.graphql.error.GraphQlFailureBoundary
dev.caskeleton.adapter.inbound.graphql.error.GraphQlInternalErrorMasker
dev.caskeleton.adapter.inbound.graphql.error.GraphQlNullabilityContract
dev.caskeleton.adapter.inbound.graphql.error.GraphQlRequestErrorMapper
dev.caskeleton.adapter.inbound.graphql.error.GraphQlSubscriptionExceptionResolver
dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError
dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlAnonymousOperationException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlDeadlinePropagator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileValidator
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNameInterceptor
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationNamePolicy
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheKey
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverBudget
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverCatalog
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlResolverDescriptor
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlTimeoutPolicy
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfile
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileClassifier
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileName
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRegistry
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileRule
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlFetchProfileValidationException
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionCoordinate
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSetView
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlSelectionSignature
dev.caskeleton.adapter.inbound.graphql.fetch.GraphQlUnmappedSelectionException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlAcceptHeader
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome
dev.caskeleton.adapter.inbound.graphql.http.GraphQlExtensionsPolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpOutcome
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponsePolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpStatusMapper
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy
dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues
dev.caskeleton.adapter.inbound.graphql.http.GraphQlMediaTypes
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestFormatException
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize
dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestTooLargeException
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModulePurity
dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBatchMutationItemResult
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlBusinessResult
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlCanonicalInput
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlExpectedVersion
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyConflictException
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlIdempotencyKey
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractException
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationContractValidator
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationCoordinate
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationFingerprint
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyContext
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyInterceptor
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationPayload
dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationResultMapper
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationContractException
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlObservationNames
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlProfilerAccessPolicy
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention
dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnection
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionAssembler
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionException
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionPolicy
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlConnectionRequest
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyRing
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyset
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorVersion
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlEdge
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlKeysetWindow
dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlPageInfo
dev.caskeleton.adapter.inbound.graphql.pagination.HmacGraphQlCursorCodec
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicyManifest
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationCatalog
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationPolicy
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlPolicyViolation
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownClientProfileException
dev.caskeleton.adapter.inbound.graphql.policy.GraphQlUnknownOperationException
dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType
dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix
dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseException
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter
dev.caskeleton.adapter.inbound.graphql.release.GraphQlStableCapabilityManifest
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBatchLoaderRegistrar
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridge
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlBlockingBridgeFullException
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionContext
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionRequest
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
dev.caskeleton.adapter.inbound.graphql.scalar.BigDecimalScalar
dev.caskeleton.adapter.inbound.graphql.scalar.DateScalar
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlDecimalBounds
dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer
dev.caskeleton.adapter.inbound.graphql.scalar.InstantScalar
dev.caskeleton.adapter.inbound.graphql.scalar.LongScalar
dev.caskeleton.adapter.inbound.graphql.scalar.UuidScalar
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlContractVersion
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingIssue
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfInputValidator
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfSchemaGate
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlOneOfViolationException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarPolicy
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssembler
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaAssemblyResult
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaContract
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
dev.caskeleton.adapter.inbound.graphql.security.ApplicationObjectAuthorization
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDecision
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationDeniedException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy
dev.caskeleton.adapter.inbound.graphql.security.GraphQlBatchContext
dev.caskeleton.adapter.inbound.graphql.security.GraphQlClientProfileResolver
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup
dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator
dev.caskeleton.adapter.inbound.graphql.security.GraphQlObjectAuthorizationPort
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationException
dev.caskeleton.adapter.inbound.graphql.security.GraphQlTenantIsolationPolicy
+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
+354
View File
@@ -0,0 +1,354 @@
# MongoDB leaf public API surface — every public top-level type in src/main/java.
# A public type in a single-jar leaf is reachable from every adopter's code, so
# additions are reviewed rather than discovered. `api` is the intended external
# surface; the rest is implementation that has not been moved under an internal
# root yet.
# Update only after review with:
# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
# types: 346
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings
dev.caskeleton.adapter.outbound.mongo.MongoRootAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedSettings
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeToIntegrationEventMapper
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventEnvelope
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoIntegrationEventPublisher
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoPublishResult
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleClientFactory
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleFieldPolicy
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleMode
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleProfile
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoDataKeyResolver
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptedFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptionMetadataOwnership
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShape
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryShapeSupport
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionCollectionManager
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionProfile
dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionQueryType
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsCompatibilityReader
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationCheckpoint
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsMigrationJob
dev.caskeleton.adapter.outbound.mongo.advanced.gridfs.MongoGridFsObjectReference
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexState
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchOperations
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchQuery
dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchReadinessGate
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.MongoRoutingClassification
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardAwareQueryValidator
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyPart
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardStrategy
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.MongoShardingAdminGateway
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ReshardApproval
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyAnalyzer
dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin.ShardKeyReadinessReport
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantClientRegistry
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantDatabaseResolver
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantLifecyclePolicy
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCheckpointStore
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCoordinator
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantManifestValidator
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantPredicateInjector
dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.TenantScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapability
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesCapabilityValidator
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesGranularity
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesOperations
dev.caskeleton.adapter.outbound.mongo.advanced.timeseries.MongoTimeSeriesSupport
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoEmbedding
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorQuery
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchBenchmarkGate
dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchOperations
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationPlan
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationProfile
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationRisk
dev.caskeleton.adapter.outbound.mongo.aggregation.MongoAggregationStageDescriptor
dev.caskeleton.adapter.outbound.mongo.aggregation.PolicyAwareMongoAggregationExecutor
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName
dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope
dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoServerVersion
dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyGuarantee
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry
dev.caskeleton.adapter.outbound.mongo.api.error.MongoBulkPartialFailureException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoConnectionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoCursorException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDocumentTooLargeException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoDuplicateKeyException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoEncryptionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory
dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoReadConcernException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoResumeException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope
dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoServerSelectionException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoShardRoutingException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTimeoutException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoUnclassifiedFailureException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConcernException
dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConflictException
dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoBigIntegerRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoEnumRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoIdRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTemporalRepresentation
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest
dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation
dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoClientPlane
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoRuntimeProfile
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoStableApiProfile
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology
dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopologyRequirement
dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionPolicy
dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionRange
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGeneration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGenerationRegistry
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription
dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.ReactiveMongoChangeStreamConsumer
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.SpringReactiveChangeStreamSource
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeStreamRunner
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeHistoryLostException
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery
dev.caskeleton.adapter.outbound.mongo.client.MongoClientSettingsFactory
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassification
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureExtractor
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailurePhase
dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoDistance
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoPoint
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeoQuery
dev.caskeleton.adapter.outbound.mongo.geo.MongoGeospatialOperations
dev.caskeleton.adapter.outbound.mongo.geo.SpringMongoGeospatialOperations
dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionAccess
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry
dev.caskeleton.adapter.outbound.mongo.imperative.MongoCompletion
dev.caskeleton.adapter.outbound.mongo.imperative.MongoConsistencyBinder
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeCallback
dev.caskeleton.adapter.outbound.mongo.imperative.MongoImperativeExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.MongoOperationResult
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCallback
dev.caskeleton.adapter.outbound.mongo.imperative.MongoPlatformCollectionAccess
dev.caskeleton.adapter.outbound.mongo.imperative.MongoTemplateSupportContract
dev.caskeleton.adapter.outbound.mongo.imperative.ScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemFailure
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkItemOutcome
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkMode
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkResult
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkWritePlan
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.SpringDataBulkFailureExtractor
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoDocumentNotFoundException
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoOptimisticConflictTranslator
dev.caskeleton.adapter.outbound.mongo.imperative.revision.MongoRevision
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedMongoUpdater
dev.caskeleton.adapter.outbound.mongo.imperative.revision.VersionedUpdateCommand
dev.caskeleton.adapter.outbound.mongo.mapping.BigDecimalToDecimal128Converter
dev.caskeleton.adapter.outbound.mongo.mapping.BigIntegerRepresentationConverters
dev.caskeleton.adapter.outbound.mongo.mapping.Decimal128ToBigDecimalConverter
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdReadConverter
dev.caskeleton.adapter.outbound.mongo.mapping.DomainIdWriteConverter
dev.caskeleton.adapter.outbound.mongo.mapping.LocalDateTimeMappingGuard
dev.caskeleton.adapter.outbound.mongo.mapping.MongoCustomConversionsFactory
dev.caskeleton.adapter.outbound.mongo.mapping.MongoMappingConfiguration
dev.caskeleton.adapter.outbound.mongo.mapping.MongoTypeMetadataConfigurer
dev.caskeleton.adapter.outbound.mongo.mapping.type.LongLivedMongoDocument
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataDescriptor
dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataRegistry
dev.caskeleton.adapter.outbound.mongo.mapping.type.PolicyAwareMongoTypeMapper
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLedger
dev.caskeleton.adapter.outbound.mongo.migration.MongoCollectionMigrationLock
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigration
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationChecksum
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationContext
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationHeartbeat
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationId
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLedger
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLock
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPostcondition
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPrecondition
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationResult
dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationRunner
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockChangeUnitView
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLedgerAdapter
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockLockAdapter
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMigrationConfiguration
dev.caskeleton.adapter.outbound.mongo.migration.flamingock.FlamingockMongoMigrationAdapter
dev.caskeleton.adapter.outbound.mongo.nativecap.ApprovedMongoNativeOperation
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCapabilityGateway
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeCommandCategory
dev.caskeleton.adapter.outbound.mongo.nativecap.MongoNativeOperationPolicy
dev.caskeleton.adapter.outbound.mongo.nativecap.PolicyAwareMongoNativeGateway
dev.caskeleton.adapter.outbound.mongo.observation.MicrometerMongoOperationObserver
dev.caskeleton.adapter.outbound.mongo.observation.MongoCommandObservationListener
dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationConvention
dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationRedactor
dev.caskeleton.adapter.outbound.mongo.observation.MongoPoolObservationListener
dev.caskeleton.adapter.outbound.mongo.observation.MongoSdamObservationListener
dev.caskeleton.adapter.outbound.mongo.query.MongoFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.query.MongoOperator
dev.caskeleton.adapter.outbound.mongo.query.MongoQueryPolicy
dev.caskeleton.adapter.outbound.mongo.query.MongoRegexPolicy
dev.caskeleton.adapter.outbound.mongo.query.MongoSortDescriptor
dev.caskeleton.adapter.outbound.mongo.query.PolicyAwareMongoQueryBuilder
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetPolicyRegistry
dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursor
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetCursorCodec
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetPageRequest
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetQueryBuilder
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSlice
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoKeysetSort
dev.caskeleton.adapter.outbound.mongo.query.pagination.MongoNullSortOrdering
dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCallback
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoCollectionAccess
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoContextKeys
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoExecutor
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveScopedMongoOperations
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorGuard
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorLease
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoCursorTermination
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoReactiveCursorPublisher
dev.caskeleton.adapter.outbound.mongo.reactive.cursor.MongoResultBudgetTracker
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexApplyPolicy
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDescriptorView
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiff
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexDiffEngine
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementPlan
dev.caskeleton.adapter.outbound.mongo.schema.index.MongoIndexRetirementState
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexDirection
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoManifestRegistry
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership
dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest
dev.caskeleton.adapter.outbound.mongo.schema.model.EmbeddedCollectionDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoBinaryFieldDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelManifest
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelValidator
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentSizeBudget
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.model.MongoReferenceLifecycle
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoExpirationAccessPolicy
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlIndexDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicy
dev.caskeleton.adapter.outbound.mongo.schema.ttl.MongoTtlPolicyValidator
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationLevel
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorApplyPolicy
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDescriptor
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiff
dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidatorDiffEngine
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver
dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialRotationPolicy
dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile
dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfileValidator
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminApproval
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditPhase
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuditRecord
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuthorization
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminCommand
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation
dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminRuntimeGuard
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionProfile
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionScope
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSession
dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSession
dev.caskeleton.adapter.outbound.mongo.transaction.ReactiveMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.SpringMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.SpringReactiveMongoTransactionSessionFactory
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoCommitReconciler
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryBudget
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryDecision
dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoTransactionRetryCoordinator
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionContext
dev.caskeleton.adapter.outbound.mongo.transaction.session.MongoCausalSessionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.session.ReactiveMongoCausalSessionExecutor
dev.caskeleton.adapter.outbound.mongo.transaction.session.SpringMongoCausalSessionExecutor
+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.
@@ -0,0 +1,6 @@
44ba9931722364a53fcb3b5f31a1d539eabcaf42db775f5a33fb558f558c7504 README.md
d064f0ac6c3be0e5c76ef22454db2a97e1d78ed287bd22f4c125f19aba3ad8e3 VALIDATION.md
1ef15812f33dc998a6332b87523ed5942ba46d79d984a0ca776b05bb9247a06a docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md
5ae70b53e22cdb852b2bb0df171dec868bfe99b15bb8e71fb2b0b3431cd7e2cd docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md
8d0203203f6bfe4b2e18625eff23bb308ba6454703a4ca4cd3236dab31ecafc3 docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md
8048fe6a536de67d2cf5b0df05d35128f2c68ba8f0dd615831b40430fc76277b validate_graphql_docs.py
@@ -0,0 +1,43 @@
# GraphQL Superpowers 설계 패키지
이 패키지는 `GraphQL API 실행 플랫폼 심층 리서치`를 구현 기준선으로 변환한 설계서와 실행 계획서다.
## 문서
- `docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md`
- Stable·Advanced 전체 아키텍처, 공개 계약, 경계, 실패 의미론, 테스트와 지원 등급
- 입력 심층 리서치 원문을 추적 부록으로 포함
- `docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md`
- Stable 구현 Task 148
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 119
- `VALIDATION.md`
- 정적 검증 결과와 검증 범위
- `validate_graphql_docs.py`
- 패키지 내부 문서 재검증 스크립트
- `MANIFEST.sha256`
- 패키지 파일 무결성 목록
## 구현 순서
```text
Stable Task 148
→ Stable Release Gate
→ Advanced Task 119
→ Capability별 Promotion Gate
```
## 명시적 전제
```text
Java 21
Gradle Kotlin DSL
Spring Boot 4.1 BOM
Spring for GraphQL 2.0
Boot-managed GraphQL Java v25 계열
Stable module root: modules/graphql
Advanced module root: modules/graphql-advanced
Root package: io.backend.skeleton.graphql
```
실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
@@ -0,0 +1,98 @@
# GraphQL Superpowers 문서 정적 검증 결과
- **검증 시각 기준:** 2026-08-12
- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개
- **검증 명령:** `python3 validate_graphql_docs.py`
- **결과:** **PASS**
- **실행 검사:** 1,475
- **통과:** 1,475
- **실패:** 0
## 문서 규모
| 문서 | 행 수 | 크기 |
|---|---:|---:|
| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes |
| Stable 구현 계획서 | 4,560 | 209,041 bytes |
| Advanced 확장 계획서 | 1,976 | 105,717 bytes |
## 계획 구조
| 항목 | Stable | Advanced |
|---|---:|---:|
| Task 수 | 48 | 19 |
| Create 경로 수 | 227 | 113 |
| Task 번호 연속성 | PASS | PASS |
| 모든 Task의 `Files`·`Interfaces` | PASS | PASS |
| 모든 Task의 Implementation Requirements | PASS | PASS |
| 모든 Task의 Step 15 | PASS | PASS |
| 실패·통과 예상 결과 | PASS | PASS |
| Task별 Git commit 명령 | PASS | PASS |
| Create 경로 중복 | 없음 | 없음 |
| Stable·Advanced 경로 충돌 | 없음 | 없음 |
## 핵심 계약 검증
```text
SDL-first external contract
Single Executable Schema Stable default
HTTP POST Stable profile
application/graphql-response+json preferred
Validation 이후 Field Error·Partial Data는 HTTP 200
Draft 294는 Stable에서 제외
JPA Entity·MongoDB Document 직접 노출 금지
GraphQL Multipart Upload 미지원·Fileserver 사용
request-wide database transaction 금지
DataLoader request scope
Finite Fetch Profile
HMAC-signed cursor
Mutation idempotency·expected version 분리
Parser·shape·complexity·runtime response budget
Actor·Field·Object·Tenant authorization
Low-cardinality observability
Stable/Advanced dependency isolation
Persisted Operation·WebSocket·SSE·Federation 분리
RSocket·HTTP GET·Incremental Delivery Experimental
```
위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다.
## 입력 리서치 추적성
- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다.
- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다.
- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다.
## 패키지 검증 항목
```text
문서 파일 존재
Markdown code fence 균형
Task 148 / 119 연속성
Task별 테스트·명령·commit
정확한 Create 경로
Placeholder 금지
Stable module에 WebSocket·Federation·Persisted Operation 경로 부재
Advanced module에 feature flag와 capability 경로 존재
금지 API pattern 부재
문서 SHA-256 계산
```
## 검증 범위의 한계
현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다.
```text
Gradle configuration·compile
Spring Boot ApplicationContext 기동
SchemaMappingInspector 실제 결과
GraphQlTester HTTP·WebFlux contract
JPA·MongoDB statement/query-count integration
query bomb·complexity load test
Virtual Thread·event-loop blocking test
WebSocket·SSE soak test
Federation composition·router integration
actual Git commit
```
실제 구현에서는 Stable Task 148을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 119를 시작해야 합니다.
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import re
import sys
import hashlib
ROOT = Path(__file__).resolve().parent
DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md"
STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md"
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md"
checks: list[tuple[str, bool, str]] = []
def check(name: str, condition: bool, detail: str = "") -> None:
checks.append((name, bool(condition), detail))
def read(path: Path) -> str:
check(f"file exists: {path.name}", path.exists(), str(path))
return path.read_text(encoding="utf-8") if path.exists() else ""
design = read(DESIGN)
stable = read(STABLE)
advanced = read(ADVANCED)
# Basic document integrity
check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines())))
check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines())))
check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines())))
for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]:
check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```")))
for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
check(f"{label} no placeholder {marker}", marker.lower() not in text.lower())
# Design required sections and source traceability
required_design_terms = [
"# GraphQL API 실행 플랫폼 설계서",
"GraphQL Platform owns",
"Domain/Application owns",
"G1 Standard GraphQL API",
"G2 Advanced Execution",
"G3 GraphQL Extension",
"G4 Admin Plane",
"SDL",
"September 2025",
"application/graphql-response+json",
"HTTP `200`",
"GraphQlRequestContext",
"DataLoader",
"GraphQlFetchProfile",
"HMAC",
"Idempotency",
"Partial Data",
"Persisted Operation",
"Subscription",
"Federation",
"GraphQL Multipart Upload",
"Fileserver",
"부록 B. 입력 심층 리서치 원문",
"# GraphQL API 실행 플랫폼 심층 리서치",
]
for term in required_design_terms:
check(f"design contains {term}", term in design)
# Critical design invariants
critical_pairs = [
("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design),
("no draft 294 stable", "294" in design and "Stable" in design),
("dataloader request scope", "request" in design.lower() and "DataLoader" in design),
("cursor HMAC", "Cursor" in design and "HMAC" in design),
("no multipart upload", "Multipart Upload" in design and "Fileserver" in design),
("single schema default", "Single Executable Schema" in design),
("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()),
("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design),
]
for name, condition in critical_pairs:
check(name, condition)
# Plan headers and global constraints
stable_header_terms = [
"# GraphQL API 실행 플랫폼 Implementation Plan",
"REQUIRED SUB-SKILL",
"**Goal:**",
"**Architecture:**",
"**Tech Stack:**",
"## Global Constraints",
"Stable Task",
]
advanced_header_terms = [
"# GraphQL Advanced Capability Expansion Implementation Plan",
"REQUIRED SUB-SKILL",
"backend.graphql.advanced.*",
"Stable 구현 계획 Task `148`",
]
for term in stable_header_terms:
check(f"stable header contains {term}", term in stable)
for term in advanced_header_terms:
check(f"advanced header contains {term}", term in advanced)
# Task sequence and per-task structure
def task_sections(text: str) -> list[tuple[int, str]]:
matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE))
result = []
for i, match in enumerate(matches):
start = match.start()
end = matches[i+1].start() if i+1 < len(matches) else len(text)
result.append((int(match.group(1)), text[start:end]))
return result
stable_tasks = task_sections(stable)
advanced_tasks = task_sections(advanced)
check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks)))
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49)))
check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20)))
def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None:
required = [
"**Files:**",
"**Interfaces:**",
"**Implementation requirements:**",
"**Step 1: Write the failing test**",
"**Step 2: Run the focused test and verify the failure**",
"**Step 3: Implement the smallest complete production contract**",
"**Step 4: Run the focused test and the owning suite**",
"**Step 5: Commit the independently reviewable change**",
"Expected: FAIL",
"Expected: PASS",
"git commit -m",
]
for number, section in tasks:
for token in required:
check(f"{label} task {number} contains {token}", token in section)
check(f"{label} task {number} has test path", "- Test: `" in section)
check(f"{label} task {number} has production file", "- Create: `" in section)
check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0)
check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section)
validate_tasks("stable", stable_tasks)
validate_tasks("advanced", advanced_tasks)
# Create paths
def create_paths(text: str) -> list[str]:
return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE)
stable_paths = create_paths(stable)
advanced_paths = create_paths(advanced)
check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths)))
check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths)))
check("stable create paths unique", len(stable_paths) == len(set(stable_paths)))
check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths)))
check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths))
for index, path in enumerate(stable_paths, 1):
check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/")))
for index, path in enumerate(advanced_paths, 1):
check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/"))
# Stable/Advanced separation
for forbidden in [
"modules/graphql/graphql-websocket/",
"modules/graphql/graphql-federation/",
"modules/graphql/graphql-persisted-operation/",
"modules/graphql/graphql-rsocket/",
]:
check(f"stable excludes {forbidden}", forbidden not in stable)
for required in [
"modules/graphql-advanced/graphql-persisted-operation/",
"modules/graphql-advanced/graphql-websocket/",
"modules/graphql-advanced/graphql-subscription/",
"modules/graphql-advanced/graphql-federation/",
"modules/graphql-advanced/graphql-rsocket/",
]:
check(f"advanced includes {required}", required in advanced)
# Stable coverage
stable_required_terms = [
"GraphQlRequestContext",
"GraphQlClientPolicy",
"GraphQlSchemaContract",
"SchemaMappingInspector",
"@oneOf",
"GraphQlHttpProfile",
"application/graphql-response+json",
"GraphQlExecutionProfile",
"GraphQlWireError",
"GraphQlTenantIsolationPolicy",
"GraphQlParserLimits",
"GraphQlComplexityCalculator",
"GraphQlRuntimeBudget",
"GraphQlPreparsedCacheKey",
"GraphQlBatchPolicy",
"GraphQlFetchProfile",
"HmacGraphQlCursorCodec",
"GraphQlConnection",
"GraphQlMutationIdempotencyContext",
"GraphQlMetricCardinalityPolicy",
"GraphQlPlatformStartupValidator",
"GraphQlReleaseGate",
]
for term in stable_required_terms:
check(f"stable coverage {term}", term in stable)
advanced_required_terms = [
"GraphQlPersistedOperation",
"GraphQlWebSocketProtocol",
"GraphQlSubscriptionBufferPolicy",
"GraphQlSubscriptionOrderingProfile",
"GraphQlSseConnectionPolicy",
"GraphQlReplayPosition",
"GraphQlDataLoaderDependencyGraph",
"GraphQlFederationEntityKey",
"GraphQlFederationCompositionGate",
"GraphQlGeneratedSourceBoundary",
"GraphQlRepositoryAllowlist",
"GraphQlRSocketRoutePolicy",
"GraphQlHttpGetOperationPolicy",
"GraphQlIncrementalCompatibilityGate",
"GraphQlAdvancedReleaseGate",
]
for term in advanced_required_terms:
check(f"advanced coverage {term}", term in advanced)
# Prohibited API patterns
prohibited_patterns = [
(r"interface\s+GenericGraphQlRepository", "no generic graphql repository"),
(r"public\s+.*\bEntityManager\b", "no public entity manager"),
(r"public\s+.*\bMongoTemplate\b", "no public mongo template"),
(r"scalar\s+Upload\b", "no upload scalar declaration"),
(r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"),
]
for pattern, name in prohibited_patterns:
check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None)
# File hashes can be printed for package evidence
for path in [DESIGN, STABLE, ADVANCED]:
if path.exists():
digest = hashlib.sha256(path.read_bytes()).hexdigest()
check(f"sha256 computed: {path.name}", len(digest) == 64, digest)
failed = [(n, d) for n, ok, d in checks if not ok]
print(f"CHECKS={len(checks)}")
print(f"PASSED={len(checks)-len(failed)}")
print(f"FAILED={len(failed)}")
for name, detail in failed:
print(f"FAIL: {name}" + (f" :: {detail}" if detail else ""))
sys.exit(1 if failed else 0)
+1 -1
View File
@@ -12,7 +12,7 @@
#
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly
# three-way :app-bootstrap:verifyEnvKeys gate can express. Everything below is per deployment and is set directly
# in the environment — templating an indexed client in application.yml would materialise a nameless
# client in every deployment, which the settings' aggregate validation refuses.
#
+3 -3
View File
@@ -1,7 +1,7 @@
# HTTP Client Platform — Repository Adaptation Contract
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
**Design source:** `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
**Plan source:** `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
The design package states its own adaptation rule:
@@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
| Design assumption | Repository reality | Adaptation |
|---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
+64
View File
@@ -0,0 +1,64 @@
# Entity Mapping Guide
Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review
and expensive in production.
## The domain owns the model
The platform defines no business entity. Table names, column semantics, keys, unique and check
requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the
domain module. There is no `GenericRepository<T, ID>` and no platform base repository, because a
single generic API forces every aggregate through the same operations — and one aggregate's later
requirement then changes behaviour for all of them.
## Entities must be proxyable
- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be
subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors.
- A non-private no-arg constructor. The provider instantiates entities reflectively before
populating fields.
`EntityMappingCondition` in the testkit enforces both.
## Identifiers
Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When
they disagree, the provider hands out identifiers the sequence has not reserved and the collision
surfaces later as a primary-key violation under load.
`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider
must execute each insert immediately to learn it, which disables JDBC insert batching entirely.
`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than
letting the import silently run an order of magnitude slower.
UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary
key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index
never stays in cache and write amplification grows with the table.
## Values
- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the
constant's position, so inserting a new constant anywhere but the end silently reinterprets every
existing row.
- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums
drift and reconciliation disagrees with the ledger.
- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and
compares wrongly in SQL.
- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the
other.
## Associations
- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a
child also queries for its parent — the most common accidental N+1 in a JPA application.
- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row
unlinked, so aggregates expose an association helper that sets both sides.
- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns.
Between independent aggregates it deletes rows another part of the system still owns.
## Entities never leave the transaction
A controller must not return an entity, or a collection or `Optional` of one. Response serialisation
happens after the transaction closes, so a lazy association touched by the serialiser either throws
or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element.
`EntityExposureCondition` checks generic type arguments, not just the erased return type.
@@ -0,0 +1,43 @@
# Experimental Promotion Checklist
`ExperimentalPromotionGate` evaluates this checklist. Every technical item, then the ADR.
## Technical evidence
- [ ] **Compatibility** — the Stable contract suite passes on the experimental target, twice, on two
supported patch releases. One passing run is a coincidence.
- [ ] **Security** — for tenancy features, cross-tenant read *and* write are both proven impossible,
including through native SQL, bulk DML, `getReference`, and the second-level cache. A filter
that covers only entity queries covers none of those.
- [ ] **Failure** — connection reuse does not leak tenant context; a failover does not silently route
a read-after-write to a stale replica; the commit-ambiguity scenarios still behave.
- [ ] **Migration** — per-tenant migration is resumable after a partial failure, and rate-limited.
With one schema per tenant, a run is N independent migrations and "it failed" is not an answer.
- [ ] **Performance** — pool capacity, replica lag under load, and per-tenant memory are measured,
not estimated. Database-per-tenant fails as a sum, not as an individual pool.
## Decision
- [ ] **Reviewed ADR** — recording what is being promised, the operational burden it carries, and
what would cause it to be withdrawn.
The ADR is not a formality. The technical suites establish that something works; the ADR records
that the platform should promise it, which is a different question with a different cost.
## What does not count as evidence
- The version being generally available.
- The feature working in one environment.
- A passing suite that skipped because Docker was unavailable.
- A green lane whose assertions were relaxed to make it pass.
## Outcomes
| Decision | Meaning |
|---|---|
| `BLOCKED_TECHNICAL` | at least one suite has not passed |
| `BLOCKED_MISSING_ADR` | evidence is complete; no reviewed decision exists |
| `ELIGIBLE_FOR_STABLE_REVIEW` | both; Stable review may begin |
The two blocked states are distinct because they need different work: one needs evidence, the other
needs a decision.
+43
View File
@@ -0,0 +1,43 @@
# Experimental Support Matrix
Everything here is off unless its `backend.jpa.experimental.*` flag is explicitly true, and none of
it is part of the Stable composition.
| Feature | Flag | State |
|---|---|---|
| Shared-schema multi-tenancy (column) | `backend.jpa.experimental.multitenancy-column` | Experimental |
| PostgreSQL RLS multi-tenancy | `backend.jpa.experimental.multitenancy-rls` | Experimental |
| Schema-per-tenant | `backend.jpa.experimental.multitenancy-schema` | Experimental |
| Database-per-tenant | `backend.jpa.experimental.multitenancy-database` | Experimental |
| Consistency-aware read replica | `backend.jpa.experimental.read-replica` | Experimental |
| Jakarta Persistence 4.0 lane | `backend.jpa.experimental.jakarta-persistence-4` | Experimental |
| Hibernate ORM 8 lane | `backend.jpa.experimental.hibernate-8` | Experimental |
| PostgreSQL 19 lane | `backend.jpa.experimental.postgresql-19` | Experimental |
Presence on the classpath is not consent. `ExperimentalFeatureGate` fails startup when a module is
present and its flag is not set, because an experimental module can arrive transitively and a
tenant-isolation feature that switched itself on would be the worst possible default.
## Known constraints
- Tenant context is fail-closed. An unbound tenant in a shared-schema deployment means a query with
no tenant predicate, which returns every tenant's rows.
- A Hibernate filter is not the security boundary. It does not apply to native SQL, bulk DML,
`getReference`, or the second-level cache.
- RLS requires all three of: `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` (the owner is
otherwise exempt from its own policies), and a runtime role without `BYPASSRLS`.
- Tenant bindings are transaction-local. A session-local setting survives the connection's return to
the pool.
- `readOnly=true` never routes to a replica on its own. Read-after-write uses a consistency token or
the primary.
- Unavailable replica lag evidence means the primary. Absence of evidence is not evidence of
freshness.
- Per-tenant pools are bounded globally. Fifty tenants with a modest pool each is five hundred
connections against a server that permits a hundred.
- Tenant ids never become metric tags. Tenant cardinality is unbounded by definition.
## Lanes never change Stable
A compatibility lane publishes nothing and changes no Stable contract. If Hibernate 8 generates
different SQL for the fetch-pagination gate, that is a finding about Hibernate 8 — the 7.x gate keeps
asserting what 7.x must do, because that is what deployments run.
+64
View File
@@ -0,0 +1,64 @@
# Migration Guide
Design §31-§32. Flyway owns the schema; Hibernate only validates.
## Who may change the schema
| Environment | Mode |
|---|---|
| local, test, dev | migrate at startup with the migration credential |
| staging, prod | deployment-owned migration; the application validates only |
Migrating from inside the application in production means every instance of a rolling deploy races
to apply the same script, and the loser's failure is indistinguishable from a real one.
`ddl-auto` is `validate` or `none`. Never `update`: it never drops or narrows anything, so it
produces a schema that is neither the old one nor the one the migrations describe — silently, on
whichever instance started first.
## Validation fails closed and never repairs
`FlywayValidationGate` throws `SchemaMismatchException` on a checksum mismatch, a missing migration,
or a schema Hibernate disagrees with. It never calls `repair`.
Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the
symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the
applied one, and the interesting question is which change is missing from this database. Repair
makes that question unaskable. It exists only as an explicit admin operation with an operator, a
reason, and an approval (design §8.4).
Only Flyway's structured error codes reach the exception. Its messages embed the script path and
part of the failing statement.
## Concurrent index builds
`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and Flyway wraps migrations in
one by default. The migration therefore needs a companion configuration:
```conf
# V42__order_index.sql.conf
executeInTransaction=false
```
`ConcurrentIndexMigrationInspector` fails validation without it, and additionally requires the
migration to contain nothing else. A failed concurrent build leaves an invalid index behind;
recovering is a single `DROP INDEX` when the migration did nothing else, and a manual reconstruction
of partial state when it did.
An invalid index is not merely useless — the planner ignores it while every write still maintains
it. `FailedConcurrentIndexRecovery` reports them with the statement to run, and deliberately does
not drop them: an invalid index can also mean a build is still running, and the two are
indistinguishable from the catalog alone.
## Upgrade scenarios
Three, each catching something the others do not:
| Scenario | Catches |
|---|---|
| `empty` | an early migration edited to match a later one, no longer applying to a fresh database |
| `previous-release` | the actual deployment path; the only one exercising this release's migrations |
| `oldest-supported` | a migration that silently assumes state only recent databases have |
Each asserts a data invariant, not just the schema version. A migration that renames a column and
loses its contents leaves the version correct and the data gone.
+61
View File
@@ -0,0 +1,61 @@
# Observability
Design §37. What is measured, and what must never appear in a measurement.
## Bounded tags, always
Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure
category. All five are registered identifiers, validated by `LowCardinality` at construction rather
than at the registry — so an unbounded value fails where it was introduced instead of surviving
until a dashboard stops loading.
Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so
each creates a time series per row or per failure; several are also the data the platform keeps out
of logs, which a metrics backend would store just as durably and export just as widely.
## Transaction metrics
| Meter | Why it exists |
|---|---|
| `jpa.transaction.duration` | the baseline |
| `jpa.transaction.rollback` | rollback rate by failure category |
| `jpa.transaction.timeout` | timeouts, distinct from other rollbacks |
| `jpa.transaction.completion.unknown` | its own counter, deliberately |
Completion-unknown gets a separate counter rather than being folded into failures. It is the one
outcome that means a human has to look: every other failure is a transaction that definitely did not
happen, while this one is a transaction that may have.
## Query metrics
`jpa.query.duration` and `jpa.query.rows`. Rows are measured as well as duration because a query
that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic
per request — a duration metric alone reports it as merely slow.
## Retry metrics
Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected
cost of concurrency; logging each at WARN pages someone for a system working as designed, after
which the retry log gets filtered out and takes the genuinely interesting entries with it.
`jpa.retry.attempt`, `jpa.retry.attempts` (distribution per operation), `jpa.retry.exhausted`.
## Query names in SQL
`NamedStatementInspector` prefixes each statement with its registered query name as a SQL comment,
which travels into `pg_stat_activity`, `auto_explain`, and the slow-query log. Without it, "which
endpoint issues this query" is answered by grepping the codebase for fragments of SQL.
## Diagnostics
`SqlDiagnosticRedactor` removes string literals, numbers, and anything email-shaped before SQL
reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing
which columns hold personal data.
## The actuator endpoint
`jpaplatform` reports database major version, provider version, schema version, OSIV state, runtime
role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity
catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of
those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a
migration or a repair would be an admin capability exposed over HTTP.
+72
View File
@@ -0,0 +1,72 @@
# PostgreSQL Extensions
Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by.
Everything here is core PostgreSQL. No server extension is required.
## Locking
`SELECT ... FOR UPDATE` with a finite bound, always. `PostgreSqlLockOptions` refuses an unbounded
lock request because it waits as long as the holder holds it, turning one slow transaction into a
pile-up of blocked connections.
`NOWAIT` and a wait timeout are separate requests, not two spellings of one — modelling them as a
single field with a magic zero is how "no wait" becomes "wait forever".
`55P03` (lock not available) and `40P01` (deadlock) drive opposite recovery and are never collapsed:
the first leaves the transaction alive and the caller in control; the second has already been rolled
back by the server.
## Work claims
`FOR UPDATE SKIP LOCKED` is reachable only through a registered `WorkQueueName`, never as a
repository flag. It deliberately returns an incomplete view of the table: correct for handing
disjoint work to competing workers, silently wrong for anything that needs to see every matching
row. A registered claim statement must skip locked rows and impose a deterministic `ORDER BY`.
## Upserts
`INSERT ... ON CONFLICT ... RETURNING` under a registered `NativeWriteName` with a fixed conflict
target and update column set. The conflict target cannot be a bound parameter, so accepting one from
a caller would mean building SQL from input.
An upsert is the correct answer to a create race precisely because the database decides.
Read-then-write cannot be made correct: another transaction can commit between the read and the
write. `(xmax = 0) AS inserted` in the `RETURNING` list is what lets the platform report
insert-versus-update without a second query.
The executor flushes before and clears after: a native write is invisible to the Persistence
Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded
beforehand would keep serving pre-upsert values.
## JSONB
`JsonDocument` carries a schema name and version alongside the payload. A JSONB column is schemaless
at the database level, so without an envelope the only record of what a stored document means is the
code that wrote it — and a document written two releases ago is indistinguishable from a current one.
The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization
gadget: whoever can write a row chooses the class the reader instantiates.
Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a
request field into one is concatenating untrusted input into a statement. Values are always bound.
## Arrays and ranges
Arrays are built with `Connection.createArrayOf`, never by formatting a literal — hand-formatting is
where quoting bugs live, and a tag containing a comma changes the array's shape rather than its
content.
`PgRange` models both endpoints as independently optional and independently inclusive, because that
is what a PostgreSQL range is. Whether `[09:00, 10:00)` and `[10:00, 11:00)` overlap depends on the
bracket, not the values, and a pair of `timestamptz` columns cannot express it.
## COPY (J4 admin)
`COPY` bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That
is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a
row and byte cap, a finite server-side `statement_timeout`, and a named operator.
The registry accepts only `COPY ... FROM STDIN`. `COPY ... FROM '/path'` reads a file on the
*database server* as the server's OS user; it is superuser-only for exactly that reason and does not
belong behind an application API.
+74
View File
@@ -0,0 +1,74 @@
# Query and Fetch Guide
Design §23-§28. How queries are chosen, bounded, and proven.
## Named queries
Every registered query carries a `QueryName`. It becomes the metric tag, the trace attribute, and
the SQL comment that appears in `pg_stat_activity` and the slow-query log — which is the only thing
that connects a statement on the server back to the use case that issued it. The format rejects raw
SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built
from a parameterised value leaks row data into telemetry.
## Fetch plans, not eager mappings
N+1 is solved per use case with a registered entity graph, not by making an association `EAGER` in
the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every
other query against that entity, including the ones that only wanted the id.
`fetchgraph` and `loadgraph` are different: a fetch graph is exhaustive (attributes outside it are
lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either
missing data or the amplification the graph was meant to avoid.
## Measuring, not guessing
`QueryMeasurement` records statements, hydrated entities, rows, fetches, and elapsed time. Statement
count alone cannot distinguish the two failures that matter:
- **N+1** — many statements, few rows.
- **Cartesian fetch** — one statement, an enormous number of rows.
A suite asserting only on statement count passes the second one every time.
## Pagination
Offset pagination makes the database walk and discard `n` rows before returning any. Keyset
pagination replaces it:
- The predicate is lexicographic. For an ordering of `(createdAt, id)`, "after `(t, x)`" is
`createdAt < t OR (createdAt = t AND id < x)`**not** `createdAt <= t AND id < x`, which reads
plausibly and silently drops rows from the middle of the result set.
- The ordering must end in a unique column. Without one, a page boundary inside a run of equal
values duplicates and skips rows.
- `size + 1` rows are fetched and `size` returned. That extra row answers `hasNext` without a count
query, which would be a second full scan whose answer is stale on arrival.
Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a
caller seek to arbitrary keys.
## Sorting
Client sort parameters are mapped through `SafeSortRegistry`, never passed through. A sort field
reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the
client's string means the client writes part of the statement. `JpaSort.unsafe` has no call site in
this platform.
The registry's tie-breaker is always appended, because a sort that does not end in a unique column
has no total order and paging over a non-total order duplicates and skips rows.
## Streaming
A JPA `Stream` is a live cursor holding a `ResultSet`, a statement, and a connection. `JpaStreamExecutor`
consumes it inside a try-with-resources and never returns it, because a stream returned past the
transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only
transaction is required: streaming inside a write transaction pins a write connection for the whole
traversal.
## Batching
Configuring `hibernate.jdbc.batch_size` proves nothing. `BatchExecutionResult.jdbcBatches` comes from
counting real `executeBatch()` calls at the JDBC layer, because an IDENTITY generator, an interleaved
select, or a mid-loop flush disables batching while the configuration still says it is on.
Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity
in the Persistence Context — the classic bulk-import out-of-memory.
+164
View File
@@ -0,0 +1,164 @@
# JPA Relational Persistence Platform — Repository Adaptation Contract
**Design source:** `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
**Stable plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
**Experimental plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
structure are explicit implementation *assumptions* made because the real Backend Skeleton
repository was not supplied. Before implementing, paths are adjusted to the repository's existing
conventions and root package while the public contracts and policy semantics are preserved.
This file is the single record of *how* that mapping was performed. Only paths, build DSL, and
composition-root ownership changed. Public contracts, policy order, retry semantics, and error
semantics are implemented as specified.
## 1. Why the module layout differs
The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7
Experimental projects under `modules/jpa-experimental/`. This repository is a Clean Architecture
template whose **fail-closed registry** (`src/config/architecture/modules.json`, enforced by
`src/settings.gradle` and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf
identities**, and `src/settings.gradle` throws when the registry does not contain exactly 19
modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
Therefore the plan's library modules become **package boundaries inside the registered leaf**
`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules.
This is the same adaptation already applied to the HTTP client platform
(`docs/httpclient/repository-adaptation.md`).
| Plan module | Repository home | Reason |
|---|---|---|
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
The package boundary is enforced by `JpaModuleBoundaryTest`. It holds a closed catalog of the
production root's direct child packages, compares that catalog against the tree for exact equality,
checks every observed top-level edge against the declared ones, and rejects cycles.
This used to be a stronger claim than the test. The catalog listed thirteen packages while the tree
held twenty-two, so nine — `audit`, `config`, `failure`, `fileserver`, `h2`, `idempotency`, `lock`,
`notification`, `outbox` — were governed by nothing, and a `transaction → postgresql` /
`postgresql → transaction` cycle passed. Both are closed now, and the catalog's exact-equality check
is what keeps a new package from being green by omission.
**Known gap.** The catalog governs top-level packages. Sub-package edges inside one top-level
package are not checked, and the target tree in the review's JPA-023 (a `capability/*` layout) is
not implemented — the notification configuration facade is the first step toward it.
## 2. Package mapping
Root package: `io.backend.skeleton.jpa``dev.caskeleton.adapter.outbound.persistence`.
| Plan module | Plan package | Repository package |
|---|---|---|
| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) |
| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` |
| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` |
| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` |
| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) |
| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) |
| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` |
| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` |
| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` |
| `jpa-envers` | `…jpa.envers` | `…persistence.envers` |
| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` |
| `jpa-observability` | `…jpa.observation` | `…persistence.observation` |
| `jpa-security` | `…jpa.security` | `…persistence.security` |
| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` |
| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set |
| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) |
The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this
leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are
**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an
existing name.
## 3. Test-suite mapping
The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`,
`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a
Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered
in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
| Plan suite | Repository lane |
|---|---|
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
| `performanceTest` | `src/jpaPlatformPerformanceTest` — pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. |
Docker-dependent lanes fail closed rather than skipping, matching the existing
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
## 4. Other deliberate substitutions
| Plan assumption | Repository reality | Adaptation |
|---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
| release blocking aggregate | `.github/workflows/jpa-release.yml` | CI names the blocking JPA lanes directly; Gradle only defines how each lane runs. |
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. |
| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. |
| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. |
### Types relocated to keep the dependency direction legal
The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three
value-only types the design places in a downstream module are consumed by a core contract, so they
live in the core here instead. Each is a pure value with no framework dependency, so the relocation
costs nothing and the alternative — a core contract importing an adapter package — would break the
boundary the module map exists to hold.
| Type | Plan module | Repository package | Consumed by |
|---|---|---|---|
| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) |
| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) |
| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator |
The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`)
is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit
is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime
classpath to serve code that only ever runs in a test.
### Findings the contracts produced against a real server
Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what
showed it. Both are recorded here because the design text still reads the old way.
- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend
with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the
commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats
`57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks.
`CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow
again.
- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s
target version is empty for a tenant that was already current, so recording it reported migrated
tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the
applied version from the tenant's schema history.
## 5. What is unchanged from the design
- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit
policy. No `GenericRepository<T, ID>` and no Spring Data CRUD re-implementation exists.
- Application Service owns the transaction boundary; OSIV is false in every runtime profile.
- `TransactionCompletionUnknownException` always reports `completionUnknown=true`,
`retryable=false`, and is never automatically retried — reconciliation handles it.
- Retry re-executes the whole use case in a new transaction and a new Persistence Context.
- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`,
`55P03`) and never parses localized message text.
- Flyway is the source of truth for production schema change; Hibernate only validates;
`ddl-auto` never mutates a deployed schema.
- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker.
- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4,
Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the
Stable composition.
+85
View File
@@ -0,0 +1,85 @@
# JPA Platform Runbooks
Operator procedures for the failures this platform is designed to surface rather than hide.
## A transaction reported completion unknown
**Signal:** `jpa.transaction.completion.unknown` incremented; a `CompletionUnknownRecord` in the
reconciliation channel.
**What it means:** the commit may or may not have happened. It is not a rollback.
**Do not** re-run the use case. That is what the platform refused to do automatically, for the same
reason.
**Procedure:**
1. Take the `transactionKey` from the record.
2. Check the idempotency record for that key.
3. Check the business row the use case would have written.
4. Check the outbox for a corresponding event.
5. If all three agree the write happened, mark the record `COMMITTED` and stop.
6. If all three agree it did not, the use case may be re-run.
7. If they disagree or are inconclusive, leave it `STILL_UNKNOWN` and escalate. An inconclusive
answer is a legitimate outcome; guessing is not.
A record with no `transactionKey` cannot be resolved automatically — use the operation name and
timestamp.
## Deadlock or serialization rate rising
**Signal:** `jpa.retry.attempt` rising; `jpa.retry.exhausted` non-zero.
Retries are expected. Exhaustion is not.
1. Group `jpa.retry.attempt` by operation. A single operation dominating means a hot row or an
inconsistent lock order.
2. For deadlocks, check whether two operations take the same rows in opposite orders — that is a
code fix, not a tuning one.
3. For serialization failures under `SERIALIZABLE`, confirm the isolation is actually required.
4. Only then consider raising `maxAttempts`. A larger budget on a hot row converts a fast failure
into a slow one.
## Pool exhaustion
**Signal:** connection acquisition timeouts; `PoolMeasurement.pending` non-zero.
1. Check `REQUIRES_NEW` usage. It takes a second connection while pinning the first, so the pool
must satisfy `(threads x (1 + depth)) + 1`.
2. Check for streaming outside a bounded scope — a `Stream` returned past the transaction holds its
connection until the pool notices.
3. Check for external calls inside a DB transaction. The design forbids them precisely because an
HTTP timeout then holds a connection for its whole duration.
## Flyway validation failed at startup
The deployment is running against a schema it was not built for. It failed closed, which is correct.
1. Read the reported error codes (the messages are deliberately not propagated).
2. `CHECKSUM_MISMATCH` — an applied migration was edited afterwards. Find which change is missing
from this database. **Do not run `repair`**: it rewrites history to match the scripts, which
resolves the symptom by deleting the evidence.
3. `MISSING_SCRIPT` — a migration applied here is not in this build. Usually a rollback to an older
artifact.
## An invalid index exists
**Signal:** `FailedConcurrentIndexRecovery.invalidIndexes()` is non-empty.
A concurrent build failed. The index is ignored by the planner and maintained by every write.
1. Confirm no build is currently running. An in-progress build looks identical in the catalog.
2. Run the reported `DROP INDEX CONCURRENTLY` outside a migration.
3. Re-apply the index migration.
The platform does not drop these automatically: on a rolling deploy every instance would race to
drop an index another instance was about to finish building.
## The runtime role failed verification
Startup refused because the runtime credential holds `CREATE`, or `search_path` contains an
unapproved schema.
This is not a false positive to be worked around. Re-provision from
`infra/jpa/roles/runtime-roles.sql`; the application's credential having DDL is the condition that
makes every other schema guarantee unenforceable.
+66
View File
@@ -0,0 +1,66 @@
# Security
Design §36. Credential separation, privilege verification, and what never leaves the process.
## Three credentials
| Role | May |
|---|---|
| `app_migration` | own the schema, apply migrations (DDL) |
| `app_runtime` | select, insert, update, delete (DML only) |
| `app_admin` | J4 operations — COPY, backfill, maintenance |
The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If
the application's own credential cannot execute DDL, then no code path, no library, and no injected
statement can alter the schema at runtime, regardless of what the application intended.
`infra/jpa/roles/runtime-roles.sql` provisions them.
## Startup verification
`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do:
```sql
select current_user,
current_setting('search_path'),
has_schema_privilege(current_user, current_schema(), 'CREATE'),
has_database_privilege(current_user, current_database(), 'CREATE')
```
Configuration cannot answer this. Effective privileges come from direct grants, inherited role
memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest
reconstructs that combination reliably.
Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the
database.
## search_path
`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves
to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly
before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the
application executes it without noticing. `$user` is exempt: only the connected role owns it.
Refusing the runtime role `CREATE` closes the same route from the other side.
## What never leaves the process
- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric
tags, not in logs. `JpaFailureContext` composes messages from bounded values only.
- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical
name maps to a bounded unknown code rather than being passed through.
- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state.
- The actuator report carries no JDBC URL, username, or SQL.
## Injection surfaces, and how each is closed
| Surface | Why it cannot be a parameter | Closed by |
|---|---|---|
| sort field | part of ORDER BY | `SafeSortRegistry` allowlist |
| JSON path | part of the statement | registered `JsonPathName` |
| schema name | an identifier | registered `SchemaTenantRegistry` |
| upsert conflict target | an identifier list | registered `UpsertConflictTarget` |
| COPY table | an identifier | registered `RegisteredCopyStatement` |
| queue claim SQL | a whole statement | registered `WorkQueueDefinition` |
Values are always bound. Identifiers are always registered.
+144
View File
@@ -0,0 +1,144 @@
# JPA Persistence Platform — Support Matrix
**This document is a rendering. The machine-readable source is
[`src/config/jpa/release-registry.json`](../../src/config/jpa/release-registry.json).**
`JpaReleaseManifest` used to parse this file with regular expressions: every `PostgreSQL NN` it
mentioned became a supported version, whatever table or sentence produced the match. An Experimental
major joined the Stable list, a version named once in prose counted as supported, and demoting a
major changed nothing so long as the string survived somewhere in the document. Now the registry
declares a support level per major as a field, each gate names the Gradle task that produces its
evidence, and this document describes what the registry says.
Being a rendering used to be a claim rather than a mechanism: the tables below were still typed by
hand, so a major demoted in the registry stayed Stable here and kept its full release job.
`JpaReleaseRenderingTest` now compares the database table, the gate table and `jpa-release.yml`'s
matrix and promotion lists to the registry, and `verifyJpaReleaseGateTasks` resolves every gate's
task against the real Gradle task graph. Edit the registry; these tables follow, or the build fails.
Two renderings stayed outside that comparison until they were added to it. `jpa-nightly.yml` runs
its own matrix and nothing checked it, so a demotion corrected the release lane and left the nightly
lane certifying the major. And an Experimental major's "compatibility lane only" named no file: the
lane existed, but the registry, this document and the release workflow could each be read end to end
without establishing that, so a reader looking for it concluded there was none. An Experimental major
now has to be recorded as the target of a lane in `.github/workflows`, and a Stable lane may not run
it.
## Database
| Database | Support | Evidence |
|---|---|---|
| PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) |
| PostgreSQL 19 | Experimental | [`jpa-next-postgresql19.yml`](../../.github/workflows/jpa-next-postgresql19.yml) — `NOT_EXECUTABLE`: no `postgres:19-alpine` is published, so no container of that major has been started; promotion requires an ADR |
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
Each major gets its **own release job**, because for a while it did not. The release lane passed
`-Pjpa.matrix.versions=16,17,18` to a `JpaPlatformContractSupport.start()` that used
`selectedVersions().get(0)`, so the whole integration suite ran against PostgreSQL 16 and this table
recorded 17 and 18 as fully covered on the strength of a three-assertion smoke test. `start()` now
refuses a multi-version selection outright, `jpa-release.yml` fans out to one job per major, and a
promotion job requires all three majors' evidence to carry the same commit SHA — so a removed major
removes the release, not the evidence for it.
**Provider baseline.** The gates run against the Hibernate version the Spring Boot BOM resolves —
**7.1.8.Final** — which the registry records as `stable-tested-baseline`. This document previously
called 7.4 the Stable baseline and the pagination gate was named `hibernate-7.4-fetch-pagination`,
so every run of that gate produced evidence labelled with a provider it had never executed against.
7.4 is recorded as `compatibility-target`; it becomes the baseline when a full lane has actually run
on it.
H2 is not a second production target. It reports different SQLSTATEs for the same violation, no JSONB
operators, no range types, and no concurrent index builds. A green H2 run is evidence that the code
compiles and runs, and nothing more.
`SKIP LOCKED` needs its own sentence, because two documents said different things about it. The
module's `CLAUDE.md` records a measurement: H2 2.4.240 accepts `FOR UPDATE SKIP LOCKED` and does
genuinely skip locked rows, which is why the outbox claim SQL is identical on both vendors. This
document previously said H2 has no such guarantee. Both are right about different questions, and
the distinction is the point: **observed behaviour in the version we measured is not a production
guarantee, and it is never PostgreSQL contract evidence.** The measurement is why the claim SQL
needs no vendor branch; the absence of a guarantee is why every concurrency contract still runs
against a real PostgreSQL.
## Specification and provider
| Component | Stable | Experimental |
|---|---|---|
| Jakarta Persistence | 3.2 | 4.0 (lane) |
| Hibernate ORM | 7.4 declared baseline | 8 (lane) |
| Spring Boot | repository BOM | — |
The Hibernate row needs a note. The design declares 7.4 as the Stable provider; this repository's
Spring Boot BOM resolves 7.1.x. `HibernateProviderPolicy` holds both — the declared baseline as a
constant, the resolved version read from Hibernate itself — and `driftsFromDeclaredBaseline()` makes
the difference visible instead of asserting a constant against itself. See
[repository-adaptation.md](repository-adaptation.md) §4.
## Capability support levels
| Capability | Level |
|---|---|
| Full-transaction retry | Stable |
| Commit completion evidence | Stable |
| Keyset pagination | Stable |
| JDBC batch | Stable |
| Flyway schema gate | Stable |
| Runtime role verification | Stable |
| Observability | Stable |
| PostgreSQL native write (`ON CONFLICT`/`RETURNING`) | Advanced |
| PostgreSQL work claim (`SKIP LOCKED`) | Advanced |
| PostgreSQL JSONB | Advanced |
| PostgreSQL array and range | Advanced |
| Bulk DML | Advanced |
| Hibernate `StatelessSession` | Advanced |
| PostgreSQL `COPY` | Admin (J4) |
| Hibernate second-level cache | Advanced |
| Hibernate Envers | Advanced |
| Technical auditing — `audit/AuditableEntity` | Stable (canonical) |
| Technical auditing — `auditing/AuditMetadata` | Candidate, not composed |
| Multi-tenancy (column, RLS, schema, database) | Experimental |
| Consistency-aware read replica | Experimental |
## Release gates
Each row is a way the platform could pass its tests and still be wrong in production.
| Gate | Kind | What it prevents |
|---|---|---|
| `postgresql-contract` | gate | a release whose only database evidence came from H2 |
| `completion-unknown-no-retry` | gate | automatically re-running a write that may already have committed |
| `osiv-disabled` | gate | lazy loading from the view layer, one query per rendered row |
| `flyway-validate` | gate | Hibernate mutating a deployed schema, or running against one it was not built for |
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
| `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
### The two audit mechanisms
`audit/AuditableEntity` is the canonical one: `created_*`/`updated_*`, a 256-character actor,
stamped explicitly by the repository adapter. It is what the sample entities extend and what the
migrations were written for.
`auditing/AuditMetadata` is a second, complete mechanism with different column names
(`modified_*`), a different actor length (64) and a different capture lifecycle (Spring Data
listeners). Nothing embeds it and nothing composes `JpaAuditingConfiguration`, which is why it is
listed as a candidate rather than as a capability: promoting it means choosing between reshaping it
to the canonical columns and writing a forward migration for the new ones, and that choice has not
been made. Until it is, an entity picks one mechanism or none — enforced on the production graph by
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`.
Neither mechanism reaches a bulk or native update. Both stamp on an ordinary save — one in the
adapter, one on a managed entity's lifecycle — so a statement that goes straight to the database
leaves the audit columns showing the previous save. A bulk update of an audited entity must
therefore set the audit column in the statement, which
`JpaAuditMechanismRule.bulkUpdatesOfAuditedEntitiesStampAudit` checks over the production graph.
## Explicitly unsupported
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
onto an event loop rather than removing it.
- Hibernate as the production schema writer. `ddl-auto` never mutates a deployed schema.
- A platform-owned generic CRUD repository. Domains own their repositories (design §10.1).
- Automatic reconciliation of a completion-unknown transaction. The platform records; the domain
resolves.
+66
View File
@@ -0,0 +1,66 @@
# Transaction Guide
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
## The application service owns the boundary
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
knows where it starts and ends.
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
## Profiles
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
write profile must carry a positive finite timeout — the type refuses to represent one without —
because an unbounded write transaction holds a connection, its locks, and its row versions for as
long as one stuck statement takes.
`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a
profile using it must be paired with the pool-pressure evidence in design §38:
```text
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
```
## Retry is per use case, never per statement
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
means the state the attempt computed against is no longer the committed state, so re-issuing the
same statement would compute the same wrong answer. The domain rules have to run again against
reloaded data.
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
Two additional refusals, independent of budget:
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
Rollback reverses database work only; an email or a card charge has already changed the world.
- Anything completion-unknown.
## Completion unknown
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
rebuilds its context through the safe factory whatever it is handed.
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
mistaken for a rollback.
Recovery is reconciliation, not retry:
```text
record the transaction key -> check the idempotency record
-> check the business row
-> check the outbox
-> still undetermined? reconciliation queue
```
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
Writing it through the same connection would make the audit trail share the failure it documents.
+246
View File
@@ -0,0 +1,246 @@
# 설정 레퍼런스
> **Prefix.** Every property below binds under `app.messaging`, which is the prefix the deployed
> runtime and the `APP_MESSAGING_*` environment variables already use. Earlier revisions of this
> page documented a bare `messaging` prefix and the starter bound `backend.messaging`; neither
> bound what this page describes, so a deployment configured from it changed nothing. A key under
> either of the old prefixes now fails startup with a message naming the key — see
> `MessagingPrefixMigrationValidator`.
> **이 페이지는 실행된다.** 아래 YAML 블록은 `MessagingConfigurationBindingTest`가 이 파일에서 직접
> 읽어 컨텍스트에 올린다. 문서가 설명하는 모양이 곧 바인딩되는 모양이라는 뜻이고, 문서를 고치면서
> 코드를 고치지 않으면 테스트가 깨진다. 이전 판은 destination·broker·security 세 섹션을 설명했지만
> 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지
> 못했다 (MSG-008).
## Application publish bridge identity
Application의 canonical integration event를 platform publish pipeline으로 보낼 때는
`app.messaging.producer-id`를 명시한다. 같은 값의 환경변수 이름은
`APP_MESSAGING_PRODUCER_ID`다. 이 값은 host/pod 이름이 아니라 배포와 무관하게 유지되는 논리적
producing-service identity다.
값이 없으면 `IntegrationEventPublishPort` bridge 자체를 만들지 않는다. `spring.application.name`이나
현재 process 이름으로 추론하지 않는다. 기존 legacy `OutboxEvent`/realtime 경로는 별도 cutover가
끝날 때까지 `app.messaging.broker` 경로를 유지한다.
## Outbox canonical transport-only cutover
`APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED` / `ca-skeleton.outbox.canonical-transport-enabled`
기존 `outbox_event` writer/claim/status authority를 유지한 채 canonical row의 **transport만** platform
publish path로 보내는 compatibility gate다. 기본값은 `false`이며 `POLLING_V2`를 활성화하지 않는다.
`true`일 때는 `OutboxAppendPort``ValidatedIntegrationEvent`의 exact envelope bytes와 canonical
metadata를 기존 outbox row에 저장하고, claim된 canonical row는 `IntegrationEventPublishPort`로 간다.
legacy row는 계속 `MessageBroker`를 사용한다. 따라서 mixed-row compatibility 기간에는 relay가 켜져
있다면 `app.messaging.broker`도 계속 필요하며, canonical path를 위해 `IntegrationEventPublishPort`
추가로 필요하다. legacy backlog가 0이라는 별도 증거 없이 broker 요구를 제거하지 않는다.
## Destination profile
```yaml
app:
messaging:
destinations:
order-events:
broker: kafka-primary
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
# | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY
tier: M1 # M1 | M2 | M3
physical:
topic: order.events.v1
schema:
codec: application/json
compatibility: BACKWARD_TRANSITIVE
message-types: [order.created]
guarantees:
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
ordering: KEY # NONE | DESTINATION | PARTITION | KEY
external-side-effect: INBOX_TRANSACTIONAL
producer:
confirmation: REPLICATION_OR_PERSISTENCE_ACK
timeout: 5s
mandatory-routing: true
idempotent: true
consumer:
group: order-projection
concurrency: 1 # DESTINATION 순서를 요구하면 1이어야 한다
max-in-flight-per-ordering-unit: 1
prefetch: 16
handler-timeout: 30s
manual-settlement: false
retry:
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
# | RETRY_DESTINATION | BROKER_DELAYED
max-attempts: 3
initial-delay: 200ms
max-delay: 2s
multiplier: 2.0
jitter: true
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
dlq:
destination: order-events-dlq
max-redrive-count: 1
payload:
max-bytes: 1048576
claim-check-threshold-bytes: 1048576
key-resolver-configured: true
production: false
topology-auto-create: false
order-events-dlq:
broker: kafka-primary
kind: WORK_QUEUE
physical:
topic: order.events.v1.dlt
schema:
message-types: [order.created]
```
`dlq.destination`이 가리키는 destination도 선언되어야 한다. 선언되지 않은 이름은 부팅 실패이며,
메시지가 갈 곳 없는 DLQ 설정이 조용히 통과하지 않는다. `retry.destination``dlq.destination`
섞여 만드는 순환(A의 retry가 B로, B의 dlq가 A로)도 하나의 그래프로 검사되어 경로와 함께 거절된다.
## 기본값
| 설정 | 기본값 | 근거 |
|---|---:|---|
| logical payload 최대 | 1,048,576 bytes | portability. 초과는 Claim Check |
| global hard 최대 | 8,388,608 bytes | 어떤 destination도 넘을 수 없는 상한 |
| header 총 크기 | 32,768 bytes | |
| header 개수 | 64 | |
| header key | 128 bytes | metric tag 안전 |
| header value | 4,096 bytes | |
| publish timeout | 5s | |
| handler timeout | 30s | |
| graceful shutdown drain | 30s | |
| 일반 destination retry | 0회 | 자동 retry는 opt-in |
| DLQ redrive batch | 100 | 한 번의 작업이 source를 덮치지 않게 |
| Outbox relay batch | 100 | |
| Outbox lease | 30s | |
| Outbox polling | 500ms | |
| metric dimension 상한 | 200 | cardinality 폭발 방지 |
`schema.codec``application/json`, `schema.compatibility``BACKWARD_TRANSITIVE`,
`guarantees.delivery``AT_LEAST_ONCE`, `retry.mode``NONE`이 기본값이다. 자동 retry가 기본으로
꺼져 있는 이유는 순서를 흐트러뜨리거나 비멱등 side effect를 두 번 실행하는 retry가 눈에 보이는
실패보다 나쁘기 때문이다.
## Broker profile
브로커는 `app.messaging.brokers` 아래에 한 번만 기술한다. `type`이 어느 계열의 설정이 적용되는지
결정하며, 다른 계열의 키(Kafka 항목의 `prefetch` 같은)는 무시되지 않고 부팅 실패로 거절된다 —
무시하면 그 줄을 쓴 사람은 무언가가 적용됐다고 믿게 된다.
### Kafka
```yaml
app:
messaging:
brokers:
kafka-primary:
type: kafka
stable: true
production: false
bootstrap-servers: [broker-1:9093, broker-2:9093]
enable-idempotence: true # stable에서 필수
acks: all # stable에서 필수
max-in-flight-requests-per-connection: 5 # 최대 5
delivery-timeout: 30s
enable-auto-commit: false # 항상 금지
consumer-group: order-projection
tls-enabled: false # production이면 필수
authentication-enabled: false # production이면 필수
```
### RabbitMQ
```yaml
app:
messaging:
brokers:
rabbit-primary:
type: rabbitmq
stable: true
production: false
addresses: [rabbit-1:5671]
publisher-confirms: true # stable에서 필수
publisher-returns: true # stable에서 필수
mandatory: true # stable에서 필수
confirm-timeout: 5s
auto-ack: false # 항상 금지
prefetch: 16
quorum-queues: true # durable work queue 필수
tls-enabled: false
authentication-enabled: false
```
`production: true`인 브로커는 `tls-enabled``authentication-enabled`가 모두 참이어야 하고,
그렇지 않으면 `KafkaProfileValidator` / `RabbitProfileValidator`가 부팅을 거절한다. 위 예시가
`production: false`인 것은 이 페이지가 그대로 실행되는 fixture이기 때문이며, 실 배포는 셋 다 참이다.
## 보안
```yaml
app:
messaging:
security:
kafka-primary:
producer: { type: SASL_SCRAM, credential-id: kafka-producer }
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
# admin은 application runtime에 설정하지 않는다
hostname-verification: true
access:
publishable: [order-events]
consumable: []
administrable: []
```
키는 `app.messaging.brokers`에 선언된 브로커 이름과 같아야 한다. `tls-enabled``production`
브로커 쪽에만 있고 여기에 중복되지 않는다 — 하나의 브로커가 두 곳에서 기술되면 두 값이 어긋나는
날이 오고, 어느 쪽이 이기는지는 아무도 모른다.
`credential-id`는 이름일 뿐이고 자격 증명 자체가 아니다. 실제 재료는 `CredentialProvider`
연결 시점에 해석하므로, 설정 덤프나 힙 덤프에서 나오는 것은 이름뿐이다. producer와 consumer는
서로 다른 `credential-id`를 써야 하며, 같으면 부팅에 실패한다.
## Experimental / Optional
기본값은 전부 `false`다.
```yaml
app:
messaging:
experimental:
kafka-share: false
pulsar: false
nats: false
bridge:
spring-cloud-stream: false
```
## Backpressure
```yaml
app:
messaging:
backpressure:
global-limit: 512
per-destination-limit: 64 # global-limit 이하여야 한다
```
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
## 바인딩되지 않는 키
섹션은 바인딩되는데 그 안의 키 하나가 오타인 경우는 접두사 오타와 달리 조용하다 — 섹션은 붙고,
플랫폼은 뜨고, 바꾸러 온 그 설정만 적용되지 않는다. `MessagingConfigurationKeyValidator`
`app.messaging.destinations|brokers|security` 아래의 모든 키를 settings 레코드에서 파생한 목록과
대조하고, 없는 키는 그 키 이름을 담아 부팅을 거절한다.
허용 키 목록은 이 문서가 아니라 레코드에서 나온다. 문서에 목록을 적으면 필드가 추가된 날 그
목록이 틀리고, 오타를 잡으라고 만든 검사가 정상 필드를 거절하게 된다.
환경변수(`APP_MESSAGING_...`)는 이 검사의 대상이 아니다. `APP_MESSAGING_DESTINATIONS_ORDER_EVENTS_
CONSUMER_PREFETCH`에서 entry 이름과 leaf를 가르는 밑줄은 둘 안에 있는 밑줄과 구별되지 않으므로,
되돌려 쪼개려면 추측해야 한다. 여기서의 추측은 정상 배포를 거절하는 쪽으로 틀리며, 그것은 배포
매니페스트에 손으로 적어야 하는 변수에서 오타 하나를 놓치는 것보다 나쁘다.
+34
View File
@@ -0,0 +1,34 @@
# 기존 runtime → 신규 messaging platform cutover (MSG-015)
## 왜 기계적 매핑이 안 되는가
두 outbox 모델의 enum 이름이 겹치는데 의미가 반대다.
| 모델 | retryable | terminal |
|---|---|---|
| 기존 `OutboxEventStatus` | `FAILED` (`next_attempt_at` 보유) | `DEAD` |
| 신규 `OutboxStatus` | `AMBIGUOUS` | `FAILED`, `EXHAUSTED` |
이름으로 매핑하면 **확정 거절이 무한 재시도**가 되고 **불확정이 park**된다. 그래서 application은
자기 어휘(`OutboxPublishOutcome`)만 쓰고, 변환은 bridge adapter가 한다.
## 지금 반영된 것
- `OutboxPublishOutcome``CONFIRMED` / `AMBIGUOUS` / `REJECTED_BEFORE_SEND` /
`REJECTED_AFTER_BROKER`. application이 소유하는 canonical 결과 타입이며, "리턴 or throw"만 가능한
기존 어댑터를 위해 `OutboxMessagePublishPort.publishForOutcome`의 default가 `CONFIRMED`를 돌려준다.
- `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` ArchUnit 규칙 — application-core가
`dev.caskeleton.messaging..`를 import하면 빌드가 깨진다.
- 반대 방향(신규 `PublishResult` → application outcome) 매핑 규칙을 테스트로 고정.
## 남은 것
- `messaging-platform-bridge` outbound leaf: validated application event → platform envelope,
`PublishResult``OutboxPublishOutcome`. registry에 leaf를 추가하는 변경이라 별도 커밋.
- golden contract 테스트: event/message ID, type, schema revision, partition/order/correlation/
causation/tenant/trace, payload digest, wire version이 bytes 단위로 보존되는지.
- 단일 publication authority: 기존 `OutboxPublicationAuthority` fence를 재사용해 writer/relay가
동시에 ACTIVE가 되지 않도록. **dual write/publish는 금지** — 한 business fact가 두 durable store와
두 relay로 나가는 상태가 cutover에서 가장 위험하다.
- 첫 cutover 범위는 **transport만** 교체(저장소는 기존 유지). storage migration은 shadow read →
authority switch → old backlog drain 순서로 별도 release.
+75
View File
@@ -0,0 +1,75 @@
# 전달 보장
## 왜 `EXACTLY_ONCE`가 없는가
어떤 브로커도 **외부 side effect를 포함한** exactly-once를 제공하지 않는다.
실제로 존재하는 것은 at-least-once 전달 + 멱등하거나 transactional한 consumer의 조합이다.
플랫폼이 지킬 수 없는 이름을 enum에 두면 그 책임이 눈에 보이지 않는 곳으로 밀려난다.
그래서 `DeliveryGuarantee`는 증거가 끝나는 지점에서 멈춘다.
```java
public enum DeliveryGuarantee { AT_MOST_ONCE, AT_LEAST_ONCE }
```
## Publish 결과는 boolean이 아니다
```java
public enum PublishCompletion { CONFIRMED, REJECTED, AMBIGUOUS }
```
`REJECTED``AMBIGUOUS`를 하나의 "실패"로 합치면 중복 주문이 만들어진다.
전자는 broker가 저장하지 않았음이 **확정**되어 포기해도 안전하고, 후자는 그렇지 않다.
| 상황 | 결과 |
|---|---|
| 로컬 validation 실패 | `REJECTED`, `NOT_TRANSMITTED` |
| broker 명시적 reject / nack | `REJECTED` |
| confirm 수신 | `CONFIRMED` |
| Rabbit confirm + unroutable return | `REJECTED`, `UNROUTABLE` |
| bytes 전송 후 connection loss | `AMBIGUOUS` |
| confirm timeout | `AMBIGUOUS` |
| adapter가 판정 불가 | 보수적으로 `AMBIGUOUS` |
`PublishResult` 생성자가 이 규칙을 강제한다. `CONFIRMED`인데 broker acceptance가 없거나,
`AMBIGUOUS`인데 confirmation level을 주장하면 **객체 생성 자체가 실패**한다.
## Ordering
```java
public enum OrderingScope { NONE, DESTINATION, PARTITION, KEY }
```
순서는 partition·key·단일 consumer의 성질이지 destination 전체의 성질이 아니다.
`GLOBAL`이 없는 이유가 이것이다.
`DestinationProfileValidator`가 다음을 거부한다.
- `ordering=KEY`인데 key resolver 없음
- ordered destination인데 `ALLOW_REORDER` retry
- `orderingImpact=PRESERVE`인데 재발행형 retry(`RETRY_DESTINATION`, `BROKER_DELAYED`)
- `ordering=DESTINATION`인데 concurrency > 1
- ordered destination인데 ordering unit당 in-flight > 1
## External side effect
```java
public enum ExternalSideEffectGuarantee { NONE, IDEMPOTENCY_REQUIRED, INBOX_TRANSACTIONAL }
```
`INBOX_TRANSACTIONAL`만이 "DB side effect와 중복 차단이 같은 transaction에서 commit된다"를 의미한다.
Kafka transaction은 **Kafka 안에서만** 원자적이므로 이 값과 함께 설정하면
`KafkaTransactionProfileValidator`가 거부한다. 두 개의 독립적인 commit을 하나로 착각하게 두지 않기 위해서다.
## Consumer settlement 순서
```text
RECEIVED → DECODING → PROCESSING → HANDLER_SUCCEEDED → SETTLEMENT_SENDING
├→ SETTLED
└→ SETTLEMENT_UNKNOWN
```
- handler는 broker ACK API를 호출하지 않는다.
- `Success` 이후에만 source settlement한다.
- `SETTLEMENT_UNKNOWN`은 성공이 아니다. redelivery 가능성을 의미한다.
- `SettlementResult` 생성자가 `SETTLED`인데 `redeliveryPossible=true`인 조합을 거부한다.
+97
View File
@@ -0,0 +1,97 @@
# Experimental 정책
## Stable과 Experimental의 차이
**Stable**은 공통 Contract Suite(`MessagingAdapterContract`)를 변경 없이 통과한 어댑터다.
컴파일되는 어댑터가 아니라, 아래 7가지를 실제로 증명한 어댑터다.
```text
publishesAndConfirms
returnsAmbiguousWhenConfirmIsLost
redeliversWhenSettlementIsLost
preservesMessageIdAcrossRetryAndDlq
keepsSourceUnsettledWhenDlqPublishFails
rejectsOversizedPayloadBeforeTransport
stopsAcceptingNewWorkDuringShutdown
```
**Experimental**은 아직 그 증명이 끝나지 않은 어댑터다.
## 규칙
### 1. 기본 비활성
```yaml
messaging.experimental.kafka-share: false
messaging.experimental.pulsar: false
messaging.experimental.nats: false
```
활성화하지 않으면 validator가 `MessagingCapabilityUnavailableException`을 던진다.
Contract Suite가 아직 증명 중인 어댑터가 누군가의 기본 설정 때문에 load-bearing이 되어서는 안 된다.
### 2. Stable 모듈이 Experimental 모듈에 의존하지 않는다
Gradle 의존 그래프로 강제된다. `messaging-spring-boot-starter``allowed_dependencies`
`messaging-kafka-share-experimental`, `messaging-pulsar-experimental`,
`messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`**없다**.
`verifyCleanArchitectureDependencies`가 위반을 빌드 실패로 만든다.
### 3. Core 계약을 바꾸지 않는다
Experimental 어댑터는 브로커의 차이를 `MessagingCapabilities`로 표현할 뿐,
`messaging-core-api`의 타입을 바꾸지 않는다.
### 4. 없는 기능을 광고하지 않는다
| 어댑터 | 광고하지 않는 것 | 이유 |
|---|---|---|
| Kafka Share Group | orderedStream, keyedOrdering, replay, brokerTransaction | 경쟁 소비자 + 개별 ack는 partition 순서를 유지할 수 없다 |
| Pulsar | brokerTransaction | Pulsar에 있지만 플랫폼 Contract Suite로 증명되지 않았다 |
| Pulsar (Shared) | keyedOrdering | round-robin 분배 |
| NATS JetStream | nativeDeadLetter | delivery limit 초과 시 terminate할 뿐 라우팅하지 않는다 |
| NATS JetStream | keyedOrdering | subject 기반 모델에 per-key 순서가 없다 |
`false`인 capability를 요구하는 profile은 startup에서 실패한다.
조용히 약화되지 않는다.
### 5. 명시적 거부
| 조합 | 결과 |
|---|---|
| Kafka Share Group + ordering != NONE | 거부 |
| Kafka Share Group + pause/resume | `MessagingCapabilityUnavailableException` |
| Pulsar Shared + ordering=KEY | 거부 (Key_Shared 필요) |
| Pulsar + ordering=DESTINATION | 거부 |
| NATS Core + AT_LEAST_ONCE | 거부 (JetStream 필요) |
| NATS ordered consumer + 경쟁 워커 > 1 | 거부 |
| NATS + ordering=KEY | 거부 |
## Spring Cloud Stream bridge
Experimental이 아니라 **Optional**이다. 위험이 다르다.
Stream은 자체 binder 설정을 소유하므로, binding이 destination profile이 모르는
serializer·error handling·acknowledgement mode를 조용히 획득할 수 있다.
따라서 브리지는 **플랫폼 보장에 의존하지 않는 destination에만** 허용한다.
```text
ordering scope 선언 → 거부
retry policy 선언 → 거부
dead letter 선언 → 거부
```
이 셋 중 하나라도 필요하면 native adapter를 쓴다. 거기서만 실제로 강제되기 때문이다.
## 승격 조건
Experimental → Stable로 올리려면 전부 필요하다.
1. `MessagingAdapterContract` 7개 테스트를 변경 없이 통과
2. 장애 주입(연결 끊김, confirm 유실, settlement 유실) 하에서 통과
3. 지원 브로커 버전 범위 명시 및 CI 검증
4. `support-matrix.md`의 capability 표 갱신
5. ADR 작성
6. 기본 활성화 여부에 대한 별도 결정
+113
View File
@@ -0,0 +1,113 @@
# 마이그레이션 가이드
## 기존 Spring Kafka / Spring AMQP 코드에서
### 1. topic 이름을 코드에서 제거한다
```java
// before
kafkaTemplate.send("order.events.v1", key, payload);
// after
publisher.publish(orderEvents, envelope, PublishOptions.defaults());
```
`MessageDestination`은 logical name만 가진다. 물리 매핑은 destination profile이 소유한다.
`DestinationName`의 패턴이 `topic://orders` 같은 값을 거부하므로 우회할 수 없다.
### 2. boolean 성공 판정을 없앤다
```java
// before
try { template.send(...).get(); success(); }
catch (Exception e) { fail(); } // REJECTED와 AMBIGUOUS를 구분하지 못한다
// after
PublishResult result = ...;
switch (result.completion()) {
case CONFIRMED -> success();
case REJECTED -> abandon(); // broker가 저장하지 않음이 확정
case AMBIGUOUS -> retrySameMessageId(result); // broker가 가지고 있을 수 있음
}
```
이 구분이 없으면 confirm 유실 한 번이 중복 주문 하나가 된다.
### 3. auto-commit / auto-ack를 끈다
```yaml
# Kafka
enable.auto.commit: false
# RabbitMQ
auto-ack: false
```
둘 다 validator가 강제로 거부한다. 타이머 기반 commit은 handler가 실행되기도 전에
메시지를 처리 완료로 표시한다.
### 4. handler에서 ack 호출을 제거한다
```java
// before
@KafkaListener(...)
void handle(ConsumerRecord<?,?> record, Acknowledgment ack) {
process(record);
ack.acknowledge(); // 실패 시 순서가 애매해진다
}
// after
CompletionStage<HandleResult> handle(MessageDelivery<OrderCreated> delivery) {
process(delivery.message().payload());
return completedFuture(HandleResult.success());
}
```
settlement는 플랫폼이 수행한다. "성공한 뒤에만 ack"가 각 handler의 기억이 아니라
플랫폼 불변식이 된다.
### 5. 중복을 정상 상황으로 다룬다
at-least-once는 중복을 전제한다. 세 가지 중 하나를 고른다.
| 방식 | 언제 |
|---|---|
| handler 자체 멱등 | 자연 멱등 연산 (upsert 등) |
| Inbox | DB side effect가 있는 경우 |
| Kafka transaction | Kafka → Kafka 파이프라인만 |
`ExternalSideEffectGuarantee`에 선언한다. `INBOX_TRANSACTIONAL`과 Kafka transaction을
동시에 설정하면 거부된다. Kafka transaction은 DB를 포함하지 않는다.
### 6. 큰 payload는 Claim Check로
broker frame 크기를 키우지 않는다. broker 메모리, replication latency,
consumer recovery가 동시에 나빠지고, 유계·검증 가능한 실패가 무계 실패로 바뀐다.
1 MiB 초과는 외부 저장소로 offload하고 digest를 포함한 참조만 발행한다.
## DB 마이그레이션
```text
V1__messaging_outbox.sql
V2__messaging_inbox.sql
```
Outbox row는 business transaction과 같은 transaction에서 쓴다.
Inbox reservation은 handler side effect와 같은 transaction에서 쓴다.
별도 transaction이면 각 패턴이 닫으려던 창이 그대로 열려 있다.
## 단계적 전환
1. **publish만 전환** — 기존 consumer는 그대로. wire format은 reserved header가 추가될 뿐이다.
2. **Outbox 도입** — publish 유실 창을 닫는다.
3. **consume 전환** — handler를 `MessageHandler`로 옮기고 ack 호출을 제거한다.
4. **Inbox 도입** — 중복 side effect를 닫는다.
5. **retry·DLQ 정책 선언** — 이 시점까지 자동 retry는 0회다.
각 단계는 독립적으로 배포 가능하고, 되돌릴 수 있다.
## 되돌릴 수 없는 것
- 한 번 발행된 message type의 wire contract
- 이미 retention 안에 있는 메시지의 schema
- redrive된 메시지의 `messageId` (바뀌지 않는다 — 이것이 의도다)
+113
View File
@@ -0,0 +1,113 @@
# 운영 Runbook
## 배포 전 체크
```bash
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyRuntimeModuleMembership --console=plain
./gradlew checkstyleMain --console=plain
```
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
- ordered destination + reorder 가능 retry
- `ordering=KEY` + key resolver 없음
- payload 상한 > 8,388,608 bytes
- DLQ 자기 참조 / retry 자기 참조
- retry·DLQ 그래프 cycle
- 미등록 retry·DLQ destination
- M1 destination + manual settlement
- `AT_LEAST_ONCE` + confirmation `NONE`
- production profile + topology auto-create
- broker topology가 manifest와 불일치
## 증상별 대응
### publish가 AMBIGUOUS로 쏟아진다
broker confirm 경로 문제다. 실패가 아니다.
1. `PublishEvidence.transmission``MAY_HAVE_BEEN_TRANSMITTED`인지 확인
2. Kafka: `delivery.timeout.ms`, ISR 상태, leader election 확인
3. Rabbit: confirm timeout, channel 상태 확인
4. Outbox를 쓰고 있다면 `status='AMBIGUOUS'` row가 같은 messageId로 재시도 중이다. **정상이다.**
5. consumer 쪽 Inbox가 중복을 흡수하는지 확인
`AMBIGUOUS`를 실패로 취급해 새 messageId로 재발행하지 말 것. 중복이 복구 불가능해진다.
### DLQ가 비어 있는데 메시지가 사라졌다
DLQ publish 실패 시 source는 settlement되지 않는다. 메시지는 source에 남아 재전달된다.
1. `msg.failure-code``DEAD_LETTER_*`인 로그 확인
2. DLQ destination이 실제로 존재하는지 (topology validation)
3. DLQ credential에 publish 권한이 있는지
### consumer lag이 한 partition에서만 증가한다
`ContiguousPartitionOffsetTracker`가 gap에서 멈춘 것이다. 설계된 동작이다.
commit은 **연속** 완료 offset까지만 전진한다. offset 11이 아직 실행 중이면
10과 12가 끝나도 watermark는 10에 머문다. 12를 commit하면 consumer가 죽었을 때 11을 잃는다.
1. 해당 partition의 in-flight를 확인
2. 느린 handler를 찾는다 (`handlerTimeout` 초과 여부)
3. 필요하면 `PAUSE_PARTITION` retry가 걸려 있는지 확인
### 재시도 폭풍
`RetryPolicy.jitter=false`인지 확인한다. jitter 없이는 같은 초에 실패한 모든 consumer가
같은 초에 재시도한다.
### shutdown이 오래 걸린다
`GracefulShutdownCoordinator`가 in-flight를 기다리는 중이다.
- `inFlight()`가 0이 되면 즉시 종료
- drain deadline(기본 30초) 초과 시 남은 작업을 **unsettled로 포기**한다 → broker가 재전달
- draining 시작 후 새 retry attempt는 만들지 않는다
## Destructive 작업
전부 `DestructiveOperationGuard`를 통과해야 한다.
| 조건 | 요구 |
|---|---|
| admin credential | application runtime은 보유하지 않음 |
| `AdminApproval` | 유효기간 내 |
| dry-run | 항상 허용 |
### Replay
```text
기본: 격리된 consumer group (replay-<requestId>)
기존 group 대상: 승인 티켓 필수
```
기존 production group으로 replay하는 것은 "다시 읽기"가 아니라 **live consumer를 되감는 것**이다.
그 사이의 모든 것이 재처리된다.
### Redrive
```text
dry-run으로 후보 수 확인
→ 승인 획득
→ batch 100건 이하로 실행
→ republish CONFIRMED 인 것만 DLQ에서 settlement
```
`redriveId`로 재구동 루프를 추적한다. 같은 메시지가 반복해서 redrive되면
근본 원인이 해결되지 않은 것이다.
### Offset reset
`KafkaOffsetResetExecutor`는 승인 predicate를 **생성자 인자**로 받는다.
승인 소스 없이 조립된 runtime은 물리적으로 reset을 수행할 수 없다.
## Topology
production topology는 IaC가 만들고 애플리케이션은 **검증만** 한다.
`TopologyValidationRuntime`은 모든 불일치를 한 번에 보고하고 startup을 실패시킨다.
partition 수가 다르면 destination이 광고하는 ordering 보장이 달라지고,
`min.insync.replicas`가 없으면 `acks=all`의 의미가 달라진다.
+112
View File
@@ -0,0 +1,112 @@
# Outbox · Inbox
## 두 패턴이 각각 무엇을 해결하는가
| 패턴 | 해결하는 문제 | 해결하지 않는 문제 |
|---|---|---|
| Transactional Outbox | DB commit과 publish 사이의 창(窓) | 중복 |
| Inbox | 중복 delivery의 side effect | 유실 |
**둘 다 필요하다.** Outbox만으로는 exactly-once가 되지 않는다.
## Outbox
business transaction과 **같은 transaction**에서 row를 쓴다. 둘 다 commit되거나 둘 다 안 된다.
```sql
BEGIN;
UPDATE orders SET status = 'PLACED' WHERE id = ?;
INSERT INTO messaging_outbox (message_id, destination, ...) VALUES (?, ?, ...);
COMMIT;
```
### relay
```text
leaseBatch(100, 30s) -- lease로 다중 relay 인스턴스 안전
→ publish (messageId 그대로)
→ CONFIRMED → markPublished
→ AMBIGUOUS → markAmbiguous (같은 messageId로 재시도 가능)
→ REJECTED → markFailed
```
### 핵심 규칙: ambiguous는 같은 messageId로 재시도
새 id를 발급하면 "전달됐을 수도 있는 메시지"가 "확실히 두 번째인 메시지"가 되어
downstream의 어떤 중복 제거도 복구할 수 없다.
failed로 표시하면 broker가 이미 가지고 있을 수 있는 메시지를 잃는다.
`message_id`를 primary key로 둔 것도 같은 이유다. 어떤 코드 경로도 실수로 새 id를 붙일 수 없다.
### lease
```text
status IN ('PENDING','AMBIGUOUS','IN_FLIGHT')
AND (lease_expires_at IS NULL OR lease_expires_at <= now)
AND next_attempt_at <= now
AND attempts < maxAttempts
```
`IN_FLIGHT`가 목록에 있는 것이 핵심이다. relay가 publish 도중 죽으면 row는 `IN_FLIGHT`로 남는데,
이를 제외하면 그 메시지는 **영원히** 발행되지 않는다 — outbox가 막으려던 바로 그 실패다. 대신
lease가 만료됐을 때만 회수하므로, 살아 있는 relay가 들고 있는 row는 회수되지 않는다.
회수는 **같은 `message_id`로** 이루어지고 `lease_token`이 1 증가한다. 새 id를 발급하면 "전달됐을
수도 있는 메시지"가 "확실히 두 번째"가 되기 때문이다 (위의 AMBIGUOUS 논의와 같은 이유).
이 문단의 근거는 실제 PostgreSQL 컨테이너 레인이다:
- `OutboxPostgresIT#anExpiredLeaseBecomesClaimableAgain` — 만료된 lease의 재회수
- `OutboxPostgresIT#anExpiryReclaimKeepsTheMessageIdAndAdvancesTheToken` — 같은 id, 증가한 token
- `OutboxPostgresIT#aLeasedRowIsInvisibleToASecondRelayInstance` — 살아 있는 lease는 회수 불가
- `OutboxPostgresIT#aSupersededRelayCannotOverwriteTheOutcomeOfTheOneThatReplacedIt` — fencing
partial index `ix_messaging_outbox_claimable`이 이 쿼리를 backlog 크기에 비례하게 유지한다.
PUBLISHED row는 retention job이 지울 때까지 쌓이기 때문이다.
## Inbox
reservation과 side effect가 **같은 transaction**이어야 한다.
```java
transactions.inTransaction(() -> {
if (!inbox.reserve(messageId, consumerId, now)) {
return InboxOutcome.duplicate(); // 이미 처리됨
}
return InboxOutcome.processed(sideEffect.get());
});
```
별도 transaction으로 예약하면 Inbox가 닫으려던 바로 그 창이 다시 열린다.
### 복합 키
`PRIMARY KEY (message_id, consumer_id)`.
message_id만으로 중복 제거하면 같은 event를 소비하는 두 번째 consumer가
첫 번째에 의해 억제된다. 각 consumer가 한 번씩 처리해야 한다.
### retention
broker의 최대 redelivery window보다 **길어야** 한다.
row를 먼저 지우면 늦게 도착한 redelivery가 두 번 처리된다.
## Debezium CDC 대안
polling relay 대신 WAL을 읽는다. polling interval과 lease 경합이 사라지지만
인프라와 그 자체의 실패 모드가 추가된다.
wire contract는 동일하다. `DebeziumOutboxEventRouter`가 polling relay와 같은 reserved header를
방출하므로 consumer는 어느 쪽이 발행했는지 구분할 수 없고, 전환은 배포 결정일 뿐 계약 변경이 아니다.
## Claim Check
1 MiB 초과 payload는 broker 프레임을 키우지 않고 외부 저장소로 offload한다.
`ClaimCheckReference`는 digest를 **필수**로 가진다. claim check는 메시지를 서로 다른 retention과
replication을 가진 두 시스템으로 쪼개므로, consumer는 producer가 저장한 바로 그 bytes를 받았음을
증명할 수 있어야 한다. 그렇지 않으면 잘린 객체와 정상 객체를 구분할 수 없다.
`ClaimCheckIntegrityGuard`는 fetch 전에 만료를, fetch 후에 크기와 digest를 검사한다.
digest 불일치는 `DESERIALIZATION`이 아니라 **validation** 실패로 분류한다.
bytes가 깨진 JSON인 게 아니라, 틀린 bytes이기 때문이다.

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