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,49 @@
---
kind: CONCEPT
slug: adapter-inbound-grpc-c01
title: 인증이 예외 변환 바깥에 있어도 진단이 새지 않는다
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-grpc-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-grpc-c01
file: ../../../final/evidence/rendered/adapter-inbound-grpc-c01.svg
- key: adapter-inbound-grpc-c01-diagram
file: ../../../final/assets/diagrams/adapter-inbound-grpc-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-grpc-c01.txt
source:
- 원본 분석 절은 analysis/15-adapter-inbound-grpc.md#L140 이다.
module: adapter-inbound-grpc
---
# 인증이 예외 변환 바깥에 있어도 진단이 새지 않는다
`ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor)`는 인증을 예외 변환보다 바깥에 놓는다. 그런데도 인증 실패가 변환되지 않은 예외로 새지 않는 이유는 인증 인터셉터가 자기 예외를 스스로 삼키기 때문이다.
## 본문
<!-- body:start -->
`ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor)` — gRPC 규약상 **마지막 인터셉터의 `interceptCall`이 먼저** 호출되므로 인증이 바깥, 예외 처리가 안쪽이다. 등록할 때 적은 순서와 실제로 요청을 감싸는 순서가 뒤집혀 있다.
## 바깥에서 안으로 놓이는 순서
:::evidence key="adapter-inbound-grpc-c01-diagram" alt="인증과 업무 처리와 예외 변환이 위에서 아래로 이어지는 구조" caption="인터셉터의 순서" zoom="false"
:::
## 인증이 예외 변환 바깥에 있어도 되는 이유
인증 인터셉터가 예외 처리 바깥에 있는데도 안전한 이유는 그것이 스스로 예외를 삼키기 때문이다. CLAUDE.md의 약속("정책이 `false`를 반환하거나 예외를 던진 요청은 ... 안정적인 `UNAUTHENTICATED` status/code/category로 종료된다")이 코드와 일치하고, 정책이 `false`를 돌려준 경우와 예외를 던진 경우가 모두 같은 `call.close(Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()), trailersFor(...))`로 끝난다. 정책 진단은 클라이언트에 닿지 않는다.
두 인터셉터가 같은 일을 두 번 하는 구조가 아니다. 중복 아님.
## 분석 원문이 적은 순서 판정
:::evidence key="adapter-inbound-grpc-c01" alt="분석 문서 analysis/15-adapter-inbound-grpc.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/15-adapter-inbound-grpc.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c09
title: 구독 경로가 guard를 건너뛰고 대신 세우는 검사
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c09
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c09.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c09.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L477 이다.
module: adapter-outbound-cache-redis
---
# 구독 경로가 guard를 건너뛰고 대신 세우는 검사
`PubSubOperationRequests`의 javadoc이 guard를 지나지 않는 경로를 스스로 밝히고, 그 자리에 어떤 검사를 대신 두었는지도 함께 적는다. 대체 검사는 실제로 존재한다.
## 본문
<!-- body:start -->
`PubSubOperationRequests`의 javadoc이 예외를 스스로 밝힌다. 발행은 보통의 명령이라 guard를 지나지만 구독은 지나지 않고, 경계 지을 응답도 적용할 타임아웃도 없어서 guard가 검사할 대상 자체가 없다는 것이 그 이유다. 대체 검사가 실제로 있다.
## 구독이 guard 대신 통과하는 세 검사
`channelTargets`·`shardTargets`·`patternTargets` 셋 다 빈 컬렉션을 거부하고 **모든 대상의 네임스페이스가 이 프로세스의 것과 같은지** 확인한다(`requireNamespace`, 다르면 "channel belongs to a namespace this process may not use"). `patternTargets`는 추가로 `context.sdkPermit(PATTERN_SUBSCRIBE)`를 호출한다. 팬아웃 폭을 요청이 아니라 서버가 정하기 때문이다.
## 코드베이스에 남은 PubSubOperationRequests 참조
:::evidence key="adapter-outbound-cache-redis-c09" alt="코드베이스에서 PubSubOperationRequests 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PubSubOperationRequests 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: domain-core-c03
title: 실패가 런타임이 아니라 빌드에서 발생한다
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:domain-core-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: domain-core-c03
file: ../../../final/evidence/rendered/domain-core-c03.svg
evidence:
- ../../../final/evidence/raw/domain-core-c03.txt
source:
- 원본 분석 절은 analysis/01-domain-core.md#L158 이다.
module: domain-core
---
# 실패가 런타임이 아니라 빌드에서 발생한다
이 module의 runtime executable behavior는 매우 작다. annotation 자체에는 success/failure path가 없고 interface도 implementation을 가지지 않으므로, 주요 failure mechanics는 build-time architecture violation이다.
## 본문
<!-- body:start -->
이 module의 runtime executable behavior는 매우 작다. annotation 자체에는 success/failure path가 없고, interface도 implementation을 가지지 않는다. 그래서 주요 failure mechanics는 **build-time architecture violation**이다.
## 위반이 걸리는 규칙 이름
| 위반 | 잡는 규칙 |
|---|---|
| forbidden framework/domain dependency | `DOMAIN_IS_PURE` |
| domain logger dependency | `DOMAIN_HAS_NO_LOGGER` |
| public no-arg value object | `VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR` |
| public `set*` aggregate mutator | `AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC` |
| non-record domain event | `DOMAIN_EVENTS_ARE_RECORDS` |
| enumerated transport dependency | `DOMAIN_EVENTS_ARE_TRANSPORT_FREE` |
| `ResourceId`에 할당할 수 없는 `id` field raw type | `NO_LONG_ID_PK` |
| registry에 없는 project dependency | `verifyCleanArchitectureDependencies` |
| production -> `sample-portfolio` edge | settings registry validation · root dependency verification · cross-module ArchUnit rule |
## 코드베이스의 ResourceId 참조
:::evidence key="domain-core-c03" alt="코드베이스에서 ResourceId 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ResourceId 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: messaging-cloudevents-c08
title: public 시그니처에 나오는 타입은 api로 선언한다
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-cloudevents-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-cloudevents-c08
file: ../../../final/evidence/rendered/messaging-cloudevents-c08.svg
evidence:
- ../../../final/evidence/raw/messaging-cloudevents-c08.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-cloudevents.md#L451 이다.
module: messaging-cloudevents
---
# public 시그니처에 나오는 타입은 api로 선언한다
build.gradle 주석이 이전 결함 하나를 보존한다. `implementation`으로 선언한 탓에 이 module의 public API에 등장하는 타입이 소비자에게는 숨겨졌던 사례이고, 그것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군이다.
## 관계
- **왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
build.gradle 주석이 이전 결함 하나를 보존한다. 의존성을 `implementation`으로 선언했는데 그 타입이 이 module의 public API에 등장하고 있었다.
> "Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract."
이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle``api`로 선언했는지 대조". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다.
## 하지 않기로 기록된 두 매핑
코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — "defaulted to the production instant"와 "inventing a tombstone"은 하지 않기로 한 것들이다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-cloudevents-c08" alt="코드베이스에서 파일 목록을 만든 출력 3줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 3줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: messaging-inbox-jdbc-postgresql-c07
title: 커넥션을 요청하기 전에 거절하도록 고정한 테스트
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-inbox-jdbc-postgresql-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-c07
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L581 이다.
module: messaging-inbox-jdbc-postgresql
---
# 커넥션을 요청하기 전에 거절하도록 고정한 테스트
한 결함이 두 파일에 기록돼 있고, 그중 하나가 그것을 막는 테스트다. 그 테스트는 거절 시점까지 고정한다 — DataSource가 커넥션을 요청받는 순간 테스트가 실패하도록 만들어 두었다.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**한 결함이 두 파일에 기록돼 있고, 그중 하나가 그것을 막는 테스트다.** `JdbcInboxRepository.reserve(Connection,…)`의 javadoc과 `JdbcInboxTransactionRequirementTest`의 javadoc이 같은 결함을 각각 구현 쪽과 테스트 쪽에서 서술한다.
## 거절 시점을 데이터 소스로 고정한다
그 테스트가 "hermetic: the refusal has to happen before any connection is requested, and the data source below fails the test by being asked for one"이라고 자기 설계를 적는다. 거절이 커넥션 요청보다 먼저 일어나야 한다는 요구를, 요청받는 것만으로 실패하는 데이터 소스를 아래에 두어 검사 순서까지 고정한 것이다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-inbox-jdbc-postgresql-c07" alt="코드베이스에서 파일 목록을 만든 출력 6줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 6줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: messaging-pulsar-experimental-c01
title: 알 수 없는 실패의 기본값을 모호로 둔다
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-pulsar-experimental-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-pulsar-experimental-c01
file: ../../../final/evidence/rendered/messaging-pulsar-experimental-c01.svg
- key: messaging-pulsar-experimental-c01-diagram
file: ../../../final/assets/diagrams/messaging-pulsar-experimental-c01.svg
evidence:
- ../../../final/evidence/raw/messaging-pulsar-experimental-c01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-pulsar-experimental.md#L50 이다.
module: messaging-pulsar-experimental
---
# 알 수 없는 실패의 기본값을 모호로 둔다
분류는 타입 있는 신호만 본다. `PulsarPreSendRejection`이면 REJECTED, `TimeoutException`이면 시간 초과, 나머지는 전부 AMBIGUOUS다. javadoc이 이전 구현과 그 결함을 적는다.
## 본문
<!-- body:start -->
javadoc 이 이전 구현과 그 결함을 적는다. 예전에는 예외의 클래스 단순 이름을 읽어서 `"Timeout"`이면 모호, 그 밖이면 거절로 분류했다.
> "Classification used to read the exception's class simple name: `"Timeout"` meant ambiguous, anything else meant rejected. A class name is not part of Pulsar's contract — it changes between client versions — and defaulting the unknown case to `REJECTED` tells the caller nothing was transmitted, which is how the same entry is published to the bookies twice."
기본값이 모호로 바뀐 것이 핵심이다. 알 수 없는 실패에서 안전한 방향은 모호다.
## 분류가 갈라지는 자리
:::evidence key="messaging-pulsar-experimental-c01-diagram" alt="사전 거절과 그 밖의 실패가 나란히 놓이고 모호가 아래에 놓인 구조" caption="분류의 기본값" zoom="false"
:::
## 성공을 영수증이 아니라 복제 증거로 적는다
확인된 성공은 복제 증거로 기록된다 — 전송 미래가 설정된 수의 저장 노드에 기록된 뒤에야 해소되므로 영수증이 아니라 복제 증거다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-pulsar-experimental-c01" alt="코드베이스에서 파일 목록을 만든 출력 8줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 8줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: messaging-schema-api-c07
title: 검사의 위치와 키가 틀렸던 두 결함
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-schema-api-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-api-c07
file: ../../../final/evidence/rendered/messaging-schema-api-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-api-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-api.md#L432 이다.
module: messaging-schema-api
---
# 검사의 위치와 키가 틀렸던 두 결함
코드 주석이 이전 결함 둘을 보존한다. 두 사례 다 형태가 같다 — 검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.
## 관계
- **port 계약은 동시성 요구를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **도달성 판정은 단어가 아니라 import로 확인한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
코드 주석이 보존한 이전 결함 둘은 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.**
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |
| `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |
`messaging-core-api` §13의 "문자 vs 바이트, 정확일치 vs 세그먼트" 목록과 같은 계열이다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-schema-api-c07" alt="코드베이스에서 파일 목록을 만든 출력 10줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 10줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: messaging-testkit-c06
title: 아는 척하지 않기 위해 던지는 자리와 삼키는 자리
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-testkit-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-c06
file: ../../../final/evidence/rendered/messaging-testkit-c06.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-c06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L492 이다.
module: messaging-testkit
---
# 아는 척하지 않기 위해 던지는 자리와 삼키는 자리
이 리프의 실패 처리 원칙은 하나다: 모르는 것을 아는 척하지 않는다. 그 원칙이 어떤 자리에서는 예외를 던지는 방향으로, 다른 자리에서는 예외를 삼키는 방향으로 나타난다.
## 본문
<!-- body:start -->
이 리프의 실패 처리 원칙은 하나다: **모르는 것을 아는 척하지 않는다.** 같은 원칙이 어느 자리에서는 예외를 던지는 쪽으로, 다른 자리에서는 예외를 삼키는 쪽으로 나타난다.
## 등록되지 않은 어댑터와 기록 없는 커버리지
`CompatibilityMatrix.of("messaging-artemis")` 는 던지고(`anUnknownAdapterIsNotSilentlyTreatedAsSupported`), `matrix.coverageOf("messaging-artemis", …)``NOT_COVERED` 를 돌려준다(`aFaultThatWasNeverRecordedReadsAsUncoveredRatherThanPassing`). 전자는 "지원 목록에 없는 것을 지원인 척"을 막고, 후자는 "기록 없음"이 곧 "커버 안 됨"이라는 자연스러운 읽기다.
## DockerAvailability가 어떤 예외든 삼키는 이유
`Class.forName("org.testcontainers.DockerClientFactory")` 를 리플렉션으로 부르고 어떤 예외든 `false` 로 삼킨다(`:26-34`). 이 리프가 testcontainers 에 의존하지 않으면서 그 존재를 물어볼 수 있게 하는 유일한 방법이고, 결과를 `static final` 로 1회만 캐시한다.
## DockerAvailability 참조 위치
:::evidence key="messaging-testkit-c06" alt="코드베이스에서 DockerAvailability 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DockerAvailability 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 인증 레인만 가드 없이 실패한다
**"skip 은 성공이 아니다"** 라는 반대 규칙이 인증 레인에는 적용되어 있다. 일반 컨테이너 스위트는 `DockerAvailability` 로 skip 하고, 인증 레인만 **가드 없이 실패**한다. 대신 `test` 태그에서 빼서 노트북 빌드를 깨지 않는다. 두 규칙이 충돌하지 않게 배치되어 있다.
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: messaging-transport-spi-c07
title: 오래 돌 때만 드러나는 수명주기 결함 네 개
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-transport-spi-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-transport-spi-c07
file: ../../../final/evidence/rendered/messaging-transport-spi-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-transport-spi-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-transport-spi.md#L579 이다.
module: messaging-transport-spi
---
# 오래 돌 때만 드러나는 수명주기 결함 네 개
코드 주석이 네 결함을 보존한다. 전부 장기 실행에서만 드러나는 종류다 — 세대 회전이 겹칠 때, 닫힌 세대가 목록에 남을 때, 회전 없이 종료할 때, 이중 해제가 계수를 음수로 만들 때.
## 관계
- **멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 개념의 sentinel은 계층을 넘어 하나로 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
코드 주석이 네 결함을 보존한다. 전부 **장기 실행에서만 드러나는** 종류다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `Generation.retiredAt` javadoc | 호출자가 넘긴 하나의 `retiredAt`을 전체 draining 목록에 적용 | 드레인 중 회전이 겹치면, 방금 은퇴한 세대를 강제 종료하거나 오래된 세대에 새 마감을 주거나 — 호출자가 우연히 넘긴 타임스탬프에 좌우 |
| `closeExpiredDraining` 주석 | 이미 닫힌 세대가 목록에 잔류 | `drainingCount()`가 끝난 작업을 영원히 보고 |
| `close()` javadoc | 회전이 은퇴시킨 것만 닫음 | 회전 없이 종료한 프로세스가 브로커 연결을 JVM 종료에 맡김 → 미전송 producer 배치 소실, consumer 세션이 그룹을 떠나지 않고 브로커에서 타임아웃 |
| `endWork` javadoc | clamp 없음 | 이중 해제가 계수를 음수로 → 작업이 도는 중에 드레인 완료로 보고 |
## 0번 해제와 2번 해제가 같은 등급인 이유
네 번째와 `LeakTrackingRuntime.closeCount()` javadoc("Closing twice is as much a defect as never closing")이 같은 주제를 반대편에서 말한다 — **해제는 정확히 한 번이어야 하고, 0번도 2번도 결함이다.**
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-transport-spi-c07" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: shared-contract-c03
title: 추정하지 않는 두 경계 — 스위치 해석과 상태 투영
topic: result-and-failure-algebra
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:shared-contract-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: shared-contract-c03
file: ../../../final/evidence/rendered/shared-contract-c03.svg
evidence:
- ../../../final/evidence/raw/shared-contract-c03.txt
source:
- 원본 분석 절은 analysis/02-shared-contract.md#L108 이다.
module: shared-contract
---
# 추정하지 않는 두 경계 — 스위치 해석과 상태 투영
`MasterSwitchParser`는 operator configuration을 permissive coercion하지 않는 fail-closed contract다. `RedisHealthSnapshotProvider`는 공유 경계로 내보낼 필드를 좁히고, 조회하지 않은 값을 관측인 것처럼 적지 않는다.
## 본문
<!-- body:start -->
`MasterSwitchParser`는 unset=false, true/false case-insensitive만 허용하며 `yes`, `1`, `on`, whitespace-padded value를 invalid로 처리한다. canonical+legacy가 동시에 있으면 값이 같아도 ambiguous로 실패하고 legacy-only는 replacement property를 반환한다. 이는 operator configuration을 permissive coercion하지 않는 fail-closed contract다.
## MasterSwitchParser 참조 위치
:::evidence key="shared-contract-c03" alt="코드베이스에서 MasterSwitchParser 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MasterSwitchParser 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 상태 스냅샷이 공유 경계로 내보내는 필드
`RedisHealthSnapshotProvider`는 Redis client/connection/credential을 shared boundary로 새지 않게 role/capability/state/reason/semantic freshness만 projection한다.
## eviction policy가 관측이 아니라 기대인 이유
eviction policy는 runtime CONFIG 조회 증명이 아니라 configured expectation임을 enum 이름과 javadoc으로 명시한다.
<!-- body:end -->