docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,97 @@
---
kind: CASE
slug: grpc-admin-f01
title: rejectNewAdmission() 이 단계만 기록하고 아무것도 거절하지 않는다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-admin-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-admin-f01
file: ../../../final/evidence/rendered/grpc-admin-f01.svg
- key: grpc-admin-f01-diagram
file: ../../../final/assets/diagrams/grpc-admin-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-admin-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-admin.md#L131 이다.
module: grpc-admin
priority: P2
---
# rejectNewAdmission() 이 단계만 기록하고 아무것도 거절하지 않는다
javadoc 은 "Starts refusing new calls" 라고 적는다. 실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.
## 문제
javadoc 은 "Starts refusing new calls" 라고 적는다.
실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.
## 결론
조정자는 GrpcAdmissionController 를 협력자로 들고 있는데, 그것을 쓰는 곳은 inFlightAdmitted() 의 조회 하나다.
그리고 승인 제어기의 공개 표면에 승인을 멈추는 메서드가 없다.
close·drain·refuseNew 에 해당하는 것이 없다.
그러므로 배수가 시작된 뒤에도 tryAdmit() 은 용량이 남아 있는 한 계속 승인한다.
admittingNewCalls() 은 그 사실과 무관하게 거짓을 돌려준다 — 표식을 읽기 때문이다.
운영자나 상위 코드가 이 값을 보고 "더 이상 받지 않는다" 고 읽으면 틀린 답을 얻는다.
배수 테스트가 단언하는 것은 admittingNewCalls() 의 값이고, 단계 이후에 tryAdmit() 이 거절되는지는 어느 테스트도 묻지 않는다.
승인 제어기에 승인 중단 상태를 두고(stopAdmitting() 과 그것을 보는 tryAdmit), 조정자의 rejectNewAdmission 이 그것을 부르게 한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcAdmissionController 참조 16건 전수 검색과 두 클래스의 공개 표면 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-admin.md#L131 에 있다.
## 본문
<!-- body:start -->
javadoc 은 "Starts refusing new calls" 라고 적는다. 실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.
## 승인 제어기에 없는 것
:::evidence key="grpc-admin-f01-diagram" alt="tryAdmit 과 inFlightAdmitted 가 승인 제어기 공개 표면 안에 놓이고 승인 중단 메서드가 바깥에 빗금으로 놓인다" caption="승인 제어기에 없는 것" zoom="false"
:::
조정자는 `GrpcAdmissionController` 를 협력자로 들고 있는데, 그것을 쓰는 곳은 `inFlightAdmitted()` 의 조회 하나다. 그리고 승인 제어기의 공개 표면에 승인을 멈추는 메서드가 없다 — `close`·`drain`·`refuseNew` 에 해당하는 것이 없다.
## GrpcAdmissionController 참조 위치
:::evidence key="grpc-admin-f01" alt="코드베이스에서 GrpcAdmissionController 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcAdmissionController 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 배수 이후에도 tryAdmit 은 계속 승인한다
용량이 남아 있는 한 그렇다. `admittingNewCalls()` 은 그 사실과 무관하게 거짓을 돌려준다 — 표식을 읽기 때문이다. 운영자나 상위 코드가 이 값을 보고 "더 이상 받지 않는다" 고 읽으면 틀린 답을 얻는다.
## 테스트가 묻지 않는 것
배수 테스트가 단언하는 것은 `admittingNewCalls()` 의 값이고, 단계 이후에 `tryAdmit()` 이 거절되는지는 어느 테스트도 묻지 않는다.
## 수정
승인 제어기에 승인 중단 상태를 두고(`stopAdmitting()` 과 그것을 보는 `tryAdmit`), 조정자의 `rejectNewAdmission` 이 그것을 부르게 한다. 지금 형태에서는 배수 순서를 지키는 장치가 순서 표식만 갖고 있다.
## 확인하지 못한 것
배수 중에 GrpcAdmissionController 를 호출해 이 결함을 재현하지 않았다. 조정자와 제어기의 공개 표면에 승인을 멈추는 메서드가 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: grpc-advanced-bootstrap-f01
title: 등급 재정의에 하한이 없어 "켤 수 없다" 는 등급이 켜질 수 있다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-bootstrap-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-f01
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L153 이다.
module: grpc-advanced-bootstrap
priority: P3
---
# 등급 재정의에 하한이 없어 "켤 수 없다" 는 등급이 켜질 수 있다
GrpcCapabilityGrade 의 javadoc 이 두 등급을 단정한다. 그런데 등급은 런타임에 갈아끼울 수 있다.
## 문제
GrpcCapabilityGrade 의 javadoc 이 두 등급을 단정한다.
그런데 등급은 런타임에 갈아끼울 수 있다.
## 결론
withGrade(EDITION_2026, ADVANCED_STABLE).enable(EDITION_2026) 이면 가드의 두 번째 조건이 통과한다.
등급 올리기 자체는 의도된 기능이다 — 테스트 a deployment may raise a capability's grade on its own evidence 가 HEDGING(EXPERIMENTAL)을 ADVANCED_STABLE 로 올린다.
문제는 그 재정의에 하한이 없다는 것이다.
EXPERIMENTAL 을 올리는 것은 "실패 양식이 충분히 규명되지 않은 것을 감수한다" 는 판단이고 배포가 자기 증거로 내릴 수 있다.
WATCH 를 올리는 것은 다르다.
그 등급의 뜻이 "추적할 뿐 구현되지 않았다" 이므로 배포가 가질 자기 증거가 없다.
그리고 승격 게이트는 WATCH 가 EXPERIMENTAL 을 먼저 거쳐야 한다는 규칙을 갖는데, 런타임 재정의는 그 게이트를 지나지 않는다.
같은 리프 안에 문이 둘이고 증거 규칙은 한쪽에만 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcCapabilityGrade 참조 9건 검색과 등급 재정의 경로에 하한 가드가 있는지 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-bootstrap.md#L153 에 있다.
## 본문
<!-- body:start -->
`GrpcCapabilityGrade` 의 javadoc 이 두 등급을 단정한다. 그런데 등급은 런타임에 갈아끼울 수 있다 — `withGrade(EDITION_2026, ADVANCED_STABLE).enable(EDITION_2026)` 이면 가드의 두 번째 조건이 통과한다.
## GrpcCapabilityGrade 참조 위치
:::evidence key="grpc-advanced-bootstrap-f01" alt="코드베이스에서 GrpcCapabilityGrade 를 검색한 출력 9줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcCapabilityGrade 코드베이스 검색 — 9줄 · exit 0" zoom="true"
:::
## 등급 올리기 자체는 의도된 기능이다
테스트 `a deployment may raise a capability's grade on its own evidence``HEDGING`(EXPERIMENTAL)을 `ADVANCED_STABLE` 로 올린다.
## 문제는 그 재정의에 하한이 없다는 것이다
`EXPERIMENTAL` 을 올리는 것은 "실패 양식이 충분히 규명되지 않은 것을 감수한다" 는 판단이고 배포가 자기 증거로 내릴 수 있다. `WATCH` 를 올리는 것은 다르다 — 그 등급의 뜻이 "추적할 뿐 구현되지 않았다" 이므로 배포가 가질 자기 증거가 없다.
## 문이 둘이고 증거 규칙은 한쪽에만 있다
승격 게이트는 `WATCH``EXPERIMENTAL` 을 먼저 거쳐야 한다는 규칙을 갖는데, 런타임 재정의는 그 게이트를 지나지 않는다. 수정은 `withGrade` 가 현재 등급이 `startable()` 인 능력에만 적용되게 하거나, `WATCH`·`DISABLED` 에서 올리는 재정의를 거부하는 것이다.
## 확인하지 못한 것
등급 재정의로 그 능력을 켜는 것을 실행으로 재현하지 않았다. 코드 경로로 판정했다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: grpc-advanced-bootstrap-f04
title: capabilitiesDraggedAlong 은 독립성을 증명하지 않는다. 상수를 상수와 비교한다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-bootstrap-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-f04
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-f04.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-f04.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L286 이다.
module: grpc-advanced-bootstrap
priority: P3
---
# capabilitiesDraggedAlong 은 독립성을 증명하지 않는다. 상수를 상수와 비교한다
javadoc 이 스스로 밝히듯 본문은 무조건 빈 목록이다. 그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다 — 증거를 능력마다 따로 기록했다는 §4 의 설계 속성과는 아무 연결이 없다.
## 문제
javadoc 이 스스로 밝히듯 본문은 무조건 빈 목록이다.
그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다 — 증거를 능력마다 따로 기록했다는 §4 의 설계 속성과는 아무 연결이 없다.
## 결론
설계가 무너져 apply 가 다른 능력의 등급을 바꾸게 되어도 이 메서드는 여전히 빈 목록을 돌려준다.
진짜 증거는 같은 테스트의 다른 줄에 있다.
승격을 실제로 적용하고 다른 능력의 등급이 그대로임을 확인한다.
이쪽은 설계가 무너지면 깨진다.
앞선 판에서 이 메서드를 "주석이 주장하는 대신 테스트가 붙든다"는 확인된 설계로 분류했다.
다시 읽으니 붙드는 것은 옆줄이고, 이 메서드는 그 옆줄이 있다는 사실을 가린다.
메서드를 지우고 단언을 매트릭스 비교 쪽으로 남긴다.
남겨 둔다면 실제로 매트릭스를 훑어 등급이 바뀐 다른 능력을 돌려주게 만든다 — 그때 비로소 이름이 하는 말과 본문이 맞는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : capabilitiesDraggedAlong 본문이 돌려주는 값과 그것을 단언하는 테스트의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-bootstrap.md#L286 에 있다.
## 본문
<!-- body:start -->
javadoc 이 스스로 밝히듯 `capabilitiesDraggedAlong` 의 본문은 무조건 빈 목록이다. 그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다.
## 본문이 무조건 빈 목록이다
:::evidence key="grpc-advanced-bootstrap-f04" alt="분석 문서 analysis/grpc/grpc-advanced-bootstrap.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-advanced-bootstrap.md 발췌 — 15줄" zoom="true"
:::
## 설계 속성과 연결이 없다
증거를 능력마다 따로 기록했다는 §4 의 설계 속성과 아무 연결이 없다. 설계가 무너져 `apply` 가 다른 능력의 등급을 바꾸게 되어도 이 메서드는 여전히 빈 목록을 돌려준다.
## 진짜 증거는 같은 테스트의 다른 줄에 있다
승격을 실제로 적용하고 다른 능력의 등급이 그대로임을 확인한다. 이쪽은 설계가 무너지면 깨진다.
## 앞선 판의 분류를 정정한다
앞선 판에서 이 메서드를 "주석이 주장하는 대신 테스트가 붙든다"는 확인된 설계로 분류했다. 다시 읽으니 붙드는 것은 옆줄이고, 이 메서드는 그 옆줄이 있다는 사실을 가린다. 메서드를 지우고 단언을 매트릭스 비교 쪽으로 남긴다. 남겨 둔다면 실제로 매트릭스를 훑어 등급이 바뀐 다른 능력을 돌려주게 만든다.
## 확인하지 못한 것
테스트를 실행하지 않았다. 본문이 무조건 빈 목록을 돌려주고 단언이 그 리터럴을 비교한다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,76 @@
---
kind: CASE
slug: grpc-advanced-compat-f02
title: 반응형 표면 두 타입은 테스트조차 없다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-compat-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-compat-f02
file: ../../../final/evidence/rendered/grpc-advanced-compat-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-compat-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-compat.md#L149 이다.
module: grpc-advanced-compat
priority: P3
---
# 반응형 표면 두 타입은 테스트조차 없다
이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 GrpcReactorCancellationBridge 는 2개 파일, GrpcReactorContextBridge 는 4개 파일에 등장한다. 두 타입은 채택자가 부를 표면이므로 production 참조 0 이 설계와 모순되지는 않는다.
## 문제
이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 GrpcReactorCancellationBridge 는 2개 파일, GrpcReactorContextBridge 는 4개 파일에 등장한다.
두 타입은 채택자가 부를 표면이므로 production 참조 0 이 설계와 모순되지는 않는다.
## 결론
어긋나는 것은 검증이다.
채택자용 표면이면 그 계약이 무엇인지를 테스트가 붙들어야 하고, 이 가족은 다른 곳에서 정확히 그렇게 한다.
ReactiveGrpcClient 의 javadoc 이 "Exposes a unary call as a Mono and a server stream as a Flux" 라고 적는데, 그 사상이 취소와 배압에서 어떻게 동작하는지는 어디에서도 확인되지 않는다.
같은 리프의 GrpcReactorCancellationBridge 가 취소 전파를 다루므로 둘을 함께 검증할 자리가 이미 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcReactorCancellationBridge 참조 6건 검색과 같은 패키지 형제 타입의 테스트 등장 수 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-compat.md#L149 에 있다.
## 본문
<!-- body:start -->
이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 `GrpcReactorCancellationBridge` 는 2개 파일, `GrpcReactorContextBridge` 는 4개 파일에 등장한다.
## GrpcReactorCancellationBridge 참조 위치
:::evidence key="grpc-advanced-compat-f02" alt="코드베이스에서 GrpcReactorCancellationBridge 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcReactorCancellationBridge 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## production 참조 0 은 설계와 모순되지 않는다
두 타입은 채택자가 부를 표면이다.
## 어긋나는 것은 검증이다
채택자용 표면이면 그 계약이 무엇인지를 테스트가 붙들어야 하고, 이 가족은 다른 곳에서 정확히 그렇게 한다. `ReactiveGrpcClient` 의 javadoc 이 "Exposes a unary call as a `Mono` and a server stream as a `Flux`" 라고 적는데, 그 사상이 취소와 배압에서 어떻게 동작하는지는 어디에서도 확인되지 않는다. 같은 리프의 `GrpcReactorCancellationBridge` 가 취소 전파를 다루므로 둘을 함께 검증할 자리가 이미 있다.
## 확인하지 못한 것
두 타입이 채택자 쪽에서 실제로 동작하는지 확인하지 않았다. 코틀린 툴체인이 없어 코틀린 계약 넷은 실행으로 확인할 수 없다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: grpc-advanced-compat-f03
title: 저장소가 참조 프록시 설정을 갖고 있는데, 그것을 판정할 코드에 넣지 않는다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-compat-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-compat-f03
file: ../../../final/evidence/rendered/grpc-advanced-compat-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-compat-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-compat.md#L162 이다.
module: grpc-advanced-compat
priority: P3
---
# 저장소가 참조 프록시 설정을 갖고 있는데, 그것을 판정할 코드에 넣지 않는다
이 리프에는 두 가지가 함께 있다. GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins) — 프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드.
## 문제
이 리프에는 두 가지가 함께 있다.
GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins) — 프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드.
## 결론
src/main/resources/envoy/envoy.yaml — 그 설정의 참조 구현.
그리고 설정 파일 자신이 그 관계를 주장한다.
GrpcWebProxyContract 는 이 파일에 대해 아무것도 단언하지 않는다.
판정기는 시험에서 리터럴 집합을 받고, 참조 설정은 시험에서 문자열 포함으로만 확인된다.
그래서 참조 설정이 grpc-status 를 노출하는지는 문자열이 확인하고, 그 노출이 충분한지 는 requiredExposedHeaders() 가 정의하는데, 둘을 잇는 코드가 없다.
필수 트레일러 목록이 늘어나면 판정기는 새 항목을 요구하고 참조 설정은 옛 문자열로 계속 통과한다.
이 리프의 다른 판정기들과 다른 점은 재료가 이미 저장소에 있다는 것이다 — grpc-testkit §17.5·grpc-server §17.1 은 스캔할 대상 자체를 만들어야 하지만, 여기서는 파일 하나를 파싱하면 된다.
수정은 시험이 envoy.yaml 의 expose_headers 와 allow_origin(exact:)을 뽑아 GrpcWebProxyContract.violations 에 넣고 비어 있음을 단언하는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcWebProxyContract 참조 10건 검색과 저장소의 참조 프록시 설정 위치 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-compat.md#L162 에 있다.
## 본문
<!-- body:start -->
이 리프에는 두 가지가 함께 있다 — `GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins)`(프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드)와 `src/main/resources/envoy/envoy.yaml`(그 설정의 참조 구현). 설정 파일 자신이 그 관계를 주장한다.
## GrpcWebProxyContract 참조 위치
:::evidence key="grpc-advanced-compat-f03" alt="코드베이스에서 GrpcWebProxyContract 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcWebProxyContract 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 둘을 잇는 코드가 없다
판정기는 시험에서 리터럴 집합을 받고, 참조 설정은 시험에서 문자열 포함으로만 확인된다. 참조 설정이 `grpc-status` 를 노출하는지는 문자열이 확인하고, 그 노출이 **충분한지**`requiredExposedHeaders()` 가 정의하는데, 둘을 잇는 코드가 없다. 필수 트레일러 목록이 늘어나면 판정기는 새 항목을 요구하고 참조 설정은 옛 문자열로 계속 통과한다.
## 다른 판정기들과 다른 점
재료가 이미 저장소에 있다 — grpc-testkit §17.5·grpc-server §17.1 은 스캔할 대상 자체를 만들어야 하지만, 여기서는 파일 하나를 파싱하면 된다. 수정은 시험이 `envoy.yaml``expose_headers``allow_origin`(`exact:`)을 뽑아 `GrpcWebProxyContract.violations` 에 넣고 비어 있음을 단언하는 것이다.
## 확인하지 못한 것
참조 프록시 설정을 실제 판정 코드에 넣어 결과를 보지 않았다. 실제 브라우저·프록시로 어떤 다리도 돌리지 않았다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: grpc-codegen-f01
title: Buf 수명주기 태스크 목록이 빌드와 대조되지 않는다. 테스트는 목록을 자기 자신과 비교한다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-codegen-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-codegen-f01
file: ../../../final/evidence/rendered/grpc-codegen-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-codegen-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-codegen.md#L190 이다.
module: grpc-codegen
priority: P3
---
# Buf 수명주기 태스크 목록이 빌드와 대조되지 않는다. 테스트는 목록을 자기 자신과 비교한다
정책이 네 태스크 이름을 담고, javadoc 이 그 이유를 적는다. 그런데 그 네 이름은 저장소의 어떤 빌드 파일에도 없다.
## 문제
정책이 네 태스크 이름을 담고, javadoc 이 그 이유를 적는다.
그런데 그 네 이름은 저장소의 어떤 빌드 파일에도 없다.
## 결론
그리고 테스트가 비교하는 대상이 실제 등록 태스크 집합이 아니다.
첫 단언은 목록을 리터럴과, 셋째는 목록을 자기 자신과 비교한다.
어느 것도 빌드가 그 단계를 등록했는지 묻지 않는다.
Buf CLI 가 이 툴체인에 없다는 것은 build.gradle 이 이미 밝힌 사실이므로 태스크가 없는 것 자체는 놀랍지 않다.
어긋난 것은 javadoc 의 주장이다 — 지금 형태에서 단계가 사라져도 테스트는 초록이다.
수정은 missingTasks 에 Gradle 이 실제로 등록한 태스크 이름 집합을 넣는 검사를 만들거나(다른 가족의 레인 등록 검사와 같은 형태), CLI 가 없는 동안에는 그 문장을 "CI 환경이 채울 계약" 으로 낮추는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 정책이 담은 네 태스크 이름을 빌드 파일 전수에서 검색하고, 테스트가 비교하는 대상 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-codegen.md#L190 에 있다.
## 본문
<!-- body:start -->
정책이 네 태스크 이름을 담고, javadoc 이 그 이유를 적는다. 그런데 그 네 이름은 저장소의 어떤 빌드 파일에도 없다.
## 정책이 담은 네 태스크 이름
:::evidence key="grpc-codegen-f01" alt="분석 문서 analysis/grpc/grpc-codegen.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-codegen.md 발췌 — 15줄" zoom="true"
:::
## 테스트가 비교하는 대상이 등록 태스크 집합이 아니다
첫 단언은 목록을 리터럴과, 셋째는 목록을 자기 자신과 비교한다. 어느 것도 빌드가 그 단계를 등록했는지 묻지 않는다.
## 태스크가 없는 것 자체는 놀랍지 않다
Buf CLI 가 이 툴체인에 없다는 것은 build.gradle 이 이미 밝힌 사실이다. 어긋난 것은 javadoc 의 주장이다 — 지금 형태에서 단계가 사라져도 테스트는 초록이다. 수정은 `missingTasks` 에 Gradle 이 실제로 등록한 태스크 이름 집합을 넣는 검사를 만들거나, CLI 가 없는 동안에는 그 문장을 "CI 환경이 채울 계약" 으로 낮추는 것이다.
## 확인하지 못한 것
실제 protoc 이나 Buf CLI 를 돌리지 않았다. 저장소에 둘 다 없다. 리플렉션이나 서비스 로더로 부르는 형태는 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-codegen-f04
title: sha256: 검사가 길이 15자 이상만 요구한다. 저장소 자신의 테스트가 32자 해시를 통과시킨다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-codegen-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-codegen-f04
file: ../../../final/evidence/rendered/grpc-codegen-f04.svg
evidence:
- ../../../final/evidence/raw/grpc-codegen-f04.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-codegen.md#L287 이다.
module: grpc-codegen
priority: P3
---
# sha256: 검사가 길이 15자 이상만 요구한다. 저장소 자신의 테스트가 32자 해시를 통과시킨다
같은 검사가 두 곳에 손으로 복사돼 있다. "sha256:" 이 7자이므로 뒤에 8자만 있으면 통과한다.
## 문제
같은 검사가 두 곳에 손으로 복사돼 있다.
"sha256:" 이 7자이므로 뒤에 8자만 있으면 통과한다.
## 결론
sha256 digest 는 hex 64자다.
그리고 이 헐거움이 테스트에 이미 드러나 있다.
32자 — sha256 이 아니다.
여기서는 "다른 해시" 역할이라 결과가 바뀌지 않지만, 형식 검사가 이런 값을 유효한 해시로 받는다는 사실 자체가 이 값 객체의 주장("the hashes that prove which bytes it was built from")을 약하게 만든다.
sha256: 뒤 64자 hex 를 정규식으로 요구하고, 검사를 한 곳에 둔다 — 두 record 가 같은 규칙을 각자 적고 있는 지금 형태에서는 한쪽만 조여도 다른 쪽이 남는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 두 곳에 복사된 sha256 검사의 길이 조건과 저장소 테스트의 해시 리터럴 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-codegen.md#L287 에 있다.
## 본문
<!-- body:start -->
같은 검사가 두 곳에 손으로 복사돼 있다. `"sha256:"` 이 7자이므로 뒤에 8자만 있으면 통과한다. sha256 digest 는 hex 64자다.
## 길이 검사가 요구하는 최소
:::evidence key="grpc-codegen-f04" alt="분석 문서 analysis/grpc/grpc-codegen.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-codegen.md 발췌 — 15줄" zoom="true"
:::
## 헐거움이 테스트에 이미 드러나 있다
테스트가 쓰는 값이 32자 — sha256 이 아니다. 여기서는 "다른 해시" 역할이라 결과가 바뀌지 않지만, 형식 검사가 이런 값을 유효한 해시로 받는다는 사실 자체가 이 값 객체의 주장("the hashes that prove which bytes it was built from")을 약하게 만든다.
## 수정
`sha256:` 뒤 64자 hex 를 정규식으로 요구하고, 검사를 한 곳에 둔다 — 두 record 가 같은 규칙을 각자 적고 있는 지금 형태에서는 한쪽만 조여도 다른 쪽이 남는다.
## 확인하지 못한 것
짧은 해시를 실제 발행 경로에 넣어 통과를 관측하지 않았다. 길이 조건과 테스트 리터럴의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,86 @@
---
kind: CASE
slug: grpc-core-api-f02
title: 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-core-api-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-core-api-f02
file: ../../../final/evidence/rendered/grpc-core-api-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-core-api-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-core-api.md#L157 이다.
module: grpc-core-api
priority: P3
---
# 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다
클래스 javadoc 이 두 SSOT 의 관계를 적는다. 그 테스트는 레지스트리를 읽지 않는다.
## 문제
클래스 javadoc 이 두 SSOT 의 관계를 적는다.
그 테스트는 레지스트리를 읽지 않는다.
## 결론
다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다.
modules.json 을 읽는 줄도, 파일 경로도 없다.
두 목록은 오늘 일치한다 — 레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다.
어긋난 것은 그 일치를 무엇이 지키는가다.
같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다 — 정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다, 세는 순간 다시 표류한다.
수정은 테스트가 modules.json 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다.
그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 테스트가 읽는 입력과 클래스 javadoc 이 선언한 두 SSOT 의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-core-api.md#L157 에 있다.
## 본문
<!-- body:start -->
클래스 javadoc 이 두 SSOT 의 관계를 적는다 — 레지스트리(`src/config/architecture/modules.json`)가 어떤 Gradle 프로젝트가 존재하는지의 SSOT 이고, 이 목록은 그중 Stable 계약이 덮는 것의 SSOT 다.
## javadoc 이 적은 두 SSOT 의 관계
:::evidence key="grpc-core-api-f02" alt="분석 문서 analysis/grpc/grpc-core-api.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-core-api.md 발췌 — 15줄" zoom="true"
:::
## 그 테스트는 레지스트리를 읽지 않는다
다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다. `modules.json` 을 읽는 줄도, 파일 경로도 없다.
## 두 목록은 오늘 일치한다
레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다. 어긋난 것은 그 일치를 무엇이 지키는가다.
## 같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다
정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다 — 세는 순간 다시 표류한다. 수정은 테스트가 `modules.json` 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다. 그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다.
## 확인하지 못한 것
레지스트리와 목록을 어긋나게 만들어 테스트가 통과하는지 실행으로 확인하지 않았다. 테스트가 레지스트리를 읽지 않는다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,87 @@
---
kind: CASE
slug: grpc-server-f01
title: 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-server-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-server-f01
file: ../../../final/evidence/rendered/grpc-server-f01.svg
- key: grpc-server-f01-diagram
file: ../../../final/assets/diagrams/grpc-server-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-server-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-server.md#L140 이다.
module: grpc-server
priority: P2
---
# 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다
GrpcApplicationBoundaryRules javadoc: 세 적용처 중 저장소에 존재하는 것이 없다. 그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다.
## 문제
GrpcApplicationBoundaryRules javadoc: 세 적용처 중 저장소에 존재하는 것이 없다.
그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다.
## 결론
인라인 소스 문자열을 넣는다.
즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다.
GrpcServiceAdapterMarker 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.
이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — src/**/*.java 를 읽어 GrpcRawApiImportRule.violations 를 돌리고 비어 있음을 단언하는 형태다.
규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이므로 재료는 갖춰져 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcApplicationBoundaryRules 참조 8건 검색과 규칙이 판정하는 입력의 출처 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-server.md#L140 에 있다.
## 본문
<!-- body:start -->
`GrpcApplicationBoundaryRules` javadoc 이 드는 세 적용처 중 저장소에 존재하는 것이 없다.
## 규칙이 닿지 않는 대상
:::evidence key="grpc-server-f01-diagram" alt="인라인 소스 문자열만 규칙이 판정하는 입력 안에 놓이고 저장소의 자바 파일이 바깥에 빗금으로 놓인다" caption="규칙이 닿지 않는 대상" zoom="false"
:::
그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다 — 인라인 소스 문자열을 넣는다. 즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다.
## GrpcApplicationBoundaryRules 참조 위치
:::evidence key="grpc-server-f01" alt="코드베이스에서 GrpcApplicationBoundaryRules 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcApplicationBoundaryRules 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 애너테이션도 붙은 곳이 없다
`GrpcServiceAdapterMarker` 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.
## 재료는 갖춰져 있다
이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — `src/**/*.java` 를 읽어 `GrpcRawApiImportRule.violations` 를 돌리고 비어 있음을 단언하는 형태다. 규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이다.
## 확인하지 못한 것
실제 서버를 세워 인터셉터 사슬을 돌리지 않았다. 이 리프에 서버를 만드는 코드가 없다.
<!-- body:end -->
@@ -0,0 +1,93 @@
---
kind: CASE
slug: grpc-testkit-f01
title: 네 레인이 check 에 붙지 않고, 이 가족을 이름으로 부르는 워크플로가 없다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-testkit-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-testkit-f01
file: ../../../final/evidence/rendered/grpc-testkit-f01.svg
- key: grpc-testkit-f01-diagram
file: ../../../final/assets/diagrams/grpc-testkit-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-testkit-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-testkit.md#L153 이다.
module: grpc-testkit
priority: P2
---
# 네 레인이 check 에 붙지 않고, 이 가족을 이름으로 부르는 워크플로가 없다
ca.strict-test-lane.gradle 은 레인을 verification 그룹의 Test 태스크로 등록만 한다. check 에 연결하는 줄이 없다.
## 문제
ca.strict-test-lane.gradle 은 레인을 verification 그룹의 Test 태스크로 등록만 한다.
check 에 연결하는 줄이 없다.
## 결론
그리고 CI 워크플로에서 이 가족을 이름으로 부르는 것이 없다.
ci-quality-gates.yml 이 ./gradlew check 를 돌리므로 각 리프의 기본 test 는 돈다(이번에 확인: classes=71 tests=579 failures=0).
네 증거 레인은 그 밖에 있다.
결과적으로 이 플랫폼의 CONTRACT·TRANSPORT·FAULT 등급을 뒷받침하는 것은 25개 테스트이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.
build.gradle 자신이 그 위험을 적는다 — "A lane that discovers nothing fails, and none of them can serve an up-to-date result".
첫 성질은 레인 규약이 지킨다.
둘째 성질은 아무도 돌리지 않으면 무의미하다.
같은 저장소가 이 형태를 두 번 기록했다 — 모듈 18 의 "붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다" 와 mongo 가족의 릴리스 게이트 지형.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 레인 등록 코드와 check 연결 여부 확인, 이 가족을 부르는 워크플로 검색
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-testkit.md#L153 에 있다.
## 본문
<!-- body:start -->
`ca.strict-test-lane.gradle` 은 레인을 `verification` 그룹의 `Test` 태스크로 **등록만** 한다. `check` 에 연결하는 줄이 없다.
## 레인을 부르는 경로
:::evidence key="grpc-testkit-f01-diagram" alt="직접 입력한 명령만 레인을 부르는 경로 안에 놓이고 check 연결과 CI 워크플로가 바깥에 빗금으로 놓인다" caption="레인을 부르는 경로" zoom="false"
:::
그리고 CI 워크플로에서 이 가족을 이름으로 부르는 것이 없다. `ci-quality-gates.yml``./gradlew check` 를 돌리므로 각 리프의 기본 `test` 는 돈다(이번에 확인: classes=71 tests=579 failures=0). 네 증거 레인은 그 밖에 있다.
## 레인이 등록되는 방식
:::evidence key="grpc-testkit-f01" alt="분석 문서 analysis/grpc/grpc-testkit.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-testkit.md 발췌 — 15줄" zoom="true"
:::
## 25개 테스트가 누군가 명령을 입력할 때만 돈다
이 플랫폼의 CONTRACT·TRANSPORT·FAULT 등급을 뒷받침하는 것이 그 25개다. build.gradle 자신이 위험을 적는다 — "A lane that discovers nothing fails, and **none of them can serve an up-to-date result**". 첫 성질은 레인 규약이 지킨다. 둘째 성질은 아무도 돌리지 않으면 무의미하다.
## 같은 저장소가 이 형태를 두 번 기록했다
모듈 18 의 "붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다" 와 mongo 가족의 릴리스 게이트 지형. 차이는 이쪽 레인이 오늘 초록이라는 것이고, 그것을 확인한 방법이 이번 분석에서 직접 돌린 것이라는 점이다. 수정은 세 레인(성능 제외)을 `check` 에 붙이거나, messaging 가족처럼 전용 워크플로를 두는 것이다.
## 확인하지 못한 것
성능 레인을 돌리지 않았다. 세 레인은 이전 분석에서 통과를 확인했고 이번 회차에는 다시 돌리지 않았다.
<!-- body:end -->
@@ -0,0 +1,93 @@
---
kind: CASE
slug: grpc-testkit-f03
title: 고장 레인의 유일한 실소켓 시험이 자기가 관측한 것을 버리고 리터럴로 증거를 만든다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-testkit-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-testkit-f03
file: ../../../final/evidence/rendered/grpc-testkit-f03.svg
- key: grpc-testkit-f03-diagram
file: ../../../final/assets/diagrams/grpc-testkit-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-testkit-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-testkit.md#L187 이다.
module: grpc-testkit
priority: P2
---
# 고장 레인의 유일한 실소켓 시험이 자기가 관측한 것을 버리고 리터럴로 증거를 만든다
GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다. 서버 핸들러가 래치로 멈춰 있는 동안 server.close() 를 부른다.
## 문제
GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다.
서버 핸들러가 래치로 멈춰 있는 동안 server.close() 를 부른다.
## 결론
거기까지는 진짜 고장이다.
그런데 그 고장이 만들어 낸 관측이 어디에도 남지 않는다.
주석이 "the exception is the observation" 이라고 말하는데 그 예외는 catch 안에서 사라지고, 변수는 null 로 고정되고, 단언은 자기 대입을 확인한다.
그리고 GrpcFaultResult 에 들어가는 증거는 방금 일어난 호출에서 오지 않는다.
즉 소켓은 실제로 죽었고, 그 죽음에서 읽어 낸 값은 하나도 쓰이지 않는다.
이 시험이 실제로 증명하는 것은 applicationStarted == true 하나다.
나머지는 분류기의 산술이고, 그것은 같은 파일의 다른 일곱 시험이 이미 소켓 없이 증명한다.
이 형태를 이 리프 자신이 이름 붙여 두었다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcTransportEvidenceClassifierTest 참조 검색과 관측값·단언값의 대입 경로 추적
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-testkit.md#L187 에 있다.
## 본문
<!-- body:start -->
`GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown` 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다. 서버 핸들러가 래치로 멈춰 있는 동안 `server.close()` 를 부른다. 거기까지는 진짜 고장이다.
## 증거가 오는 곳
:::evidence key="grpc-testkit-f03-diagram" alt="리터럴로 쓴 값만 증거가 오는 곳 안에 놓이고 실제 소켓 관측이 바깥에 빗금으로 놓인다" caption="증거가 오는 곳" zoom="false"
:::
그 고장이 만들어 낸 관측이 어디에도 남지 않는다. 주석이 "the exception is the observation" 이라고 말하는데 그 예외는 `catch` 안에서 사라지고, 변수는 `null` 로 고정되고, 단언은 자기 대입을 확인한다. 그리고 `GrpcFaultResult` 에 들어가는 증거는 방금 일어난 호출에서 오지 않는다.
## GrpcTransportEvidenceClassifierTest 참조 위치
:::evidence key="grpc-testkit-f03" alt="코드베이스에서 GrpcTransportEvidenceClassifierTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcTransportEvidenceClassifierTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 이 시험이 실제로 증명하는 것
`applicationStarted == true` 하나다. 나머지는 분류기의 산술이고, 그것은 같은 파일의 다른 일곱 시험이 이미 소켓 없이 증명한다.
## 한 단계 아래의 같은 치환이다
이 형태를 이 리프 자신이 이름 붙여 두었다. 여기서는 소켓이 열렸는데도 등급을 뒷받침해야 할 증거가 여전히 손으로 쓴 값이다. 수정은 `callUnary` 를 부른 스레드가 잡은 예외와 그 시점의 진행 상태를 밖으로 넘겨(`AtomicReference`) 그것으로 `ClientObservation` 을 구성하는 것이다 — 그러면 `sendCompleted`·`responseHeadersReceived` 가 관측값이 되고 이 시험이 FAULT 등급을 실제로 뒷받침한다.
## 확인하지 못한 것
observedFailure 경로를 실행으로 확인하지 않았다. 대입과 단언이 같은 메서드 안에 있고 그 사이에 재대입이 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: grpc-testkit-f04
title: 호환성 표의 레인 이름과 빌드의 레인 이름이 서로 다른 집합이다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-testkit-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-testkit-f04
file: ../../../final/evidence/rendered/grpc-testkit-f04.svg
evidence:
- ../../../final/evidence/raw/grpc-testkit-f04.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-testkit.md#L233 이다.
module: grpc-testkit
priority: P3
---
# 호환성 표의 레인 이름과 빌드의 레인 이름이 서로 다른 집합이다
GrpcStableReleaseGate.evaluate 의 둘째 인자는 Map<String, Boolean> laneResults 이고, GrpcCompatibilityMatrix.missingResults 가 그 키를 자기 목록과 대조한다. 그 목록은 배포 조합의 이름이다 — boot-managed-platform, netty-shaded, upstream-grpc-java-override, protobuf-edition-2024 … 빌드가 등록하는 레인의 이름은 증거 종류다 — grpcInProcessContractTest, grpcNettyContractTest, grpcFaultTest, grpcPerformanceTest.
## 문제
GrpcStableReleaseGate.evaluate 의 둘째 인자는 Map<String, Boolean> laneResults 이고, GrpcCompatibilityMatrix.missingResults 가 그 키를 자기 목록과 대조한다.
그 목록은 배포 조합의 이름이다 — boot-managed-platform, netty-shaded, upstream-grpc-java-override, protobuf-edition-2024 … 빌드가 등록하는 레인의 이름은 증거 종류다 — grpcInProcessContractTest, grpcNettyContractTest, grpcFaultTest, grpcPerformanceTest.
## 결론
두 집합의 교집합이 비어 있다.
그래서 §17.1 을 고쳐 네 Gradle 레인을 check 에 붙이더라도, 그 결과가 이 게이트의 laneResults 를 채우지는 못한다 — 이름이 다른 축을 가리키기 때문이다.
게이트가 요구하는 것은 "Boot 관리 플랫폼 조합에서 돌았는가" 이고, 레인이 답할 수 있는 것은 "전송 증거를 냈는가" 다.
두 축이 다 필요하다는 것 자체는 옳다.
기록하는 이유는 §17.1·§17.2 의 수정이 이것까지 함께 다루지 않으면 게이트가 여전히 손으로 만든 값을 먹는다는 점이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcStableReleaseGate 참조 7건 검색과 두 레인 이름 집합의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-testkit.md#L233 에 있다.
## 본문
<!-- body:start -->
`GrpcStableReleaseGate.evaluate` 의 둘째 인자는 `Map<String, Boolean> laneResults` 이고, `GrpcCompatibilityMatrix.missingResults` 가 그 키를 자기 목록과 대조한다.
## GrpcStableReleaseGate 참조 위치
:::evidence key="grpc-testkit-f04" alt="코드베이스에서 GrpcStableReleaseGate 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcStableReleaseGate 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 두 집합의 교집합이 비어 있다
목록은 배포 조합의 이름이다 — `boot-managed-platform`, `netty-shaded`, `upstream-grpc-java-override`, `protobuf-edition-2024` …. 빌드가 등록하는 레인의 이름은 증거 종류다 — `grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`.
## 그래서 §17.1 을 고쳐도 채워지지 않는다
네 Gradle 레인을 `check` 에 붙이더라도 그 결과가 이 게이트의 `laneResults` 를 채우지는 못한다 — 이름이 다른 축을 가리키기 때문이다. 게이트가 요구하는 것은 "Boot 관리 플랫폼 조합에서 돌았는가" 이고, 레인이 답할 수 있는 것은 "전송 증거를 냈는가" 다.
## 두 축이 다 필요하다는 것 자체는 옳다
기록하는 이유는 §17.1·§17.2 의 수정이 이것까지 함께 다루지 않으면 게이트가 여전히 손으로 만든 값을 먹는다는 점이다.
## 확인하지 못한 것
두 집합을 실제로 맞물려 게이트를 실행하지 않았다. 이름 목록의 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,89 @@
---
kind: CASE
slug: messaging-admin-api-f01
title: DestructiveOperationGuard 의 두 분기가 문서에도 없고 테스트에도 없다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-api-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-f01
file: ../../../final/evidence/rendered/messaging-admin-api-f01.svg
- key: messaging-admin-api-f01-diagram
file: ../../../final/assets/diagrams/messaging-admin-api-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L915 이다.
module: messaging-admin-api
priority: P2
---
# DestructiveOperationGuard 의 두 분기가 문서에도 없고 테스트에도 없다
operation 불일치(:72-83)와 source 불일치(:84-93)는 클래스 javadoc 의 "Four conditions" 에 포함되지 않고, 두 에러 코드를 단언하는 테스트도 저장소 전체에 없다(EVD-303). 이 둘은 사소한 검사가 아니다 — 5번 분기의 인라인 주석이 정확히 말한다: "A guard that only checks presence and window lets a verified redrive approval authorise a destination deletion." 즉 검증된 승인으로 목적지 삭제를 인가하는 것을 막는 검사다.
## 문제
operation 불일치(:72-83)와 source 불일치(:84-93)는 클래스 javadoc 의 "Four conditions" 에 포함되지 않고, 두 에러 코드를 단언하는 테스트도 저장소 전체에 없다(EVD-303).
이 둘은 사소한 검사가 아니다 — 5번 분기의 인라인 주석이 정확히 말한다: "A guard that only checks presence and window lets a verified redrive approval authorise a destination deletion." 즉 검증된 승인으로 목적지 삭제를 인가하는 것을 막는 검사다.
## 결론
혼동을 키우는 정황이 하나 더 있다.
anApplicationRuntimeCannotRedrive 는 REDRIVE 요청에 REPLAY 승인을 넘기지만 adminCredentialPresent=false 라 분기 2에서 먼저 걸린다.
불일치 조합이 테스트에 등장하지만 그 분기는 실행되지 않는다.
수정: javadoc 을 여섯으로 고치고, new DestructiveOperationGuard(true) 위에서 operation 불일치·source 불일치 각각 1건씩 테스트를 추가한다.
이 리프에는 이미 verified(operation, source, from, until) 헬퍼가 있어 두 줄이면 된다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DestructiveOperationGuard 참조 19건 검색과 javadoc 이 든 조건 대비 본문 분기 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-api.md#L915 에 있다.
## 본문
<!-- body:start -->
operation 불일치(`:72-83`)와 source 불일치(`:84-93`)는 클래스 javadoc 의 "Four conditions" 에 포함되지 않고, 두 에러 코드를 단언하는 테스트도 저장소 전체에 없다(`EVD-303`).
## 목록에서 빠진 검사
:::evidence key="messaging-admin-api-f01-diagram" alt="javadoc 이 든 조건 쪽에 승인 존재와 유효 기간과 빠진 둘이 놓이고 본문이 보는 조건 쪽에 작업 종류와 출처가 놓인다" caption="목록에서 빠진 검사" zoom="false"
:::
## 사소한 검사가 아니다
5번 분기의 인라인 주석이 정확히 말한다 — "A guard that only checks presence and window lets a verified redrive approval authorise a destination deletion." 즉 **검증된 승인으로 목적지 삭제를 인가하는 것**을 막는 검사다.
## DestructiveOperationGuard 참조 위치
:::evidence key="messaging-admin-api-f01" alt="코드베이스에서 DestructiveOperationGuard 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestructiveOperationGuard 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 혼동을 키우는 정황
`anApplicationRuntimeCannotRedrive``REDRIVE` 요청에 `REPLAY` 승인을 넘기지만 `adminCredentialPresent=false` 라 분기 2에서 먼저 걸린다. 불일치 조합이 테스트에 등장하지만 그 분기는 실행되지 않는다.
## 수정
javadoc 을 여섯으로 고치고, `new DestructiveOperationGuard(true)` 위에서 operation 불일치·source 불일치 각각 1건씩 테스트를 추가한다. 이 리프에는 이미 `verified(operation, source, from, until)` 헬퍼가 있어 두 줄이면 된다.
## 확인하지 못한 것
guard 를 실제로 호출해 두 분기에 도달시키지 않았다. 두 에러 코드를 단언하는 테스트가 저장소 전체에 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,100 @@
---
kind: CASE
slug: messaging-cloudevents-f01
title: 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-cloudevents-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-cloudevents-f01
file: ../../../final/evidence/rendered/messaging-cloudevents-f01.svg
- key: messaging-cloudevents-f01-diagram
file: ../../../final/assets/diagrams/messaging-cloudevents-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-cloudevents-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-cloudevents.md#L518 이다.
module: messaging-cloudevents
priority: P2
---
# 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다
fromCloudEvent가 new MessageId(UUID.fromString(event.getId()))로 id를 파싱한다. CloudEvents 1.0.2는 id를 비어 있지 않은 문자열로만 제약한다.
## 관계
- **왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
fromCloudEvent가 new MessageId(UUID.fromString(event.getId()))로 id를 파싱한다.
CloudEvents 1.0.2는 id를 비어 있지 않은 문자열로만 제약한다.
## 결론
런타임 probe 결과: 명세 예시 id A234-1234-1234 → java.lang.IllegalArgumentException: Invalid UUID string, UUIDv4 → java.lang.IllegalArgumentException: a message identity is UUIDv7.
둘 다 MessagingException이 아니다.
(1) 범위.
v4 거절은 의도이고 테스트 주석이 그렇게 적는다.
그러나 비UUID 거절은 어디에도 언급되지 않았고 그것은 다른 판단이다 — v4 거절은 "우리 정책", 비UUID 거절은 "CloudEvents 상호운용 포기"다.
이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.
(2) 실패 어휘.
같은 메서드의 다른 검증 실패 넷은 전부 MessageValidationException이고 안정 코드(CLOUDEVENT_TIME_REQUIRED 등)를 갖는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : MessagingException 참조 36건 검색과 id 파싱 경로의 예외 분류 확인. 런타임 probe 로 거절을 실행 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-cloudevents.md#L518 에 있다.
## 본문
<!-- body:start -->
`fromCloudEvent``new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다.
## 구현이 받는 id 범위
:::evidence key="messaging-cloudevents-f01-diagram" alt="UUIDv7 id 만 구현이 받는 범위 안에 놓이고 명세 예시 id 와 UUIDv4 id 가 바깥에 빗금으로 놓인다" caption="구현이 받는 id 범위" zoom="false"
:::
런타임 probe 결과 — 명세 예시 id `A234-1234-1234``java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.**
## MessagingException 참조 위치
:::evidence key="messaging-cloudevents-f01" alt="코드베이스에서 MessagingException 를 검색한 출력 36줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingException 코드베이스 검색 — 36줄 · exit 0" zoom="true"
:::
## 범위 — 두 거절의 성격이 다르다
v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 "우리 정책", 비UUID 거절은 "CloudEvents 상호운용 포기"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.
## 실패 어휘 — id 실패만 분류되지 않는다
같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 "코드 없음" 여덟 행).
## 확인하지 못한 것
실제 외부 CloudEvents producer 의 id 형식 분포를 측정하지 않았다. 명세가 형식을 제약하지 않으므로 UUID 가 아닐 가능성이 높다는 것까지만 말할 수 있다.
<!-- body:end -->
@@ -0,0 +1,94 @@
---
kind: CASE
slug: messaging-inbox-jdbc-postgresql-f02
title: 속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-inbox-jdbc-postgresql-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-f02
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-f02.svg
- key: messaging-inbox-jdbc-postgresql-f02-diagram
file: ../../../final/assets/diagrams/messaging-inbox-jdbc-postgresql-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L657 이다.
module: messaging-inbox-jdbc-postgresql
priority: P2
---
# 속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다
InboxOperationsTest.cleanupDeletesInBoundedBatches가 InMemoryInbox(List.of(1000, 500))에 대해 removed == 1500과 cutoffs.hasSize(3)을 단언한다. 그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 대본이고 아무것도 삭제하거나 제한하지 않는다.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
InboxOperationsTest.cleanupDeletesInBoundedBatches가 InMemoryInbox(List.of(1000, 500))에 대해 removed == 1500과 cutoffs.hasSize(3)을 단언한다.
그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 대본이고 아무것도 삭제하거나 제한하지 않는다.
## 결론
bounded 오버로드는 fake에도 있지만 job이 부르지 않아 실행되지 않는다.
이 테스트가 통과로 증명하는 것은 "0을 받을 때까지 루프를 돈다"이고 이름이 주장하는 "배치로 제한된다"가 아니다.
1000·500은 배치처럼 보이는 숫자다.
P1이 이 테스트를 통과한 채로 존재할 수 있었던 이유다.
그리고 컨테이너 레인(InboxPostgresIT.retentionRemovesOldRows)도 무제한 오버로드를 한 행에 대해 부르므로 실 DB에서도 드러나지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : InboxOperationsTest 참조 검색과 테스트가 쓰는 fake 의 무제한 메서드 구현 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L657 에 있다.
## 본문
<!-- body:start -->
`InboxOperationsTest.cleanupDeletesInBoundedBatches``InMemoryInbox(List.of(1000, 500))`에 대해 `removed == 1500``cutoffs.hasSize(3)`을 단언한다.
## 대역이 재현하지 못하는 것
:::evidence key="messaging-inbox-jdbc-postgresql-f02-diagram" alt="실제 저장소 쪽에 LIMIT 로 제한과 삭제가 일어남이 놓이고 테스트 대역 쪽에 대본 목록 반환과 삭제 없음이 빗금으로 놓인다" caption="대역이 재현하지 못하는 것" zoom="false"
:::
그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 **대본**이고 아무것도 삭제하거나 제한하지 않는다. bounded 오버로드는 fake에도 있지만 job이 부르지 않아 실행되지 않는다.
## InboxOperationsTest 참조 위치
:::evidence key="messaging-inbox-jdbc-postgresql-f02" alt="코드베이스에서 InboxOperationsTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InboxOperationsTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 통과가 증명하는 것과 이름이 주장하는 것
증명하는 것은 "0을 받을 때까지 루프를 돈다"이고 이름이 주장하는 "배치로 제한된다"가 아니다. 1000·500은 배치처럼 보이는 숫자다. **P1이 이 테스트를 통과한 채로 존재할 수 있었던 이유**다.
## 컨테이너 레인도 드러내지 못한다
`InboxPostgresIT.retentionRemovesOldRows`도 무제한 오버로드를 한 행에 대해 부른다. 후보는 fake의 무제한 메서드가 실제로 컬렉션에서 삭제하게 하고 bounded 메서드가 `limit`를 존중하게 하는 것 — 그러면 테스트가 P1을 잡는다.
## 확인하지 못한 것
테스트를 고쳐 결함 있는 구현과 올바른 구현을 가르는지 확인하지 않았다. 대역이 삭제도 제한도 하지 않는다는 코드로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: messaging-nats-experimental-f01
title: deduplicatedPublish 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-nats-experimental-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-nats-experimental-f01
file: ../../../final/evidence/rendered/messaging-nats-experimental-f01.svg
- key: messaging-nats-experimental-f01-diagram
file: ../../../final/assets/diagrams/messaging-nats-experimental-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-nats-experimental-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L146 이다.
module: messaging-nats-experimental
priority: P2
---
# deduplicatedPublish 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다
검증기의 capabilities() 도 같은 값을 돌려준다. 그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.
## 문제
검증기의 capabilities() 도 같은 값을 돌려준다.
그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.
## 결론
NatsJetStreamProfile.deduplicationWindow 는 Optional<Duration> 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다.
창이 없으면 Nats-Msg-Id 가 실리지 않고 서버는 중복을 제거하지 않는다.
즉 능력 선언이 프로파일과 무관하게 참이다.
이 저장소에서 능력 열두 개 중 부재가 예외를 만드는 유일한 것이 deduplicatedPublish 다(DefaultMessagePublisher:250).
나머지는 읽히지 않거나 분기에 쓰인다.
그러므로 이 플래그의 과대 선언은 다른 어느 플래그의 과대 선언보다 직접적이다 — 창 없는 목적지가 그 가드를 통과한다.
클래스 javadoc: "when the profile enables one" 이 정확히 능력이 담지 않은 조건이다.
창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : NatsJetStreamProfile 참조 17건 검색과 능력 상수 대비 중복 제거 식별자 생성 조건 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-nats-experimental.md#L146 에 있다.
## 본문
<!-- body:start -->
능력은 상수이고 검증기의 `capabilities()` 도 같은 값을 돌려준다. 그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.
## 선언과 실제가 갈리는 조건
:::evidence key="messaging-nats-experimental-f01-diagram" alt="창 없는 프로파일이 능력은 참과 가드 통과를 지나 중복 저장으로 이어진다" caption="선언과 실제가 갈리는 조건" zoom="false"
:::
`NatsJetStreamProfile.deduplicationWindow``Optional<Duration>` 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다. 창이 없으면 `Nats-Msg-Id` 가 실리지 않고 서버는 중복을 제거하지 않는다.
## NatsJetStreamProfile 참조 위치
:::evidence key="messaging-nats-experimental-f01" alt="코드베이스에서 NatsJetStreamProfile 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NatsJetStreamProfile 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
## 이 플래그의 과대 선언이 가장 직접적이다
능력 열두 개 중 부재가 예외를 만드는 유일한 것이 `deduplicatedPublish` 다(`DefaultMessagePublisher:250`). 나머지는 읽히지 않거나 분기에 쓰인다. 그러므로 창 없는 목적지가 그 가드를 통과한다. 클래스 javadoc 의 "when the profile enables one" 이 정확히 능력이 담지 않은 조건이고, 창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.
## 모순이 테스트로 고정되어 있다
`NatsAdapterContractTest` 안에서 같은 빈 창 프로파일(`confirming(Optional.empty())`)에 대해 둘 다 통과한다. 우연히 남은 것이 아니라는 뜻이고, 수정할 때 함께 고쳐야 할 지점이 어디인지도 이 두 개가 알려 준다.
## 수정
능력을 프로파일에서 파생시킨다. `NatsJetStreamProfile.durable(...)` 는 창을 2분으로 채워 주지만 호출자가 없고, 정규 생성자는 빈 창을 정상값으로 받는다(§4).
## 확인하지 못한 것
중복 제거 창이 없는 프로파일로 모호 재발행을 재현하지 않았다. 실제 JetStream 서버를 띄우지 않았다 — 클라이언트 브리지를 싣지 않는 리프다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: messaging-nats-experimental-f04
title: 경과 시간 회귀를 막으려는 어셈블이 항상 참이다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-nats-experimental-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-nats-experimental-f04
file: ../../../final/evidence/rendered/messaging-nats-experimental-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-nats-experimental-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-nats-experimental.md#L238 이다.
module: messaging-nats-experimental
priority: P3
---
# 경과 시간 회귀를 막으려는 어셈블이 항상 참이다
as(...) 가 막으려는 회귀는 "모든 결과가 Duration.ZERO 를 보고하던 것"이다. 그런데 어셈블은 >= Duration.ZERO 다.
## 문제
as(...) 가 막으려는 회귀는 "모든 결과가 Duration.ZERO 를 보고하던 것"이다.
그런데 어셈블은 >= Duration.ZERO 다.
## 결론
Duration.ZERO 는 이 조건을 통과한다.
경과 시간은 시작 시점에서 잰 값이라 음수가 될 수 없으므로, 이 어셈블은 구현이 무엇을 하든 통과한다.
이름과 as 메시지가 정확히 짚은 회귀를, 어셈블만 못 잡는다.
그래서 이 테스트는 회귀 방지가 아니라 회귀 방지의 표시다.
isGreaterThan(Duration.ZERO) 로 바꾼다.
시간 분해능이 불안하면 전송 람다에 관측 가능한 지연을 넣고 그 하한과 비교한다 — 같은 클래스의 aPublishThatNeverCompletesIsBoundedByTheCallersTimeout 가 이미 50밀리초 마감으로 그 방식을 쓴다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : as 어셈블의 비교 연산자와 그것이 막겠다고 한 회귀 값의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-nats-experimental.md#L238 에 있다.
## 본문
<!-- body:start -->
`as(...)` 가 막으려는 회귀는 "모든 결과가 `Duration.ZERO` 를 보고하던 것"이다. 그런데 어셈블은 `>= Duration.ZERO` 다 — `Duration.ZERO` 는 이 조건을 통과한다.
## as() 가 막으려던 회귀
:::evidence key="messaging-nats-experimental-f04" alt="분석 문서 analysis/messaging/messaging-nats-experimental.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-nats-experimental.md 발췌 — 15줄" zoom="true"
:::
## 구현이 무엇을 하든 통과한다
경과 시간은 시작 시점에서 잰 값이라 음수가 될 수 없다. 이름과 `as` 메시지가 정확히 짚은 회귀를 어셈블만 못 잡는다. 그래서 이 테스트는 회귀 방지가 아니라 회귀 방지의 표시다.
## 수정
`isGreaterThan(Duration.ZERO)` 로 바꾼다. 시간 분해능이 불안하면 전송 람다에 관측 가능한 지연을 넣고 그 하한과 비교한다 — 같은 클래스의 `aPublishThatNeverCompletesIsBoundedByTheCallersTimeout` 가 이미 50밀리초 마감으로 그 방식을 쓴다.
## 확인하지 못한 것
테스트를 실행하지 않았다. 항상 통과한다는 것은 어셈블 의미론으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,93 @@
---
kind: CASE
slug: messaging-outbox-jdbc-postgresql-f02
title: 배포되는 Debezium 설정이 수정 이전 버전이다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-outbox-jdbc-postgresql-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-outbox-jdbc-postgresql-f02
file: ../../../final/evidence/rendered/messaging-outbox-jdbc-postgresql-f02.svg
- key: messaging-outbox-jdbc-postgresql-f02-diagram
file: ../../../final/assets/diagrams/messaging-outbox-jdbc-postgresql-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-outbox-jdbc-postgresql-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L888 이다.
module: messaging-outbox-jdbc-postgresql
priority: P2
---
# 배포되는 Debezium 설정이 수정 이전 버전이다
src/main/resources/debezium/outbox-event-router.properties 가 event.key=destination 을 유지하고 있다. 같은 저장소의 Java(DebeziumOutboxEventRouter), V4 마이그레이션 주석, 그리고 전용 테스트(theRoutedKeyIsNotTheTopicName)가 모두 그것이 결함이라고 말한다 — "keying by destination puts every message on a topic onto one partition".
## 문제
src/main/resources/debezium/outbox-event-router.properties 가 event.key=destination 을 유지하고 있다.
같은 저장소의 Java(DebeziumOutboxEventRouter), V4 마이그레이션 주석, 그리고 전용 테스트(theRoutedKeyIsNotTheTopicName)가 모두 그것이 결함이라고 말한다 — "keying by destination puts every message on a topic onto one partition".
## 결론
추가로 헤더 매핑이 15개 중 4개뿐이라, 이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 전부 잃는다.
V4 가 존재하는 이유가 그 유실을 막는 것이다.
1.
properties 를 Java 설정에서 생성하거나, 최소한 둘을 대조하는 테스트를 둔다.
DebeziumOutboxEventRouter.connectorConfiguration("") 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다.
지금은 두 표현을 잇는 코드가 한 줄도 없다.
2.
aggregateIdAsPartitionKey 를 connectorConfiguration 에 전달하거나, 전달할 수 없다면 DebeziumOutboxRecordMapper 에서 그 분기를 제거한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DebeziumOutboxEventRouter 참조 5건 검색과 배포 properties·Java 설정의 항목별 텍스트 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L888 에 있다.
## 본문
<!-- body:start -->
`src/main/resources/debezium/outbox-event-router.properties``event.key=destination` 을 유지하고 있다.
## 두 판본의 차이
:::evidence key="messaging-outbox-jdbc-postgresql-f02-diagram" alt="Java 설정 쪽에 aggregate id 키와 헤더 매핑 열다섯이 놓이고 배포 properties 쪽에 destination 키와 헤더 매핑 넷이 빗금으로 놓인다" caption="두 판본의 차이" zoom="false"
:::
같은 저장소의 Java(`DebeziumOutboxEventRouter`), V4 마이그레이션 주석, 그리고 전용 테스트(`theRoutedKeyIsNotTheTopicName`)가 모두 그것이 결함이라고 말한다 — "keying by destination puts every message on a topic onto one partition".
## DebeziumOutboxEventRouter 참조 위치
:::evidence key="messaging-outbox-jdbc-postgresql-f02" alt="코드베이스에서 DebeziumOutboxEventRouter 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DebeziumOutboxEventRouter 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 헤더 매핑도 4개뿐이다
이 파일로 배포한 CDC 는 tenant·correlation·causation·producer·trace·partition/ordering key 를 **전부 잃는다**. V4 가 존재하는 이유가 그 유실을 막는 것이다.
## 수정 둘
properties 를 Java 설정에서 생성하거나, 최소한 둘을 대조하는 테스트를 둔다 — `DebeziumOutboxEventRouter.connectorConfiguration("")` 의 항목이 파일에 모두 있는지 확인하는 테스트면 충분하다. 그리고 `aggregateIdAsPartitionKey``connectorConfiguration` 에 전달하거나, 전달할 수 없다면 `DebeziumOutboxRecordMapper` 에서 그 분기를 제거한다 — 지금은 모델이 커넥터가 하지 않을 일을 예측한다.
## 확인하지 못한 것
실제 Debezium 커넥터를 띄워 properties 의 동작을 확인하지 않았다. 두 설정의 차이는 텍스트 대조로 확인했다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CASE
slug: messaging-outbox-jdbc-postgresql-f03
title: 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-outbox-jdbc-postgresql-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-outbox-jdbc-postgresql-f03
file: ../../../final/evidence/rendered/messaging-outbox-jdbc-postgresql-f03.svg
- key: messaging-outbox-jdbc-postgresql-f03-diagram
file: ../../../final/assets/diagrams/messaging-outbox-jdbc-postgresql-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-outbox-jdbc-postgresql-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L899 이다.
module: messaging-outbox-jdbc-postgresql
priority: P2
---
# 역슬래시로 끝나는 헤더 값이 헤더 맵을 깨뜨린다
findClosingQuote(:657-664)가 이스케이프된 역슬래시를 고려하지 않는다. 값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(EVD-314, 런타임 재현).
## 문제
findClosingQuote(:657-664)가 이스케이프된 역슬래시를 고려하지 않는다.
값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(EVD-314, 런타임 재현).
## 결론
HeaderValue 는 제어문자만 금지하므로 이 입력은 플랫폼 검증을 통과한다.
헤더 주입으로 이어지지는 않는다 — 예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다.
수정: 종료 판정을 "앞의 연속된 역슬래시 개수가 짝수" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다.
후자가 unescape 와 대칭이라 낫다.
테스트는 OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips 옆에 역슬래시 종결 케이스를 추가하면 된다 — 실 DB 왕복까지 확인할 수 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : HeaderValue 참조 26건 검색과 파서의 종료 지점 탐색 코드 확인. jshell 리플렉션으로 왕복 손상을 런타임 재현
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L899 에 있다.
## 본문
<!-- body:start -->
`findClosingQuote`(`:657-664`)가 이스케이프된 역슬래시를 고려하지 않는다.
## 구분자가 삼켜지는 지점
:::evidence key="messaging-outbox-jdbc-postgresql-f03-diagram" alt="역슬래시로 끝난 값이 종료 인용부 놓침과 뒤 헤더 흡수를 지나 헤더 맵 붕괴로 이어진다" caption="구분자가 삼켜지는 지점" zoom="false"
:::
값이 역슬래시로 끝나면 파서가 종료 지점을 놓치고, 뒤에 헤더가 더 있으면 맵 전체가 붕괴한다(`EVD-314`, 런타임 재현).
## HeaderValue 참조 위치
:::evidence key="messaging-outbox-jdbc-postgresql-f03" alt="코드베이스에서 HeaderValue 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="HeaderValue 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 플랫폼 검증을 통과한다
`HeaderValue` 는 제어문자만 금지한다.
## 헤더 주입으로 이어지지는 않는다
예약 이름은 키가 아니라 값이 되고, 쓰기 경로가 예약 이름을 이미 거절한다.
## 수정
종료 판정을 "앞의 연속된 역슬래시 개수가 짝수" 로 바꾸거나, 인덱스를 앞에서부터 스캔하며 이스케이프 상태를 추적한다 — 후자가 `unescape` 와 대칭이라 낫다. 테스트는 `OutboxPostgresIT.aHeaderValueWithControlCharactersRoundTrips` 옆에 역슬래시 종결 케이스를 추가하면 실 DB 왕복까지 확인할 수 있다.
## 확인하지 못한 것
없음 — 역슬래시 종결 값의 헤더 맵 붕괴를 런타임으로 재현했다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: messaging-reliability-api-f01
title: 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 @Deprecated가 없다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-reliability-api-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-f01
file: ../../../final/evidence/rendered/messaging-reliability-api-f01.svg
- key: messaging-reliability-api-f01-diagram
file: ../../../final/assets/diagrams/messaging-reliability-api-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L689 이다.
module: messaging-reliability-api
priority: P2
---
# 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 @Deprecated가 없다
OutboxRepository가 다섯 전이 각각에 대해 MessageId 기반(반환 void)과 OutboxLease 기반(반환 OutboxTransitionResult) 두 형태를 선언한다. javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 @Deprecated 애노테이션이 이 leaf 전체에 0건이다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
OutboxRepository가 다섯 전이 각각에 대해 MessageId 기반(반환 void)과 OutboxLease 기반(반환 OutboxTransitionResult) 두 형태를 선언한다.
javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 @Deprecated 애노테이션이 이 leaf 전체에 0건이다.
## 결론
전자에는 fencing이 없다 — OutboxLease javadoc이 그 부재가 만든 이중 발행 사고를 기록한다.
컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, 실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다(§12.1c).
그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : OutboxRepository 참조 35건 검색과 다섯 전이의 두 세대 시그니처 및 @Deprecated 수 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-reliability-api.md#L689 에 있다.
## 본문
<!-- body:start -->
`OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다.
## 표시가 없는 두 형태
:::evidence key="messaging-reliability-api-f01-diagram" alt="lease 기반 쪽에 결과 반환과 fencing 있음이 놓이고 MessageId 기반 쪽에 void 반환과 fencing 없음이 빗금으로 놓인다" caption="표시가 없는 두 형태" zoom="false"
:::
javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다.
## OutboxRepository 참조 위치
:::evidence key="messaging-reliability-api-f01" alt="코드베이스에서 OutboxRepository 를 검색한 출력 35줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxRepository 코드베이스 검색 — 35줄 · exit 0" zoom="true"
:::
## 컴파일러가 경고하지 않는다
전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
## 확인하지 못한 것
구세대를 쓰는 배포를 만들어 fencing 부재의 결과를 관측하지 않았다. 이 저장소에는 그런 배포가 없다.
<!-- body:end -->
@@ -0,0 +1,83 @@
---
kind: CASE
slug: messaging-spring-boot-starter-f05
title: 배치 발행자가 CompletionStage 를 돌려주면서 동기 예외를 던진다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-spring-boot-starter-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-boot-starter-f05
file: ../../../final/evidence/rendered/messaging-spring-boot-starter-f05.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-boot-starter-f05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md#L367 이다.
module: messaging-spring-boot-starter
priority: P3
---
# 배치 발행자가 CompletionStage 를 돌려주면서 동기 예외를 던진다
같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다. 즉 "게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다.
## 관계
- **검증기는 발행이 아니라 주입이 강제다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다.
즉 "게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다.
## 결론
비동기 파이프라인으로 배치를 부르는 코드는 .exceptionally(...) 로 잡히지 않는 예외를 만난다.
등급이 P3 인 이유는 이것이 프로그래밍 오류(배치 크기 초과)이고 결과가 손실이 아니라 예외 형태의 불일치이기 때문이다.
전용 테스트(aBatchLargerThanItsLimitIsRefusedBeforeAnythingIsPublished)가 assertThatThrownBy 로 현재 동작을 고정하고 있으므로, 고치려면 그 테스트도 함께 바꾼다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 반환 타입과 예외 전달 경로 대조, 같은 클래스가 의존 대상에 적용한 방어와의 비교
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-spring-boot-starter.md#L367 에 있다.
## 본문
<!-- body:start -->
배치 발행자가 `CompletionStage` 를 돌려주면서 동기 예외를 던진다.
## 반환 타입과 예외 전달 방식
:::evidence key="messaging-spring-boot-starter-f05" alt="분석 문서 analysis/messaging/messaging-spring-boot-starter.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-spring-boot-starter.md 발췌 — 15줄" zoom="true"
:::
## 같은 클래스가 자기 의존 대상에는 이 형태를 방어한다
"게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다.
## 비동기 파이프라인이 잡지 못한다
`.exceptionally(...)` 로 잡히지 않는 예외를 만난다. 등급이 P3 인 이유는 이것이 프로그래밍 오류(배치 크기 초과)이고 결과가 손실이 아니라 예외 형태의 불일치이기 때문이다.
## 테스트가 현재 동작을 고정하고 있다
`aBatchLargerThanItsLimitIsRefusedBeforeAnythingIsPublished``assertThatThrownBy` 를 쓰므로, 고치려면 그 테스트도 함께 바꾼다.
## 확인하지 못한 것
@ConditionalOnBean 의 평가 순서를 실제 컨텍스트로 재현하지 않았다. 스프링의 문서화된 제약으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,68 @@
---
kind: CASE
slug: messaging-testkit-f07
title: 항등식을 단언하는 테스트가 하나 있다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-testkit-f07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-f07
file: ../../../final/evidence/rendered/messaging-testkit-f07.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-f07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L980 이다.
module: messaging-testkit
priority: P3
---
# 항등식을 단언하는 테스트가 하나 있다
CompatibilityMatrixTest.aCertificationClaimCannotBeMadeWithoutEvidence(:48-60)의 좌변과 우변이 같은 식이다(§12.3(c)). 이름이 약속하는 것을 검사하지 않는다.
## 문제
CompatibilityMatrixTest.aCertificationClaimCannotBeMadeWithoutEvidence(:48-60)의 좌변과 우변이 같은 식이다(§12.3(c)).
이름이 약속하는 것을 검사하지 않는다.
## 결론
실질 검사는 같은 파일의 다른 두 테스트가 하고 있으므로 커버리지 손실은 없다.
이 테스트를 지우거나, "증거를 비우면 Stable 주장이 무너진다" 를 실제로 검사하도록 바꾼다 — 후자가 이름에 맞는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : CompatibilityMatrixTest 참조 검색과 해당 단언의 좌변·우변 식 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-testkit.md#L980 에 있다.
## 본문
<!-- body:start -->
`CompatibilityMatrixTest.aCertificationClaimCannotBeMadeWithoutEvidence`(`:48-60`)의 좌변과 우변이 같은 식이다(§12.3(c)). 이름이 약속하는 것을 검사하지 않는다.
## CompatibilityMatrixTest 참조 위치
:::evidence key="messaging-testkit-f07" alt="코드베이스에서 CompatibilityMatrixTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CompatibilityMatrixTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 커버리지 손실은 없다
실질 검사는 같은 파일의 다른 두 테스트가 하고 있다. 이 테스트를 지우거나, "증거를 비우면 Stable 주장이 무너진다" 를 실제로 검사하도록 바꾼다 — 후자가 이름에 맞는다.
## 확인하지 못한 것
테스트를 실행하지 않았다. 좌변과 우변이 같은 식이라는 것을 본문으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,79 @@
---
kind: CASE
slug: messaging-transport-spi-f01
title: 드레인 마감 30초가 세 곳에서 독립적으로 결정된다
topic: verification-path-coverage
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-transport-spi-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-transport-spi-f01
file: ../../../final/evidence/rendered/messaging-transport-spi-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-transport-spi-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-transport-spi.md#L659 이다.
module: messaging-transport-spi
priority: P3
---
# 드레인 마감 30초가 세 곳에서 독립적으로 결정된다
MessagingLifecycle.DEFAULT_DRAIN_DEADLINE(public), DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE(private), MessagingShutdownLifecycle의 생성자 인자. public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다.
## 관계
- **멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 개념의 sentinel은 계층을 넘어 하나로 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
MessagingLifecycle.DEFAULT_DRAIN_DEADLINE(public), DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE(private), MessagingShutdownLifecycle의 생성자 인자.
public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다.
## 결론
MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다.
그리고 §17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다 — 즉 살아 있는 값(private)이 죽은 인터페이스의 값(public)을 참조하지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : MessagingShutdownLifecycle 참조 11건 검색과 같은 값이 독립적으로 선언된 세 지점 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-transport-spi.md#L659 에 있다.
## 본문
<!-- body:start -->
`MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`(public), `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE`(private), `MessagingShutdownLifecycle`의 생성자 인자 — 30초가 세 곳에서 독립적으로 결정된다.
## MessagingShutdownLifecycle 참조 위치
:::evidence key="messaging-transport-spi-f01" alt="코드베이스에서 MessagingShutdownLifecycle 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingShutdownLifecycle 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 테스트가 한쪽만 고정한다
`MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign`이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다.
## 살아 있는 값이 죽은 인터페이스를 참조하지 않는다
§17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다.
## 확인하지 못한 것
세 값이 실제 배포에서 어긋난 적이 있는지 확인하지 않았다. 오늘은 같은 값이고, 잇는 코드가 없다는 것으로 판정했다.
<!-- body:end -->