Files
document-haness/docs/clean-architecture-backend-template/analysis/99-cross-scope.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

553 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 99 · 교차 스코프 분석 — 사이클 2
> 상태: COMPLETE
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분모: 등록 리프 **62** (COMPLETE 61 · EXCLUDED 1 `sample-portfolio`)
> 근거: 각 리프 SSOT 문서 61건 · `evidence/raw/` 370건
> 사이클 1의 같은 경로에 초안이 있었고(364줄, 상태 `NOT_STARTED`), 이 문서가 그것을 대체한다.
> 초안은 보존하지 않았다 — 덮어쓰기 전에 읽지 않은 것은 이 사이클의 절차 실수다.
> 따라서 **사이클 1의 절 번호와 줄 번호는 이 문서에 적용되지 않는다.** `root-tree.md`가 옛 번호로
> 걸어 둔 앵커는 이 문서의 절로 다시 걸었다(§3.1 · §3.4 · §5 · §6 · §2·§4).
> 옛 초안에만 있던 서술이 있었다면 그것은 복구되지 않았다. 다만 그 초안이 인용하던 1차 근거는
> 전부 리프 SSOT와 `final/document.md`에 있으며, 이 문서는 그것들을 다시 읽고 작성했다.
> **사이클 2 후반 갱신.** 이 문서의 §0~§6 은 18개 리프 재검증까지를 반영해 쓰였다. 그 뒤 같은 사이클에서
> 23개 리프(messaging 5 · grpc 18)의 production 구현을 통독했고, 그 결과를 §1.2 · §3.7 · §4 의
> 6~9항 · §6 에 더했다.
>
> **그 통독 자체를 다시 했다 — 이 문서의 §0 · §1.2 · §3.5 · §6 숫자가 그래서 바뀌었다.** 앞선 판에서
> 23개 리프를 `FULL_READ_DONE` 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 실제로 읽은 것은 리프마다
> 일부였다. 그 표시가 사실이 아니었으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한
> 분모는 **main 357파일 / 29,542줄 · test 121파일 / 19,756줄**이고,
> 각 리프 SSOT 의 Coverage ledger 를 그 숫자로 다시 썼다. §1.2 의 결론 하나가 그 과정에서 뒤집혔다 —
> "통독이 만든 새 finding 에 P1 은 없다" 는 서술이 더 이상 참이 아니다.
> 지위: 이 문서는 **2차 증거**다. 리프의 사실은 리프 SSOT가 소유하고, 여기서는 리프 경계를 넘을 때만 성립하는 것을 다룬다.
---
## 0. 이 문서가 서 있는 분모
| 항목 | 수 |
|---|---:|
| 등록 리프 | 62 (COMPLETE 61 · EXCLUDED 1) |
| `analysisFile`을 공유하는 리프 | **0** — SSOT 게이트 통과 |
| 리프 SSOT가 확정한 finding | **462** (P1 30 · P2 147 · P3 285) — 아래 주 참조 |
| finding 0건인 리프 | 8 |
| evidence 파일 | 370 |
| 끊긴 evidence 참조 | **0** |
| 사이클 2 전수 통독 리프 | **23** (messaging 5 · grpc 18) |
| 그 23개 리프의 main production | **357파일 / 29,542줄** |
| 그 23개 문서의 finding | **100** (P1 2 · P2 31 · P3 67) |
| 그중 사이클 1 가족 문서에서 옮겨온 것 | 8 |
| 통독에서 처음 나온 것 | **92** (P1 2 · P2 25 · P3 65) |
**두 숫자의 근거가 다르다.**
23개 리프의 100건은 이번에 직접 센 것이다 — 23개 문서의 §17 에서 `### 17.n P<k> —` 형태를 파싱했고,
23개 문서 전부를 처음부터 끝까지 읽은 뒤이므로 표기 누락이 없다. 앞선 판의 60건은 통독이 실제로는
부분 통독이던 시점의 수치다.
전체 462건은 **직접 다시 센 것이 아니라 델타로 조정한 값**이다. 앞선 판의 422건에서 23개 리프 몫 60을
빼고 100을 더했다(422 60 + 100 = 462). 나머지 38개 문서는 이번 재작업의 대상이 아니었고, 그 문서들이
쓰는 「모듈 findings 표」 형식은 §17 형식과 파싱 규칙이 달라 두 형식을 함께 세는 스크립트를 이번에
다시 돌리지 않았다. 그러므로 **462는 델타가 정확하다는 가정 위에 있고, 422 자체의 재측정은 아니다.**
직접 재측정이 필요하면 두 형식을 모두 파싱하는 원래 스크립트를 61개 문서에 다시 돌려야 한다.
P1·P2·P3 내역도 같은 방식으로 조정했다 — P1 291+2=30, P2 13519+31=147, P3 25840+67=285.
---
## 1. 사이클 2가 실제로 바꾼 것
사이클 2의 재검증 대상은 사이클 1이 남긴 18개 리프 문서였다. 결과는 다음과 같다.
**소스는 움직이지 않았다.** 18개 문서가 모두 기준으로 삼은 `a24ece9c`와 현재 HEAD `21234e38` 사이는
커밋 하나이고, 그 커밋은 `src/grpc/**`·`src/grpc-advanced/**`와 공통 파일 둘만 건드렸다. 18개 리프 경로의
변경 파일 수는 전부 **0**이다. 공통 파일 둘도 이 18개에 영향이 없다 — `src/build.gradle`의 변경은
plain-JUnit 테스트 클래스패스 조건에 `:grpc:`·`:grpc-advanced:`를 더한 것뿐이고,
`modules.json`은 197줄 순수 추가로 18개 리프 id가 diff에 한 번도 등장하지 않는다 (`EVD-333`).
**그래서 재검증의 실질은 재작성이 아니라 재확인이었다.** 18개 문서의 lane을 HEAD에서 다시 돌렸고
(`EVD-334`), 실패 5건이 나왔다. 그 5건에 대한 판정은 이렇다.
| 리프 | 실패 | 사이클 1의 판정 | 사이클 2의 재측정 |
|---|---|---|---|
| `adapter-outbound-fileserver` | 1 | 환경(로케일) | **확인**`LANG=C.utf8`로 통과, `sun.jnu.encoding` ANSI→UTF-8 |
| `app-bootstrap` | 1 | 환경(`jq` 부재) + 가드 비대칭 P3 | **확인**, 그리고 남은 공백을 메움 — 15개 레인 계약을 독립 경로로 검증 |
| `adapter-outbound-httpclient` | 3 | **P1 제품 결함** | **철회** — 픽스처의 듀얼스택 호스트명이 원인 |
세 번째가 이 사이클의 유일한 판정 번복이다. 사이클 1은 `ApacheFailureClassifier`의 분기 순서를 읽고
"Apache가 TLS 실패를 `HttpHostConnectException`으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고
결론했다. 예외 사슬을 실제로 출력해 보면 그 사슬에 `SSLHandshakeException`**없다**. 원인은
`MockHttpServer.uri()`가 호스트명 `localhost`를 돌려주는데 이 컨테이너의 `localhost``127.0.0.1`
`::1` 양쪽으로 풀리고 `MockWebServer`는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가
첫 주소(127.0.0.1)의 진짜 TLS 실패를 삼키고 마지막 주소(::1)의 연결 거부만 승격시킨다. 접속 호스트를
`127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다 (`EVD-332`).
**17/18은 확인, 1/18은 번복.** 이 비율 자체가 사이클 1 문서의 신뢰도에 대한 측정치다.
---
### 1.2 그 뒤에 이어진 전수 통독 — 23개 리프
18개 재검증과 별개로, 사이클 2 는 `ssotReview: FULL_READ_REQUIRED` 로 열려 있던 23개 리프를 닫았다.
그 리프들의 사이클 1 SSOT 는 production 구현을 `STRUCTURAL_ONLY` 로 판정하고 파일 이름·LOC·build.gradle
주석으로 서술한 상태였다.
**통독은 두 번에 걸쳐 이뤄졌고, 첫 번째는 통독이 아니었다.** 첫 판에서 23개 리프를 `FULL_READ_DONE` 으로
표시하고 각 SSOT 에 "production N파일 축자 통독 완료" 를 적었으나, 리프마다 읽은 것은 일부였다. 그 상태로는
Coverage ledger 가 사실이 아니므로 23개 리프를 다시 세고 파일 단위로 다시 읽었다.
| 대상 | 리프 | main 파일 | main 줄 | test 파일 | test 줄 |
|---|---:|---:|---:|---:|---:|
| grpc 계열 | 18 | 260 | 18,726 | 55 | 9,383 |
| messaging 계열 | 5 | 97 | 10,816 | 66 | 10,373 |
| **합계** | **23** | **357** | **29,542** | **121** | **19,756** |
각 SSOT 가 주장을 거는 test 파일도 전부 읽었다. 통독 후 `STRUCTURAL_ONLY` 잔여는 0 이고,
23개 SSOT 의 Coverage ledger 는 위 숫자로 다시 썼다.
**결과의 성격.** 통독이 만든 것은 대부분 새로운 사고가 아니라 **이미 알려진 패턴의 정확한 위치**다.
사이클 1 의 가족 문서(19·20)는 두 가족을 각각 하나의 문서로 다루면서 "블록 전체가 미배선" 이라는 층위에서
멈췄고, 통독은 그 블록 안에서 배선되더라도 성립하지 않을 것들을 찾았다.
**다만 P1 이 둘 나왔다.** 앞선 판은 "새 finding 에 P1 은 0" 이라고 적었고 그것은 부분 통독의 결과였다.
둘 다 messaging 계열이고, 둘 다 "선언과 실제가 반대인데 관측은 정상" 이라는 §3.2 의 형태다.
1. **운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다.**
`KafkaProfileValidator` 는 운영 프로파일이 전송 보안 없이 뜨는 것을 거부하고 그 거부를 테스트가 지킨다
(`aProductionKafkaBrokerWithoutTransportSecurityFailsStartup`). 그런데 실제로 조립되는 `KafkaProducer`
설정에는 `security.protocol` 이 없다 — Kafka 기본값 `PLAINTEXT` 다. 그 값을 만드는
`KafkaSecurityConfigurer` 는 저장소 전역에서 production 호출자가 0 이다.
(`messaging-spring-boot-starter` §17.1)
2. **지원 문서가 `deduplicatedPublish` 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다.**
(`messaging-kafka` §17.1)
이 둘이 P1 인 이유는 배선 여부와 무관하게 성립하기 때문이다. 나머지 23개 리프의 finding 대부분은
"조립되면 성립하는 결함" 이지만, 이 둘은 messaging 계열이 실제로 배선되는 경로 위에 있다.
그 아래 층위에서 가장 무거운 예 셋:
1. **`JpaGrpcOperationLedger.claim` 의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다.**
엔티티의 식별자가 배정값이라 `save``merge` 로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리키므로
두 번째 청구가 유니크 위반을 일으키지 않고 **커밋된 결과를 덮어쓴다.** 테스트 이중의 `save` 는 INSERT 를
흉내 내 그 차이를 가린다. (`grpc-operation-ledger-jpa` §17.1)
2. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다.** 읽기와 쓰기 사이에
회전이 일어나면 방금 교체된 자격증명이 되살아난다. (`grpc-policy` §17.2)
3. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다.** 그 검증기가 유일한 소비자인 설정 키 넷
(`transport`·`tls-enabled`·`trust-all-certificates`·`operation-ledger-enabled`)이 아무것도 게이트하지 않는다.
(`grpc-spring-boot-starter` §17.1)
셋 다 "조립되면 성립하는 결함" 이고, 블록 수준 서술로는 보이지 않는 층위다.
---
## 2. 배포 지도 — 등록된 것과 배포되는 것의 거리
이 저장소에는 런타임 컴포지션이 둘 있다(`runtime_compositions`: `app-bootstrap`, `sample-portfolio`).
62개 리프의 소속을 그대로 세면 다음과 같다.
| 소속 | 리프 수 |
|---|---:|
| `app-bootstrap`에 속함 | 33 |
| `sample-portfolio`에만 속함 | 2 (`adapter-outbound-objectstorage`, `sample-portfolio`) |
| **어느 컴포지션에도 속하지 않음** | **27** |
속하지 않는 27개의 내역:
- **grpc 블록 18개 전부** — `grpc-*` 9개와 `grpc-advanced-*` 9개. 이는 결함이 아니라 명시된 상태다.
`src/grpc/CLAUDE.md:74-98`이 블록의 build-only 상태를 정확히 적고 있고, 사이클 2의 측정은 그 서술의
확인이다 (`EVD-325`).
- **messaging 7개** — `schema-avro`, `schema-protobuf`, `kafka-share-experimental`,
`pulsar-experimental`, `nats-experimental`, `spring-cloud-stream-bridge`, `testkit`.
실험 어댑터와 선택적 스키마·테스트 지원이라는 성격상 예상되는 목록이다.
- **`adapter-inbound-grpc``adapter-inbound-websocket`** — 이 둘은 위 두 범주 어디에도 속하지 않는
일반 인바운드 어댑터인데 어떤 배포에도 들어가지 않는다.
여기서 리프 경계를 넘어야만 보이는 사실이 하나 있다. `adapter-inbound-websocket` 문서는 이 리프의
내부 구조를 충실히 기술하지만, "이 리프가 어떤 배포에도 없다"는 것은 리프 안에서는 보이지 않는다 —
`modules.json`의 다른 항목과 대조해야 나온다. 같은 형태로 `adapter-outbound-objectstorage`
**샘플에만** 있다. 출하 애플리케이션에는 오브젝트 스토리지 어댑터가 없다.
---
## 3. 저장소 전체를 관통하는 패턴
422건을 finding 제목 텍스트에 대해 기계 분류했다. 제목만 읽는 분류이므로 아래 수치는 **하한**이고
census가 아니다 — 268건은 제목이 너무 짧아 어느 유형에도 걸리지 않았다.
| 유형 | 건수 | P1 | P2 | P3 | 나타난 리프 |
|---|---:|---:|---:|---:|---:|
| A. 만들어졌지만 조립되지 않음 | 57 | 8 | 27 | 22 | **23** |
| F. 문서·주석·이름이 코드와 다름 | 57 | 1 | 20 | 36 | 18 |
| E. 동시성·경합·순서 | 26 | 5 | 8 | 13 | 12 |
| B. 선언은 통과하는데 강제하는 주체가 없음 | 14 | 4 | 6 | 4 | 9 |
| D. 같은 문제에 메커니즘이 둘 이상 | 12 | 0 | 2 | 10 | 9 |
| C. 테스트가 픽스처를 검증함 / 검증 공백 | 11 | 4 | 2 | 5 | 6 |
### 3.1 A — 만들어졌지만 조립되지 않는다 (23개 리프)
이 저장소에서 압도적으로 반복되는 형태다. 세 층위로 나타난다.
**층위 1 — 컴포지션 루트가 패키지를 제외한다.** `adapter-inbound-web`의 P1 여섯 건은 전부 하나의
원인으로 수렴한다. 컴포지션 루트가 `mvc.error`·`webflux.error`·`mvc.budget`·`mvc.operation`·
`webflux.operation` 다섯 패키지를 컴포넌트 스캔에서 제외하고, 그 결과 RFC 9457 계약 23개 파일,
용량 보호 계층 41개, 멱등 실행 계층 38개가 출하 애플리케이션에 등록되지 않는다. 그 문서 자신이
여섯 번째 finding에서 단일 원인을 지목한다 — "다섯 레인·세 런타임 패리티가 검증하는 것은 픽스처의
조립이고, '플랫폼이 능력을 설치하는가'를 묻는 레인이 없다."
**층위 2 — 클래스는 있는데 생성자가 없다.** `messaging-inbox-jdbc-postgresql`의 bounded purge는
구현돼 있고 호출되지 않는다. `messaging-outbox-jdbc-postgresql`은 그 반대 방향으로 같은 형태다 —
무제한 DELETE가 호출되고, 그것을 막는 bounded 오버로드가 호출되지 않는다.
`messaging-spring-boot-starter`의 종료 수명주기는 아무도 증가시키지 않는 카운터가 0이 되기를 기다린다.
`adapter-outbound-httpclient``POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드는 생성자에 가려 발화할 수 없다.
**층위 3 — 블록 전체.** grpc 18개 리프. 이 경우만은 저장소가 그 상태를 문서로 인정하고 있다.
이 세 층위의 차이가 중요하다. 층위 3은 **선언된 미완성**이고, 층위 1·2는 **선언되지 않은 미완성**이다.
같은 저장소가 전자를 다루는 좋은 선례를 갖고 있다 —
`MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT`는 rabbit의 미완성에 이름을 붙이고 기동에서
거절하며 "An entry leaves this map on the day its transport does exist"라고 적는다. 층위 1·2에는
그런 이름이 없다.
### 3.2 B — 검증기는 통과시키고, 그 값을 읽는 코드는 없다 (9개 리프)
A의 특수형이지만 결과가 다르다. A는 기능이 없는 것이고, B는 **없는 기능이 있다고 보고되는 것**이다.
가장 선명한 사례는 `adapter-outbound-persistence-mongo`다. `MongoClientSettingsFactory`가 저장소
전체에서 호출되지 않아 프로파일의 `tlsRequired`·타임아웃·풀 상한·Stable API·UUID 표현이 드라이버에
도달하지 않는다. 검증기는 "TLS 필수" 선언을 통과시키고, 연결은 평문일 수 있다. 선언과 실제가
반대인데 관측은 정상이다.
같은 형태가 `messaging-pulsar-experimental``claimCheckThresholdBytes`에 있다 — 필드는 있고
그것을 읽는 액터가 없다. `adapter-outbound-persistence-jpa`의 바이트 쿼터도 같다 — persistent
`reserved + committed` 값을 실제 admission에서 읽거나 상한과 비교하는 경로가 없다.
**사이클 2 가 더한 것 — 능력 선언이 프로파일에서 파생되지 않는다 (3개 어댑터).**
세 실험/출하 브로커 어댑터가 모두 `MessagingCapabilities`**상수** 로 둔다. 그리고 그 상수가 답하는 것과
그 능력이 실제로 성립하는 조건이 갈린다.
| 어댑터 | 상수가 답하는 것 | 실제 조건 |
|---|---|---|
| `messaging-nats-experimental` | `deduplicatedPublish = true` | 프로파일에 중복 제거 창이 있을 때만 `Nats-Msg-Id` 를 보낸다 |
| `messaging-kafka` | `brokerTransaction = true` | 트랜잭션 식별자 접두·멱등 생산자·`acks=all`·수동 커밋이 모두 필요하고, 그것을 검사하는 검증기는 주입되지 않는다 |
| `messaging-pulsar-experimental` | 전송과 검증기가 `orderedStream`**서로 다른** 값 | Key_Shared 의 순서 단위는 키다 |
첫째가 특히 무겁다. `deduplicatedPublish` 는 능력 열둘 중 부재가 예외를 만드는 유일한 플래그이므로
(`DefaultMessagePublisher:250`), 창 없는 목적지가 그 가드를 통과한다.
이 셋은 B 의 거울상이다. B 는 선언을 통과시키고 읽는 코드가 없는 것이고, 이것은 **읽히는 값이 조건과
무관하게 참** 인 것이다.
### 3.3 C — 레인이 검증하는 것이 픽스처의 조립일 때 (6개 리프)
`adapter-inbound-web`의 다섯 레인, `grpc` 경계 규칙이 실제 소스를 보지 않는 것(`EVD-328`),
`messaging-testkit`의 인증 매니페스트 드리프트(`EVD-300`)가 같은 계열이다.
여기에 사이클 2가 사례 하나를 보탠다 — `app-bootstrap``everyLaneMatchesItsContract`
`docker compose` 부재는 skip으로 막고 `jq` 부재는 실패로 낸다. 스크립트는 전제 결손을 `exit 78`
(sysexits.h의 `EX_CONFIG`)로, 계약 위반을 `exit 1`**구분해서** 알리는데 테스트가 그 구분을 버린다.
결과적으로 아무것도 검사되지 않은 상태가 "레인이 계약과 다르다"로 보고된다 (`EVD-334`).
### 3.4 D — 같은 문제에 메커니즘이 둘 (9개 리프)
`messaging-admin-runtime`의 두 토폴로지 스택(`EVD-307`), `messaging-kafka`의 세 층위 재시도 중
하나만 도는 것, grpc의 두 규칙 엔진 중 하나만 급여되는 것(`EVD-329`),
`adapter-outbound-httpclient`의 재생 가능성 판정이 호출마다 반사로 재계산되는 것.
이 유형이 P3에 몰려 있는 것(12건 중 10건)은 우연이 아니다. 둘 중 하나는 대개 돌고 있어서
증상이 없다. 비용은 **다음에 고치는 사람이 어느 쪽이 정본인지 모른다**는 데서 나온다.
### 3.5 E — 동시성·경합 (12개 리프)
P1 다섯 건이 여기 있다. `adapter-outbound-persistence-jpa`의 outbox stale worker가 owner fencing
없이 newer/terminal 상태를 덮어쓰는 것, provider call recorder가 lease-unaware `save()`를 써서
만료된 holder의 stale projection이 새 holder를 덮는 것, `adapter-outbound-persistence-mongo`
change stream high-water mark가 "본 위치"여서 failover 중 이벤트가 조용히 영구 소실되는 것,
`messaging-admin-runtime`의 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛰는 것.
이 넷의 공통 형태는 **실패가 상태로 남지 않는다**는 것이다. 넷 다 로그도 상태 전이도 남기지 않고,
`RUNNING` 또는 성공으로 보이는 채로 데이터가 사라지거나 덮인다.
**사이클 2 가 더한 것 — 원자 타입을 쓰면서 비교 후 교체를 하지 않는다 (5곳).**
전수 통독이 찾은 가장 일관된 형태다. 다섯 곳이 `AtomicReference`·`AtomicInteger` 를 선택해 놓고
읽고-판단하고-쓰는 세 단계를 원자적으로 묶지 않는다.
| 위치 | 형태 | 결과 |
|---|---|---|
| `GrpcCredentialRotationManager.rotate`·`completeDrain` | `get()` 후 조건 없는 `set()` | 회전이 되돌아가 교체된 자격증명이 되살아난다 |
| `GrpcChannelRuntimeRegistry.rotate` | 〃 (같은 파일의 `install` 은 CAS 를 쓴다) | 덮인 대체본이 배수도 회수도 되지 않는다 |
| `GrpcChannelRuntime.finishUnaryCall`·`closeStream` | `get() > 0` 후 별도 감소 | 음수가 되면 `quiescent()` 가 영원히 거짓 → 세대가 회수 불가 |
| `GrpcAdmissionController.tryAdmit`·`release` | 〃 | 경계 초과, 그리고 경계가 영구히 느슨해짐 |
| `GrpcStreamAdmission.tryAdmit`·`release` | 〃 + caller 별 맵이 줄지 않음 | 재접속 폭풍에서 가장 많이 샌다 |
같은 저장소 안에 정본이 둘 있다 — `GrpcRetryBudget.tryConsume``GrpcHedgingBudget.tryConsume`
정확한 비교 후 교체 루프다. 그리고 `GrpcDemandController` 는 같은 형태를 `synchronized` 로 닫는다.
즉 이 저장소는 올바른 형태를 알고 있고, 다섯 곳에서만 쓰지 않았다.
**사이클 2 가 더한 것 — 선언되고 주입되지 않는 검증기 (4곳).**
`StartupProfileValidation` 의 javadoc 이 이 형태를 이미 이름 붙였다 — "the context published a validator
per broker and validated nothing." 그 수정이 messaging 에 적용됐는데, 같은 형태가 네 곳에 남아 있다.
| 검증기 | 상태 |
|---|---|
| `GrpcPlatformStartupValidator` | 호출자 0 (자기 테스트 제외) |
| `KafkaTransactionProfileValidator` | 빈으로 발행되고 `StartupProfileValidation` 에 감싸이지 않음 |
| `GrpcApplicationBoundaryRules` | 저장소 소스에 적용하는 코드 없음 |
| `GrpcRawApiImportRule` | 〃 (테스트가 인라인 문자열만 판정) |
**재통독이 다섯 곳을 더 찾았다.** 그리고 그중 셋은 앞의 넷보다 무겁다 — **문서가 그 검증기를 "빌드를
실패시키는 것" 이라고 단언하기 때문이다.** 아무도 부르지 않는 검증기는 공백이고, 부른다고 적힌 채
아무도 부르지 않는 검증기는 오해다.
| 검증기 / 게이트 | 상태 | 그렇게 적은 곳 |
|---|---|---|
| `GrpcProtoContractValidator` | 코드 호출자 0. `*.gradle`·`*.kts`·`*.yml` 어디에도 없음 | `buf.yaml:3-5``GrpcBufPolicy:8-10` 이 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다 |
| `GrpcBufPolicy` 의 네 수명주기 태스크 | `bufFormatCheck`·`bufLint`·`bufBuild`·`bufBreaking` 이 어떤 빌드 파일에도 없음 | javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다. 테스트는 목록을 리터럴·자기 자신과 비교한다 |
| `GrpcAdvancedModuleGuard.requireStableStarterIsClean` | 호출자 0 | javadoc 이 "a runtime that was assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다 |
| `NatsJetStreamProfileValidator` | 코드 호출자 0 · 테스트 0. 흔적은 javadoc `{@link}` 한 줄 | `NatsJetStreamTransport:35` 가 "refuses the combination at startup" 이라고 적는다 |
| `PulsarProfileValidator` | 저장소 전체에서 자기 선언 한 줄 말고 아무 데도 없음 | 그 리프 SSOT §4 가 "검증기가 합의를 요구한다" 로 서술했다(이번에 정정) |
`GrpcProtoContractValidator``GrpcBufPolicy` 는 서로를 가리킨다 — `buf.yaml` 은 CLI 가 없으니 자바
검증기가 게이트라고 하고, `GrpcBufPolicy` 는 태스크 이름이 CI 의 계약이고 자바 검증기가 실제 게이트라고
한다. 두 쪽 다 상대가 게이트라고 말하고, 어느 쪽도 실행되지 않는다.
**형태의 이름.** 앞의 넷은 "만들어졌지만 조립되지 않음"(§3.1 A)이고, 이 다섯은 **"조립되었다고 적힌 채
조립되지 않음"** 이다. A 와 F(문서가 코드보다 앞섬)가 같은 지점에서 겹치는 자리이고, 이 저장소의 주석
밀도가 높기 때문에 겹칠 때 특히 비싸다 — 읽는 사람이 게이트의 존재를 근거 있게 믿게 된다.
### 3.8 H — 선언만 있고 코드가 닿지 않는 project 의존 (재통독 신설, 6곳)
리프 SSOT 는 자기 build.gradle 을 읽지만, "이 의존이 실제로 쓰이는가" 는 그 리프의 import 를 전수로 봐야
나온다. 재통독이 그것을 리프마다 확인했고 여섯 곳이 나왔다.
| 리프 | 선언 | 실제 import |
|---|---|---|
| `grpc-advanced-edition` | `grpc-core-api` · `grpc-proto-contract` · `grpc-advanced-bootstrap` | **0**`dev.caskeleton` import 가 한 줄도 없다 |
| `grpc-advanced-diagnostics` | 위 셋 중 `grpc-client` 포함 셋 | bootstrap 만 4줄. `grpc-core-api`·`grpc-client` 0 |
| `grpc-spring-boot-starter` | `grpc-proto-contract` · `grpc-codegen` · `grpc-operation-ledger-jpa` (implementation) | 셋 다 0 |
| `grpc-advanced-streaming` · `-compat` | `grpc-advanced-bootstrap` | 둘 다 0 |
| `grpc-testkit` · `grpc-spring-boot-starter` | `grpc-observability` (api) | 둘 다 0 |
`api` 로 선언된 것은 그 모듈을 쓰는 쪽까지 전파된다. `grpc-observability` 의 경우 Micrometer 가 두 모듈을
거쳐 전파되는데, 그 두 모듈은 관측 타입을 하나도 쓰지 않는다.
**이 유형이 A 와 다른 점.** A 는 코드가 있고 부르는 곳이 없는 것이고, 이것은 **의존 그래프가 코드보다 넓은
것**이다. 결과는 반대 방향으로 나타난다 — A 는 기능이 없는 것으로, 이것은 경계가 실제보다 느슨해 보이는
것으로. `grpc-spring-boot-starter` 는 이 저장소가 "구성 경계" 라고 이름 붙인 리프이므로, 그 리프의 의존이
실제 조립에 필요한 것보다 넓다는 사실은 그 이름이 주장하는 바에 직접 걸린다.
의도를 읽을 수 있는 경우도 있다 — `grpc-advanced-edition``grpc-proto-contract` 의존은 그 리프의
`compatibility.proto` 가 저쪽 스키마 규칙의 관할이라는 선언으로 읽힌다. 다만 그 관할은 코드로 연결되어
있지 않고, `grpc-proto-contract` 의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않는다.
즉 의존 선언이 표현하려던 관계가 실제로는 어느 쪽에도 없다.
### 3.6 F — 문서가 코드보다 앞서 있다 (18개 리프, 57건)
건수로는 A와 동률 1위인데 P1이 하나뿐이다. 대부분 javadoc·README·주석이 이제는 사실이 아닌 것을
말하는 형태다. `adapter-outbound-httpclient``BoundedDataBufferFlux`가 javadoc이 처리한다고 적은
두 경로가 no-op인 것, `adapter-outbound-fileserver`의 README 주장이 여덟 개 port 구현 앞에서
성립하지 않는 것(P2)이 대표적이다.
이 저장소의 주석 밀도는 이례적으로 높고 — `adapter-outbound-httpclient``build.gradle`은 이 저장소에서
가장 긴 근거 주석을 갖는다 — 그 밀도가 자산인 동시에 부채라는 것이 이 유형의 내용이다. 사고를 인용하는
주석은 그 사고를 다시 겪지 않게 하지만, 코드가 바뀔 때 함께 바뀌지 않으면 **틀린 근거를 권위 있게**
전달한다.
---
### 3.7 G — 전송 계열 가정 (사이클 2 신설)
`EVD-332` 가 사이클 1 의 P1 을 철회시킨 원인은 픽스처의 듀얼스택 호스트명이었다. 즉 **IPv4 만 가정한 코드가
IPv6 가 있는 환경에서 다르게 동작한다** 는 형태다. 통독이 같은 형태를 하나 더 찾았다.
`GrpcDiagnosticsRedactor.maskAddress` 는 IPv4 정규식 하나만 갖고, 맞지 않는 입력을 **그대로 돌려준다.**
그리고 스냅숏 생성자의 검사가 "마스킹 결과가 입력과 같으면 이미 마스킹된 것" 이므로, IPv6 주소·호스트 이름·
유닉스 소켓 경로가 전부 검사를 통과한다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고 헤드리스 레코드의
엔드포인트가 파드 DNS 이름이라는 점에서 도달 가능한 형태다.
두 사례의 공통점은 **주소 표현의 다양성** 이 아니라 **판정의 방향** 이다. 둘 다 "모르는 형태" 를 안전한 쪽이
아니라 통과 쪽으로 접었다.
---
## 4. 리프 경계를 넘을 때만 보이는 것
리프 SSOT가 원칙적으로 볼 수 없는 사실을 여기 모은다.
1. **`adapter-inbound-grpc`·`adapter-inbound-websocket`이 어떤 배포에도 없다.** §2.
2. **`adapter-outbound-objectstorage`가 샘플에만 있다.** 출하 애플리케이션에는 없다. §2.
3. **`messaging-admin-api`의 토폴로지 BLOCKING 보장을 배선하려면 인스펙터가 필요한데,
`messaging-kafka`에는 줄 것이 없다.** 두 리프의 문서가 각자 자기 쪽 절반만 볼 수 있다 —
admin-api는 "`@ConditionalOnBean(BrokerTopologyInspector)`가 참이 된 적이 없다"를 보고,
kafka는 "`KafkaTopologyInspector` 구현이 0"을 본다. 둘을 겹쳐야 **같은 하나의 미배선**이 된다.
4. **`adapter-inbound-web`의 P1 여섯 건은 web 리프가 아니라 `app-bootstrap`이 원인이다.**
web 문서가 그 사실을 스스로 지목하지만, 고칠 파일은 다른 리프에 있다.
5. **grpc 블록의 Stable/Advanced 경계는 실제로 강제된다.** 사이클 2에서 한 번 반대로 판단했다가
`grpc-spring-boot-starter/build.gradle:4-7`의 근거 주석을 따라가 세 겹의 강제
(레지스트리 `allowed_dependencies` · `verifyCleanArchitectureDependencies` ·
`GrpcPlatformStartupValidatorTest:257`이 실제 `build.gradle`을 읽는 것)를 확인하고 철회했다
(`EVD-326`). 남은 것은 카탈로그의 이름 목록이 `modules.json`과 대조되지 않는다는 P3뿐이다.
---
6. **같은 자료구조 오용이 두 리프에 있다.** `GrpcCredentialRotationManager`
`GrpcChannelRuntimeRegistry` 가 각각 `AtomicReference` 를 조건 없는 `set` 으로 쓴다. 두 리프의 문서는
각자 자기 쪽만 볼 수 있고, 겹쳐야 "이 가족이 회전을 다루는 방식" 이라는 하나의 사실이 된다. §3.5.
7. **Kafka 트랜잭션 검증의 절반이 다른 리프에 있다.** 검증기는 `messaging-kafka` 가 소유하고, 그것을
시작 시 부르는 배선은 `messaging-spring-boot-starter` 가 소유한다. 후자에 감싸는 블록이 없어서 전자가
돌지 않는다. 어느 쪽 문서도 혼자서는 "이 검증이 실행되지 않는다" 를 말할 수 없다.
8. **정책 목록의 가장 강한 성질이 다른 리프의 미완성에 걸려 있다.** `GrpcMethodPolicyCatalog`
서술자 대조는 이름 변경을 잡는 장치인데, 서술자를 만드는 `grpc-codegen` 이 protoc 을 돌리지 않으므로
이 저장소에서는 그 대조를 켤 수 없다. `withDescriptorMethods` 의 production 호출자는 0 이다.
10. **같은 문제의 올바른 판본과 틀린 판본이 두 리프에 나란히 있다 — 결정을 그 결정이 판정한 대상에 묶는 것.**
`grpc-codegen``GrpcSchemaArtifactPublisher.publish(candidate, decision)``decision.allowed()`
보고 기록한다. `PublishDecision` 은 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로
B 를 발행할 수 있고 그러면 소비자 게이트와 버전 불변성을 둘 다 우회한다(그쪽 §17.4).
`grpc-advanced-bootstrap``GrpcAdvancedSupportMatrix.apply(decision)` 는 정반대다 — 결정의 `from`
현재 등급과 다르면 던지고, 그 이유를 "두 승격이 경합했거나 하나가 재생된 경우" 라고 적는다.
같은 저장소가 같은 형태를 한 번은 맞게, 한 번은 틀리게 썼다. §3.5 의 check-then-act 계열과 같은 뿌리이나
여기서는 경합이 아니라 **인자 짝 맞추기**가 깨진 자리다.
11. **"실환경 증거" 의 정의와 그 요구가 다른 리프에 있고 서로를 부르지 않는다.**
`grpc-advanced-diagnostics``GrpcAdvancedInfrastructureTestkit` 이 능력별로 무엇이 실환경인지
정의한다(gRPC-Web 프록시 · 서블릿 컨테이너 · 멈출 수 있는 xDS 통제 평면 · 코틀린 툴체인).
`grpc-advanced-bootstrap``GrpcAdvancedPromotionEvidence` 는 그것을 `boolean realEnvironmentTest`
하나로 받는다. 두 쪽이 만나지 않으므로 `complete(XDS, 7일)` 이 통제 평면 없이도 참을 넣는다 —
테스트킷 javadoc 이 경계한 "a suite that runs without the infrastructure passes and establishes
nothing" 을 승격 게이트가 그대로 통과시킨다.
12. **승격 기준이 같은 능력에 대해 두 게이트에서 다르다.** `EDITION_2024``GrpcAdvancedPromotionGate`
에서 증거 일곱 항목 + 7일 담금을 요구받고, `GrpcEdition2024Gate` 에서 호환성 보고서 + 소비자 이관 +
ADR 을 요구받는다. 어느 쪽도 상대를 부르지 않고 관계가 문서에도 없다. 그리고 전자에는 별도 결함이 있다 —
30일 담금 갈래가 열거형에 없는 등급(`STABLE_DEFAULT`)을 위해 쓰여, `ADVANCED_STABLE` 이 아닌 목표 전부를
삼킨다. 그 결과 `WATCH → EXPERIMENTAL`(WATCH 가 밟도록 강제된 유일한 첫 걸음)이 30일을 요구하고
`EXPERIMENTAL → ADVANCED_STABLE` 은 7일을 요구한다 — 중간 등급이 상위 등급보다 어렵다.
그 갈래를 실행하는 테스트가 `ADVANCED_STABLE → DISABLED`(철회)를 골라 놓고 이름을
"becoming a Stable default" 라고 붙인 것이 그 뒤틀림의 흔적이다. (`grpc-advanced-bootstrap` §17.4)
9. **Stable 모듈 목록과 레지스트리를 붙드는 장치가 없다.** `GrpcStableModuleCatalog` 의 javadoc 은
`GrpcStableModuleCatalogTest` 가 둘을 함께 붙든다고 적지만, 그 테스트는 `modules.json` 을 읽지 않고
목록을 리터럴과 대조한다. 두 집합은 오늘 일치한다(12 + 6 = 레지스트리의 grpc 계열 18). 어긋난 것은
그 일치를 무엇이 지키는가다. messaging 가족이 같은 형태를 이미 기록했다 — "세는 순간 다시 drift 한다."
---
## 5. 측정 방법에 대해 이 사이클이 배운 것
사이클 2가 만든 판정 번복 한 건과 자기 교정 세 건은 모두 같은 형태의 실수에서 나왔다.
**코드를 읽고 런타임의 모양을 추론한 뒤, 그 추론을 측정으로 확인하지 않은 것.**
- `EVD-332` — 분기 순서를 읽고 예외 사슬의 모양을 단정했다. 사슬을 출력하니 달랐다.
- `EVD-326` — 레지스트리에 검사가 없다고 단정했다. `build.gradle`의 주석이 가리키는 세 곳을 따라가니 있었다.
- `PulsarPreSendRejection` — 전역 승인 게이트가 임계값을 읽을 것이라고 가정했다. `PayloadLimitGuard`는 그 필드를 읽지 않았다.
- outbox 무제한 DELETE의 서술 — "프로덕션 기본 경로"라고 적었다. 자동설정은 스케줄러를 등록하지 않는다.
네 건 모두 **하나의 추가 측정이면 갈렸다**. 이 저장소의 코드는 자기 근거를 주석으로 남기는 밀도가 높아서
읽는 것만으로 확신이 생기기 쉽고, 바로 그 점이 함정이다. 사이클 3에 남기는 규칙은 하나다 —
**런타임의 모양에 대한 주장은 런타임에서 확인한다.** jshell로 사슬을 출력하는 데 든 비용은 몇 분이었고,
그 몇 분이 P1 하나를 지웠다.
---
## 6. 확인하지 못한 것
- **다른 두 전송 분류기.** `ReactorFailureClassifier`·`JdkFailureClassifier`는 개별 실행으로 확인하지
않았다. `EVD-332`가 반증한 것은 "Apache가 TLS 실패를 연결 예외로 감싼다"는 전제이므로 그 전제에
기대던 열린 항목은 소멸하지만, 두 분류기 자체를 측정한 것은 아니다.
- **`jq`가 있는 기계에서의 `verify-compose-profile-contracts.sh`.** 파이썬 이식본으로 15개 레인이
계약과 일치함을 확인했으나, 이는 테스트 자신이 경계한 "두 번째 의견"이다. 원본 스크립트 실행이 정본이다.
- **422건 중 268건의 기계 분류.** 제목이 짧아 유형 분류가 되지 않았다. §3의 수치는 하한이다.
그리고 그 분류는 **재통독 이전의 422건에 대해 돌린 것**이다. 23개 리프가 60→100 으로 늘어난 뒤 다시
돌리지 않았으므로, §3 의 유형별 건수는 새로 추가된 40건을 반영하지 않는다. §3.5 의 검증기 표와 §3.8 은
기계 분류가 아니라 재통독에서 직접 확인해 손으로 적은 것이다.
- **전체 462건의 직접 재측정.** §0 이 밝힌 대로 462는 델타 조정값이고 61개 문서를 다시 센 값이 아니다.
- **`sample-portfolio`.** 레지스트리에서 EXCLUDED이며 이 사이클의 분석 대상이 아니다.
- **런타임 컴포지션의 실제 기동.** compose 계약은 정적으로만 검증했다. 아무 스택도 기동하지 않았다.
- **사이클 2 통독이 만든 92건의 실행 확인.** 전부 코드 통독과 정적 대조로 판정했다. 두 가족 모두 배선 경로가
없거나(grpc 18개 리프) 선택할 수 없어서(rabbit), 실행으로 재현할 대상이 애초에 없다. 예외는
`grpc-testkit` 의 세 레인으로, 이번에 직접 돌려 통과를 확인했다(계약 7 · Netty 9 · 고장 9).
- **재통독의 도달성 판정 방법.** 리프마다 `grep` 으로 타입 이름·패키지 이름을 훑어 리프 밖 참조를 셌다.
리플렉션·서비스 로더·문자열 기반 조립으로 닿는 경로가 있다면 이 방법으로는 잡히지 않는다. 이 저장소가
그런 조립을 쓰는 곳은 발견하지 못했으나, 찾아본 것이 아니라 마주치지 않은 것이다.
- **23개 리프의 테스트 실행.** 재통독은 테스트 본문을 전부 읽었지만 이번 판에서 다시 돌리지는 않았다.
"이 단언은 항상 통과한다" 류의 판정(`messaging-nats-experimental` §17.4 등)은 단언 의미론으로 내린 것이다.
예외는 `grpc-testkit` 의 세 레인으로, 앞선 판에서 직접 돌려 통과를 확인했다.
- **`@ConditionalOnBean` 사슬의 실제 평가.** `MessagingReliabilityAutoConfiguration` 이 같은 클래스 안에서
방금 선언한 빈을 조건으로 삼는다. 지금은 그 앞 조건이 만족되지 않아 셋 다 만들어지지 않으므로 결과가
드러나지 않는다. 스프링의 문서화된 제약으로 판정했고 컨텍스트로 재현하지 않았다.
---
사이클 1 초안이 열린 질문 다섯 개를 번호로 관리했다. 그 제목이 `candidate-ledger.json`
보존되어 있어 아래에 그대로 되살린다 — 초안 본문은 복구되지 않았으므로, 각 항목의 내용은
사이클 2가 실제로 확인한 것과 확인하지 못한 것으로 다시 썼다.
### 남은 질문 1 — 컨테이너·브로커·DB가 필요한 레인의 실제 결과
사이클 2는 Docker 가용을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 레인을 실제로 돌렸다 —
persistence-jpa 477, persistence-mongo 72, cache-redis 435 테스트가 전부 통과했고, kafka 인증 레인과
실 브로커 왕복, Postgres IT, grpc-testkit 엄격 레인 넷도 실행했다. 그러나 **전부는 아니다.**
`jpaPlatformFailureTest`는 이 리비전에서 실행하지 않았고(§6의 커밋 모호성 항목), mongo 쪽 컨테이너 레인 중
릴리스를 막지 않는 것들도 실행하지 않았다. 어느 레인이 실행됐고 어느 레인이 아닌지는 `EVD-334`가 목록으로 갖는다.
### 남은 질문 2 — sample-portfolio 내부
레지스트리에서 `EXCLUDED`이며 사용자 지시에 따라 이 사이클의 분석 대상이 아니다. 이 리프에만 속하는
`adapter-outbound-objectstorage`가 출하 애플리케이션에 없다는 사실(§2)은 레지스트리 대조로 확인했지만,
샘플 내부의 도메인 모델과 그 조립은 읽지 않았다.
### 남은 질문 3 — 런타임 관측
compose 계약은 정적으로만 검증했다. 어떤 스택도 기동하지 않았고, 애플리케이션을 부팅해 액추에이터나
조건 평가 리포트를 읽지도 않았다. 부팅 한 번이면 확증되는 정적 추론이 최소 두 건 남아 있다 —
messaging 관측 시리즈 부재와 `@ConditionalOnBean` 사슬의 실제 평가 결과다.
### 남은 질문 4 — `@ConditionalOnBean` 실제 평가 순서
`@ConditionalOnBean`이 클래스 파싱 시점에 평가되어 빈이 사라지는 계열의 판정은 코드와 javadoc의 사후
기록에 근거한다. 이 리비전에서 `ConditionEvaluationReport`를 읽어 실제 평가 순서와 결과를 확인하지 않았다.
`debug=true`로 부팅 한 번이면 확인된다.
### 남은 질문 5 — 성능·용량 주장
이 사이클은 성능을 측정하지 않았다. 문서에 남은 성능·용량 관련 서술은 전부 코드가 선언한 상한과 그 강제
여부에 대한 것이며, 실제 처리량·지연·자원 사용에 대한 주장은 하지 않는다. httpclient의 성능 레인과 jmh
벤치마크는 기계 의존적이라는 이유로 `check`에서 빠져 있고, 이 사이클에서도 돌리지 않았다.
## 7. 이 사이클의 작업 제약
- 애플리케이션 소스는 한 줄도 수정하지 않았다.
- 패키지를 설치하지 않았다(`jq` 포함).
- 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았다.
- git 상태를 변경하지 않았다 — 커밋·푸시·리셋·클린 없음.
- 컨테이너를 기동하지 않았다. Testcontainers를 쓰는 lane은 저장소 자신의 테스트가 기동한 것이다.
## Source anchors
```
src/config/architecture/modules.json (62개 리프의 runtime_memberships)
src/build.gradle:477-492 (사이클 1→2 사이 변경 지점)
src/grpc/CLAUDE.md:74-98 (grpc 블록 build-only 상태)
src/grpc/grpc-spring-boot-starter/build.gradle:4-7 (Stable/Advanced 경계 근거)
scripts/verify-compose-profile-contracts.sh:20-30,42-160
src/config/runtime/compose-profile-contracts.json (15개 레인)
analysis/01-domain-core.md … analysis/18-app-bootstrap.md (18개 리프 SSOT)
analysis/messaging/*.md (25개)
analysis/grpc/*.md (18개)
analysis/19-messaging-platform.md, analysis/20-grpc-platform.md (가족 문서, INTEGRATION_ONLY)
evidence/raw/325-grpc-block-is-entirely-unreachable.txt
evidence/raw/326-grpc-stable-advanced-boundary-has-no-registry-check.txt
evidence/raw/328-grpc-boundary-rules-never-see-real-source.txt
evidence/raw/329-grpc-two-rule-engines-one-fed.txt
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
evidence/raw/333-eighteen-docs-source-drift-zero.txt
evidence/raw/334-eighteen-leaf-lane-rerun.txt
```