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,308 @@
# grpc-core-api 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 32파일 1,897줄 + `src/test` 7파일 926줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-core-api`
> SSOT owner: `grpc-core-api`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: **`[]`** — 이 저장소에서 의존성이 하나도 없는 두 리프 중 하나(다른 하나는 `messaging-core-api`)
- `runtime_memberships`: **`[]`** — build-only
```groovy
// build.gradle:3-9
// The platform's port layer: identifiers, method policy, execution evidence, failure model,
// deadline primitives and request context.
//
// No dependencies at all, and that is the contract rather than an accident. The Stable plan's
// Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about
// a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds.
// A type here may not name io.grpc, Spring, Netty, protobuf or a database.
```
| 패키지 | 파일 | 줄 | 성격 |
|---|---:|---:|---|
| `core` | 8 | 390 | 식별자·상태 코드·RPC 종류·Stable 모듈 목록과 불변식 |
| `error` | 4 | 276 | 실패 문맥·범주·완료 결과·플랫폼 예외 |
| `context` | 4 | 273 | 요청 문맥·메타데이터 키와 예산·클라이언트 신원 |
| `evidence` | 4 | 239 | 전송·업무·스트림 세 축 |
| `deadline` | 4 | 238 | 예산·프로파일·취소 토큰·마감 예외 |
| `policy` | 4 | 295 | 메서드 정책과 목록, 멱등 프로파일, wait-for-ready |
| `ledger` | 4 | 176 | 연산 원장 포트와 기록·신원·상태 |
가장 큰 파일 넷: `GrpcMethodPolicyCatalog` 124 · `GrpcMethodPolicy` 100 · `GrpcFailureContext` 99 · `GrpcRequestContext`·`GrpcExecutionEvidence` 87.
main 총 **32파일 / 1,897줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 32 | `FULL_READ` | 1,897줄. 위 표가 전부 |
| `test/java/**` | 7 | `FULL_READ` | 926줄 |
| `build.gradle` | 1 | `FULL_READ` | 10줄 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 `main/java/**` 를 "전 파일", `test/java/**` 를 6(실제 7)으로 적었다. 그 미세한 오차가 §17.4–§17.6 이 표에 없던 이유다.
`UNCLASSIFIED` 0.
---
## 1. 증거 세 축
`GrpcExecutionEvidence` 가 전송·업무·스트림을 함께 들고 절대 합치지 않는다.
> "The same type is used by the failure model and by the observation convention. That is deliberate:
> when the exception and the metric are built from different snapshots of what happened, the
> incident review has two accounts of one call and no way to choose between them."
관측될 수 없는 조합을 생성자가 거부한다 — 단항이 스트림 증거를 들 수 없고, 보내지 않은 요청이 업무 증거를 들 수 없다.
승격 메서드가 하나뿐인 것도 의도다.
> "This is the promotion the plan forbids, written as the one method that is allowed to observe
> headers — so the forbidden edit is visible as a change to this method rather than as a plausible
> line somewhere in an interceptor."
즉 응답 헤더를 봤다는 사실이 업무 축을 건드리지 못하게 하고, 그 규칙을 어기려면 이 메서드를 고쳐야 한다.
## 2. 완료 결과가 상태 코드와 분리된 이유
> "a mutation that times out is `DEADLINE_EXCEEDED` on the wire and `COMPLETION_UNKNOWN` in the
> business, and a caller that reads the first as the second's answer either loses a committed write
> or performs it twice."
`forMutation` 의 판정 순서가 다섯 단계다.
```
커밋 확인됨 → COMPLETED
부분 스트림 → PARTIAL_STREAM
상태 OK → COMPLETED
전송이 미시작을 증명 → REJECTED
그 밖 → 상태별 표
```
상태별 표에서 `DEADLINE_EXCEEDED`·`UNAVAILABLE`·`CANCELLED`·`UNKNOWN`·`INTERNAL`·`ALREADY_EXISTS`·`ABORTED`·`DATA_LOSS``COMPLETION_UNKNOWN` 이다. `ALREADY_EXISTS` 가 모호에 있는 것이 특히 정확하다 — 재시도가 그 답을 받으면 첫 시도가 성공했다는 뜻일 수 있다.
## 3. 메서드 정책 목록
가장 유용한 성질이 빌드를 깨는 쪽이다.
> "when a descriptor method set is declared, registering a policy for a method the schema does not
> have is an error. That catches the rename — the method becomes `CreateDocumentV2`, the policy still
> names `CreateDocument`, and every call to the new method silently runs with default deadline,
> default retry and no idempotency requirement."
그리고 정책 없는 메서드는 조회에서 던진다 — 정책 없는 호출은 마감도 멱등 프로파일도 없고, 그것을 서비스하려면 둘 다 지어내야 한다.
## 4. Stable 모듈 목록과 불변식
`GrpcStableModuleCatalog` 이 Stable 12 와 Advanced 6 을 상수로 든다.
`GrpcStableBuildInvariant.advancedDependencyAllowed()` 가 인자를 받지 않는 이유가 적혀 있다.
> "the answer does not vary by module, by capability or by environment. A method that could return
> true for some input would be the seam through which 'just this one Advanced type in the starter'
> arrives."
그리고 누출을 던지지 않고 집합으로 돌려주는 이유도 적혀 있다 — 첫 하나만 보고하는 게이트는 넷을 지우는 일을 네 번의 대화로 만든다.
## 10. 테스트 레인
여섯 테스트. 증거 조합 거부, 완료 결과 파생, 정책 목록의 서술자 대조와 중복 거부, 마감 예산, 메타데이터 예산, 식별자 경계, 모듈 목록을 확인한다.
## 12. negative-space probes
**12.1 도달성.** 이 리프는 가족 전체의 포트 계층이므로 참조가 가장 많다. 다만 §17.3 의 타입은 예외다.
**12.2 프레임워크 부재 확인.** `io.grpc`·Spring·Netty·protobuf·JDBC 를 이름으로 부르는 import 가 main 에 없다. build.gradle 의 의존 블록도 비어 있다.
**12.3 실제로 쓰이는 게이트.** 이 가족의 다른 게이트들과 달리 `GrpcStableBuildInvariant.requireNoAdvancedDependency` 는 production 호출자가 둘 있다 — `grpc-spring-boot-starter` 의 시작 검증기와 `grpc-advanced-bootstrap` 의 모듈 가드. 불변식의 양쪽을 각각 다른 리프가 부른다.
**12.4 드리프트.** build.gradle 이 서술한 일곱 패키지가 전부 존재하고, 파일 수는 `core` 8 · `policy` 4 · `evidence` 4 · `error` 4 · `deadline` 4 · `context` 4 · `ledger` 4 = 32 다.
**12.5 검증만 되고 강제되지 않는 성분.** `GrpcMetadataBudget.maxTotalBytes`(§17.5). 같은 형태를 `grpc-policy` 에서도 찾았다 — `GrpcContextPropagationPolicy.clearAfterTask`(그 리프 §17.8). 두 자리 모두 compact constructor 의 가드가 유일한 소비자다.
## 16. 확인하지 못한 것
- 서술자 대조 경로를 실제 스키마로 돌려 보지 않았다(§17.1). 저장소에 컴파일된 서술자가 없다.
- 상태 코드별 매핑을 실제 서버 응답으로 재현하지 않았다. 표와 근거 문장으로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다
`withDescriptorMethods` 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다.
```
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:145
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:175
```
`GrpcMethodPolicyCatalog.builder()` 를 부르는 곳은 저장소 전체에서 전부 테스트다. 그리고 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외).
자바독이 그 상태를 미리 서술한다 — 서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against."
그리고 서술자가 없는 이유는 옆 리프에 있다. `grpc-codegen` 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 `protoc` 이 돌지 않는다. 즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다.
기록하는 이유는 이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있기 때문이다. 수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다.
### 17.2 P3 — 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다
클래스 javadoc 이 두 SSOT 의 관계를 적는다.
> "This repository's module registry (`src/config/architecture/modules.json`) is the SSOT for which
> Gradle projects exist; this catalog is the SSOT for which of them the Stable contract covers, and
> **`GrpcStableModuleCatalogTest` holds the two together.**"
그 테스트는 레지스트리를 읽지 않는다. 다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다.
```java
assertThat(catalog.modules()).containsExactlyInAnyOrder(리터럴);
assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable);
```
`modules.json` 을 읽는 줄도, 파일 경로도 없다.
두 목록은 오늘 일치한다 — 레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다. 어긋난 것은 그 일치를 무엇이 지키는가다.
같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다 — 정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다, 세는 순간 다시 표류한다.
수정은 테스트가 `modules.json` 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다. 그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다.
### 17.3 P3 — `RESOURCE_EXHAUSTED` 매핑이 그 상태의 두 출처 중 하나만 가정한다
```java
case INVALID_ARGUMENT, UNAUTHENTICATED, PERMISSION_DENIED, NOT_FOUND,
FAILED_PRECONDITION, OUT_OF_RANGE, UNIMPLEMENTED, RESOURCE_EXHAUSTED -> REJECTED;
```
이 분기는 전송이 미시작을 증명하지 못한 뒤에 도달한다. 즉 "보냈는지 모르지만 이 상태 코드는 거절을 뜻한다" 는 판정이다.
목록의 나머지 일곱은 서버가 일을 시작하기 전에 답하는 상태다. `RESOURCE_EXHAUSTED` 는 두 출처를 갖는다.
- 이 플랫폼 자신의 승인 제어기가 부하를 흘려보낼 때 — 일을 쓰기 전이므로 거절이 맞다.
- 원격 서버가 작업 중 자원(할당량·디스크)을 소진했을 때 — 부분 커밋이 있을 수 있다.
이 클래스의 원칙은 보수적이다. 자바독이 두 기본값(`DEADLINE_EXCEEDED`·`UNAVAILABLE` 를 모호로)을 계획의 전역 제약이라 부르고, 그 이유는 "보냈는지 모르면 모호" 다. `RESOURCE_EXHAUSTED` 는 그 원칙에서 벗어난 유일한 항목이다.
`ABORTED` 가 모호에 있는 것과 대비된다 — 트랜잭션 충돌은 서버가 일을 시작한 뒤의 상태이고, 그래서 모호다.
수정은 둘 중 하나다. `RESOURCE_EXHAUSTED` 를 모호로 옮기거나, 그 상태를 이 플랫폼이 발행한 것과 원격이 발행한 것으로 구분해 전자만 거절로 두는 것이다. 후자는 증거 축에 발신자 정보를 요구하므로 전자가 현실적이다.
### 17.4 P3 — 하나의 상태 코드가 같은 메서드 안에서 두 답을 갖는다
`forMutation` 은 스위치에 닿기 전에 `OK` 를 먼저 처리한다.
```java
if (statusCode == GrpcStatusCode.OK) { return COMPLETED; }
if (evidence.transport().provesNotStarted()) { return REJECTED; }
return switch (statusCode) {
case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN; // ← OK 가 여기에도 있다
};
```
스위치의 `OK` 분기는 도달하지 않는다. 열거형 전수 처리를 컴파일러가 요구하므로 항목 자체는 필요하지만, 그 값이 위의 가드와 반대다.
결과는 잠재적 함정이다. 누군가 위의 `OK` 가드를 "중복이니까" 지우면 컴파일은 통과하고 `OK` 인 변경이 `COMPLETION_UNKNOWN` 이 된다 — 성공한 변경마다 대사(reconciliation)를 요구하게 된다. 이 리프의 다른 자리들은 그런 편집이 눈에 띄도록 설계되어 있다(예: 승격 메서드를 하나로 좁힌 것).
수정은 한 글자다. 스위치의 `OK``COMPLETED` 로 옮기면 두 자리의 답이 같아지고, 가드가 사라져도 결과가 바뀌지 않는다.
### 17.5 P3 — 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
`GrpcMetadataBudget` 은 세 성분을 갖는다 — `maxTotalBytes`·`maxUserDefinedBytes`·`maxEntries`.
`check(...)` 가 보는 것은 뒤의 둘뿐이다.
```java
if (metadata.size() > maxEntries) { throw ; }
int userDefinedBytes = 0;
for () { userDefinedBytes += entry.getKey().name().length() + value.length(); }
if (userDefinedBytes > maxUserDefinedBytes) { throw ; }
// maxTotalBytes 는 여기서 쓰이지 않는다
```
**첫째, `maxTotalBytes` 는 읽히지 않는다.** 저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다. 자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것. 판단은 옳다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
**둘째, 단위가 어긋난다.** 성분 이름은 `...Bytes` 인데 세는 것은 `String.length()`, 즉 UTF-16 코드 단위다. 키는 `[a-z0-9._-]` 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다. 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다.
수정은 둘 다 작다 — `value.getBytes(StandardCharsets.US_ASCII).length` 로 세거나 값의 문자 집합을 `GrpcMetadataKey.Kind.ASCII` 에 맞춰 검증하고, `maxTotalBytes` 는 성분에서 빼고 javadoc 의 서술로 남긴다.
### 17.6 P3 — 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다
```java
public class GrpcPlatformException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient GrpcFailureContext context; // ← transient
public boolean requiresReconciliation() { return context.completionOutcome().requiresReconciliation(); }
}
```
`serialVersionUID` 는 이 타입이 직렬화된다는 선언이고, `transient` 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 `context == null` 이고, 공개 메서드 둘 중 하나(`requiresReconciliation()`)가 NPE 를 던진다.
`transient` 자체는 강제된 선택이다 — `GrpcFailureContext``Serializable` 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.
기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다. 다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design"). 여기에는 `transient` 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다.
도달성은 낮다. gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다. 수정은 셋 중 하나다 — `GrpcFailureContext` 와 그 구성 요소를 `Serializable` 로 만들거나, `serialVersionUID` 를 지워 직렬화를 지원하지 않음을 명시하거나, `context()``requiresReconciliation()` 이 null 문맥을 다루도록 하고 그 이유를 적는 것.
### 확인된 설계(문제 아님)
- **의존성 0 을 계약으로 삼고 그 이유를 적은 것** — "evidence and policy do not know about a transport" 가 검증 가능해진다.
- **증거 세 축을 한 타입에 두고 관측 불가 조합을 생성자가 거부한 것.**
- **승격 메서드를 하나로 좁혀 금지된 편집이 그 메서드의 변경으로 보이게 한 것.**
- **완료 결과를 상태 코드와 분리한 것과 그 예시.**
- **`ALREADY_EXISTS`·`ABORTED` 를 모호로 둔 것.**
- **정책 없는 메서드를 조회에서 던지는 것.**
- **서술자 대조를 선택 사항으로 두되 그 부재의 대가를 자바독에 적은 것.**
- **`advancedDependencyAllowed()` 가 인자를 받지 않는 것과 그 근거.**
- **누출을 집합으로 돌려주는 것.**
---
## Source anchors
```
src/grpc/grpc-core-api/build.gradle:1-10
main/java/…/policy/GrpcMethodPolicyCatalog.java:1-124 (§17.1 withDescriptorMethods:80-87)
main/java/…/policy/GrpcMethodPolicy.java:1-100
main/java/…/error/GrpcFailureContext.java:1-99
main/java/…/context/GrpcRequestContext.java:1-87
main/java/…/evidence/GrpcExecutionEvidence.java:1-87
main/java/…/deadline/GrpcDeadlineBudget.java:1-85
main/java/…/evidence/GrpcStreamEvidence.java:1-80
main/java/…/core/GrpcStableModuleCatalog.java:1-79 (§17.2)
main/java/…/error/GrpcCompletionOutcome.java:1-69 (§17.3 · §17.4 forMutation:360-390)
main/java/…/deadline/GrpcCancellationToken.java:1-68
main/java/…/context/GrpcMetadataBudget.java:1-66 (§17.5 check:130-154)
main/java/…/context/GrpcMetadataKey.java:1-65
main/java/…/error/GrpcFailureCategory.java:1-64
main/java/…/deadline/GrpcDeadlineProfile.java:1-59
main/java/…/ledger/GrpcOperationLedgerRecord.java:1-59
main/java/…/context/GrpcClientIdentity.java:1-55
main/java/…/core/GrpcMethodName.java:1-55
main/java/…/core/{GrpcStableBuildInvariant:1-53, RpcType:1-53, GrpcStatusCode:1-52,
GrpcIdentifiers:1-47, GrpcServiceName:1-37, GrpcChannelProfileName:1-24}
main/java/…/policy/{RpcIdempotencyProfile:1-49, WaitForReadyPolicy:1-22}
main/java/…/ledger/{GrpcOperationLedger:1-50, GrpcOperationIdentity:1-39, GrpcOperationLedgerState:1-28}
main/java/…/error/GrpcPlatformException.java:1-44 (§17.6)
main/java/…/evidence/{GrpcTransportEvidence:1-41, GrpcBusinessEvidence:1-31}
main/java/…/deadline/GrpcDeadlineExceededException.java:1-26
test/java/…/ 7파일 926줄 (GrpcMethodPolicyCatalogTest:183 · GrpcFailureContextTest:182 ·
GrpcMetadataBudgetTest:174 · GrpcDeadlineBudgetTest:124 · GrpcExecutionEvidenceTest:120 ·
GrpcCoreIdentifiersTest:83 · GrpcStableModuleCatalogTest:60)
src/config/architecture/modules.json (§17.2 — 테스트가 읽지 않는 SSOT)
grpc-spring-boot-starter/…/GrpcPlatformStartupValidator.java:176 (GrpcStableBuildInvariant 실사용)
grpc-advanced/grpc-advanced-bootstrap/…/GrpcAdvancedModuleGuard.java:76 (같은 불변식의 반대편)
```