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,128 @@
---
kind: CASE
slug: a01-f002-idfactory-newid
title: never-before-used 는 시그니처가 줄 수 없는 보장이다
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a01-f002-idfactory-newid
evidenceCapturedOn: 2026-09-02
assets:
- key: a01-f002-idfactory-newid
file: ../../../final/evidence/rendered/a01-f002-idfactory-newid.svg
evidence:
- ../../../final/evidence/raw/a01-f002-idfactory-newid.txt
source:
- 분석 문서는 도메인 코어 편 §11 P3 이다. 인터페이스가 저장소 충돌 검사를 요구하지 않고 샘플 테스트도 전역 유일성을 증명하지 않는다는 판정이 그 항목에 있다. 포트와 어댑터의 배선은 §4 에, 어댑터 테스트가 실제로 무엇을 검증하는지는 §9 에 있다.
---
# never-before-used 는 시그니처가 줄 수 없는 보장이다
식별자 생성 포트의 메서드 문서가 한 번도 쓰인 적 없는 값이라고 적는다. 그 메서드는 인자도 확인 예외도 없어 저장소를 물어볼 수도 충돌을 알릴 수도 없다. 저장소 수준 유일성을 실제로 강제하는 것은 스키마의 기본 키다.
## 관계
- **이름은 값이 아니라 registry key다**
식별자가 무엇을 보장하는지를 타입 계약으로 적으라는 규칙이다.
- **문서가 UUIDv7이라 말하고 생성되는 것은 v4다**
다른 리프에서 같은 형태로 나타난 문서와 구현의 거리다.
## 문제
포트 파일은 열다섯 줄이고, 메서드 문서는 한 줄이다. 그 한 줄이 충돌 없는 값을 약속한다.
그 문장은 두 가지로 읽힌다. 확률적 유일성이거나 저장소 수준 유일성이다.
## 결론
시그니처가 답을 정한다. T newId() 는 인자를 받지 않고 확인 예외도 선언하지 않는다. 질의할 저장소도 실패를 알릴 반환 통로도 시그니처에 없다.
값 계약도 같은 쪽을 가리킨다. 포트 문서는 그 값을 36자 canonical 형식으로, 규격은 RFC 9562 의 UUIDv7 로 못박는다. UUID 가 주는 유일성은 확률에 기댄 것이다.
포트를 직접 구현하는 클래스는 없다. 구현은 샘플이 정의한 하위 포트를 거친다. 작업 로그와 포스터의 팩토리가 각각 포트를 확장하고 타입 파라미터만 채우며, 스프링 컴포넌트인 어댑터 둘이 그것을 구현한다.
어댑터 쪽 구현 자체는 규격대로다. 밀리초 안에서도 단조 증가하는 생성기를 부른다. 그렇다고 저장소를 확인할 자리가 생기지는 않는다. 오버라이드할 시그니처가 바뀌지 않기 때문이다.
샘플의 다른 팩토리 둘은 이 포트를 확장하지 않는다. 반환형이 포트의 바운드를 만족하지 못한다. 하나는 문자열을 돌려주고, 다른 하나는 두 값을 한 번에 예약하는 레코드를 돌려준다.
값 객체가 검사하는 것은 8-4-4-4-12 열여섯진수 모양뿐이다. 버전 자리는 보지 않는다. 계약이 지정한 UUIDv7 조차 타입이 강제하지 않는다.
저장소 수준 유일성이 없는 것은 아니다. 작업 로그와 포스터에서 식별자 열은 기본 키로 잡혀 있다. 중복 삽입을 막는 것은 이 포트가 아니라 스키마다. 포트만 읽고 유일성이 확보됐다고 보면 틀린다. 그 보장은 데이터베이스에서 빌려 온 것이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 포트 시그니처와 값 계약, 구현 경로 추적, 값 객체의 검사 범위, 마이그레이션의 제약 확인
소스 수정 : x
## 재현 조건
1. 포트 파일 전문을 읽고 메서드의 인자와 예외 선언을 확인한다.
2. 그 포트가 만드는 값의 계약 문장을 읽는다.
3. 포트를 직접 구현하는 클래스를 센다. 0 이면 하위 포트를 거치는 구현을 찾는다.
4. 그 구현이 무엇을 부르는지, 그리고 시그니처가 바뀌었는지 확인한다.
5. 값 객체의 정규식이 무엇을 검사하는지 읽는다.
6. 그 식별자가 들어가는 테이블의 제약을 마이그레이션에서 찾는다.
## 본문
<!-- body:start -->
식별자 생성 포트는 열다섯 줄이다. 그중 13행의 메서드 문서 한 줄이 이 사례의 대상이다.
## 포트가 줄 수 있는 것은 시그니처가 정한다
:::evidence key="a01-f002-idfactory-newid" alt="식별자 생성 포트의 파일 전문과 그것이 만드는 값의 계약, 포트를 직접 구현하는 클래스 수와 샘플이 정의한 팩토리 인터페이스 넷, 그중 포트를 확장한 둘을 구현하는 어댑터, 확장하지 않는 둘의 반환형, 실제 구현이 부르는 생성기, 값 객체가 검사하는 정규식, 모듈 README 의 책임 분리, 그리고 식별자 열의 기본 키 제약을 차례로 출력한 터미널 기록." caption="포트 전문 · 값 계약은 36자 canonical UUIDv7 · 직접 구현 0 과 하위 포트 경유 어댑터 둘 · 값 객체는 모양만 검사 · 유일성은 기본 키가 강제 — 73줄 · exit 0" zoom="true"
:::
메서드 문서가 새 식별자를 신선하고 한 번도 쓰인 적 없는 값이라고 적는다.
그 메서드는 인자를 받지 않고 확인 예외도 선언하지 않는다. 이미 쓰인 값인지 물어볼 대상이 없고, 충돌을 알릴 통로도 없다. 저장소 수준 유일성을 약속하려면 둘 중 하나는 있어야 한다.
## 값 계약이 어느 쪽인지 정한다
이 포트가 만드는 값의 문서는 36자 canonical UUID 이고, RFC 9562 의 UUIDv7 이라고 버전까지 적는다.
UUID 의 유일성은 확률적이다. 생성기가 같은 값을 두 번 낼 확률이 무시할 만큼 작다는 뜻이고, 저장소에 그 값이 없다는 확인은 아니다.
값 객체 쪽은 그보다 더 느슨하다. 정규식이 보는 것은 8-4-4-4-12 열여섯진수 모양뿐이고, 버전 자리는 검사하지 않는다. 생성 어댑터를 거치지 않는 입력 경로에서는 계약이 적은 UUIDv7 조차 강제되지 않는다.
## 직접 구현은 없고, 구현은 하위 포트를 거친다
이 포트를 `implements IdFactory<…>` 로 직접 구현하는 클래스는 없다.
샘플이 팩토리 인터페이스 넷을 정의한다. 그중 둘이 포트를 확장하고, 메서드를 더하지 않은 채 타입 파라미터만 채운다. 나머지 둘은 확장하지 않는데, 반환형이 포트의 바운드 `T extends ResourceId<?>` 를 만족하지 못하기 때문이다. 하나는 문자열을 돌려주고, 다른 하나는 의도 식별자와 연산 식별자를 한 번에 예약하는 레코드를 돌려준다.
확장한 둘을 스프링 컴포넌트 어댑터가 구현한다. 그 구현은 진짜 UUIDv7 을 만든다.
```java
return WorkLogId.of(UuidCreator.getTimeOrderedEpochPlus1().toString());
```
밀리초 안에서도 단조 증가하고 보안 난수를 쓴다고 어댑터 javadoc 이 적는다. 그래도 저장소를 볼 자리는 생기지 않는다. 오버라이드하는 시그니처가 그대로이기 때문이다.
## README 는 왜 그렇게 나눴는지를 적는다
발급할 책임은 도메인이 소유하고, 실제로 만드는 행위는 인프라 어댑터가 수행하며, 애플리케이션 유스케이스가 둘을 조율한다. 그렇게 나누면 도메인이 난수나 시계 같은 구체적 소스를 알지 못한 채 계약만 갖는다는 것이다. 괄호 안의 예시가 UUIDv7 생성기다.
README 는 책임의 경계를 정할 뿐 보장의 범위를 정하지 않는다. 그 범위를 정하는 것은 시그니처다.
## 유일성은 있다. 다른 데서 온다
작업 로그와 포스터의 식별자 열은 기본 키다.
```text
CONSTRAINT pk_work_log PRIMARY KEY (id)
CONSTRAINT pk_poster PRIMARY KEY (id)
```
중복 삽입을 거절하는 것은 이 제약이다. 식별자를 자연 키로 쓰거나 중복을 막는 근거로 삼는 코드는 저장소 수준 유일성을 전제하는데, 그 전제를 주는 것은 포트가 아니라 스키마다. 포트를 읽고 유일성을 얻었다고 생각하면, 실제로는 그 보장을 데이터베이스에서 빌려 오고 있다.
문구를 어떻게 할지는 의도를 먼저 확인해야 정해진다. 확률적 유일성을 뜻한 것이라면 문장을 그 범위로 좁히면 되고, 저장소 수준을 뜻한 것이라면 시그니처가 바뀌어야 한다.
## 확인하지 못한 것
저자가 그 문구로 무엇을 의도했는지는 이 저장소가 말하지 않는다. 여기서 확인한 것은 계약이 무엇을 보장할 수 있는가다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c19
title: 상속으로 강제하는 계약과 태스크 그래프로 강제하는 계약
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c19
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c19
file: ../../../final/evidence/rendered/adapter-inbound-web-c19.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c19.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1555 이다.
module: adapter-inbound-web
---
# 상속으로 강제하는 계약과 태스크 그래프로 강제하는 계약
같은 "계약 강제"라는 이름 아래 두 가지 다른 장치가 있다. notification 쪽은 상속에 의존해 강제가 없고, web 쪽은 소스셋과 `dependsOn` 태스크 그래프로 강제하며 빠진 것을 잡는 장치까지 갖는다.
## 본문
<!-- body:start -->
같은 "계약 강제"라는 이름 아래 두 가지 다른 장치가 있다.
| | notification `ProviderAdapterContract` | web `WebBudgetContract` 외 6종 |
|---|---|---|
| 강제 수단 | 상속(강제 없음) | 소스셋 + `dependsOn` 태스크 그래프 |
| 실제 적용 | 8종 중 3종 | 세 런타임 전부 |
| 빠진 것을 잡는 장치 | 없음 | `webCrossStackParityTest`가 세 기록을 비교하고, 하나라도 없으면 실패 |
## 빠진 것을 잡는 장치가 왜 필요한가
web 쪽이 구조적으로 우월하다. `build.gradle`이 그 이유를 적는다 — "a parity check that compares whatever happens to be present would report agreement across a matrix with a hole in it."
## 분석 원문의 비교 표
:::evidence key="adapter-inbound-web-c19" alt="분석 문서 analysis/14-adapter-inbound-web.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/14-adapter-inbound-web.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c03
title: 부재 주장을 지키는 세 자리 — 선언·구현·정책
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c03
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c03.svg
- key: adapter-outbound-cache-redis-c03-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c03.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L235 이다.
module: adapter-outbound-cache-redis
---
# 부재 주장을 지키는 세 자리 — 선언·구현·정책
"설계상 부재"라는 주장이 API 문서에만 있는지 확인했다. `sdk/api`의 선언, Lettuce 구현 계층, 명령 정책 파일 세 자리가 같은 부재를 각각 지킨다.
## 본문
<!-- body:start -->
주장이 API 문서에만 있는지 확인했다(`160-...` §8.4).
- `sdk/api` 전체에서 `SETNX`·`SETEX`·`PSETEX`·`ZREVRANGE`·`RPOPLPUSH`·`BRPOPLPUSH`·`GEORADIUS`가 등장하는 곳은 **"없다"고 적는 javadoc 네 줄뿐**이다.
- Lettuce 구현 계층에서 걸린 둘은 무해하다 — `HashOperationRequests:128``HSETNX`(다른 명령이다), `WritePresence:6`의 주석("This is what replaces `SETNX` and `SETEX`").
- 명령 정책 SSOT(`redis-command-policy.yml`, 1,406줄)에서 `KEYS`**`risk: R4`, `support: BLOCKED`**이고, 파일 머리의 표에 따르면 `BLOCKED`의 access는 `NONE`이다. 같은 자리에 `RANDOMKEY`(R2 BLOCKED)·`DUMP`·`RESTORE`·`MIGRATE`·`SELECT`·`SWAPDB`도 BLOCKED다. 즉 raw gateway로도 `KEYS`에 닿을 수 없다.
## 부재를 지키는 세 자리
:::evidence key="adapter-outbound-cache-redis-c03-diagram" alt="sdk/api 선언과 Lettuce 구현 계층과 명령 정책 파일이 위에서 아래로 쌓이고 강제 방향 화살표가 아래로 그려진 구조" caption="부재를 지키는 세 자리" zoom="false"
:::
## 분석 원문의 확인 절차
:::evidence key="adapter-outbound-cache-redis-c03" alt="분석 문서 analysis/10-adapter-outbound-cache-redis.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/10-adapter-outbound-cache-redis.md 발췌 — 15줄" zoom="true"
:::
## 정책 파일이 스스로 적는 권위 분리
정책 파일 자체의 구조는 sub-scope 05에서 다룬다 — 머리 주석이 "Official server metadata … decides what a command *is*. This file decides what this SDK is willing to *do* with it. **The catalog drift gate compares the two and fails the build when the server grows a command this file has not judged.**"라고 적는다.
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: adapter-outbound-notification-c05
title: SSRF 가드의 호출처 둘과 javadoc이 지목하는 둘이 다르다
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-notification-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-notification-c05
file: ../../../final/evidence/rendered/adapter-outbound-notification-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-notification-c05.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L703 이다.
module: adapter-outbound-notification
---
# SSRF 가드의 호출처 둘과 javadoc이 지목하는 둘이 다르다
`NotificationEndpoints.requireExternallyRoutable`의 프로덕션 호출처는 SES endpoint와 webhook target 둘이다. 가드 자신의 javadoc이 위험 대상으로 지목하는 것은 Web Push endpoint와 webhook target이고, Web Push는 호출처 목록에 없다.
## 본문
<!-- body:start -->
`NotificationEndpoints.requireExternallyRoutable`의 프로덕션 호출처는 **둘**뿐이다.
```text
.../ses/SesProviderProperties.java:27: NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
.../webhook/WebhookSubscription.java:49: NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);
```
## javadoc이 위험 대상으로 지목하는 둘
> "**Web Push endpoints and webhook targets are supplied by clients**, which makes this a server-side request forgery primitive."
Web Push는 호출처 목록에 없다. §25.1.
## NotificationEndpoints 참조 위치
:::evidence key="adapter-outbound-notification-c05" alt="코드베이스에서 NotificationEndpoints 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NotificationEndpoints 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: adapter-outbound-notification-c06
title: 상속을 강제하는 장치가 없어 8종 중 3종만 계약을 탄다
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-notification-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-notification-c06
file: ../../../final/evidence/rendered/adapter-outbound-notification-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-notification-c06.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L913 이다.
module: adapter-outbound-notification
---
# 상속을 강제하는 장치가 없어 8종 중 3종만 계약을 탄다
공유 계약 `ProviderAdapterContract`를 실제로 상속하는 어댑터는 8종 중 3종이다. 클래스 javadoc은 새 프로바이더가 같은 질문에 답하지 않고는 추가될 수 없다고 쓰지만, 상속을 강제하는 장치는 없다.
## 본문
<!-- body:start -->
공유 계약을 실제로 상속하는 어댑터는 이 셋이다.
```text
.../ses/SesNotificationProviderAdapterTest.java:50
.../twilio/TwilioSmsProviderAdapterTest.java:24
.../apns/ApnsNotificationProviderAdapterTest.java:31
```
**8종 중 3종.** 클래스 javadoc은 "Subclasses supply an adapter and a fault harness; the assertions are here **so that a new provider cannot be added without answering the same three questions**"라고 쓰지만, 상속을 강제하는 장치는 없다.
## 같은 종류의 강제가 다른 곳에는 있다
이 저장소는 같은 종류의 강제를 다른 곳에서는 만들어 두었다 — `EndpointGuardCallSiteTest`(가드가 호출처에서 실제로 도달하는가), `verifyNotificationApiSurface`(공개 타입 586개 스냅샷 고정). 여기에는 없다.
## EndpointGuardCallSiteTest 참조 위치
:::evidence key="adapter-outbound-notification-c06" alt="코드베이스에서 EndpointGuardCallSiteTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="EndpointGuardCallSiteTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 크로스-프로바이더 스위트에 등록된 다섯
`ContractAdapters`가 크로스-프로바이더 스위트에 등록하는 것은 **5종**(ses · twilio · apns · webpush · webhook)이다. 빠진 둘은 fcm과 smtp이고, 그것은 harness의 구조적 한계로 설명된다 — 스위트는 HTTP 루프백 서버 위에서 돌고, SMTP는 JavaMail 릴레이로, FCM은 `FcmGateway` 심으로 나간다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c09
title: 경로 방어는 세그먼트마다, 발행은 배타적 하드링크로
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c09
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c09.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c09.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L601 이다.
module: adapter-outbound-objectstorage
---
# 경로 방어는 세그먼트마다, 발행은 배타적 하드링크로
`LocalObjectPathGuard`는 부모 경로를 root부터 한 세그먼트씩 내려가며 심링크와 비디렉터리를 거부한다. 발행은 `Files.createLink`가 주는 배타성에 기대고, 하드링크를 지원하지 않는 파일시스템에서 조용히 약한 방식으로 내려가지 않는다.
## 본문
<!-- body:start -->
`LocalObjectPathGuard`는 이 저장소에서 반복해 본 강한 형태다 — root 정규화 + `startsWith` 봉쇄 + root 자신 거부에 더해, 부모 경로를 **root부터 한 세그먼트씩 내려가며** 심링크와 비디렉터리를 거부하고(`createParentsWithoutLinks` / `rejectExistingLinks`), 대상 자신도 심링크면 거부한다. control key는 `control/v1/` 접두사 + `[a-z0-9._/-]+` + `//`·`/./`·`/../` 금지 + 세그먼트별 재검사다.
그리고 control 레코드는 물리 파일명에 `.record`를 붙인다 — 객체 저장소가 허용하는 `reference``reference/lifecycle` 쌍이 파일시스템에서 파일/디렉터리 충돌을 일으키지 않도록. 논리 키는 그대로 유지된다.
## LocalObjectPathGuard 참조 위치
:::evidence key="adapter-outbound-objectstorage-c09" alt="코드베이스에서 LocalObjectPathGuard 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="LocalObjectPathGuard 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## 발행이 배타적 하드링크인 이유
`LocalDevObjectDataStore.create`가 임시 파일에 쓰고 `channel.force(true)``Files.createLink(target, temporary)`를 하며, `FileAlreadyExistsException``CONFLICT`로, `UnsupportedOperationException`을 "local filesystem cannot prove immutable create"로 번역한다 — 하드링크를 지원하지 않는 파일시스템에서 조용히 약한 방식으로 내려가지 않는다. 앞선 `Files.exists(NOFOLLOW)` 검사는 빠른 경로일 뿐이고 배타성은 `createLink`가 준다. POSIX면 소유자 읽기 전용 권한을 씌운다.
## 테스트 이름이 잡는 여섯 가지
`traversalAbsoluteUnicodePercentAndSymlinkEscapesAreRejected`, `exclusiveCreateRaceHasOneWinner`, `injectedDiskFailureLeavesNoFinalOrTemporaryData`, `restartInspectsCommittedDataWithoutReplayingProducer`, `corruptControlRecordRemainsPresentAndNeverAppearsAbsent`, `createsRestrictivePermissionsWherePosixIsSupported`. 마지막에서 두 번째가 특히 이 leaf의 규칙이다 — 손상된 control 레코드는 **부재로 보이지 않는다**.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: app-bootstrap-c04
title: 자동설정 안의 @ConditionalOnBean은 컴포넌트 스캔과 다르다
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:app-bootstrap-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: app-bootstrap-c04
file: ../../../final/evidence/rendered/app-bootstrap-c04.svg
evidence:
- ../../../final/evidence/raw/app-bootstrap-c04.txt
source:
- 원본 분석 절은 analysis/18-app-bootstrap.md#L386 이다.
module: app-bootstrap
---
# 자동설정 안의 @ConditionalOnBean은 컴포넌트 스캔과 다르다
7개 main 파일 전부 `@Configuration`이고 `@ConditionalOnProperty`/`@ConditionalOnBean`으로 게이트된다. 자동설정 안에서의 `@ConditionalOnBean`은 Boot가 평가 순서를 통제하므로 모듈 14 §7.1이 경고한 위험이 없다.
## 본문
<!-- body:start -->
7개 main 파일 전부 `@Configuration`이고 `@ConditionalOnProperty`/`@ConditionalOnBean`으로 게이트된다.
## 세 조건을 함께 쓰는 자리
`MongoPlatformHealthConfig``@ConditionalOnBean` + `@ConditionalOnMissingBean` + `@ConditionalOnProperty` 셋을 함께 쓰는데, 자동설정 안에서의 `@ConditionalOnBean`은 Boot가 평가 순서를 통제하므로 모듈 14 §7.1이 경고한 컴포넌트 스캔 상의 위험이 없다.
## MongoPlatformHealthConfig 참조 위치
:::evidence key="app-bootstrap-c04" alt="코드베이스에서 MongoPlatformHealthConfig 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoPlatformHealthConfig 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,42 @@
---
kind: CONCEPT
slug: grpc-core-api-c01
title: 게이트가 인자를 받지 않고 집합을 돌려주는 이유
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-core-api-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-core-api-c01
file: ../../../final/evidence/rendered/grpc-core-api-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-core-api-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-core-api.md#L105 이다.
module: grpc-core-api
---
# 게이트가 인자를 받지 않고 집합을 돌려주는 이유
`GrpcStableModuleCatalog`이 Stable 12와 Advanced 6을 상수로 든다. 그 위의 `GrpcStableBuildInvariant`는 인자를 받지 않는 판정 메서드와, 첫 위반이 아니라 위반 집합을 돌려주는 반환 형태를 갖는다. 둘 다 이유가 코드에 적혀 있다.
## 본문
<!-- body:start -->
`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."
## GrpcStableModuleCatalog 참조 위치
:::evidence key="grpc-core-api-c01" alt="코드베이스에서 GrpcStableModuleCatalog 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcStableModuleCatalog 코드베이스 검색 — 14줄 · exit 0" zoom="true"
:::
## 첫 위반이 아니라 위반 집합을 돌려준다
누출을 던지지 않고 집합으로 돌려주는 이유도 적혀 있다 — 첫 하나만 보고하는 게이트는 넷을 지우는 일을 네 번의 대화로 만든다.
<!-- body:end -->
@@ -0,0 +1,65 @@
---
kind: CONCEPT
slug: messaging-testkit-c04
title: 계약 테스트 일곱 개를 어댑터가 지울 수 없게 만든 방식
topic: identity-and-value-contracts
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-testkit-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-c04
file: ../../../final/evidence/rendered/messaging-testkit-c04.svg
- key: messaging-testkit-c04-diagram
file: ../../../final/assets/diagrams/messaging-testkit-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L169 이다.
module: messaging-testkit
---
# 계약 테스트 일곱 개를 어댑터가 지울 수 없게 만든 방식
계약을 `abstract class` + `@Test` 로 만들었기 때문에 어댑터가 `@Test` 를 삭제하는 방법이 없다. 그 위에 리플렉션으로 메서드 이름 집합을 상수와 대조하는 자물쇠가 하나 더 있어 계약의 크기 자체가 잠겨 있다.
## 본문
<!-- body:start -->
계약을 `abstract class` + `@Test` 로 만든 결정의 효과는 `InMemoryHarnessContractTest` 의 javadoc 에 있다.
> "Every broker adapter adds the same nested class over its own harness, so a guarantee can only be weakened by editing the contract, where the change is visible, rather than by an adapter quietly not implementing it."
즉 어댑터가 `@Test`**삭제하는 방법이 없다**. 상속받는 순간 7개가 전부 실행된다.
## 계약을 상속하면 일어나는 것
:::evidence key="messaging-testkit-c04-diagram" alt="계약 클래스에서 어댑터 중첩 클래스로 상속 화살표가 가고 거기서 계약 테스트 실행으로 이어지는 왼쪽에서 오른쪽 흐름" caption="계약을 상속하면 일어나는 것" zoom="false"
:::
어댑터 쪽에서 하나를 빼려면 이 파일을 고쳐야 하고, 그것은 리뷰에 보인다.
## 계약의 크기를 잠그는 두 번째 자물쇠
`CompatibilityMatrixTest` 가 리플렉션으로 `@Test` 가 붙은 메서드 이름 집합을 `REQUIRED_CONTRACT_TESTS` 상수와 정확히 대조한다. 계약에서 테스트 하나를 지우면 이 테스트가 깨진다. 추가해도 깨진다. 계약의 크기 자체가 잠겨 있다.
## InMemoryHarnessContractTest 참조 위치
:::evidence key="messaging-testkit-c04" alt="코드베이스에서 InMemoryHarnessContractTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InMemoryHarnessContractTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 시나리오가 기대 결과를 소유한다
5개 시나리오, 그리고 각각이 `rationale`**비어 있으면 생성 자체가 실패하도록** 강제한다. `REJECTED``BEFORE_TRANSMISSION` 하나뿐이라는 사실이 테스트로 잠겨 있다(`CrossBrokerContractSuite.aFailureBeforeTransmissionIsTheOnlyOneReportedAsRejected`). `byName` 은 알 수 없는 이름을 건너뛰지 않고 거절한다.
## 증거 파일을 커밋하고 손으로 못 쓰게 한다
두 javadoc 이 **자기가 고친 결함을 이름 붙여** 남겼고, 네 가지 결정이 한 문단에 압축되어 있다.
**(a) 커밋한다.** `src/main/resources/messaging/broker-certification-evidence.jsonl`. `build/` 를 읽으면 깨끗한 체크아웃에서 답이 달라진다는 이유가 명시되어 있다. 확인: `src``build/resources` 사본이 diff 로 동일(`EVD-300`).
**(b) 손으로 못 쓰게 하는 게이트.** `messaging-kafka/build.gradle:82``verifyMessagingCertificationEvidence`. `gitCommit``observedAt` 을 정규식으로 지우고 나머지 집합을 비교한다. 그 둘은 매 실행마다 달라지므로 비교 대상이 아니라는 주석이 붙어 있다.
<!-- body:end -->