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,180 @@
---
kind: CASE
slug: an-unselectable-broker-listed-with-features
title: 실 브로커로 증명된 어댑터가 선택하면 기동이 실패하는 이름으로 등록돼 있다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:an-unselectable-broker-listed-with-features
evidenceCapturedOn: 2026-09-04
body: case-an-unselectable-broker-listed-with-features.body.md
assets:
- key: an-unselectable-broker-listed-with-features
file: ../../../final/evidence/rendered/an-unselectable-broker-listed-with-features.svg
evidence:
- ../../../final/evidence/raw/an-unselectable-broker-listed-with-features.txt
source:
- 원본 분석 절은 analysis/19-messaging-platform.md §6.2 이다.
---
# 실 브로커로 증명된 어댑터가 선택하면 기동이 실패하는 이름으로 등록돼 있다
`RabbitBrokerIT``rabbitmq:4.3-management` 컨테이너를 띄우고 브로커의 큐 깊이를 단언하므로 rabbit 전송 경로는 실 브로커로 돌아간다. 그런데 선택기를 직접 부르면 `broker=rabbit``IllegalStateException` 으로 끝나고, `:147``selectImports` 가 자동설정을 import 하기 전에 그 검사를 지나므로 채택자가 seam 을 구현해도 고를 수 없다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례가 그 우선순위의 대상이다. 운영 설정 문서가 실제보다 많은 선택지를 제시한다.
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
`rabbit` 은 등록 목록에 남고 `assemblableBrokerIds()` 에서는 빠진다. 그 필드 자바독이 이 이름이 언제 맵을 떠나는지까지 적는다.
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
그 기록이 경계 밖으로 지목한 셋 중 하나가 브로커 등급 표의 제한 칸이고, 이 사례가 그 칸에서 빠진 사실이다.
## 문제
메시징 전송은 app.messaging.broker 값 하나로 고른다. 등록되지 않은 이름과 클래스패스에 없는 클라이언트를 각각 다른 메시지로 거절한다.
세 번째 거절 사유가 있다. 등록은 됐지만 전송이 아직 없는 브로커다. 그 상태가 코드와 문서와 시험에서 어떻게 나타나는지 확인했다.
## 결론
거절 자체는 정확하다. :63 의 맵이 이유 문자열을 담고 :121 이 그것을 읽어 예외에 넣으며, 같은 메시지가 assemblableBrokerIds() 로 오늘 고를 수 있는 이름 목록을 붙인다. 그 필드의 자바독 :51~:62 는 항목이 맵을 떠나는 조건까지 적어 둔다.
전송 클래스는 있다. MessagingTransport 를 구현한다고 선언한 여섯 중 넷이 main 어댑터이고 RabbitMessagingTransport 가 그중 하나다. main 에서 빠진 것은 그 생성자가 받는 RabbitChannelPublisher 의 구현인데, 그 인터페이스는 추상 메서드가 둘이라 람다로도 채울 수 없다.
그 구현이 시험에는 둘 있는데 성격이 다르다. RabbitRuntimeTest:48 이 만드는 것은 아무 데도 붙지 않는 더블이다. RabbitBrokerIT:93 은 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 :187 의 channel.basicPublish 까지 위임하며, :61 의 Testcontainer 에 붙어 :154·:161 이 브로커의 큐 깊이를 읽는다.
채택자가 그 seam 을 채워도 소용이 없다. messaging-rabbit/build.gradle:12~:14 는 seam 을 구현할 소비자를 위해 spring-amqp 를 api 로 노출한다고 적는다. 그런데 :147 의 selectImports 가 selectedBroker 를 먼저 부르므로 :123 의 예외가 자동설정 import 이전에 터진다. KafkaMessagingAutoConfiguration:163 은 @ConditionalOnMissingBean 으로 자기 전송 빈에 탈출구를 두었는데 rabbit 에는 그 자리가 없다.
RabbitMessagingAutoConfiguration 이 선언하는 빈은 :35·:48·:70·:94 넷이고 그중 전송이 없다. 선택이 먼저 던지므로 이 넷도 만들어지지 않는다.
그런데도 어댑터는 출하된다. modules.json:429~:430 이 이 리프의 runtime_memberships 를 app-bootstrap 으로 적고, 부트스트랩 락파일이 amqp 클라이언트를 프로덕션 런타임 클래스패스에 싣는다. 파일 스무 개, 주석과 빈 줄까지 세면 2443 줄이다.
이 상태를 적는 문서는 있다. src/messaging/CLAUDE.md:62 가 Rabbit 을 shipped, inactive, unqualified 로 분류하고 docs/reviews/2026-08-14-messaging-module-code-review.md:444 는 production 구현이 없다고 적는다.
적지 않는 쪽은 운영 설정 문서다. docs/messaging/support-matrix.md:34 의 RabbitMQ 행은 등급 Experimental 과 인증 기준 4.3.x 와 Stable 기능 일곱을 적는다. 제한 칸에 적힌 둘은 증거가 없다는 것과 특정 기능군을 지원하지 않는다는 것이고, 선택 자체가 막혔다는 항목은 없다.
같은 문서 :23~:27 은 messaging 리프가 모두 runtime_memberships 가 비어 build-only 라는 단서를 표 전체에 붙인다. 그 단서도 지금은 맞지 않는다. configuration-reference.md:132 은 이 브로커의 설정 예시를 싣고, docs/registries/env-keys.yaml 은 이 프로퍼티에 허용값 목록도 검증 규칙도 걸지 않는다.
문서 계약 시험은 이 차이를 볼 수 없다. 여덟 중 두 개만 브로커 등급 표에 닿고, 그 둘이 확인하는 것은 어댑터 이름과 등급 낱말의 조합뿐이다.
거절 로직 자체도 시험이 없다. 이름을 가진 시험 파일이 0 개이고, env-keys.yaml:3326 이 요구하는 adapter-contract:messaging-broker-selection 을 정의한 자리도 0 개다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 선택기의 거절 맵과 자바독과 예외 인용, 그 선택을 붙드는 시험 파일과 호출자 계수 및 레지스트리가 요구하는 시험 id 의 정의 여부, 채널 발행자가 나오는 자리 전수와 구현 형태별 계수, 두 익명 구현의 본문과 컨테이너 배선 인용, build.gradle 의 seam 공개 주석과 Kafka 쪽 조건 애너테이션과 selectImports 순서 대조, 두 자동설정의 빈 목록, 출하 파일 수와 물리적 줄과 빈 줄 제외 줄, 전송 클래스의 자기 호칭과 호환성 표의 등급, 지원 매트릭스 표와 그 전체에 붙은 단서와 startup 언급 전수, 문서 계약 시험의 단언 범위, 이 상태를 적는 문서와 운영 설정 문서 대조
소스 수정 : x
## 재현 조건
1. 선택기의 거절 맵과 자바독, 그리고 예외를 던지는 자리를 인용한다.
2. 그 선택을 검증하는 시험 파일과 selectedBroker 호출자를 세고, 레지스트리가 요구하는 시험 id 가 정의돼 있는지 본다.
3. 채널 발행자 이름이 나오는 자리를 전부 찾아 implements 와 익명 구현을 나눠 세고, 대조 타입 MessagingTransport 로 같은 검색을 걸어 0 이 아닌 수가 나오는지 확인한다.
4. 두 익명 구현의 본문과 그 파일의 컨테이너 배선을 인용한다.
5. build.gradle 의 의존 노출 주석, Kafka 전송 빈의 조건 애너테이션, selectImports 의 호출 순서를 나란히 놓는다.
6. 두 브로커의 자동설정이 만드는 빈을 대조한다.
7. 출하 파일 수와 줄 수를 물리적 줄과 빈 줄 제외로 나눠 센다.
8. 지원 매트릭스의 표와 그 위에 붙은 전체 단서, 그리고 startup 언급을 전부 싣는다.
9. 문서 계약 시험이 단언하는 범위를 읽는다.
10. 이 상태를 적는 개발 문서와 적지 않는 운영 설정 문서를 나란히 놓는다.
## 본문
<!-- body:start -->
`MessagingProviderSelection``app.messaging.broker` 값 하나로 전송을 고른다. 등록되지 않은 이름, 클래스패스에 없는 클라이언트, 전송이 아직 없는 브로커를 각각 다른 메시지로 거절한다.
## 선택기가 rabbit 을 거절하는 자리
:::evidence key="an-unselectable-broker-listed-with-features" alt="저장소 루트에서 돌린 정적 검색과 선택기 프로브의 출력 296줄. 먼저 MessagingProviderSelection 48~70번 줄이 실려 BROKERS_WITHOUT_A_TRANSPORT 맵과 그 필드 자바독이 보이는데, rabbit 어댑터가 검증기와 보안 설정은 출하하지만 전송이 없고 네이티브 채널 다리에 시험 구현만 있다는 것, 그리고 항목이 이 맵을 떠나는 날은 전송이 실제로 생기는 날이라는 것을 적는다. 118~132번 줄이 선택 시 던지는 예외를 만드는 코드다. 이어서 그 선택기를 직접 부른 프로브 결과가 나온다. 등록된 이름은 kafka 와 rabbit 이고 조립 가능한 이름은 kafka 뿐이며, broker=kafka 는 kafka 로 선택되고, broker=rabbit 은 IllegalStateException 과 함께 전송이 구현되지 않아 발행이 타고 갈 것이 없다는 메시지를 내며, broker=pulsar 는 등록되지 않은 전송이라는 다른 메시지를, 빈 값은 브로커를 지정하라는 또 다른 메시지를 낸다. 다음으로 그 거절을 붙드는 시험이 없다는 것이 나온다. MessagingProviderSelection 을 참조하는 파일은 넷인데 전부 main 이고, env-keys.yaml 3326번 줄이 required_test 로 adapter-contract:messaging-broker-selection 을 선언하는데 그 id 를 정의한 자리는 0 개다. RabbitChannelPublisher 18~41번 줄이 실려 추상 메서드가 nextPublishSequence 와 publish 둘이라는 것이 보인다. 그 이름이 나오는 자리는 여섯이고 implements 를 가진 파일은 0 개 익명 구현을 가진 파일은 2 개이며, 대조로 실은 implements MessagingTransport 목록은 여섯인데 넷이 main 어댑터이고 둘은 시험 클래스다. 그 두 익명 구현이 나란히 실린다. RabbitBrokerIT 89~108번 줄은 RabbitMessagingTransport 를 만들면서 nextPublishSequence 를 channel.getNextPublishSeqNo 로 잇고 publish 를 그 시험 클래스의 publish 로 위임한다. RabbitRuntimeTest 44~63번 줄은 시퀀스를 AtomicLong 으로 세고 메시지를 리스트에 담는 인메모리 더블이다. 그 IT 가 붙는 브로커로 rabbitmq:4.3-management 컨테이너 선언과 basicPublish 호출과 messageCount 단언 줄이 나온다. 그 아래에 build.gradle 12~14번 줄의 seam 공개 주석, KafkaMessagingAutoConfiguration 163번 줄의 ConditionalOnMissingBean, MessagingProviderSelection 146~147번 줄의 selectImports 가 차례로 실린다. 두 자동설정이 선언하는 빈은 Kafka 일곱과 Rabbit 넷이다. 출하 여부로는 modules.json 417~431번 줄이 messaging-rabbit 의 runtime_memberships 를 app-bootstrap 으로 적고 app-bootstrap/gradle.lockfile 77번 줄이 amqp-client 를 productionRuntimeClasspath 에 싣는다. main 자바 20 개 파일 물리적 줄 2443 빈 줄 제외 2232 이고, RabbitMessagingTransport 27번 줄은 자기를 Stable RabbitMQ adapter 라 부르는데 CompatibilityMatrix 92번 줄은 같은 항목을 EXPERIMENTAL 로 적는다. 운영 문서로는 support-matrix.md 29~38번 줄의 등급 표와 22~27번 줄의 단서, configuration-reference.md 132~146번 줄의 RabbitMQ 설정 절, env-keys.yaml 의 allowed_values null 과 validation none 이 나온다. 문서 계약 시험은 여덟이고 그중 61번과 70번이 등급 이름을 단언하며 제한이나 선택 가능 여부를 담은 줄은 0 개다. 마지막으로 src/messaging/CLAUDE.md 56~63번 줄이 실려 대부분의 leaf 가 app-bootstrap 멤버십을 갖고 배포된 아티팩트가 싣고 있다는 것과 Rabbit 이 shipped, inactive, unqualified 라는 것을 적고, 코드 리뷰 문서도 같은 상태를 적는다." caption="선택기의 거절 맵과 그것을 직접 부른 프로브 네 경우 · 그 거절을 붙드는 시험 0 과 정의되지 않은 required_test · 추상 메서드 둘과 여섯 자리와 두 익명 구현의 본문 · seam 공개와 Kafka 의 탈출구와 selectImports 순서 · modules.json 의 runtime_memberships 와 락파일의 productionRuntimeClasspath · 자기 호칭과 호환성 등급 · 운영 문서 세 곳과 문서 계약 시험 여덟 · 이 상태를 적는 개발 문서 — 296줄 · exit 0" zoom="true"
:::
`:63``BROKERS_WITHOUT_A_TRANSPORT``rabbit` 하나를 담고 값은 이유 문자열이다. `:121` 이 그 값을 꺼내고 `:123` 이 프로퍼티 이름과 이유와 오늘 조립 가능한 브로커 목록을 붙여 `IllegalStateException` 을 만든다.
자바독은 이 설계의 이유를 적는다. 거절하지 않으면 코어 설정 깊은 곳에서 `MessagingTransport` 의존이 충족되지 않아, 운영자에게는 자기가 고른 전송이 미완성이라는 사실 대신 빈이 없다는 말이 도달한다는 것이다.
## 그 거절을 붙드는 시험이 없다
`MessagingProviderSelection` 이나 브로커 선택을 이름에 가진 시험 파일은 0 개다. `selectedBroker` 를 부르는 자리는 `:93` 의 선언과 `:147` 의 호출 둘뿐이고 둘 다 main 이다.
`docs/registries/env-keys.yaml:3326` 은 이 프로퍼티의 `required_test``adapter-contract:messaging-broker-selection` 을 선언한다. 그 id 를 정의한 자리는 저장소에 0 개다.
맵을 비우거나 키를 고쳐도 실패하는 시험이 없다.
## 시험에 있는 두 구현은 성격이 다르다
`RabbitChannelPublisher:18`\~`:41` 은 추상 메서드로 `nextPublishSequence``publish` 둘을 요구한다. 함수형 인터페이스가 아니라 람다로 채울 수도 없다.
그 이름은 저장소에 여섯 번 나온다. `:13` 의 인터페이스, `RabbitMessagingTransport` 의 필드와 생성자 인자 셋, 시험 둘의 익명 구현이다. `implements` 를 가진 파일은 0 개이고 익명 구현을 가진 파일이 2 개다.
같은 검색식을 `implements MessagingTransport` 에 걸면 여섯 파일이 나온다. 넷은 `KafkaMessagingTransport:41` · `NatsJetStreamTransport:54` · `PulsarMessagingTransport:47` · `RabbitMessagingTransport:39` 어댑터이고, 둘은 `DefaultMessagePublisherTest:426``MessagingRuntimeRegistryTest:230` 의 시험 클래스다. 그 0 은 검색식이 깨져서 나온 값이 아니다.
`RabbitRuntimeTest:44`\~`:63` 이 만드는 것은 인메모리 더블이다. 시퀀스를 `AtomicLong` 으로 세고 메시지를 리스트에 담는다.
`RabbitBrokerIT:89`\~`:108` 은 다르다. `nextPublishSequence``channel.getNextPublishSeqNo()` 를 부르고, `publish` 는 그 시험 클래스의 메서드로 위임해 `:187``channel.basicPublish` 까지 간다. `:61``rabbitmq:4.3-management` 컨테이너를 띄우고 `:154``:161``channel.messageCount(QUEUE)` 로 브로커의 큐 깊이를 단언한다.
전송 경로는 실 브로커 상대로 끝까지 돈다. 프로덕션 배선만 없다.
## 채택자가 seam 을 채워도 고를 수 없다
`messaging-rabbit/build.gradle:12`\~`:14``org.springframework.amqp:spring-rabbit``api` 로 노출하는 이유를 적는다 — 이 seam 을 구현하는 소비자가 그 타입을 컴파일 클래스패스에 두어야 하기 때문이다. 확장점으로 공개해 둔 것이다.
`KafkaMessagingAutoConfiguration:163` 은 전송 빈에 `@ConditionalOnMissingBean(MessagingTransport.class)` 를 걸어 두었다. 애플리케이션이 자기 전송을 주면 양보한다.
rabbit 에는 그 자리가 없다. `:146`\~`:147``selectImports``PROVIDER_CONFIGURATIONS.get(selectedBroker(environment))` 를 부르므로, 자동설정이 import 되기도 전에 `:123` 의 예외가 터진다. `RabbitChannelPublisher` 를 직접 구현하고 `MessagingTransport` 빈까지 준 배포도 `broker=rabbit` 을 고를 수 없다.
## RabbitMessagingAutoConfiguration 에는 전송 빈이 없고 그 넷도 만들어지지 않는다
`KafkaMessagingAutoConfiguration` 은 빈 일곱을 만들고 `:164` 가 전송이다. `RabbitMessagingAutoConfiguration` 은 넷이고 전송에 해당하는 빈이 없다.
선택이 먼저 던지므로 이 자동설정은 import 되지 않고 그 넷도 만들어지지 않는다.
그래도 어댑터 자체는 출하된다. `modules.json:429`\~`:430` 이 이 리프의 `runtime_memberships``app-bootstrap` 으로 적고, `app-bootstrap/gradle.lockfile:77``com.rabbitmq:amqp-client``productionRuntimeClasspath` 에 싣는다. main 자바 20 개, 물리적 줄 2443, 빈 줄을 빼면 2232 다.
`RabbitMessagingTransport:27` 의 클래스 자바독은 자기를 "The Stable RabbitMQ adapter" 라고 부른다. `CompatibilityMatrix` 는 같은 항목을 `EXPERIMENTAL` 로 적는다.
## 적는 문서와 적지 않는 문서
`src/messaging/CLAUDE.md:60`\~`:63` 은 Rabbit 을 shipped, inactive, unqualified 로 분류하고 그 구분을 `MessagingMembershipQualificationTest` 가 붙든다고 적는다. `docs/reviews/2026-08-14-messaging-module-code-review.md:444` 는 production 구현이 없다고 직접 적는다.
운영 설정 문서는 다르다. `docs/messaging/support-matrix.md:34` 의 RabbitMQ 행은 등급 Experimental, 인증 기준 4.3.x, Stable 기능 일곱을 적고 제한 칸에는 장애 시나리오 레인 미실행과 stream 미지원만 적는다. `configuration-reference.md:132` 는 RabbitMQ 설정 절을 두고, `env-keys.yaml:3319``allowed_values``null``:3324``validation``none` 으로 적는다.
그 표에 단서가 하나 붙어 있는데 그것도 지금은 맞지 않는다. `support-matrix.md:23`\~`:27` 은 registry 의 messaging leaf 가 모두 `runtime_memberships` 가 비어 build-only 라고 적지만, `modules.json:429`\~`:430` 은 이 리프에 `app-bootstrap` 을 적고 `src/messaging/CLAUDE.md:56`\~`:57` 도 대부분의 leaf 가 그 멤버십을 갖고 배포 아티팩트가 싣고 있다고 적는다. 같은 문서 안의 두 번째 어긋남이다.
그 문서에서 startup 이라는 낱말이 나오는 줄은 `:42` 하나이고, 브로커 선택이 아니라 destination profile 이야기다.
## 문서 계약 시험이 보는 칸
`MessagingDocumentationContractTest` 에는 시험이 여덟 있다. 등급 표의 값을 보는 것은 `:61``:70` 이다. 앞엣것은 코드가 STABLE 로 분류한 어댑터 이름이 문서에 있는지 보고, 뒤엣것은 EXPERIMENTAL 인 어댑터가 Stable 등급으로 적히지 않았는지 본다. 나머지 여섯은 문서 존재 여부와 Kafka 버전 문자열과 없는 상수 둘과 실험 정책 문장과 문서 길이를 본다.
그 파일에서 제한 칸이나 선택 가능 여부를 담은 줄은 0 개다.
## 원문과 갈리는 자리
원문은 `RabbitChannelPublisher` 의 구현이 main·test 통틀어 0 건이라고 적었다. `implements` 로 센 것은 0 이 맞지만 시험 두 파일에 익명 구현이 있고, 그중 하나는 실 컨테이너에 붙는다. `BROKERS_WITHOUT_A_TRANSPORT` 의 자바독 자신이 시험 구현만 있다고 적어 이 상태를 정확히 서술한다.
원문은 이 어댑터가 출하 아티팩트에 들어 있다고 적었다. 맞다. 다만 같은 운영 문서 `:23`\~`:27` 이 messaging 리프 전체를 build-only 라고 적어 그 사실과 어긋난다.
원문은 결함이 운영 문서에 있다고 보았다. 이 기록은 그 판정을 유지하되 범위를 좁힌다 — `src/messaging/CLAUDE.md` 와 코드 리뷰 문서는 이 상태를 적는다.
원문이 다루지 않은 것이 둘 있다. 거절 로직에 시험이 없다는 것과, `selectImports` 의 순서 때문에 seam 을 구현한 채택자도 이 브로커를 고를 수 없다는 것이다.
## 확인하지 못한 것
`app.messaging.broker=rabbit` 을 넣고 애플리케이션을 띄워 실패 메시지를 받아 보지 않았다. 정적으로는 예외를 만드는 거절 분기까지 따라갔다.
컨테이너를 띄우는 그 통합 시험을 돌리지 않았다. 배선과 단언 줄을 읽었다.
문서 계약 시험을 실행하지 않았다. 여덟 시험의 단언 코드를 읽었다.
`support-matrix.md:22`\~`:27` 의 단서를 읽고 그 브로커를 고른 사례가 있었는지는 저장소 안에서 확인할 수 없다.
`support-matrix.md:23`\~`:27` 의 단서가 언제부터 낡았는지는 이력으로 추적하지 않았다.
## 등급에 대해
원본은 P2 다. 근거는 코드가 아니라 운영 문서에 결함이 있다는 것이다. 맵도 표도 이 리비전에서 그대로이므로 등급을 새로 매기지 않는다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CASE
slug: documented-uuidv7-generates-v4
title: 문서가 UUIDv7이라 말하고 생성되는 것은 v4다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:documented-uuidv7-generates-v4
evidenceCapturedOn: 2026-09-01
assets:
- key: documented-uuidv7-generates-v4
file: ../../../final/evidence/rendered/documented-uuidv7-generates-v4.svg
evidence:
- ../../../final/evidence/raw/documented-uuidv7-generates-v4.txt
source:
- 원본 분석 절은 final/document.md#7-4 · analysis/07 §5, §6, §7, §8 · analysis/01 §11 이다.
---
# 문서가 UUIDv7이라 말하고 생성되는 것은 v4다
식별자 리프의 문서 두 곳이 RFC 9562 UUIDv7 을 명시한다. 실제 생성기는 JDK 의 randomUUID 를 부르고 그것은 v4 다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례가 그 우선순위의 대상이다.
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
같은 계열의 규칙이다.
## 문제
리프의 문서 두 곳이 같은 주장을 한다.
CLAUDE.md 는 UUID 처리가 JDK 의 java.util.UUID 위에서 RFC 9562 UUIDv7 로 동작한다고 적는다
README.md 는 이 리프가 식별자 생성과 인코딩을 UUIDv7 로 담당한다고 적고, 같은 문장을 한 번 더 반복한다
UUIDv7 은 시간 순서를 갖는 식별자다. 정렬 가능성과 인덱스 지역성이 그 선택의 이유다.
## 결론
생성기는 v4 를 만든다.
RandomUploadIdentifierFactory 는 두 메서드에서 UUID.randomUUID 를 부른다. 그 메서드는 버전 4 를 만든다. 무작위이고 시간 순서를 갖지 않는다.
같은 클래스의 javadoc 은 randomUUID 가 시드된 SecureRandom 을 쓰므로 공유해도 안전하다고 적는다. 그 서술은 정확하고, 버전에 대해서는 아무 말도 하지 않는다.
드리프트의 방향이 과대 진술이다. 문서를 읽고 시간 순서 식별자를 전제한 설계 — 예를 들어 식별자 순 페이징이나 시간 기반 파티셔닝 — 는 성립하지 않는다.
그리고 이 주장은 두 문서에 각각 적혀 있으므로, 하나를 고쳐도 다른 하나가 남는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 문서 문장과 구현 대조
소스 수정 : x
## 재현 조건
1. 식별자 리프의 CLAUDE.md 와 README.md 에서 UUIDv7 언급을 찾는다.
2. 리프의 main 소스에서 UUID 를 생성하는 지점을 찾는다.
3. 그 호출이 어느 버전을 만드는지 확인한다.
## 본문
<!-- body:start -->
문서는 UUIDv7을 말하고 구현은 v4를 만든다.
## 문서의 버전과 구현의 버전
:::evidence key="documented-uuidv7-generates-v4" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 같은 leaf의 다른 문서 오류들
`CLAUDE.md`의 의존성 서술 **세 항목이 모두** 틀렸고, README에 사실 오류가 셋 있으며, `CLAUDE.md`가 근거로 대는 두 가드 중 하나는 저장소에 없다.
## 함께 읽어야 할 사실
10 파일짜리 leaf에서 문서 오류가 이만큼 나오는 것은 이 leaf에 production 소비자가 없다는 사실(§7 §3)과 함께 읽어야 한다 — 아무도 쓰지 않으면 문서도 검증되지 않는다.
## 확인하지 못한 것
생성된 값의 버전 비트를 실제로 읽어 확인하지 않았다. randomUUID 가 v4 를 만든다는 것은 JDK 계약이다.
리프의 다른 식별자 경로가 별도의 v7 생성기를 갖는지 전수 확인하지 않았다. 확인한 것은 업로드 식별자 팩토리다.
없음
<!-- body:end -->
@@ -0,0 +1,86 @@
---
kind: CASE
slug: five-documents-say-nineteen-leaves
title: 다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:five-documents-say-nineteen-leaves
evidenceCapturedOn: 2026-09-01
assets:
- key: five-documents-say-nineteen-leaves
file: ../../../final/evidence/rendered/five-documents-say-nineteen-leaves.svg
evidence:
- ../../../final/evidence/raw/five-documents-say-nineteen-leaves.txt
source:
- 원본 분석 절은 final/document.md#7-4 · analysis/05 §17 P3 이다.
---
# 다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다
여러 문서가 이 저장소를 19개 리프로 서술한다. 레지스트리의 실제 리프 수는 62 다. 그 수를 검사하는 게이트가 없다.
## 관계
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
이 사례가 그 규칙을 만든 형태다.
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
수치에 게이트가 없다는 점이 같은 계열이다.
## 문제
여러 문서가 리프 수를 문장 안에 적는다. 저장소 적응 문서들과 ADR 과 코드 리뷰가 그렇다.
그 수는 19 다.
레지스트리의 실제 항목 수는 62 다. messaging 25 개와 grpc 18 개가 더해진 결과다.
## 결론
수치가 문서에 하드코딩되어 있고 그것을 붙드는 게이트가 없다.
빌드 설정에는 리프 수를 검사하는 코드가 없다. 레지스트리 항목이 늘어도 문서의 숫자는 그대로 남는다.
이 드리프트의 성질은 앞의 사례들과 다르다. 능력 표의 불일치는 동작에 대한 오해를 만들지만, 이 숫자는 동작을 바꾸지 않는다. 대신 문서 전체의 신뢰도를 깎는다. 19 라는 수를 근거로 삼은 서술 — 예를 들어 모듈 경계 설명이나 의존 그래프 서술 — 이 어느 시점의 것인지 알 수 없게 된다.
그리고 이 수는 여러 문서에 흩어져 있으므로 하나를 고쳐도 나머지가 남는다. 파생하거나 게이트로 붙들지 않는 한 같은 드리프트가 반복된다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 문서 문장 검색과 레지스트리 항목 계수
소스 수정 : x
## 재현 조건
1. 문서 전체에서 리프 수를 언급하는 문장을 찾는다.
2. 레지스트리의 항목 수를 센다.
3. 빌드 설정에 리프 수를 검사하는 코드가 있는지 확인한다.
## 본문
<!-- body:start -->
다섯 문서가 "exactly 19 leaf identities"와 "`src/settings.gradle` throws when the registry does not contain exactly 19 modules"를 적는데, 레지스트리는 62개이고 `settings.gradle`에는 `19`도 수 검사도 없다 — 검증은 플러그인에 위임됐다.
## 다섯 문서가 적은 수와 레지스트리의 수
:::evidence key="five-documents-say-nineteen-leaves" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 게이트의 walk 대상에 docs가 없다
`verifyDocumentedLeafCount`가 존재하지만 `CLAUDE.md`·`AGENTS.md`·leaf `build.gradle`만 walk하고 `docs/**`는 대상이 아니다.
## 그 게이트 자신이 같은 형태의 사고를 기록한다
주석이 "이름이 적힌 목록은 다섯 개의 모듈 CLAUDE.md와 네 개의 leaf build.gradle을 놓쳤다"고 적는다.
## 확인하지 못한 것
19 라고 적은 각 문서가 어느 시점을 기준으로 한 것인지 추적하지 않았다. 일부는 messaging 과 grpc 가 추가되기 전에 작성된 것으로 보인다.
없음
<!-- body:end -->
@@ -0,0 +1,108 @@
---
kind: CASE
slug: mongo-default-throws-on-first-write
title: 출하 default 조합이 첫 write에서 예외를 던진다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:mongo-default-throws-on-first-write
evidenceCapturedOn: 2026-09-01
body: case-mongo-default-throws-on-first-write.body.md
assets:
- key: mongo-default-throws-on-first-write
file: ../../../final/evidence/rendered/mongo-default-throws-on-first-write.svg
evidence:
- ../../../final/evidence/raw/mongo-default-throws-on-first-write.txt
source:
- 원본 분석 절은 final/document.md#4-2 · analysis/06 §23 이다.
---
# 출하 default 조합이 첫 write에서 예외를 던진다
기본 설정은 정책 인식 타입 매퍼를 모든 변환기에 설치하고 레지스트리는 비어 있게 둔다. 그 조합으로 평범한 쓰기를 하면 예외가 난다. 같은 문서를 스프링 기본 매퍼로 쓰면 정상이다.
## 관계
- **README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다**
같은 조합이 문서 쪽에서 드러난 사례다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
기본 빈이 존재하는 것과 그 조합이 동작하는 것이 다르다는 사례다.
## 문제
두 개의 기본 빈이 함께 온다.
정책 인식 타입 매퍼가 모든 매핑 변환기에 설치된다
타입 메타데이터 레지스트리는 빈 것으로 만들어진다
이 조합에서 등록되지 않은 타입을 쓰면 어떻게 되는가.
## 결론
예외가 난다. 실행해서 확인했다.
probe 는 임시 분석 테스트로 추가하고 실행 후 제거했다. 결과는 이렇다.
policyFor 는 CLASS_METADATA_ALLOWED 를 돌려준다
쓰기 타입 결정은 IllegalStateException
루트 문서 쓰기는 IllegalStateException
중첩 문서 쓰기도 IllegalStateException
같은 문서를 스프링 기본 매퍼로 쓰면 키 세 개가 정상 기록된다
예외 메시지는 그 타입에 대한 타입 메타데이터 정책이 등록되지 않았다고 말하고, 저장된 문서의 타입 메타데이터는 클래스보다 오래 살아남으므로 그 정책은 기본값이 아니라 기록해야 할 결정이라고 덧붙인다.
그 설계 의도는 타당하다. 문제는 같은 컴포넌트가 등록되지 않은 타입에 대해 세 가지 다른 답을 준다는 점이다.
정책 조회는 스프링 기본값으로 대체한다
쓰기 경로는 예외를 던진다
조회가 관대하고 쓰기가 엄격하면, 설정을 점검하는 코드는 통과하고 실제 쓰기는 실패한다.
그리고 출하 기본값이 이 조합이다. 애플리케이션이 타입을 하나도 등록하지 않은 채 mongo 를 켜면 첫 쓰기에서 실패한다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 임시 분석 테스트를 추가해 실행하고 제거. 실행 전 작업 트리 변경 0
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/129-mongo-empty-type-registry-write-probe.txt 이고 probe 소스는 129a 다.
1. 빈 레지스트리로 정책 인식 매퍼를 만든다.
2. 등록되지 않은 타입에 대해 policyFor 를 호출한다. 기본값이 돌아온다.
3. 같은 타입으로 쓰기 타입 결정과 루트 쓰기와 중첩 쓰기를 시도한다. 셋 다 예외다.
4. 같은 문서를 스프링 기본 매퍼로 쓴다. 정상 기록된다.
5. 기본 빈 정의에서 레지스트리가 비어 있게 만들어지는지, 매퍼가 모든 변환기에 설치되는지 확인한다.
## 본문
<!-- body:start -->
세 사실이 겹친다.
1. 기본 bean이 **비어 있는** registry이고 그 javadoc이 "An empty registry so a deployment with no long-lived collection still starts"로 의도를 적는다.
2. configurer가 policy-aware mapper를 **모든** `MappingMongoConverter`에 무조건 설치한다.
3. 그 mapper가 미등록 타입 write에 `IllegalStateException`을 던진다.
## MappingMongoConverter 참조 위치
:::evidence key="mongo-default-throws-on-first-write" alt="코드베이스에서 MappingMongoConverter 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MappingMongoConverter 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 시작은 하고 첫 write에서 실패한다
module을 켜고 type metadata를 등록하지 않은 배포가 그렇다. probe가 shipped default 조합을 실제로 구성해 확인했고, Spring 기본 mapper로 바꾸면 같은 write가 성공한다.
## 같은 컴포넌트가 같은 질문에 세 가지로 답한다
미등록 타입의 정책은 `CLASS_METADATA_ALLOWED`, type-restricted **query**는 class name을 predicate에 쓰고, **write**는 예외다. 읽기와 쓰기가 정반대로 답하고 어느 쪽도 registry가 문서화한 기본값과 일치하지 않는다.
## 확인하지 못한 것
실제 MongoDB 서버에 대고 재현하지 않았다. probe 는 매퍼와 레지스트리 수준에서 관측했다.
<!-- body:end -->
@@ -0,0 +1,98 @@
---
kind: CASE
slug: support-matrix-said-the-opposite-of-the-code
title: 지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:support-matrix-said-the-opposite-of-the-code
evidenceCapturedOn: 2026-09-01
body: case-support-matrix-said-the-opposite-of-the-code.body.md
assets:
- key: support-matrix-said-the-opposite-of-the-code
file: ../../../final/evidence/rendered/support-matrix-said-the-opposite-of-the-code.svg
evidence:
- ../../../final/evidence/raw/support-matrix-said-the-opposite-of-the-code.txt
- ../../../final/evidence/raw/tl-kafka-dedup-drift.txt
source:
- 원본 분석 절은 final/document.md#8-1 · analysis/19 §6.3 이다.
---
# 지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다
Kafka 어댑터의 능력 상수는 중복 제거 발행을 거짓으로 선언한다. 지원 매트릭스는 그것을 지원으로 적는다. 능력 표 60칸 중 유일한 불일치이고, 하필 실제 거부를 일으키는 유일한 플래그다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례가 그 우선순위를 만든 형태다.
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
이 드리프트가 왜 잡히지 않았는지 설명한다.
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
같은 계열의 규칙이다.
## 문제
메시징 어댑터는 자기 능력을 불리언 열둘로 선언한다. 열 번째가 중복 제거 발행이다.
Kafka 어댑터의 상수에서 열 번째 값은 거짓이다.
지원 매트릭스의 해당 행은 Kafka 열에 지원 표시를 적는다.
능력 표는 어댑터 다섯 곱하기 플래그 열둘로 60칸이다. 이 한 칸이 유일한 불일치다.
## 결론
하필 그 한 칸이 실제 거부를 일으키는 유일한 플래그다.
발행기는 열두 플래그 중 하나만 강제한다. 프로파일이 중복 제거 발행을 요구하는데 전송이 그 능력을 갖지 않으면 거부한다.
그래서 오해의 방향이 나쁜 쪽이다. 매트릭스를 읽고 Kafka 가 중복 제거를 해 준다고 믿은 소비자는 자기 멱등성 처리를 생략한다. 실제로는 브로커가 중복 제거를 하지 않으므로 중복이 그대로 소비자에게 도달한다.
그리고 그 소비자가 프로파일에 중복 제거를 요구하면 발행 자체가 거부된다. 두 결과 중 어느 쪽도 매트릭스를 읽은 사람이 예상한 것이 아니다.
문서 계약 테스트가 이 불일치를 잡지 못한 이유는 그 테스트의 단언 여덟 개가 등급 이름과 버전 문자열과 존재하지 않는 상수를 붙들고 능력 표 60칸은 붙들지 않기 때문이다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 코드 상수와 문서 표의 위치 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/tl-kafka-dedup-drift.txt 와 255-messaging-capability-doc-vs-code-drift.txt 에 있다.
1. 능력 레코드의 컴포넌트 순서를 확인해 열 번째가 중복 제거 발행임을 확인한다.
2. Kafka 전송의 능력 상수에서 열 번째 값을 읽는다. 거짓이다.
3. 지원 매트릭스의 해당 행에서 Kafka 열을 읽는다. 지원으로 적혀 있다.
4. 발행기에서 능력 플래그를 강제하는 지점을 찾는다. 중복 제거 발행 하나뿐이다.
## 본문
<!-- body:start -->
capability 표 60칸을 코드 배열과 전수 대조한 결과 불일치가 정확히 한 칸이다 — Kafka `deduplicatedPublish`가 문서 `O`, 코드 `false`.
## 60칸을 전수 대조한 결과
:::evidence key="support-matrix-said-the-opposite-of-the-code" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 하필 그 플래그다
12개 중 유일하게 실제 거부를 발생시키는 것이고, 코드 javadoc이 `true``false` 변경 이력과 피해를 직접 이름 붙인다 — "the caller believes the broker is deduplicating and skips the idempotency it would otherwise build."
## 나머지 48칸은 일치한다
Pulsar의 `keyedOrdering`은 문서가 두 배열 차이까지 반영해 코드보다 정밀하다.
## 확인하지 못한 것
실제 브로커에 대고 중복 발행을 시도해 거부가 나는 것을 재현하지 않았다. 이 기록은 선언과 문서의 불일치, 그리고 그 플래그가 강제되는 유일한 것이라는 사실에 대한 것이다.
없음 — 60칸 전수 대조
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: the-readme-recipe-does-not-start
title: README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-readme-recipe-does-not-start
evidenceCapturedOn: 2026-09-01
assets:
- key: the-readme-recipe-does-not-start
file: ../../../final/evidence/rendered/the-readme-recipe-does-not-start.svg
evidence:
- ../../../final/evidence/raw/the-readme-recipe-does-not-start.txt
source:
- 원본 분석 절은 final/document.md#7-4 · analysis/06 §4, §23 이다.
---
# README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다
리프의 README 는 mongo 를 켜는 절차를 적는다. 그 절차만 따르면 타입 메타데이터가 하나도 등록되지 않고, 그 상태의 첫 쓰기는 예외로 끝난다.
## 관계
- **출하 default 조합이 첫 write에서 예외를 던진다**
같은 조합을 실행으로 확인한 사례다.
- **과대 진술 문서를 과소보다 먼저 고친다**
문서가 성립하지 않는 절차를 적었을 때의 우선순위다.
## 문제
README 는 이 리프를 활성화하는 절차를 안내한다. 마스터 스위치를 켜고 접속 설정을 주는 형태다.
그 절차에는 타입 메타데이터 등록이 없다.
## 결론
절차만 따르면 첫 쓰기가 예외로 끝난다.
기본 빈 정의가 정책 인식 타입 매퍼를 모든 매핑 변환기에 설치하고 레지스트리는 빈 것으로 만든다. 등록되지 않은 타입에 대한 쓰기 경로는 예외를 던진다.
실행으로 확인한 결과는 관련 Case 에 있다. 빈 레지스트리에서 루트 쓰기와 중첩 쓰기가 모두 IllegalStateException 이고, 같은 문서를 스프링 기본 매퍼로 쓰면 정상이다.
즉 README 의 절차는 문서 안에서는 완결되어 보이지만 실행하면 완결되지 않는다. 빠진 단계가 무엇인지도 문서에 없다.
드리프트의 방향이 나쁜 쪽이다. 문서가 실제보다 적게 요구한다. 읽는 사람은 절차를 다 따랐다고 믿고, 실패는 배포 후 첫 쓰기에서 나타난다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : README 절차와 기본 빈 정의 대조, 그리고 별도 probe 의 실행 결과 참조
소스 수정 : x
## 재현 조건
1. 리프의 README 에서 활성화 절차를 읽는다.
2. 그 절차가 타입 메타데이터 등록을 요구하는지 확인한다.
3. 기본 빈 정의에서 레지스트리가 비어 있게 만들어지는지 확인한다.
4. 빈 레지스트리 조합의 쓰기 결과를 확인한다. 원문은 evidence/raw/129-mongo-empty-type-registry-write-probe.txt 다.
## 본문
<!-- body:start -->
README가 제시하는 활성화 절차를 그대로 따르면 시작은 하고 첫 write에서 예외가 난다.
## README 가 제시하는 절차
:::evidence key="the-readme-recipe-does-not-start" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 세 사실이 겹친다
빈 registry가 기본 bean이고, policy-aware mapper가 모든 converter에 무조건 설치되며, 미등록 타입 write가 `IllegalStateException`을 던진다.
## 실패의 출처를 probe 가 좁혔다
shipped default 조합을 실제로 구성해 확인했고, 같은 converter에 Spring 기본 mapper를 두면 같은 write가 성공한다 — 실패는 문서·엔티티 형태가 아니라 이 leaf가 설치한 mapper에서 온다.
## 확인하지 못한 것
README 절차를 처음부터 끝까지 실제로 수행해 애플리케이션을 기동하지 않았다. 확인한 것은 절차에 빠진 단계와 그 단계가 없을 때의 쓰기 결과다.
실제 MongoDB에 붙이지 않았다(probe는 converter 수준)
<!-- body:end -->
@@ -0,0 +1,65 @@
---
kind: PROJECT_DECISION
slug: capability-grade-is-declared-not-inferred
title: 지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:capability-grade-is-declared-not-inferred
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java
- src/gradle/jpa-evidence.gradle
- src/messaging/messaging-testkit/src/test/java/dev/caskeleton/messaging/testkit/MessagingDocumentationContractTest.java
- analysis/05-adapter-outbound-persistence-jpa.md
- analysis/19-messaging-platform.md
- analysis/20-grpc-platform.md
---
# 지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다
## 결정문
능력의 지원 등급은 코드가 존재한다는 사실에서 추론하지 않고 명시적으로 선언하며, 승격은 정해진 증거를 요구한다.
## 판단 이유
코드가 있다는 것과 그 능력이 지원된다는 것은 다르다. 이 저장소에는 그 차이가 실제로 벌어진 사례가 여럿 있다. 조립되지 않은 능력, 소비자가 없는 코디네이터, 적용 지점이 없는 검증기가 그렇다.
등급을 추론하면 그 사례들이 전부 지원으로 보고된다. 코드가 있기 때문이다.
그래서 등급을 값으로 둔다. 능력 선언 레코드가 능력과 등급과 제약 목록을 담고, 그 값이 리포트로 공개된다.
승격에는 증거가 붙는다. 증거에는 등급이 있고, 높은 등급은 결과의 내용뿐 아니라 출처까지 요구한다. 어떤 프로파일에서 돌았는지, 워크트리가 깨끗했는지, 실제 CI 잡이었는지, 산출물이 외부에 보존되었는지다.
그리고 실험 등급이 안정으로 적히지 않는지를 문서 계약 테스트가 확인한다.
## 영향
감수하는 것
능력이 실제로 동작하는데 선언이 없으면 지원되지 않는 것으로 보고된다. 과소 진술 방향의 드리프트가 생길 수 있다.
증거 조건을 만족시키려면 CI 를 거쳐야 한다. 로컬에서 승격할 수 없다.
선언과 코드가 어긋날 수 있다. 능력 표의 한 칸이 코드와 반대를 적은 사례가 실제로 있었고, 그것을 잡는 단언은 아직 없다.
얻는 것
조립되지 않은 코드가 지원으로 보고되지 않는다.
등급이 값이므로 리포트로 공개할 수 있고 기계로 검증할 수 있다.
## 근거
- **증거 등급과 provenance — R1과 R2를 가르는 것**
승격이 요구하는 증거 체계다.
- **후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다**
같은 체계의 승격 규칙이다.
- **지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다**
선언과 문서가 어긋난 사례다.
- **진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다**
등급을 담는 값 타입이 지키는 제약이다.
@@ -0,0 +1,40 @@
---
kind: QUESTION
slug: widen-doc-contract-assertions
title: 문서 계약 테스트의 단언 범위를 capability 표까지 넓힐 것인가
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:widen-doc-contract-assertions
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- final/document.md#7-4 · analysis/19 §9.3
---
# 문서 계약 테스트의 단언 범위를 capability 표까지 넓힐 것인가
capability 표는 기계로 검증 가능하다 — 어댑터 이름과 능력 상수와 마크다운 표가 전부 소스에 있다.
## 사실
capability 표는 기계로 검증 가능하다 — 어댑터 이름과 능력 상수와 마크다운 표가 전부 소스에 있다. 그리고 현재 계약 테스트의 좁은 단언 범위 밖에 발견된 드리프트 세 건이 전부 있다.
## 미지수
프로젝트가 이 확장을 채택할 것인지. 테스트 javadoc 은 오히려 좁게 유지하는 근거를 적는다 — "Asserting on wording would make every edit a test failure and the check would be deleted."
## 다음 검증
capability 표 60칸을 코드 상수에서 파생시키는 검사를 시제품으로 만들어 문구 변경에 대한 취약성을 실측한다.
파생 검사가 문구 변경에 취약하지 않다는 것이 실측되면 확장을 채택한다. 그렇지 않으면 좁은 단언을 유지하고 경계를 문서에 적는다.
## 관계
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
같은 구조에서 실제로 확인된 사건이다.
- **과대 진술 문서를 과소보다 먼저 고친다**
여기서 뽑아낸 재사용 기준이다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: fix-overstatement-before-understatement
title: 과대 진술 문서를 과소보다 먼저 고친다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:fix-overstatement-before-understatement
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 과대 진술 문서를 과소보다 먼저 고친다
## 목적
문서 드리프트를 발견 순서대로 고치다가, 읽는 사람에게 위험한 방향의 드리프트를 뒤로 미루는 것을 막는다.
## 규칙
1. 방향을 먼저 판정한다
문서가 실제보다 많이 약속하는가 적게 약속하는가.
2. 과대 진술이 먼저다
문서를 믿고 자기 방어를 생략한 사람이 손해를 본다. 과소 진술은 불편을 주지만 안전한 쪽으로 틀린다.
3. 강제되는 값의 과대 진술이 가장 위험하다
선언만 되고 아무것도 강제하지 않는 값의 오기와, 실제 분기를 만드는 값의 오기는 무게가 다르다.
4. 수치보다 능력 서술이 먼저다
숫자의 드리프트는 신뢰도를 깎지만 동작을 바꾸지 않는다. 능력 서술의 드리프트는 설계 판단을 바꾼다.
5. 같은 주장이 여러 문서에 있으면 함께 고친다
하나만 고치면 나머지가 남고, 다음 사람은 어느 쪽이 최신인지 모른다.
## 적용 조건
지원 매트릭스와 능력 표와 활성화 절차 문서
여러 드리프트를 한 번에 발견해 순서를 정해야 할 때
## 예외
과소 진술이 실제로 있는 기능을 아무도 쓰지 못하게 만들고 있다면 그것이 더 급할 수 있다. 그 판단에는 그 기능이 필요하다는 근거가 함께 있어야 한다.
## 예시
능력 표 60칸 중 하나의 오기가 하필 실제 거부를 일으키는 유일한 플래그였다. 그것을 믿은 소비자는 자기 멱등성 처리를 생략한다.
리프 수를 19 라고 적은 문서들은 실제 62 와 다르지만 동작을 바꾸지 않는다.
## 관계
- **지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다**
세 번째 규칙의 사례다.
- **여러 문서가 19개 리프라고 적고 레지스트리는 62다**
네 번째 규칙의 사례다.
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
같은 계열의 예방 규칙이다.
@@ -0,0 +1,57 @@
---
kind: REFERENCE
slug: numbers-in-docs-should-be-derived
title: 문서의 수치는 세지 말고 파생하거나 게이트로 붙든다
topic: drift-direction
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:numbers-in-docs-should-be-derived
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 문서의 수치는 세지 말고 파생하거나 게이트로 붙든다
## 목적
문서에 손으로 적은 수가 코드보다 뒤처져, 그 수를 근거로 한 서술 전체가 언제 것인지 알 수 없게 되는 것을 막는다.
## 규칙
1. 셀 수 있는 것은 세지 말고 파생한다
모듈 수 어댑터 수 규칙 수는 레지스트리나 소스 트리에서 계산할 수 있다.
2. 파생할 수 없으면 게이트로 붙든다
문서의 수와 실제의 수를 비교하는 검사를 만든다. 검사가 없으면 그 수는 작성 시점의 스냅숏이다.
3. 같은 수가 여러 문서에 있으면 출처를 하나로 만든다
흩어진 수는 한 번에 갱신되지 않는다.
4. 수를 근거로 한 서술을 함께 표시한다
그 수가 틀리면 그 서술도 틀린다. 어느 서술이 그 수에 의존하는지 알 수 있어야 한다.
## 적용 조건
모듈 수 리프 수 규칙 수 지원 버전 수처럼 코드에서 셀 수 있는 모든 수치
지원 매트릭스와 아키텍처 개요 문서
## 예외
운영상의 가정으로 정한 임계값은 세는 수가 아니다. 그런 값은 출처가 판단이므로 근거를 적는 것으로 충분하다.
## 예시
여러 문서가 리프 수를 19 로 적고 레지스트리는 62 다. 빌드 설정에 그 수를 검사하는 코드가 없다.
반대 사례로, 오류 코드 태그의 카디널리티 상한은 오류 코드 정의 파일의 행 수와 동기화된다고 상수 주석에 적혀 있다.
## 관계
- **여러 문서가 19개 리프라고 적고 레지스트리는 62다**
이 규칙을 만든 사례다.
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
두 번째 규칙이 기대는 상위 규칙이다.
- **과대 진술 문서를 과소보다 먼저 고친다**
드리프트가 이미 생겼을 때의 우선순위다.