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>
19 KiB
07 · adapter-outbound-identifier
SSOT identity — 2026-08-31 재검증
- registered leaf id:
adapter-outbound-identifier - canonical state
analysisFile:analysis/07-adapter-outbound-identifier.md(이 문서) — 이 leaf의 단일 SSOT - source path:
src/adapter/outbound/identifier· Gradle:adapter:outbound:identifier - registry
allowed_dependencies:["domain-core", "application-core"] - registry
runtime_memberships:["app-bootstrap", "sample-portfolio"] - coverage ledger:
FULL_READ10 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 0 - 재검증 증거:
EVD-333(소스 드리프트 0),EVD-334(lane 재실행)
재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
상태: COMPLETE
revision:a24ece9cf797f7ea647e33bf846b115208ed1ba5
경로:src/adapter/outbound/identifier· Gradle::adapter:outbound:identifier
0. Denominator와 coverage ledger
이 leaf는 tracked file 10개다. 하위 범위로 나눌 크기가 아니라 한 범위로 처리한다.
| 구분 | 파일 | LOC | 상태 |
|---|---|---|---|
governance (CLAUDE.md, README.md, build.gradle, gradle.lockfile) |
4 | 297 | FULL_READ |
main Java (HmacUserPrincipalPseudonymizer, RandomUploadIdentifierFactory, UuidCodec, package-info) |
4 | 114 | FULL_READ |
test Java (HmacUserPrincipalPseudonymizerTest) |
1 | 112 | FULL_READ |
test Groovy (UuidCodecSpec) |
1 | 39 | FULL_READ |
| 합계 | 10 | 562 | 10 / 10 FULL_READ |
structural-only 0 · excluded 0 · unclassified 0.
manifest: evidence/raw/139-identifier-module-inventory.txt.
probe(정적 + 실행): evidence/raw/140-identifier-negative-space-probes.txt.
레지스트리 항목:
{ "id": "adapter-outbound-identifier",
"gradle_path": ":adapter:outbound:identifier",
"allowed_dependencies": ["domain-core", "application-core"],
"runtime_memberships": ["app-bootstrap", "sample-portfolio"] }
1. 이 모듈이 존재하는 이유
CLAUDE.md와 README가 같은 논거를 편다: 외부 시스템 연동이 없는(non-IO) 인프라 능력만 모아 두어, adapter-outbound가 문서화된 의미("외부 HTTP / messaging / cache / notifications")를 유지하게 한다는 것이다.
Kept out of
adapter-outboundon purpose: a UUID id/codec capability is infrastructure, not an outbound integration point.
그 논거의 예시로 드는 능력이 UUID id/codec이다. §3에서 보듯 그 능력에는 production 소비자가 없고, 실제로 배선돼 도는 것은 나머지 둘(가명화, 업로드 식별자)이다.
2. Confirmed — HmacUserPrincipalPseudonymizer는 이 leaf에서 가장 잘 만들어진 부분이다
UserPrincipalPseudonymizerPort(application-core)의 유일한 구현이고, app-bootstrap과 sample-portfolio가 각자 싱글톤 빈으로 배선한다(140-... §8.1). 결정들이 코드와 문서 양쪽에 맞물려 있다.
- thread safety를 타입이 아니라 수명으로 푼다.
Mac은 thread-safe가 아니므로pseudonymize호출마다 새로 만든다 — 그래서 공유 싱글톤으로 안전하다. 주석이 그 이유를 그 자리에 적는다. - salt를 스스로 조달하지 않는다.
APP_PRIVACY_PSEUDONYMIZATION_SALT에서app-bootstrap이 공급하고(분류 secret, 90일 회전), 생성자는 null/빈 배열을 거부하며 거부 메시지가 그 환경변수 이름을 그대로 말한다. - 방어적 복사.
salt.clone()으로 호출자 배열을 붙들지 않는다. - 도달 불가 예외를 정직하게 감싼다.
HmacSHA256은 JCA 필수 알고리즘이라NoSuchAlgorithmException·InvalidKeyException은 사실상 도달 불가이고, 호출부에 checked exception 잡음을 남기지 않으려IllegalStateException으로 감싸며 주석이 "this should never happen on a compliant JDK"라고 적는다. - Spring-free. 어노테이션이 없고 빈 생성은 composition root 책임이다.
test 11개가 경계를 실제로 나눠 덮는다 — 생성자 가드 2, null/blank/empty 입력 3, 같은 salt의 결정성(같은 인스턴스·다른 인스턴스) 2, salt 민감도 1, 단방향성 2(출력≠입력, 출력이 입력을 부분문자열로 포함하지 않음), 출력 포맷 1(^[0-9a-f]{64}$). 값 하나를 고정하는 golden test가 아니라 성질을 검사한다.
RandomUploadIdentifierFactory의 판단도 기록해 둘 만하다. 파일 식별자가 공개 핸들이므로 시퀀스나 타임스탬프가 아니라 암호학적 난수에서 뽑는다고 적고, 그 대가까지 명시한다 — "A time-ordered identifier would be the better database key, and is deliberately not used: it would let anyone holding one id infer when neighbouring files were created and enumerate towards them." 결정과 그 결정이 포기한 것을 함께 적는 서술이다.
경계 규칙도 실재한다. CLAUDE.md가 이름을 대는 ArchUnit 규칙은 CleanArchitectureTest에 대문자 상수 IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP로 존재하고, inbound web · persistence · bootstrap · Spring Data · JPA · Hibernate를 금지하며 형제 배제(..adapter.outbound.. 중 자기 패키지 제외)까지 처리한다(140-... §8.4e). CLAUDE.md의 표기가 snake_case일 뿐 가드는 진짜다 — confirmed match.
3. P2 — 모듈의 존재 논거인 UuidCodec에 production 소비자가 없다
140-... §8.1·§8.1b의 저장소 전수 검색 결과:
| 타입 | leaf 밖 production 소비자 |
|---|---|
HmacUserPrincipalPseudonymizer |
app-bootstrap, sample-portfolio (각 1) |
RandomUploadIdentifierFactory |
app-bootstrap (1) |
UuidCodec |
0 |
UuidCodec. 형태의 호출은 저장소 전체에서 자기 Spock 스펙 5줄뿐이다. 이름이 겹쳐 걸린 나머지 둘은 무관하다 — mongo testkit의 org.bson.codecs.UuidCodec(드라이버 타입)과 sample-portfolio/README.md:333의 산문 언급("UuidCodec 같은 공용…").
그 자리를 대신 채우고 있는 것들이 있다. UUID.fromString을 직접 부르는 파일이 leaf 밖에 20개 이상이고(graphql UuidScalar, notification JacksonRoutingPlanCodec, jpa PostgreSqlIdempotencyClaimRepository, application-core FileId/UploadId …), CLAUDE.md가 toUuid/fromUuid의 목적으로 든 **PostgreSQL uuid 컬럼 변환(D10)**은 실제로는 Hibernate의 @JdbcTypeCode(SqlTypes.UUID)가 처리한다(140-... §8.4b, JPA 엔티티 다수).
판정: P2. 코드 자체에는 결함이 없다 — 30줄짜리 유틸이고 자기 스펙을 통과한다. 문제는 §1의 논거다. 모듈을 adapter-outbound 밖에 두는 근거로 "UUID id/codec 능력"을 들고 있는데, 그 능력은 아무도 쓰지 않고 같은 일이 저장소 곳곳에서 각자 수행된다. 나머지 두 타입(가명화·업로드 식별자)만으로도 non-IO 능력 모듈의 논거는 성립하므로, 수정은 둘 중 하나다: UuidCodec을 실제 단일 경로로 만들거나(그러면 §4가 먼저 고쳐져야 한다), 모듈의 논거에서 빼는 것.
4. P2 — normalize는 canonical이 아닌 입력을 받아 다른 UUID로 조용히 바꾼다
UuidCodec.normalize의 계약은 Javadoc과 README 양쪽에 적혀 있다.
Accepts a case-insensitive canonical UUID string and returns the canonical 36-character lowercase form…
@throws IllegalArgumentExceptionon a malformed UUID. (README) 형식 오류 UUID 에는IllegalArgumentException.
구현은 UUID.fromString(input).toString() 한 줄이다. JDK의 UUID.fromString은 길이 36 fast path 밖에서 대시로 나뉜 5개 hex 그룹을 길이 검사 없이 받는다. 실행 probe로 확인했다(140-... EXECUTION PROBE).
PROBE normalize("0190BD6E-7C3E-7ABC-8DEF-0123456789AB") -> "0190bd6e-7c3e-7abc-8def-0123456789ab" ← 의도된 동작
PROBE normalize("1-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
PROBE normalize("0-0-0-0-0") -> "00000000-0000-0000-0000-000000000000"
PROBE normalize("1-2-3-4-5") -> "00000001-0002-0003-0004-000000000005"
PROBE normalize("0190bd6e7c3e7abc8def0123456789ab") -> IllegalArgumentException
PROBE normalize("not-a-uuid") -> IllegalArgumentException
"1-1-1-1-1"은 canonical UUID가 아니다. 계약대로면 IllegalArgumentException이어야 하는데, 수용된 뒤 다른 문자열로 재작성되어 반환된다. 결과적으로 서로 다른 두 입력("1-1-1-1-1"과 "00000001-0001-0001-0001-000000000001")이 같은 식별자로 정규화되고, 거부됐어야 할 값이 정상적으로 보이는 id가 된다. normalize는 D3 — 호출자가 준 텍스트를 저장 형태로 바꾸는 지점 — 이므로, 관대함이 남는 위치가 하필 신뢰 경계다.
기존 스펙이 이것을 놓친 이유도 코드에 있다. UuidCodecSpec의 거부 케이스는 "not-a-uuid" 하나이고, 그 문자열은 대시 그룹이 5개가 아니라 관대한 경로에 닿지 않는다.
도달성. 지금 이 메서드를 부르는 production 코드는 없다(§3). 그래서 현재 노출은 0이고, UuidCodec을 단일 경로로 승격하는 순간 결함이 된다. 판정: P2. 수정은 input.length() != 36이거나 대시 위치가 8-13-18-23이 아니면 먼저 거부하는 것 — 또는 계약 문구를 실제 동작(JDK 관대 파싱)에 맞추는 것이다. 전자가 문서가 말하는 바다.
5. P2 — 문서는 UUIDv7이라고 말하고, 생성되는 것은 v4다
CLAUDE.md:21과 README:19가 같은 문장을 쓴다 — UuidCodec은 "JDK java.util.UUID (RFC 9562 UUIDv7)" 위에서 동작한다. README:12도 이 모듈의 능력을 "식별자 생성/인코딩(UUIDv7)"이라 적는다. 그리고 이 주장은 leaf 밖으로도 번져 있다 — CleanArchitectureTest가 이 패키지를 설명하는 세 곳(:948, :981, :1310)이 전부 "UUIDv7 id/event-id generation & codec"이라고 쓴다.
실행 probe(140-...):
PROBE newFileId version=4 variant=2
PROBE newUploadId version=4 variant=2
PROBE UUID.randomUUID() version=4 (RFC 9562 UUIDv7 would report version=7)
java.util.UUID에는 v7 생성기가 없고, 이 leaf의 유일한 생성기 RandomUploadIdentifierFactory는 UUID.randomUUID()(v4)를 쓴다. 더 분명한 것은 그 클래스의 javadoc이 v7을 명시적으로 거부한다는 점이다 — "A time-ordered identifier would be the better database key, and is deliberately not used." 즉 코드는 숙고된 결정을 내렸고, 모듈 문서와 아키텍처 test의 설명문이 정반대 결정을 서술한다.
저장소의 진짜 UUIDv7은 다른 곳에 있다: sample-portfolio가 com.github.f4b6a3:uuid-creator를 자기 build.gradle에 선언하고 UuidCreator.getTimeOrderedEpochPlus1()을 쓴다(SampleOperationStore:27).
판정: P2. 런타임 동작은 옳다 — 잘못된 것은 세 문서와 한 test의 설명문이다. 그러나 "이 모듈이 UUIDv7을 제공한다"는 서술은 fork가 시간정렬 키를 기대하게 만들고, 그 기대는 인덱스 지역성과 열거 가능성 양쪽에서 반대 방향의 결과를 낳는다.
6. P3 — CLAUDE.md의 의존성 서술이 세 항목 모두 틀렸다
CLAUDE.md:31–33:
:application-code,:domain-core,:shared-contract(Gradle matrix). Currently only:domain-core+com.github.f4b6a3:uuid-creatorare declared in build.gradle.
build.gradle의 실제 dependencies 블록은 두 줄이다.
implementation project(':application-core')
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
:domain-core— 선언돼 있지 않다.uuid-creator— 선언돼 있지 않다(저장소 검색상 이 leaf의 classpath에 없다;sample-portfolio와app-bootstrap의 lockfile에만 있다).:application-core— 선언돼 있는데 문장은 언급하지 않는다.
레지스트리의 allowed_dependencies(domain-core, application-core)와 실제 선언(부분집합)은 정합한다. 어긋난 것은 CLAUDE.md의 서술뿐이다. P3.
7. P3 — README의 세 가지 사실 오류
| README | 실제 |
|---|---|
:3 패키지 루트 dev.caskeleton.adapter.identifier |
dev.caskeleton.adapter.outbound.identifier (CLAUDE.md:11은 정확) |
| :59 "Spock 2.4 / Groovy 4.0 variant" | spock-core:2.4-groovy-**5.0** |
:64 edge는 src/build.gradle의 allowedProjectDependencies['**adapter-identifier**']로 허용 |
build.gradle:1416의 allowedProjectDependencies는 리터럴 맵이 아니라 registry.modules.collectEntries { … }로 레지스트리에서 파생되며, 이 모듈의 키는 adapter-outbound-identifier다 |
셋 다 메커니즘 자체는 실재하고 동작한다 — 틀린 것은 이름과 버전이다. P3.
8. P3 — CLAUDE.md가 대는 두 가드 중 하나는 저장소에 없다
CLAUDE.md:38–39가 금지 사항의 근거로 둘을 든다.
- ArchUnit
identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap— 존재한다(§2, 상수명은 대문자). confirmed. .claude/hooks/ca_import_gate.pyG4가 쓰기 시점에 차단 —.claude/디렉터리에는settings.local.json하나뿐이고hooks/하위 디렉터리도ca_import_gate.py도 tracked 되어 있지 않다(140-...§8.4e).
개발자 머신에 로컬로 존재할 여지는 있으나, 저장소를 새로 clone한 사람에게 그 가드는 없다. "쓰기 시점에 차단된다"는 서술은 clone에서 성립하지 않는다. P3.
9. P3/기록 — 결정 SSOT가 이 revision에서 해석되지 않는다
CLAUDE.md와 README가 UuidCodec의 동작을 D3(normalize)와 D10(toUuid/fromUuid)로 지목하고, 모듈 분류 근거로 "feature-resource-identifier-contract §4 taxonomy"를 든다. CleanArchitectureTest도 같은 문서를 §4·D5·D9·D17로 네 곳에서 인용한다.
그 문서는 이 revision에 파일로 존재하지 않는다(find -iname '*resource-identifier*' 매치 0; docs/에서 걸리는 D3/D10은 전부 MongoDB의 무관한 노출 평면 표기다). CleanArchitectureTest:2432의 주석이 이유를 밝힌다 — "decision SSOT: resource-identifier branch". 즉 다른 브랜치에 있다.
은폐가 아니라 명시된 상태이므로 결함으로 올리지 않고 기록한다. 다만 이 leaf의 문서가 자기 동작의 근거로 대는 결정 ID들은 이 브랜치만 읽어서는 확인할 수 없고, §4·§5의 어긋남이 "구현이 결정을 벗어난 것"인지 "결정이 그 사이 바뀐 것"인지도 여기서는 판정 불가다. P3/기록.
10. Negative-space probes
- 8.1 public-surface reachability: 세 production 타입의 저장소 전수 소비자 계수.
UuidCodec0(§3), 나머지 둘은 composition root가 배선. - 8.2 조건부 형제 비교:
UuidCodec의 세 자매 메서드가 null을 다르게 다룬다 —normalize(null)→null(문서화됨),toUuid(null)→NullPointerException,fromUuid(null)→NullPointerException(둘 다 미문서화). 실행 probe로 확인. 계약 문구는normalize에만 있다. P3. - 8.2b 계약 ↔ 구현 대조:
normalize의 "canonical만 수용" 주장과 JDK 관대 파싱(§4). 실행 probe로 확정. - 8.3 중복 mechanism sweep: UUID 문자열 변환이 leaf 밖 20+ 파일에서
UUID.fromString으로 각자 수행되고, D10이 지목한 PostgreSQLuuid컬럼 변환은 Hibernate@JdbcTypeCode(SqlTypes.UUID)가 담당(§3). 저장소의 UUIDv7 생성은sample-portfolio의UuidCreator(§5). - 8.4 문서/개수 drift: §5(UUID 버전, leaf 문서 3곳 + 아키텍처 test 3곳) · §6(의존성 서술 3항목) · §7(패키지 루트·Groovy 버전·설정 키) · §8(가드 하나 부재) · §9(결정 SSOT 미해석).
11. Findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P2 | UuidCodec.normalize가 canonical이 아닌 5-그룹 입력("1-1-1-1-1" 등)을 수용해 다른 UUID로 재작성한다 — Javadoc·README는 canonical만 받고 malformed는 거부한다고 적는다 (실행 probe) |
현재 호출자 0; UuidCodec을 단일 경로로 쓰는 순간 신뢰 경계 결함 |
| P2 | UuidCodec에 production 소비자가 0인데, 모듈을 adapter-outbound 밖에 두는 논거가 바로 이 "UUID id/codec 능력"이다. 같은 변환이 leaf 밖 20+ 파일에서 각자 수행되고 D10의 대상은 Hibernate가 처리한다 |
문서/모듈 경계 논거 |
| P2 | leaf 문서 3곳과 CleanArchitectureTest 설명문 3곳이 "RFC 9562 UUIDv7"이라 적지만 유일한 생성기는 v4를 만들고, 그 클래스의 javadoc은 시간정렬 id를 명시적으로 거부한다 (실행 probe: version=4) |
fork가 시간정렬 키를 기대하는 경우 |
| P3 | CLAUDE.md:31–33의 build.gradle 선언 서술이 세 항목 모두 사실과 다르다 | 문서 |
| P3 | README의 패키지 루트·Spock/Groovy variant·allowedProjectDependencies 키 3건 오류 |
문서 |
| P3 | CLAUDE.md가 대는 write-time 가드 .claude/hooks/ca_import_gate.py가 저장소에 tracked 되어 있지 않다 |
새 clone |
| P3 | UuidCodec 세 자매 메서드의 null 처리 비대칭이 normalize에만 문서화돼 있다 |
호출 시점 |
| P3/기록 | 결정 SSOT feature-resource-identifier-contract(§4·D3·D5·D9·D10·D17)가 다른 브랜치에 있어 이 revision에서 해석되지 않는다 |
결정 대조 |
12. 완료 조건
- denominator 10 / 10 FULL_READ — structural-only 0 · excluded 0 · unclassified 0 (§0)
- §8.1(공개 표면 도달성) · §8.2(조건부 형제) · §8.3(중복 mechanism) · §8.4(문서/개수 drift) 네 종 probe 수행
- 정적으로 결정 불가한 세 지점(normalize의 실제 수용 범위, 세 자매의 null 처리, 생성되는 UUID 버전)을 실행 probe로 확정(
140-...) - CLAUDE.md가 대는 두 가드를 각각 추적해 하나는 실재(confirmed), 하나는 부재로 분리 판정 — 이름 표기 차이를 결함으로 올리지 않았다
- 임시 probe class 1개 추가 후 제거,
git status --short= 0, 소스 미변경
Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 8개 (main 3 · test 1 · 기타 4).
src/adapter/outbound/identifier/build.gradle
src/config/architecture/modules.json (adapter-outbound-identifier 항목)
main:
src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java
src/main/java/dev/caskeleton/adapter/outbound/identifier/RandomUploadIdentifierFactory.java
src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java
test:
src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java
기타:
CLAUDE.md
README.md
src/build.gradle
src/sample-portfolio/README.md
해석되지 않은 인용 (3종) — 외부 타입·문서상 약칭 등:
evidence/raw/139-identifier-module-inventory.txt
evidence/raw/140-identifier-negative-space-probes.txt
settings.local.json