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,103 @@
---
kind: CASE
slug: a-delayed-delivery-flag-without-the-topology-that-delivers-it
title: 지연 배달을 참으로 선언하는데 그 지연을 제공할 토폴로지가 조립되지 않는다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-delayed-delivery-flag-without-the-topology-that-delivers-it
evidenceCapturedOn: 2026-09-02
assets:
- key: a-delayed-delivery-flag-without-the-topology-that-delivers-it
file: ../../../final/evidence/rendered/a-delayed-delivery-flag-without-the-topology-that-delivers-it.svg
evidence:
- ../../../final/evidence/raw/a-delayed-delivery-flag-without-the-topology-that-delivers-it.txt
source:
- 원본 분석은 Rabbit 어댑터 문서 §17.4 다. 성분 위치와 소비 사슬, 큐 선언 코드의 부재는 위 자산에서 확인할 수 있다.
---
# 지연 배달을 참으로 선언하는데 그 지연을 제공할 토폴로지가 조립되지 않는다
Rabbit 어댑터가 지연 배달을 참으로 선언한다. 그 지연을 만드는 큐를 선언하는 코드는 저장소에 없다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
- **능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다**
- **선택할 수 없는 브로커가 지원 매트릭스에 기능 목록과 함께 실려 있다**
## 문제
능력 선언은 재시도 엔진이 읽는 값이다. 지연 배달이 참이면 브로커에게 지연을 맡기는 재시도 모드를 고를 수 있다.
RabbitMQ 의 코어 브로커에는 메시지별 지연이 없다. 지연 교환 플러그인을 설치하거나, 메시지 수명과 데드레터 라우팅으로 대기 큐를 만들어야 한다. 둘 다 토폴로지 선언을 요구한다.
## 결론
선언과 그것을 뒷받침할 큐 사이가 비어 있다.
이 어댑터는 그 큐를 어떻게 만드는지 이미 기술해 두었다. 그런데 그 기술을 참조하는 파일이 자기 자신과 시험 하나뿐이고, 큐를 실제로 선언하는 코드는 저장소 전체에 없다.
값을 읽는 엔진은 조립되어 있다. 지금 그 값이 엔진까지 닿지 않는 이유와, 닿더라도 남는 문제는 본문이 다룬다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 능력 성분의 위치 확인, 값을 읽는 엔진과 그 조립 지점 확인, 재시도 결정 소비자의 인자 확인, 큐 선언 코드 검색
소스 수정 : x
## 재현 조건
1. 능력 record 의 성분 순서에서 지연 배달이 몇 번째인지 확인하고, Rabbit 전송이 그 자리에 넘기는 값을 읽는다.
2. 그 값을 읽는 조건문과 그 엔진이 빈으로 등록되는 지점을 확인한다.
3. 재시도 결정을 소비하는 코드가 지연 값을 어떻게 다루는지 확인한다.
4. 지연 큐를 기술하는 타입을 참조하는 파일과, 큐를 선언하는 코드를 각각 검색한다.
## 본문
<!-- body:start -->
능력 record 는 열두 개의 불리언을 위치로 받고, 여덟째가 지연 배달이다. Rabbit 전송이 그 자리에 참을 넘긴다.
## 소비 사슬을 끝까지 따라가면 세 군데가 끊겨 있다
:::evidence key="a-delayed-delivery-flag-without-the-topology-that-delivers-it" alt="코드베이스에서 능력 성분의 여덟째 자리와 Rabbit 이 넘기는 값, 그 값을 읽는 엔진과 엔진의 조립 지점, 재시도 결정의 유일한 소비자, 지연 큐 타입을 참조하는 파일, 큐 선언 코드 매치 수를 뽑은 출력 23줄. 엔진이 빈으로 등록되고 소비자가 지연 값을 넘기지 않으며 큐를 선언하는 코드가 0 이라는 것이 그 출력에 그대로 보인다." caption="여덟째 성분 · 엔진과 조립 지점 · 결정 소비자 · 지연 큐 참조 · 큐 선언 매치 0 — 23줄 · exit 0" zoom="true"
:::
`DefaultRetryDecisionEngine` 이 64행에서 그 값을 읽는다. 그리고 그 엔진은 자동 설정이 빈으로 등록한다 — 오늘 조립되어 돌고 있다.
끊긴 곳은 그 앞이다. Rabbit 은 전송을 출하하지 않아서 Rabbit 의 능력 record 가 엔진까지 도달하지 못한다. 스타터의 브로커 선택이 rabbit 을 이름으로 거절하고, 이유를 문장으로 적는다 — 검증기와 보안 설정은 출하하지만 전송이 없어 발행이 탈 것이 없다는 것이다.
## 지연을 만드는 방법은 이미 기술되어 있다
`RabbitRetryQueueTopology` 가 대기 큐를 기술한다. javadoc 이 왜 필요한지부터 적는다 — 코어 브로커에 메시지별 지연이 없으므로, 재시도 큐는 메시지 수명이 걸린 큐이고 그 데드레터 교환이 작업 큐를 다시 가리킨다. 메시지는 수명이 다할 때까지 앉아 있다가 다시 라우팅된다.
플러그인 뒤에 숨기지 않고 명시적으로 모델링한 이유도 적는다 — 그래야 동작이 검토 가능하다는 것이다. 함정까지 같이 적는다. 수명 만료는 큐 머리에서 평가되므로, 한 재시도 큐에 서로 다른 지연이 섞이면 각자 독립적으로 만료되지 않는다.
그 타입을 참조하는 파일은 자기 자신과 시험 하나다. 그리고 큐를 선언하는 코드를 이름으로 찾으면 매치가 0 이다.
## 배선해도 지연은 아직 흐르지 않는다
전송을 구현하는 것만으로 끝나지 않는다.
재시도 결정은 목적지와 지연을 함께 담는다. 그런데 그 결정을 소비하는 production 코드가 하나뿐이고 — Kafka 쪽 실행기다 — 그 실행기는 목적지만 넘기고 **지연 값을 넘기지 않는다.**
Rabbit 에는 대응하는 실행기가 없다. 그러니 배선하는 쪽이 해야 할 일은 전송 구현과 재시도 실행기와 큐 선언 셋이고, 그중 어느 하나만 해도 이 플래그는 여전히 참이다.
## 상수는 아무것도 강제하지 않는다
이 플래그는 프로파일에서 파생된 값이 아니라 소스에 박힌 상수다. 큐가 선언되었는지, 플러그인이 설치되었는지 보지 않는다.
그래서 위의 세 가지 중 무엇이 언제 채워지든 이 값은 바뀌지 않고, 바꿔야 한다고 알려 주는 것도 없다.
## 오늘 무엇이 이 결함을 막고 있나
Rabbit 능력이 엔진에 닿지 않는다는 것 하나다. 어댑터 자신이 아니라 그 위의 배선 부재가 막고 있다.
## 확인하지 못한 것
실제 배달 시점은 브로커를 띄워 확인해 보지 못했다. 큐 선언의 부재는 이름 기반 검색으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,133 @@
---
kind: CASE
slug: a-transaction-capability-true-and-its-validator-never-run
title: 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-transaction-capability-true-and-its-validator-never-run
evidenceCapturedOn: 2026-09-02
assets:
- key: a-transaction-capability-true-and-its-validator-never-run
file: ../../../final/evidence/rendered/a-transaction-capability-true-and-its-validator-never-run.svg
evidence:
- ../../../final/evidence/raw/a-transaction-capability-true-and-its-validator-never-run.txt
source:
- 분석 문서는 메시징 플랫폼 편 §3.5 다. 그 절이 프로파일 검증기 여덟 개의 도달성을 세고, 조립에서 실행되는 셋을 적는다. 실행되지 않는 다섯 중 넷은 빌드 전용 모듈에 있어 조립 지점이 없는 것이 등급과 일치한다. 출하되는 모듈에서 조립되지 않은 것은 이 트랜잭션 검증기 하나뿐이고, 그래서 이 항목이 P2 다.
- 능력 상수의 아홉째가 프로파일과 무관한 상수라는 것은 Kafka 어댑터 편이고, 아홉째와 열째의 독자 수 대비는 같은 플랫폼 편 §3.4 의 표에 있다.
---
# 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다
Kafka 어댑터의 능력 상수가 브로커 트랜잭션을 프로파일과 무관하게 참으로 답한다. 그 조건을 검사하는 검증기는 스타터가 빈으로 만들지만 기동 검증에 감싸지 않아 실행되지 않는다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 사례가 속한 구조다.
- **검증기는 발행이 아니라 주입이 강제다**
이 사례의 두 번째 절반에 해당하는 규칙이다.
- **능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다**
첫 번째 절반에 해당하는 규칙이다.
## 문제
Kafka 트랜잭션은 조건 다섯이 모두 맞아야 활성화된다.
그 다섯을 전부 보는 클래스가 이 저장소에 있다.
## 결론
능력 상수가 프로파일을 보지 않는다. 열두 성분 중 아홉째 자리가 고정으로 참이고 그 선언에 프로파일 참조가 없다.
그 플래그를 읽는 프로덕션 코드는 0 이다. 바로 옆 열째 플래그는 발행 경로가 읽는데, 그 플래그는 일부러 거짓으로 내려져 있다. 그 자리 javadoc 이 이유를 적는다 — 참으로 선언하면 호출자가 브로커가 중복을 제거한다고 믿고 자기 멱등성을 만들지 않는다는 것이다.
아홉째의 과대 선언이 오늘 낳는 결과는 조회 경로의 피해와 다르다.
두 번째 절반이 검증 경로다. 스타터가 트랜잭션 검증기를 빈으로 발행하지만 기동 검증에 감싸지 않는다. 자동설정 바깥에서 이것을 아는 코드는 하나도 없다. 다섯 규칙이 어디에서도 실행되지 않는다.
감쌀 수 없는 이유는 인자 수가 아니다. 래퍼는 소비자 함수를 받으므로 나머지를 캡처하는 람다면 들어간다. 두 번째 인자가 그것을 막는다. 트랜잭션 식별자 접두는 이 저장소의 main 에서 이 검증기의 파라미터 이름으로만 존재한다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 능력 상수의 성분 위치와 독자 계수, 검증기의 규칙과 언급 계수, 래퍼 시그니처와 인자 출처 확인
소스 수정 : x
## 재현 조건
1. 능력 record 에서 브로커 트랜잭션이 몇 번째인지 확인하고, Kafka 가 그 자리에 넘기는 값과 그 선언의 프로파일 참조를 확인한다.
2. 그 플래그를 읽는 프로덕션 코드를 세고, 옆 열째 플래그와 대조한다.
3. 트랜잭션 검증기가 요구하는 조건을 전부 나열한다.
4. 기동 검증으로 감싸이는 검증기와 직접 불리는 검증기를 확인한다.
5. 트랜잭션 검증기를 언급하는 프로덕션 코드와 테스트를 센다.
6. 래퍼의 시그니처와 두 번째 인자의 출처를 확인한다.
## 본문
<!-- body:start -->
Kafka 트랜잭션은 생산자 설정 넷과 목적지 선언 하나가 동시에 맞아야 성립한다. 그중 어느 하나라도 어긋나면 커밋 경계가 갈라진다.
이 저장소에는 그 다섯을 전부 검사하는 클래스가 있다. 스타터가 그것을 빈으로 만든다. 그리고 아무도 그것을 부르지 않는다.
## 아홉째 자리가 프로파일을 보지 않는다
:::evidence key="a-transaction-capability-true-and-its-validator-never-run" alt="코드베이스에서 능력 record 의 아홉째 성분과 Kafka 가 그 자리에 넘기는 값과 그 상수의 프로파일 참조 수, 그 플래그를 읽는 코드 수와 바로 옆 열째 플래그를 읽는 코드와 그 열째가 거짓인 이유를 적은 javadoc, 트랜잭션 검증기가 요구하는 다섯 조건, 기동 검증으로 감싸이는 검증기와 직접 불리는 검증기와 감싸이지 않은 채 빈으로만 발행되는 트랜잭션 검증기와 그것을 언급하는 코드 수, 그리고 감쌀 수 없는 진짜 이유인 래퍼 시그니처와 두 번째 인자의 출처를 뽑은 출력 54줄. 아홉째 플래그를 읽는 코드가 0 이고 열째는 읽히면서 일부러 거짓이라는 대비가 그 출력에 보인다." caption="아홉째 성분과 그 값 · 읽는 코드 0 · 옆 열째는 읽히고 거짓 · 검증기의 다섯 조건 · 감싸인 것과 아닌 것 · 두 번째 인자의 출처 없음 — 54줄" zoom="true"
:::
능력 record 는 열두 개의 불리언을 위치로 받고, 아홉째가 브로커 트랜잭션이다. Kafka 전송이 그 자리에 참을 넘기고, 그 선언에 프로파일 참조는 0 이다.
트랜잭션 식별자 없이 구성된 배포도 같은 답을 받는다.
## 옆자리가 이 플래그의 무게를 보여 준다
이 아홉째 플래그를 읽는 프로덕션 코드가 0 이다.
바로 옆 열째는 다르다. 발행 경로가 그 값을 읽어 판단한다. 그리고 Kafka 는 그 자리에 거짓을 넘긴다.
그 자리 javadoc 이 왜 거짓인지 적는다. 참으로 선언하면 중복 제거 요청이 받아들여진 뒤 조용히 아무 일도 하지 않고, 호출자는 브로커가 중복을 제거한다고 믿어 원래 만들었을 멱등성을 건너뛴다. 거짓으로 두면 그 요청이 기동 실패가 되는데, 그것이 이 플래그가 존재하는 이유라는 것이다.
같은 종류의 과대 선언이 하나는 실제 피해를 만들고 하나는 만들지 않는다. 차이는 읽는 코드가 있느냐다.
그러므로 아홉째의 과대 선언이 오늘 만드는 것은 조회 경로의 피해가 아니다. 남는 것은 검증 경로다.
## 검증기가 요구하는 다섯
트랜잭션 식별자 접두가 비어 있지 않을 것, 생산자가 멱등일 것, 응답 확인이 전부일 것, 오프셋 커밋이 수동일 것. 그리고 다섯째로 목적지가 인박스 트랜잭션을 선언하지 않을 것이다.
다섯째가 중요하다고 javadoc 이 직접 말한다. 목적지가 인박스 트랜잭션을 선언한다는 것은 부작용이 데이터베이스에 있다는 뜻이고, Kafka 트랜잭션은 거기까지 걸칠 수 없다. 둘을 함께 설정할 수 있게 두면 팀이 "트랜잭션"이라는 단어를 두 번 읽고 경로 전체가 원자적이라고 결론짓게 된다는 것이다.
## 그 검증기는 어디에서도 실행되지 않는다
스타터에는 기동 시 프로파일마다 검증기를 돌리는 래퍼가 있다. 그 래퍼로 감싸인 검증기가 셋이다. 목적지 프로파일 검증기는 래퍼 대신 직접 호출로 돈다.
트랜잭션 검증기는 그냥 빈이다. 자동설정 밖에서 그것을 언급하는 프로덕션 코드가 0 이고, 그것을 만드는 테스트도 0 이다.
다섯 규칙은 main 에서도 test 에서도 한 번도 실행되지 않는다.
## 같은 결함이 이 스타터에서 한 번 고쳐졌다
래퍼 클래스의 javadoc 이 왜 만들어졌는지 적는다.
Kafka 와 Rabbit 과 보안 검증기가 전부 빈이었고 어디에도 주입되지 않았다. 컨텍스트는 브로커마다 검증기를 발행했고 아무것도 검증하지 않았다.
그다음 문장이 결과를 적는다. 브로커가 줄 수 없는 보증을 약속하는 프로파일이 — 비트랜잭션 생산자 위의 정확히 한 번 주장, 복제본 하나짜리의 정족수 확인, 프로덕션 리스너의 평문 자격증명이 — 깨끗하게 부팅한 뒤 그것에 의존하는 첫 메시지에서 실패한다. 그것을 알게 되는 자리로는 틀린 곳이다.
그 수정이 그 세 검증기에 적용됐다. 트랜잭션 검증기가 남았다.
## 감쌀 수 없는 이유는 인자 수가 아니다
래퍼는 프로파일 공급자와 소비자 함수를 받는다. 인자 수 자체는 장애가 아니다 — 나머지 둘을 캡처하는 람다면 타입이 맞는다.
걸리는 것은 두 번째 인자다. 트랜잭션 식별자 접두는 이 저장소의 main 에서 이 검증기의 파라미터 이름과 그 javadoc 과 그것을 검사하는 조건문, 셋으로만 존재한다. 브로커 프로파일에도 설정 키에도 그 값이 없다.
감쌀 자리보다 공급할 값이 먼저 없다.
## 확인하지 못한 것
검증기가 실제로 건너뛰는지 컨텍스트를 세워 보지는 않았다. 판정 근거가 감싸기 목록과 언급 계수의 대조라, 리플렉션으로 부르는 경로까지는 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,149 @@
---
kind: CASE
slug: a05-f006-stable
title: 안정 등급으로 광고한 증거를 만드는 매니저가 어디서도 만들어지지 않는다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f006-stable
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f006-stable
file: ../../../final/evidence/rendered/a05-f006-stable.svg
- key: a05-f006-stable-chain
file: ../../../final/evidence/rendered/a05-f006-stable-chain.svg
evidence:
- ../../../final/evidence/raw/a05-f006-stable.txt
- ../../../final/evidence/raw/a05-f006-stable-chain.txt
source:
- 분석 문서는 persistence-jpa 편 §23 이고 세부는 §23.1 이다. 능력이 안정으로 보고되는데 매니저 생성이 0 이라는 판정과, 루트의 몫으로 남은 분류기 팩토리에도 소비자가 없다는 관찰이 거기 있다. 같은 문서 §23.5 가 범위를 한정한다.
---
# 안정 등급으로 광고한 증거를 만드는 매니저가 어디서도 만들어지지 않는다
완료 증거 능력이 안정 등급으로 보고된다. 그 증거를 만드는 트랜잭션 매니저를 생성하는 코드는 자기 파일의 정적 팩토리뿐이고, 그것을 부르는 곳이 프로덕션에도 테스트에도 없다.
## 관계
- **커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지**
이 능력이 만드는 증거 모델이다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
이 사례가 그 규칙의 형태다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
등급과 실제의 거리를 다룬 결정이다.
## 문제
완료 증거란 커밋 진행 지점을 남길 수 있는가의 문제다. 그 기록이 없으면 커밋 실패를 롤백된 것과 결과 미상으로 나눌 수 없다.
이 능력의 등급은 능력 리포트에 안정으로 올라 있다.
## 결론
계수는 이렇다. 매니저를 만드는 코드 0, 클래스 이름을 언급하는 다른 프로덕션 파일 1 이고 그것도 javadoc 안이다. 설정 리소스에 FQCN 0, 상속 0, 트랜잭션 매니저 빈을 등록하는 main 코드 0 이다.
그래서 완료 불명 예외는 출하 조립에서 던져질 경로가 없다. 그것을 만드는 프로덕션 코드는 분류기 한 곳이고, 그 분류기를 부르는 프로덕션 코드는 매니저의 커밋 catch 한 곳이며, 그 매니저를 설치하는 코드가 없다.
컴포지션 루트의 javadoc 에는 루트에서 만들면 ORM 타입이 루트의 컴파일 클래스패스에 올라오기 때문에 매니저를 영속성 리프 안에서 만든다고 적혀 있다. 그 설명은 클래스패스에서 사실로 확인된다. 그런데 영속성 리프 쪽에도 그것을 만드는 코드가 없다.
그 javadoc 은 루트가 맡을 것으로 둘을 든다. 설치 여부의 결정, 그리고 거기에 쓸 커밋 실패 분류기다. 팩토리는 있는데 그것을 부르는 코드가 없다. 그 팩토리를 담은 클래스는 스프링 설정이 아니라 평범한 클래스이고, 그것을 쓰는 프로덕션 코드가 부르는 메서드는 실행기와 재시도 코디네이터 둘뿐이다.
프레임이 안 만들어지는 것은 아니다. 실제로 조립되는 실행기는 트랜잭션마다 프레임을 밀어 넣는다. 그 프레임을 커밋 단계로 옮기는 것이 설치되지 않는 매니저뿐이라 프레임은 시작 전 상태로 남는다.
매니저의 javadoc 에는 코드와 맞지 않는 문단도 있다. 증거가 모든 경로에서 지워진다고 적는데, 커밋과 롤백의 finally 는 둘 다 비어 있고 여기서 지우지 않는다고 주석이 달려 있다. 꺼내는 쪽은 실행기의 스코프이고 매니저가 하는 일은 단계 표시다. 두 주인이 꺼내던 시절의 서술이 남은 것이다.
단계를 읽는 접근자도 아무도 부르지 않는다. 분류기가 프레임에서 꺼내는 것은 작업 이름과 시작 시각과 시도 횟수와 조정 키이고, 단계는 보지 않는다. 단계에 민감해지는 것은 검사 때문이 아니라 어디서 부르느냐 때문이다.
그 순서를 실제로 돌리는 테스트도 없다. 이름만 같은 테스트가 정작 그 타입을 건드리지 않고, 순서는 다른 시험이 본다고 자기 javadoc 에 적어 둔다.
범위는 한정된다. 정규 트랜잭션 경로 쪽은 다른 감시자가 커밋 예외를 불확정 결과로 바꿔 놓고 재실행은 하지 않는다. 없는 것은 자동 재시도 안전이 아니라 안정 등급으로 내건 조정 증거 쪽이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 생성 지점과 빈 등록의 정적 계수, javadoc 과 코드 대조, 예외 생산·호출 사슬 추적
소스 수정 : x
## 재현 조건
1. 능력 리포트에서 이 능력의 등급을 확인한다.
2. 매니저의 javadoc 과 같은 파일의 doCommit·doRollback 을 나란히 읽는다.
3. 그 매니저를 만드는 코드를 센다. 자기 파일과 javadoc 을 뺀다.
4. 설정 리소스의 FQCN 과 상속과 트랜잭션 매니저 빈 등록을 각각 센다.
5. 루트가 몫이라고 적은 분류기 팩토리의 호출자를 센다.
6. 완료 불명 예외의 생산 지점과 그것을 부르는 지점을 따라간다.
7. 단계를 읽는 접근자의 호출자를 센다.
## 본문
<!-- body:start -->
완료 증거는 트랜잭션이 어디까지 갔는지를 기록한다. 커밋 실패를 롤백된 것과 결과를 모르는 것으로 나누려면 그 기록이 있어야 한다.
능력 리포트는 이 능력을 안정 등급으로 보고한다.
## javadoc 이 주장하는 것과 코드가 하는 것
:::evidence key="a05-f006-stable" alt="능력 리포트가 완료 증거 능력에 매긴 등급, 그 증거를 만드는 트랜잭션 매니저의 javadoc 과 같은 파일의 커밋·롤백 구현, 그 매니저를 만드는 코드와 자기 파일을 뺀 생성 지점 수, 설정 리소스의 FQCN 과 상속 수, 그리고 트랜잭션 매니저 빈을 등록하는 main 과 test 코드 수를 출력한 터미널 기록." caption="능력 등급은 stable · javadoc 의 정리 규칙과 비어 있는 finally · 자기 파일 밖 생성 0 · 설정 리소스 0 · 상속 0 · 매니저 빈 main 0, test 1 — 56줄 · exit 0" zoom="true"
:::
매니저의 javadoc 에는 단계가 제공자 커밋 직전에 표시되고 그 뒤에는 표시되지 않는다고 한 문장으로 적혀 있다. 커밋 안에서 죽으면 마지막으로 기록된 것은 `we asked, we do not know` 이고, javadoc 은 그것이 롤백으로 오인되어서는 안 되는 상태라고 적는다.
같은 javadoc 에는 증거가 커밋 성공과 실패와 롤백과 정리, 모든 경로에서 지워진다는 정리 규칙도 적혀 있다. 코드는 그렇지 않다.
```java
} finally {
// Deliberately not cleared here. The executor's scope owns the frame's lifetime; a second
// owner popping was how an inner REQUIRES_NEW transaction deleted its outer frame.
}
```
커밋과 롤백의 `finally` 가 둘 다 비어 있고 같은 주석이 붙어 있다. 프레임을 꺼내는 것은 실행기의 스코프뿐이고 매니저는 단계만 표시한다. 그 javadoc 문단은 주인이 둘이던 시절의 서술이 남은 것이다.
## 그 매니저를 만드는 코드가 없다
자기 파일 안에 정적 팩토리가 있고, 그것을 부르거나 생성자를 쓰는 코드는 자기 파일 밖에 0 이다. 클래스 이름을 언급하는 다른 프로덕션 파일은 하나뿐이고 그것도 자동설정의 javadoc 안이다.
설정 리소스에 FQCN 이 나오는 곳도 0 이고, 상속하는 코드도 0 이다. 트랜잭션 매니저 빈을 등록하는 main 코드도 0 이다. 테스트에 하나 있는데, 그것은 자동설정 시험이 조건을 만족시키려고 세운 평범한 매니저다.
이 저장소가 등록하는 매니저가 없다는 뜻이고, 매니저가 없다는 뜻은 아니다. 부트의 JPA 자동설정이 평범한 것을 넣고, 그것은 단계를 표시하지 않는다.
## 루트가 남긴 몫도 비어 있다
:::evidence key="a05-f006-stable-chain" alt="컴포지션 루트가 매니저를 만들지 않는 이유와 루트의 몫으로 지목한 두 가지를 적은 javadoc, 그 분류기 팩토리의 호출자 수, 그 클래스가 스프링 설정이 아니라는 서술과 그것을 쓰는 프로덕션 코드가 부르는 메서드, 완료 불명 예외의 생산 지점과 그것을 부르는 지점, 분류기가 프레임에서 꺼내는 값과 단계 접근자의 호출자 수, 조립되는 실행기가 프레임을 미는 줄, 그리고 같은 이름의 테스트가 그 타입을 참조하는 횟수를 출력한 터미널 기록." caption="루트가 만들지 않는 이유와 남긴 몫 둘 · 분류기 팩토리 호출자 0 · 예외 생산 1곳과 호출 1곳 · 분류기는 단계를 보지 않음 · 단계 접근자 호출자 0 · 실행기는 프레임을 만듦 — 45줄 · exit 0" zoom="true"
:::
javadoc 이 루트가 여기서 만들지 않는 이유를 적는다. 매니저는 영속성 리프 안에서 만들어지고, 루트가 만들면 `jakarta.persistence``org.hibernate` 가 루트의 컴파일 클래스패스에 올라온다는 것이다.
그 이유는 클래스패스 구성으로 성립한다. 다만 영속성 리프 안에도 그것을 만드는 코드는 없다. javadoc 은 일어난 일이 아니라 일어났어야 할 일을 서술한다.
같은 javadoc 이 루트의 몫으로 둘을 지목한다. 설치할지 말지의 결정과 그것이 쓸 커밋 실패 분류기다.
분류기를 만드는 팩토리는 존재하고, 부르는 코드는 0 이다. 그 팩토리를 담은 클래스는 `@Configuration``@Bean` 도 없는 평범한 클래스이고, 스스로 그렇게 적는다. 그것을 쓰는 유일한 프로덕션 코드가 부르는 메서드는 실행기와 재시도 코디네이터 둘이다.
## 예외로 가는 길이 한 줄씩 끊긴다
완료 불명 예외를 만드는 프로덕션 코드는 분류기 97행 한 곳이다. 그 분류기의 번역 메서드를 부르는 프로덕션 코드는 매니저 57행의 커밋 catch 한 곳이다. 그 매니저를 설치하는 코드가 0 이다.
분류기가 프레임에서 꺼내는 것은 작업 이름, 시작 시각, 시도 횟수, 조정 키다. 단계는 보지 않는다. 단계 민감성은 검사가 아니라 호출 위치에서 나온다. 단계를 읽는 접근자를 부르는 코드는 저장소 전체에 0 이다.
## 프레임은 만들어지고, 단계만 오르지 않는다
조립되는 실행기가 트랜잭션마다 프레임을 민다. 그 실행기는 플랫폼 트랜잭션 매니저 빈이 있을 때 붙는 빈이고, 부트가 넣은 매니저가 그 조건을 만족시킨다.
그 프레임을 활성과 커밋 중과 커밋됨으로 옮기는 것은 설치되지 않는 매니저뿐이다. 프레임은 시작 전 상태로 남는다.
순서를 실행하는 테스트도 없다. 같은 이름의 테스트는 그 타입을 한 번도 참조하지 않고 컨텍스트와 분류기를 따로 검증하며, 자기 javadoc 이 실제 순서는 커밋 모호성 계약 시험이 본다고 적는다.
## 범위
이 사건이 모든 유스케이스가 불확정 커밋을 중복 실행한다는 뜻은 아니다. 애플리케이션의 정규 트랜잭션 경로는 별도의 스프링 동기화 감시자로 커밋 예외를 불확정 결과로 되돌리고 재실행하지 않는다.
빠진 것은 자동 재시도 안전이 아니라, 안정 등급으로 광고한 조정 증거다. 지속되는 기록도 런북 지표도 없고, 애플리케이션이 돌려주는 불확정 결과에도 조정 참조가 비어 있다.
## 확인하지 못한 것
애플리케이션을 부팅해 어떤 트랜잭션 매니저가 실제로 쓰이는지 관측하지 않았다. 조립 코드에 그것을 만드는 자리가 없다는 것까지만 확인했다.
<!-- body:end -->
@@ -0,0 +1,185 @@
---
kind: CASE
slug: a05-f022-stable
title: 검증기는 도는데 정책을 넘기는 한 번의 호출이 없다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f022-stable
evidenceCapturedOn: 2026-09-02
body: case-a05-f022-stable.body.md
assets:
- key: a05-f022-stable
file: ../../../final/evidence/rendered/a05-f022-stable.svg
evidence:
- ../../../final/evidence/raw/a05-f022-stable.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §69 다. 등급은 P1 이다. 검증기 자체는 빈으로 구성되지만 정책을 적용하는 호출자가 없다는 판정과, 액추에이터 불리언이 생성 권한 일부만 확인한다는 관찰이 그 절에 있다.
- 검증기 javadoc 의 두 주장과 실제 호출 시점·예외 처리의 대조, 그리고 두 시험이 각각 절반만 덮는다는 것은 이 기록에서 확인했다.
---
# 검증기는 도는데 정책을 넘기는 한 번의 호출이 없다
런타임 롤 검증기가 빈으로 등록되고 프로덕션에서 실제로 권한을 읽는다. 다만 기동 시점이 아니라 액추에이터 리포트를 만들 때이고, 읽은 결과를 정책에 넘기지 않는다. 검증기 자신의 javadoc 은 기동 시점에 돌고 닫힌 방식으로 실패한다고 적는다.
## 관계
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
그 대응이 깨지는 경우다. 루트가 있고 검증기가 빈으로 등록되고 호출까지 되는데, 정책을 넘기는 호출만 빠져 있다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
등급이 뜻하는 것과 문서가 약속한 것이 다른 경우다.
- **RLS가 성립하기 위한 세 전제**
런타임 롤 속성이 격리 판정에 관여하는 다른 국면이다.
## 문제
보안 문서가 기동 실패 조건을 적는다. 런타임 롤이 허용 목록에 없거나 스키마나 데이터베이스에 생성 권한을 가지면 기동이 실패한다는 것이다.
검증기의 클래스 javadoc 은 더 직접적이다. 검증이 기동 시점에 돌고 닫힌 방식으로 실패한다고 적는다.
## 결론
배선 자체는 되어 있다. 자동설정이 검증기를 빈으로 만들고, 액추에이터 리포트를 만들 때마다 roleVerifier.verify(dataSource) 를 부른다.
기동 시점이 아니다. 기동 검사 빈에 걸린 것은 위험 설정 가드 하나이고, 그 가드의 인자에 롤 정책이 없다.
닫히지도 않는다. 리포트를 만드는 쪽이 검증기의 예외를 잡아 널을 돌려준다. 실패는 기동을 막는 대신 미검증 표시가 된다.
정책이 보는 항목은 넷이다. 허용 목록, 스키마 생성 권한, 데이터베이스 생성 권한, 검색 경로다. 그 정책에 검증 결과를 넘기는 두 인자짜리 메서드가 검증기에 있고, 그것을 부르는 곳은 코드베이스 전체에 하나다. PostgreSQL 보안 계약 시험이다.
정책 객체를 만드는 main 코드는 0 이다. 언급하는 파일을 세면 자기 자신, 검증기, 시험 둘이다.
액추에이터의 검증 완료 표시는 별개의 문제다. 권한 보고서가 널이 아닌지와 생성 권한을 갖지 않는지 둘로 계산한다. 그 넷 중 가운데 둘만 들어간다.
그 위 javadoc 은 현재 사용자와 검색 경로를 하나의 불리언으로 일부러 줄였다고 적고, 운영자는 그 롤이 검증을 통과했는지를 알면 된다고 덧붙인다. 이 축소가 하는 일은 둘이다. 리포트에서 어느 롤인지가 빠지고, 그 롤이 허용 목록과 검색 경로 정책을 통과했는지도 같이 빠진다.
그 불리언을 고정하는 시험 셋은 입력의 롤이 전부 허용된 이름이고 검색 경로도 전부 안전하다. 허용 목록 밖 롤을 넣은 입력이 없다. 정책 쪽 단위 시험은 바로 그 두 조합을 넣지만 액추에이터 불리언은 보지 않는다.
능력 등급 자체는 다른 이야기다. 안정 등급이란 계약 시험 스위트가 매트릭스 전체를 검증했다는 뜻이고, 그 시험 자체는 존재한다. 어긋난 것은 등급이 아니라 문서와 javadoc 이 약속한 기동 실패, 그리고 액추에이터 불리언의 의미다.
## 검증 환경
OpenJDK : 해당 없음. 정적 검색이다.
확인 방식 : 검증기 호출 경로 추적, 정책의 검사 항목 열람, 액추에이터 계산식과 그 시험 입력 대조
소스 수정 : x
## 재현 조건
1. 검증기의 클래스 javadoc 과 보안 문서의 기동 실패 조건을 읽는다.
2. 검증기의 verify 를 부르는 프로덕션 코드를 찾고, 그 호출이 언제 일어나는지 본다.
3. 그 호출을 감싼 코드가 예외를 어떻게 다루는지 읽는다.
4. 정책이 검사하는 항목을 열거하고, 정책을 넘기는 두 인자짜리 메서드의 호출처를 레포 전체에서 센다.
5. 정책 객체를 만드는 main 코드와 그 타입을 언급하는 파일을 센다.
6. 액추에이터의 검증 완료 계산식과 그것을 고정하는 시험의 입력을 나란히 본다.
7. 안정 등급의 정의를 읽는다.
## 본문
<!-- body:start -->
검증기의 클래스 javadoc 이 이렇게 적는다.
```text
* <p>The verification runs at startup and fails closed. Discovering after an incident that the
* application's own credential could drop tables is discovering it too late.
```
보안 문서도 같은 방향으로 적는다. 롤이 허용 목록에 없거나 생성 권한을 가지면 기동이 실패한다는 것이다.
## 검증기는 돈다. 기동 시점이 아닐 뿐이다
:::evidence key="a05-f022-stable" alt="검증기 클래스의 javadoc 주장, 정책이 검사하는 네 항목, 검증기를 프로덕션에서 부르는 코드와 그 예외 처리, 정책을 넘기는 두 인자짜리 메서드와 그 호출처와 정책 객체를 만드는 코드 수, 기동 검사 빈이 실행하는 것, 액추에이터의 검증 완료 계산식과 그 위 javadoc, 그 표시를 고정하는 시험의 입력과 정책 쪽 단위 시험의 입력, 그리고 안정 등급의 정의를 출력한 터미널 기록." caption="javadoc 은 기동 시점·닫힌 실패를 주장 · 정책은 네 항목 검사 · verify 는 리포트 요청 때 불리고 예외는 널로 삼켜짐 · 두 인자짜리 호출처는 계약 시험 하나, 정책 생성 0 · 표시는 네 항목 중 둘만 · 시험 입력에 허용 목록 밖 롤 없음 — 63줄 · exit 0" zoom="true"
:::
```java
private DatabasePrivilegeReport readPrivileges(DataSource dataSource) {
try {
return roleVerifier.verify(dataSource);
} catch (IllegalStateException unverified) {
return null;
}
}
```
액추에이터 리포트를 만들 때 불린다. 기동 검사 빈이 실행하는 것은 위험 설정 가드 하나이고, 그 가드는 롤 정책을 인자로 받지 않는다.
닫히지도 않는다. 검증기의 예외는 널이 되고, 널은 미검증 표시가 된다.
## 정책을 넘기는 호출이 없다
정책은 네 항목을 검사한다.
```java
if (!allowedRoles.contains(currentUser)) { ... }
if (report.canCreateInSchema()) { ... }
if (report.canCreateInDatabase()) { ... }
searchPathPolicy.requireSafe(report.searchPath());
```
검증 결과를 그 정책에 넘기는 메서드는 검증기에 있다.
```java
public void requireSafe(DataSource dataSource, DatabaseRolePolicy policy) {
Objects.requireNonNull(policy, "policy");
policy.requireSafe(verify(dataSource));
}
```
그것을 부르는 곳은 코드베이스 전체에 하나이고, PostgreSQL 보안 계약 시험이다. 정책 객체를 만드는 main 코드는 0 이므로 프로덕션에서는 그 메서드를 부를 수도 없다. 정책 타입을 언급하는 파일은 자기 자신과 검증기, 그리고 시험 둘뿐이다.
## 액추에이터 불리언의 계산식
```java
privileges != null && !privileges.holdsCreatePrivilege(),
```
정책이 검사하는 네 항목 중 가운데 둘만 들어간다. 허용 목록도 검색 경로도 계산에 없다.
그 위 javadoc 이 이렇게 적는다.
```text
* <p>The privilege report's {@code currentUser} and {@code searchPath} are deliberately reduced
* to a single boolean here: an operator needs to know the runtime role passed verification, not
* which role it is.
```
축소는 두 가지를 동시에 한다. 어느 롤인지를 리포트에서 지우고, 그 롤이 허용 목록과 검색 경로 정책을 통과했는지도 함께 지운다. 문서가 기동 실패 조건으로 지목한 값이 그 둘이다.
같은 불리언이 플랫폼 안전 판정에도 그대로 들어간다.
```java
return openInViewDisabled && runtimeRoleVerified;
```
## 두 시험이 각각 절반만 덮는다
이 불리언을 고정하는 시험은 셋인데, 입력의 롤이 전부 `app_runtime` 이고 검색 경로도 전부 안전하다.
```text
new DatabasePrivilegeReport("app_runtime", "app, pg_catalog", false, false)
new DatabasePrivilegeReport("app_runtime", "app", true, false)
```
허용 목록 밖 롤을 넣은 입력이 없으니 시험은 통과한다.
정책 쪽 단위 시험은 바로 그 조합을 넣는다.
```text
new DatabasePrivilegeReport("postgres", "app", false, false)
new DatabasePrivilegeReport("app_runtime", "app, public", false, false)
```
다만 그 시험은 정책 객체만 보고 액추에이터 불리언은 보지 않는다. 둘 사이의 틈을 아무도 보지 않는다.
## 등급은 어긋나지 않았다
안정 등급의 정의는 계약 시험 스위트가 전체 PostgreSQL 매트릭스에서 검증했다는 것이고, 그 시험은 실제로 있다. 두 인자짜리 호출이 있는 유일한 자리가 바로 그 시험이다.
어긋난 것은 등급이 아니다. 문서와 javadoc 이 약속한 기동 실패가 어디서도 일어나지 않고, 액추에이터가 내는 판정이 정책의 네 항목 중 둘만 반영한다.
## 확인하지 못한 것
허용 목록 밖 롤로 기동해 실패하지 않는 것을 재현하지 않았다. 정적 도달성과 계산식까지만 확인했다.
<!-- body:end -->
@@ -0,0 +1,87 @@
---
kind: CASE
slug: the-support-matrix-says-nothing-is-deployed-and-eighteen-are
title: 운영자용 지원 매트릭스가 런타임 편입을 반대로 적고, 틀린 쪽이 옳은 쪽을 권위로 지목한다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-support-matrix-says-nothing-is-deployed-and-eighteen-are
evidenceCapturedOn: 2026-09-01
assets:
- key: the-support-matrix-says-nothing-is-deployed-and-eighteen-are
file: ../../../final/evidence/rendered/the-support-matrix-says-nothing-is-deployed-and-eighteen-are.svg
evidence:
- ../../../final/evidence/raw/the-support-matrix-says-nothing-is-deployed-and-eighteen-are.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md §17 이다.
---
# 운영자용 지원 매트릭스가 런타임 편입을 반대로 적고, 틀린 쪽이 옳은 쪽을 권위로 지목한다
지원 매트릭스가 messaging 리프는 모두 어느 배포에도 편입되지 않았다고 적는다. 레지스트리는 25개 중 18개가 출하 애플리케이션에 편입되어 있다고 말한다. 틀린 문단이 권위로 지목하는 문서는 이미 그 사실을 정정했다.
## 관계
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
이 사례가 만든 규칙의 상위형이다.
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례는 과소 진술이고 방향이 반대다.
- **다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다**
같은 형태가 다른 숫자에서 나타난 사례다.
## 문제
운영자가 messaging 플랫폼을 도입할 때 먼저 읽는 문서가 지원 매트릭스다. 그 문서에 어떤 리프가 실제 배포에 들어가는지를 적은 문단이 있다.
## 결론
그 문단이 반대를 적는다.
문서는 registry 의 messaging 리프가 모두 런타임 편입이 비어 있고 어느 composition root 에도 들어가지 않는다고 적는다. 현재 레지스트리는 25개 중 18개가 출하 애플리케이션 소속이고, 그 문서가 속한 리프 자신이 그 안에 있다.
형태가 특이한 것은 틀린 문단이 자기 권위로 지목하는 문서가 이미 정정을 마쳤다는 점이다. 그 문서는 같은 사실을 고쳤고 결론까지 적어 두었다.
> 정확한 목록은 registry 가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift 한다
그 결론이 지원 매트릭스에는 적용되지 않았다. 같은 리비전에서 두 문서가 모순되고, 틀린 쪽이 옳은 쪽을 가리키고 있다.
운영자에게 남는 결과는 구체적이다. 배포 아티팩트가 실제로 이 리프들을 싣고 설정 한 줄로 켜진다는 사실을 문서에서 알 수 없다. 켜져 있는 것을 꺼져 있다고 읽는 방향이므로 과대 진술보다 덜 위험하지만, 그 대신 도입 검토 자체가 잘못된 전제 위에서 이뤄진다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 레지스트리의 런타임 편입 필드 집계와 두 문서의 해당 문단 대조
소스 수정 : x
## 재현 조건
1. 레지스트리에서 messaging 리프의 런타임 편입 필드를 전부 세어 비어 있지 않은 것의 수를 구한다.
2. 지원 매트릭스에서 편입을 서술하는 문단을 찾는다.
3. 그 문단이 권위로 지목하는 문서의 해당 절을 읽는다.
## 본문
<!-- body:start -->
지원 매트릭스가 "registry 의 messaging leaf 는 모두 `runtime_memberships` 가 비어 있고 어느 composition root 에도 편입되지 않았다" 고 적는다. 현재 레지스트리는 25개 중 18개가 `["app-bootstrap"]` 이고 `messaging-core-api` 자신이 그 안에 있다.
## 매트릭스의 문장과 레지스트리의 값
:::evidence key="the-support-matrix-says-nothing-is-deployed-and-eighteen-are" alt="분석 문서 analysis/messaging/messaging-core-api.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-core-api.md 발췌 — 18줄" zoom="true"
:::
## 틀린 문단이 권위로 지목하는 문서는 이미 정정을 마쳤다
`src/messaging/CLAUDE.md` 는 같은 사실을 고쳤고 "정확한 목록은 registry 가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift 한다" 는 결론까지 적었다. 그 결론이 지원 매트릭스에는 적용되지 않았다.
## 운영자가 문서에서 알 수 없는 것
배포 아티팩트가 실제로 이 리프들을 싣고 `app.messaging.enabled` 하나로 켜진다는 사실이다.
## 확인하지 못한 것
없다. 레지스트리와 두 문서를 전수 대조했다.
<!-- body:end -->
@@ -0,0 +1,95 @@
---
kind: CASE
slug: the-transport-and-the-validator-answer-differently
title: 같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 record 의 의미와 어긋난다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-transport-and-the-validator-answer-differently
evidenceCapturedOn: 2026-09-01
assets:
- key: the-transport-and-the-validator-answer-differently
file: ../../../final/evidence/rendered/the-transport-and-the-validator-answer-differently.svg
evidence:
- ../../../final/evidence/raw/the-transport-and-the-validator-answer-differently.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-pulsar-experimental.md §17.1 이다.
---
# 같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 record 의 의미와 어긋난다
Pulsar 어댑터에서 키 공유 구독의 능력을 전송과 검증기가 다르게 답한다. 성분 문서를 기준으로 보면 검증기 쪽이 맞고 전송 쪽이 자기 안에서 모순인데, 런타임이 읽는 것은 전송 쪽이다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 사례가 속한 구조다.
- **능력 플래그의 무게는 그것을 읽는 코드가 정한다**
어느 쪽이 틀렸는지가 아니라 어느 쪽이 읽히는지가 심각도를 정한다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 판정 절차의 일반형이다.
## 문제
이 어댑터는 두 구독 종류를 노출한다. 공유 구독은 경쟁 소비자에 순서 없음이고, 키 공유 구독은 경쟁 소비자에 키별 순서다.
능력을 답하는 자리가 둘이다. 전송이 구독 종류에 따라 두 상수 중 하나를 고르고, 검증기가 같은 판단을 자기 메서드로 한다.
## 결론
키 공유에 대해 두 답이 갈린다.
전송은 순서 있는 스트림을 거짓, 키별 순서를 참으로 답한다. 검증기는 둘 다 참으로 답한다.
성분 문서가 판정 기준이다. 순서 있는 스트림은 순서 단위 안에서 순서가 보존되는지를 뜻하고, 키 공유의 순서 단위는 키다. 그 단위 안에서 순서는 보존된다. 그러므로 검증기 쪽이 문서화된 의미와 맞다.
전송 쪽은 자기 안에서도 모순이다. 키별 순서를 참이라고 하면서 순서 있는 스트림을 거짓이라고 하면, 순서가 보존되는 단위가 있는데 그 단위 안에서 순서가 보존되지 않는다는 말이 된다.
그리고 어긋난 쪽이 런타임이 읽는 쪽이다. 목적지별 능력을 돌려주는 것은 SPI 메서드이고 그것을 구현하는 것은 전송이다. 순서 있는 스트림은 이 저장소에서 production 코드가 실제로 읽는 몇 안 되는 능력 중 하나로, 재시도 결정 엔진이 그 값을 보고 순서 보존 재시도를 고를지 정한다. 결과적으로 키별 순서를 약속한 목적지가 순서 보존 재시도를 받지 못한다.
두 리터럴을 묶는 것은 아무것도 없다. 열두 개의 불리언이 두 파일에 각각 손으로 적혀 있다. 테스트는 키별 순서만 단언하고 순서 있는 스트림은 보지 않는다.
자매 어댑터인 NATS 는 두 곳이 같은 값을 답한다. 다만 그 일치도 공유가 아니라 손으로 복사한 리터럴이므로, 오늘 같다는 것이 내일도 같으리라는 보장은 코드에 없다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 두 열두 성분 리터럴의 성분별 대조와 성분 문서 확인
소스 수정 : x
## 재현 조건
1. 전송의 키 공유용 능력 상수 열두 성분을 순서대로 적는다.
2. 검증기의 능력 메서드가 키 공유에 대해 만드는 열두 성분을 적는다.
3. 두 목록을 성분별로 대조한다.
4. 능력 record 의 성분 문서에서 두 이름의 정의를 읽는다.
5. 순서 있는 스트림을 읽는 production 코드를 찾는다.
## 본문
<!-- body:start -->
Key_Shared 구독에 대해 전송은 `orderedStream=false, keyedOrdering=true` 를, 검증기는 `orderedStream=true, keyedOrdering=true` 를 답한다.
## 성분 문서가 판정 기준이다
`orderedStream` 은 "순서 단위 안에서 순서가 보존되는가" 이고 Key_Shared 의 순서 단위는 키다. 그러므로 검증기 쪽이 문서화된 의미와 맞고, 전송 쪽은 자기 안에서 모순이다.
## 어긋난 쪽이 런타임이 읽는 쪽이다
:::evidence key="the-transport-and-the-validator-answer-differently" alt="코드베이스에서 DefaultRetryDecisionEngine 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultRetryDecisionEngine 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
`capabilities(DestinationName)` 이 SPI 메서드이고 `orderedStream` 은 production 코드가 실제로 읽는 세 능력 중 하나다 — `DefaultRetryDecisionEngine` 이 그 값으로 순서 보존 재시도를 고른다.
## 두 리터럴을 묶는 것이 없다
테스트는 `keyedOrdering` 만 단언해 `orderedStream` 을 보지 않는다. 자매 어댑터 NATS 는 두 곳이 같은 값을 답하지만 그 일치도 공유가 아니라 손으로 복사한 리터럴이다.
## 확인하지 못한 것
두 답이 실제 재시도 선택을 어떻게 가르는지 실행으로 재현하지 않았다. 이 가족은 배선 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CONCEPT
slug: three-sources-of-a-capability-answer
title: 능력 선언의 세 출처와 그것이 파생되지 않을 때
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:three-sources-of-a-capability-answer
evidenceCapturedOn: 2026-09-01
assets:
- key: three-sources-of-a-capability-answer
file: ../../../final/evidence/rendered/three-sources-of-a-capability-answer.svg
- key: three-sources-of-a-capability-answer-diagram
file: ../../../final/assets/diagrams/three-sources-of-a-capability-answer.svg
evidence:
- ../../../final/evidence/raw/three-sources-of-a-capability-answer.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md §4.12 · analysis/99-cross-scope.md §3.2 이다.
---
# 능력 선언의 세 출처와 그것이 파생되지 않을 때
이 플랫폼에서 어댑터가 무엇을 증명할 수 있는지에 답하는 곳이 셋이다. 전송의 능력 상수, 검증기의 같은 이름 메서드, 그리고 운영자가 읽는 지원 매트릭스. 셋이 같은 값을 답해야 한다는 것이 계약인데 그것을 붙드는 장치가 없다.
## 관계
- **능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다**
이 개념에서 나온 규칙이다.
- **능력 플래그의 무게는 그것을 읽는 코드가 정한다**
같은 개념의 심각도 판정 쪽이다.
- **능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다**
이 개념이 실제로 발현한 사례다.
## 본문
<!-- body:start -->
이 플랫폼에서 "이 어댑터가 무엇을 증명할 수 있는가" 에 답하는 곳이 셋이다.
## 능력을 답하는 세 자리
:::evidence key="three-sources-of-a-capability-answer-diagram" alt="능력 질문에서 전송의 상수와 검증기의 메서드와 지원 매트릭스 문서 세 갈래가 나온다" caption="능력을 답하는 세 자리" zoom="false"
:::
전송의 `MessagingCapabilities` 상수(SPI `capabilities(DestinationName)` 가 런타임에 돌려주는 값), 검증기의 같은 이름 메서드(기동 시점 판정용), 그리고 운영자가 읽는 지원 매트릭스 문서다.
## MessagingCapabilities 참조 위치
:::evidence key="three-sources-of-a-capability-answer" alt="코드베이스에서 MessagingCapabilities 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilities 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 열두 성분과 그 소유자
전부 `boolean` 이고 의미는 record javadoc 이 소유한다 — `brokerAcknowledgement` · `replicationOrPersistenceEvidence` · `perMessageSettlement` · `batchSettlement` · `orderedStream` · `keyedOrdering` · `replay` · `delayedDelivery` · `brokerTransaction` · `deduplicatedPublish` · `nativeDeadLetter` · `topologyManagement`.
## 세 출처를 붙드는 장치가 없다
세 출처가 같은 값을 답해야 한다는 것이 계약인데, 그것을 붙드는 장치가 없다. 그리고 열둘의 무게가 같지 않다 — 부재가 예외를 만드는 것은 `deduplicatedPublish` 하나이고(`DefaultMessagePublisher`), 나머지는 읽히지 않거나 분기에만 쓰인다. record javadoc 이 그 위험을 미리 서술한다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."
:::note
세 출처를 전수 대조하는 스크립트를 돌리지 않았다. 어댑터별 SSOT 의 §능력 절을 읽어 대조했다
:::
## 세 출처
전송이 SPI 메서드로 돌려주는 값이 런타임의 답이다. 호출자가 목적지를 넘기면 그 목적지에 대한 능력 집합을 받는다.
검증기가 같은 이름의 메서드를 갖는다. 이쪽은 기동 시점 판정용이고, 목적지 프로파일이 요구하는 보장을 어댑터가 줄 수 있는지 확인할 때 쓴다.
지원 매트릭스 문서가 셋째다. 운영자가 브로커를 고를 때 읽는 표이고, 어댑터별로 열두 성분의 지원 여부를 적는다.
## 열두 성분
브로커 승인, 복제·지속 증거, 개별 메시지 정착, 배치 정착, 순서 있는 스트림, 키별 순서, 재생, 지연 배달, 브로커 트랜잭션, 중복 제거 발행, 네이티브 데드레터, 토폴로지 관리.
전부 불리언이고 의미는 record 의 javadoc 이 소유한다. 성분 이름만으로는 판정할 수 없는 것들이 있다. 순서 있는 스트림은 "순서 단위 안에서 순서가 보존되는가" 이고 그 단위가 무엇인지는 구독 형태가 정한다.
## 무게가 같지 않다
열둘 중 부재가 예외를 만드는 것은 중복 제거 발행 하나다. 발행자가 중복 제거를 요구하는 목적지에 대해 그 플래그를 확인하고 없으면 던진다.
나머지는 읽히지 않거나 분기에만 쓰인다. 순서 있는 스트림은 재시도 결정 엔진이 읽어 순서 보존 재시도를 고를지 정한다.
그래서 같은 정도의 과대 선언이라도 결과가 다르다. 심각도를 매기려면 그 플래그를 읽는 코드를 먼저 세어야 한다.
## 이 구조가 미리 경고한 것
능력 record 의 클래스 javadoc 이 이 상황을 서술한다.
> a silently weakened guarantee is indistinguishable from a working one until the incident.
조용히 약해진 보장은 사고가 나기 전까지 동작하는 보장과 구별되지 않는다. 세 출처가 갈리는 것이 정확히 그 형태다. 어느 것도 오류를 내지 않고, 셋 중 하나만 읽은 사람은 자기가 읽은 것이 사실이라고 믿는다.
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: REFERENCE
slug: a-capability-constant-must-derive-from-the-profile
title: 능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:a-capability-constant-must-derive-from-the-profile
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다
## 목적
어댑터가 무엇을 할 수 있는지와 이 구성에서 무엇이 성립하는지를 구분한다. 둘이 갈리는 조건이 프로파일에 있으면 상수는 그 답을 담을 수 없다.
## 규칙
1. 이 플래그가 참이 되는 조건을 문장으로 쓴다
조건이 없으면 상수가 맞다.
2. 그 문장에 프로파일 필드가 등장하는지 본다
등장하면 상수는 틀린 표현이다.
3. 파생시킬 수 없으면 검증기가 그 조건을 기동 시점에 요구한다
창이 없는 목적지를 거부하는 것도 답이다. 다만 그 검증기가 실제로 도는지를 함께 확인해야 한다.
4. 어느 쪽도 못 하겠다면 문서에 조건을 적는다
가장 약한 답이고, 문서가 코드보다 먼저 낡는다는 것을 감수하는 선택이다.
## 적용 조건
능력 record 의 모든 성분과 그에 대응하는 gRPC 쪽 선언. 브로커가 제공하는 기능을 어댑터가 대신 선언하는 자리 전부.
## 예외
어댑터가 브로커와 무관하게 항상 제공하는 성질은 상수가 맞다. 구분 기준은 이 값을 거짓으로 만드는 구성이 존재하는지이고, 존재하지 않으면 상수다.
## 예시
NATS 의 중복 제거 발행이 참인데, 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어지고 창은 선택 사항이다.
Kafka 의 브로커 트랜잭션이 참인데, 트랜잭션은 생산자에 트랜잭션 식별자가 있어야 성립한다.
Rabbit 의 지연 배달이 참인데, 그 지연을 만드는 토폴로지가 조립되지 않는다.
셋 다 형태가 같다. 조건을 아는 코드가 같은 리프에 있고, 상수가 그것을 참조하지 않는다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 규칙이 나온 구조다.
- **능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다**
이 규칙을 어긴 사례 중 가장 무거운 것이다.
- **브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다**
같은 규칙을 어기면서 검증기까지 함께 빠진 사례다.
@@ -0,0 +1,57 @@
---
kind: REFERENCE
slug: the-weight-of-a-flag-is-set-by-the-code-that-reads-it
title: 능력 플래그의 무게는 그것을 읽는 코드가 정한다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:the-weight-of-a-flag-is-set-by-the-code-that-reads-it
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 능력 플래그의 무게는 그것을 읽는 코드가 정한다
## 목적
같은 record 의 성분이라고 무게가 같지 않다. 과대 선언의 심각도를 매기기 전에 그 플래그의 소비자를 먼저 센다.
## 규칙
1. 성분 접근자 이름으로 저장소를 훑는다
호출자를 전부 모은다.
2. 호출자를 셋으로 나눈다
아무도 읽지 않음, 분기에만 쓰임, 부재가 예외를 만듦.
3. 심각도는 그 분류에서 나온다
읽히지 않는 플래그의 과대 선언은 문서 결함이고, 예외를 만드는 플래그의 과대 선언은 가드 우회다.
4. 배선되지 않은 블록에서는 미래의 소비자를 센다
지금 무게가 0 이어도 배선되면 무엇이 그것을 읽게 되는지가 답이고, 그 답은 같은 가족의 배선된 리프에 있다.
## 적용 조건
능력·기능 플래그를 담은 모든 record 와 그것을 읽는 정책 코드.
## 예외
플래그가 외부에 공개되는 계약의 일부이면 소비자 수와 무관하게 정확해야 한다. 지원 매트릭스에 실리는 값이 그렇다.
## 예시
이 플랫폼의 능력 열두 성분 중 부재가 예외를 만드는 것은 중복 제거 발행 하나다. 발행자가 그 플래그를 확인하고 없으면 던진다.
순서 있는 스트림은 재시도 결정 엔진이 읽어 순서 보존 재시도를 고를지 정한다. 분기에만 쓰이는 쪽이다.
나머지 열은 production 코드가 읽지 않는다. 같은 정도로 틀렸더라도 결과가 다르다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 규칙이 나온 구조다.
- **같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 문서화된 의미와 어긋난다**
어느 쪽이 틀렸는지보다 어느 쪽이 읽히는지가 중요했던 사례다.
- **`runtime_memberships`를 먼저 읽고 심각도를 정한다**
같은 계열의 판정 순서 규칙이다.