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>
272 lines
17 KiB
Markdown
272 lines
17 KiB
Markdown
# grpc-advanced-edition 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 재오픈 게이트: cycle 2 — `src/main` production 6파일 326줄, 스키마 리소스 1개 28줄, test 2파일 211줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/grpc-advanced/grpc-advanced-edition`
|
|
> SSOT owner: `grpc-advanced-edition`
|
|
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
|
|
|
---
|
|
|
|
## 0. SSOT identity / 커버리지
|
|
|
|
- `allowed_dependencies`: `["grpc-core-api", "grpc-proto-contract", "grpc-advanced-bootstrap"]`
|
|
- `runtime_memberships`: **`[]`** — build-only
|
|
|
|
| 파일 | LOC |
|
|
|---|---:|
|
|
| `GrpcEditionCompatibilityReport` | 72 |
|
|
| `GrpcEdition2026WatchReport` | 70 |
|
|
| `GrpcEdition2024Gate` | 61 |
|
|
| `GrpcEdition2026Guard` | 48 |
|
|
| `GrpcEdition2024Policy` | 47 |
|
|
| `GrpcEdition2026Status` | 28 |
|
|
| **main java 합계 (6파일)** | **326** |
|
|
| `compatibility.proto` | 28 |
|
|
| `GrpcEdition2024GateTest` · `GrpcEdition2026GuardTest` | 118 · 93 |
|
|
|
|
### Coverage ledger
|
|
|
|
| scope | count | disposition | reason |
|
|
|---|---:|---|---|
|
|
| `main/java/**` | 6 | `FULL_READ` | 전 본문 축자 확인 |
|
|
| `main/resources/proto/edition2024/*.proto` | 1 | `FULL_READ` | 28줄 전문 |
|
|
| `test/java/**` | 2 | `FULL_READ` | 211줄 전 본문 · 테스트 13개 |
|
|
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
|
|
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
|
|
|
`UNCLASSIFIED` 0.
|
|
|
|
---
|
|
|
|
## 1. 모듈의 정체
|
|
|
|
```groovy
|
|
// build.gradle:3-5
|
|
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
|
|
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
|
|
// release/toolchain status and is refused as a Stable contract source.
|
|
```
|
|
|
|
두 레인이 성격이 다르다. 하나는 증거를 만들어야 승격되는 레인이고, 하나는 사실만 기록하는 감시 레인이다.
|
|
|
|
## 2. Edition 2024 — 두 결정을 분리한다
|
|
|
|
`GrpcEdition2024Policy` 는 모듈 옵트인과 공개 서비스 이동을 따로 다룬다.
|
|
|
|
```java
|
|
public boolean serviceMayMove(String serviceName) {
|
|
return !publicServices.contains(serviceName) || promotionApproved;
|
|
}
|
|
```
|
|
|
|
> "the opt-in is a build decision and the promotion is a consumer-migration decision."
|
|
|
|
그 이유가 클래스 javadoc 에 있다.
|
|
|
|
> "an edition change is invisible to the schema's owner and consequential for its consumers: the
|
|
> wire bytes are usually identical, so nothing fails locally, and the breakage appears in whichever
|
|
> consumer's generator handles the edition's features differently."
|
|
|
|
## 3. 세 종류의 호환성
|
|
|
|
`GrpcEditionCompatibilityReport` 는 하나가 아니라 셋을 본다.
|
|
|
|
| 비교 | 답하는 질문 |
|
|
|---|---|
|
|
| wire | 저장된 메시지와 이동 중 메시지가 계속 디코딩되는가 |
|
|
| JSON | 전사 프록시와 브라우저 클라이언트가 계속 동작하는가 |
|
|
| source(툴체인별) | 생성된 코드가 여전히 컴파일되는가 |
|
|
|
|
> "An edition migration can preserve the first two and break the third for a language whose
|
|
> generator handles the edition's features differently — which is exactly the failure this lane
|
|
> exists to find before a public service moves."
|
|
|
|
그리고 툴체인 결과가 비어 있으면 생성자가 거부한다 — "Java alone is not cross-language evidence."
|
|
|
|
## 4. 레인 실패의 범위
|
|
|
|
```java
|
|
blocksStableRelease() → 항상 false
|
|
blocksEditionPromotion() → 항상 true
|
|
```
|
|
|
|
> "Without that split, an opt-in lane that nobody depends on can hold up every release, and the
|
|
> first response to that is to stop running the lane."
|
|
|
|
두 메서드 모두 상수를 돌려주고 javadoc 이 그 이유를 적는다 — "Stated as a method so the property is tested rather than described."
|
|
|
|
## 5. Edition 2026 — 감시 레인
|
|
|
|
네 게이트를 따로 추적한다 — 명세, `protoc`, Buf, 자바 런타임.
|
|
|
|
> "An edition can be released by the specification while `protoc` does not emit it, or emitted while
|
|
> Buf cannot lint it, or lintable while the Java runtime does not implement its features. A single
|
|
> 'supported yes/no' flag collapses four different waiting states into one."
|
|
|
|
보고서는 날짜를 필수로 요구한다 — 날짜 없는 감시 기록은 오래된 메모와 구분되지 않는다.
|
|
|
|
그리고 가드가 보고서와 **무관하게** 거부한다.
|
|
|
|
> "The guard is deliberately not conditional on the watch report… letting the same record also
|
|
> authorise use means the moment somebody marks four fields SUPPORTED, a schema can move onto an
|
|
> edition with no promotion decision, no consumer migration and no ADR. Turning the watch into a
|
|
> lane that can be used is a code change here, and that is the point."
|
|
|
|
## 10. 테스트 레인
|
|
|
|
두 테스트 211줄 · 13개.
|
|
|
|
`GrpcEdition2024GateTest` 7개 — 모듈 옵트인의 기본 꺼짐, 공개 서비스의 승격 요구, 자바 단독이 증거가 아님(빈 툴체인 맵 거부 포함), 세 호환성의 분리, 승격 차단 셋, 레인 실패의 격리, 그리고 픽스처 파일 자체를 리소스로 읽어 `edition = "2024";` 로 시작하는지와 `features.field_presence = EXPLICIT` 를 담는지 대조하는 것.
|
|
|
|
`GrpcEdition2026GuardTest` 6개 — 네 게이트의 개별 추적, 날짜 필수, `SUPPORTED` 만 usable, 전부 SUPPORTED 여도 가드가 거부, 거부 메시지의 미해결 항목, 감시 레인이 Stable 빌드를 막지 않음.
|
|
|
|
`theGuardIsNotConditionalOnTheReport` 가 이 레인에서 가장 중요한 한 줄을 붙든다 — 보고서가 `readyToEvaluate() == true` 인 상태를 만들어 놓고, 그래도 `requireNotUsedAsSource` 가 던지는지 확인한다. 기록이 사용을 허가하지 않는다는 설계가 테스트로 고정되어 있다.
|
|
|
|
## 12. negative-space probes
|
|
|
|
**12.1 도달성.** 리프 밖에서 이 리프를 참조하는 것이 하나도 없다 — 자바 코드도, build.gradle 도.
|
|
|
|
```
|
|
$ grep -rn "advanced.edition" --include=*.java src/ | grep -v grpc-advanced-edition/
|
|
grpc-core-api/…/GrpcStableModuleCatalog.java:38: "grpc-advanced-edition", ← 목록 안의 문자열
|
|
$ grep -rn "grpc-advanced-edition" --include=*.gradle src/
|
|
(매치 없음)
|
|
```
|
|
|
|
**12.2 선언된 의존 셋이 전부 쓰이지 않는다.**
|
|
|
|
```groovy
|
|
api project(':grpc:grpc-core-api')
|
|
api project(':grpc:grpc-proto-contract')
|
|
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
|
```
|
|
|
|
이 리프의 자바 6파일에는 `dev.caskeleton` 으로 시작하는 import 가 **한 줄도 없다.**
|
|
|
|
```
|
|
$ grep -rn "^import dev.caskeleton" grpc-advanced/grpc-advanced-edition/src/main/java/
|
|
(매치 없음)
|
|
```
|
|
|
|
여섯 파일이 쓰는 것은 `java.time` · `java.util` 뿐이다. 세 의존 중 어느 것도 코드에 닿지 않는다.
|
|
|
|
셋 중 둘은 의도를 읽을 수 있다 — `grpc-proto-contract` 는 `compatibility.proto` 가 그쪽 스키마 규칙의 관할이라는 선언으로, `grpc-advanced-bootstrap` 은 `GrpcAdvancedCapability.EDITION_2024` 가 이 레인의 등급을 들고 있다는 선언으로. 다만 어느 쪽도 코드로 연결되어 있지 않고, 그 연결 없음이 §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 사실의 빌드 파일 쪽 표현이다.
|
|
|
|
`grpc-advanced-bootstrap` §12.1 이 반대편에서 같은 것을 기록했다 — 그 리프를 의존 선언한 다섯 모듈 중 실제로 부르는 것은 둘뿐이고, 이 리프는 부르지 않는 셋 중 하나다.
|
|
|
|
**12.3 대조군.** 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` 과 같다. 이 리프에는 그런 메서드가 넷 있다 — `blocksStableRelease` · `blocksEditionPromotion` · `allowedAsStableSource` · `blocksStableBuild`.
|
|
|
|
그중 셋(`blocksStableRelease` · `blocksEditionPromotion` · `blocksStableBuild`)은 `capabilitiesDraggedAlong` 과 같은 한계를 갖는다 — 리터럴을 리터럴과 비교하므로, 그 속성이 실제로 지켜지는지는 이 저장소에 릴리스 파이프라인이 생겨야 알 수 있다. `grpc-advanced-bootstrap` §17.5 에 그 판정을 적어 두었다.
|
|
|
|
넷째 `allowedAsStableSource` 는 다르다. 같은 클래스의 `requireNotUsedAsSource` 가 그 상수와 **독립적으로** 무조건 던지고, `theGuardIsNotConditionalOnTheReport` 가 "전부 SUPPORTED 인 보고서"라는 실제 상태를 만들어 그 독립성을 확인한다. 상수 하나를 읽는 것이 아니라 설계 속성을 실행으로 밟는다.
|
|
|
|
**12.2 대조군.** 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` 과 같다. 이 저장소가 "주석이 주장하는 대신 테스트가 붙든다" 를 반복해서 쓴다.
|
|
|
|
**12.5 저장소의 `.proto` 넷.** 이 리프의 `compatibility.proto` 는 `edition = "2024";` 로 시작하므로 `grpc-proto-contract` 의 `PROTO3_SYNTAX` 규칙에 걸린다. 그 검증기의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않으므로 지금은 충돌하지 않는다. 그 테스트를 전수 훑기로 바꾼다면(그쪽 §17.3) 이 파일에 대한 면제가 함께 필요하다.
|
|
|
|
**12.4 드리프트.** build.gradle 이 서술한 두 레인의 성격이 코드와 일치한다. 드리프트 없음.
|
|
|
|
## 16. 확인하지 못한 것
|
|
|
|
- `protoc` 을 돌려 이 편집 파일이 실제로 컴파일되는지 확인하지 않았다. 저장소에 protobuf 플러그인이 없다.
|
|
- 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
|
|
- 세 의존이 쓰이지 않는다는 것(§12.2)은 `^import dev.caskeleton` grep 으로 판정했다. 같은 패키지 안의 타입이나 완전 한정명 사용이라면 잡히지 않는다 — 다만 이 리프의 패키지는 `dev.caskeleton.grpc.advanced.edition` 하나이고 세 의존의 패키지와 겹치지 않는다.
|
|
- 편집 기능(`features.field_presence = EXPLICIT`)이 proto3 의 `optional` 과 같은 유선 결과를 내는지 확인하지 않았다. 그것이 이 레인의 질문이고 §17.1 이 그 질문에 답할 수 없는 이유다.
|
|
|
|
## 17. 손볼 것
|
|
|
|
### 17.1 P2 — 비교 픽스처에 비교 대상이 없다
|
|
|
|
`compatibility.proto` 의 주석이 존재 이유를 적는다.
|
|
|
|
> "It exists to be compiled beside its **proto3 twin** and compared: same fields, same numbers, same
|
|
> JSON names, with presence expressed by the edition's features rather than by `optional`. The
|
|
> lane's question is whether the two produce the same wire bytes and the same JSON, and **answering
|
|
> it needs both files to exist.**"
|
|
|
|
그 쌍둥이가 저장소에 없다.
|
|
|
|
```
|
|
$ grep -rn "DocumentSummary" --include=*.proto --include=*.java .
|
|
./src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto:17
|
|
```
|
|
|
|
한 곳뿐이다. 같은 필드와 번호를 proto3 로 선언한 파일이 없으므로 비교가 성립하지 않는다.
|
|
|
|
그리고 두 번째 전제도 없다. 이 저장소에는 protobuf 플러그인이 어디에도 없다 — `grpc-proto-contract` 와 `adapter-inbound-grpc` 의 build.gradle 이 그 사실을 주석으로 명시한다. 그러므로 편집 파일도 proto3 파일도 컴파일되지 않고, 유선 바이트와 JSON 을 비교할 산출물 자체가 만들어지지 않는다.
|
|
|
|
결과적으로 `GrpcEditionCompatibilityReport` 는 사람이 손으로 채우는 기록이 된다. 승격 게이트가 그것을 읽어 판정하므로, 게이트의 입력이 측정이 아니라 선언이다.
|
|
|
|
**등급.** Advanced 가족이라 오늘의 배포에는 영향이 없다. 기록하는 이유는 이 리프의 목적이 "공개 서비스가 옮겨 가기 전에 그 실패를 찾는 것" 이고, 그 실패를 찾을 장치가 픽스처 하나만 있고 짝이 없다는 점이다.
|
|
|
|
**수정.** `compatibility_proto3.proto` 를 같은 디렉터리에 두어 필드·번호·JSON 이름을 맞추고, 두 파일을 컴파일해 산출물을 비교하는 레인을 만든다. 그 레인이 생기기 전까지는 `GrpcEditionCompatibilityReport` 가 측정이 아니라 선언이라는 것을 자바독에 적는 편이 낫다.
|
|
|
|
### 17.2 P3 — 승격 차단 목록에 담금 기간과 실환경 항목이 없다
|
|
|
|
`GrpcEdition2024Gate.promotionBlockers` 가 보는 것은 셋이다 — 호환성 보고서의 문제들, 소비자 이관 계획, 승격 ADR.
|
|
|
|
같은 가족의 `GrpcAdvancedPromotionGate` 는 `EDITION_2024` 능력에 대해 일곱 증거 항목과 7일 담금을 요구한다. 두 게이트가 같은 능력의 승격을 서로 다른 기준으로 판정한다.
|
|
|
|
두 게이트가 각각 다른 것을 묻는다고 볼 수도 있다 — 하나는 편집 자체의 호환성, 하나는 능력의 운영 준비도. 다만 어느 쪽도 상대를 부르지 않고, 문서에도 두 게이트의 관계가 적혀 있지 않다. 승격을 실제로 수행할 때 어느 쪽을 만족해야 하는지가 코드에서 답해지지 않는다.
|
|
|
|
수정은 `promotionBlockers` 가 `GrpcAdvancedPromotionGate.evaluate` 의 결과를 포함하게 하거나, 두 게이트의 역할 분담을 자바독에 적는 것이다.
|
|
|
|
### 17.3 P3 — 정책의 자바독이 하지 않는 거부를 한다고 적고, 승격 승인이 두 곳에 따로 있다
|
|
|
|
**첫째, 서술과 코드가 어긋난다.**
|
|
|
|
```java
|
|
/** Copies both sets and refuses an approval nobody recorded. */
|
|
public GrpcEdition2024Policy {
|
|
if (optedInModules == null || publicServices == null) {
|
|
throw new IllegalArgumentException("an edition policy states both sets");
|
|
}
|
|
optedInModules = Set.copyOf(optedInModules);
|
|
publicServices = Set.copyOf(publicServices);
|
|
}
|
|
```
|
|
|
|
"refuses an approval nobody recorded" 에 해당하는 검사가 없다. `promotionApproved` 는 읽히지도 검증되지도 않고 그대로 저장된다. `new GrpcEdition2024Policy(Set.of(), Set.of(), true)` — 옵트인한 모듈도 공개 서비스도 없는데 승인만 참인 정책 — 이 아무 저항 없이 만들어지고, `serviceMayMove` 는 모든 서비스에 참을 답한다.
|
|
|
|
**둘째, 같은 사실이 두 곳에 따로 있다.**
|
|
|
|
| 어디 | 무엇 |
|
|
|---|---|
|
|
| `GrpcEdition2024Policy.promotionApproved` | 승격이 승인되었는가 (record 성분) |
|
|
| `GrpcEdition2024Gate.promotionBlockers(..., boolean promotionAdr)` | 승격 ADR 이 있는가 (메서드 인자) |
|
|
|
|
게이트는 정책을 인자로 받지도, 참조하지도 않는다. 그래서 "ADR 이 없다"고 판정한 게이트와 "승인되었다"고 답하는 정책이 동시에 성립할 수 있고, 둘을 맞추는 코드가 없다. §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 구조가 정책과 게이트 사이에도 있다.
|
|
|
|
**왜 P3 인가.** 정책도 게이트도 production 호출자가 없고(§12.1), 승격은 사람이 수행하는 절차다. 다만 이 리프가 존재하는 이유가 "그 절차를 코드로 적어 두는 것" 이므로, 적힌 절차 안에서 같은 사실이 둘로 갈라져 있는 것은 그 목적에 어긋난다.
|
|
|
|
**수정.** `promotionBlockers` 가 `GrpcEdition2024Policy` 를 받아 `promotionApproved` 를 `promotionAdr` 자리에 쓰고, 정책 생성자가 자바독대로 "승인이 참이면 그 근거(공개 서비스 집합이 비어 있지 않을 것 등)"를 요구한다. 어느 쪽도 하지 않겠다면 자바독의 그 문장을 지운다.
|
|
|
|
### 확인된 설계(문제 아님)
|
|
|
|
- **모듈 옵트인과 공개 서비스 이동을 분리한 것** — 빌드 결정과 소비자 이관 결정은 다른 결정이다.
|
|
- **호환성을 셋으로 나눈 것** — 앞의 둘이 보존돼도 셋째가 깨지는 것이 이 레인이 찾는 실패다.
|
|
- **툴체인 결과가 비면 생성자가 거부하는 것** — 자바 하나는 교차 언어 증거가 아니다.
|
|
- **레인 실패가 Stable 릴리스를 막지 않게 한 것과 그 근거** — 막으면 사람들이 레인을 끄게 된다.
|
|
- **감시 레인의 네 게이트를 따로 추적한 것.**
|
|
- **감시 보고서에 날짜를 필수로 둔 것.**
|
|
- **가드를 보고서와 무관하게 만든 것** — 기록이 사용을 허가하지 않는다. 사용하려면 코드를 고쳐야 한다.
|
|
|
|
---
|
|
|
|
## Source anchors
|
|
|
|
```
|
|
src/grpc-advanced/grpc-advanced-edition/build.gradle:1-10
|
|
main/java/…/edition/GrpcEditionCompatibilityReport.java:1-72
|
|
main/java/…/edition/GrpcEdition2026WatchReport.java:1-70
|
|
main/java/…/edition/GrpcEdition2024Gate.java:1-61
|
|
main/java/…/edition/GrpcEdition2026Guard.java:1-48
|
|
main/java/…/edition/GrpcEdition2024Policy.java:1-47
|
|
main/java/…/edition/GrpcEdition2026Status.java:1-28
|
|
main/resources/proto/edition2024/compatibility.proto:1-28
|
|
test/java/…/edition/GrpcEdition2024GateTest.java:1-118
|
|
test/java/…/edition/GrpcEdition2026GuardTest.java:1-93
|
|
```
|