Files
document-haness/docs/clean-architecture-backend-template/checkpoints/root-tree-before-recall-gate-20260831.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

2237 lines
229 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
schemaVersion: 1
project: clean-architecture-backend-template
sourceDocument: final/document.md
sourceDocumentSha256: 10eabd7a4fd2d20444effb966e2779175cb2a1b15c53a5a3cdf8a3e3e402b2f7
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
generatedAt: 2026-08-31T00:00:00+09:00
---
# Root Tree
PROJECT
clean-architecture-backend-template
> **분해 기준.** Topic은 공학 문제 공간이고 디렉터리가 아니다. 한 Topic 안의 Case들은 서로 다른
> 메커니즘을 갖되 같은 판정 기준과 어휘를 공유한다. Reference는 그 Topic의 Case를 지우고도 남는
> 규칙만 올린다. 빈 브랜치는 채우지 않는다.
>
> 노드 수가 많은 이유는 분모가 크기 때문이다 — 62 leaf · main Java 4,614 파일 · 320,318 LOC ·
> 모듈 문서 18,769줄. `analysis/05`(persistence-jpa) 하나가 4,674줄이고 그 안에 P1급만 열 건이 넘는다.
>
> **CONCEPT 브랜치에 대하여.** Studio에 개념 카테고리가 추가됐고 작성 구조는 Case와 같다(본문
> `bodyMarkdown`을 갖는다). 저장소의 계약 파일은 아직 이 종류를 기술하지 않는다 —
> `record-kinds.md`의 종류 고르기 흐름도에 없고, `review-checklist.md`는 "Case is a concrete
> event/verification sequence, **not a concept article**"로 배제만 한다. 그래서 이 트리는 다음
> 기준으로 나눈다.
>
> - **CASE** — 재현하고 검증한 하나의 사건. "이때 이런 일이 일어났고 이렇게 확인했다."
> - **CONCEPT** — 이 코드베이스가 구현한 메커니즘의 설명. 사건이 아니라 구조다. "이것이 무엇이고
> 왜 이 모양인가." 본문에 코드·상태도·표가 들어간다.
> - **REFERENCE** — 그 메커니즘에서 뽑은 재사용 기준. 본문이 없고 규칙·적용조건·예외로 나뉜다.
>
> 셋의 관계는 보통 CONCEPT ← CASE → REFERENCE다. 사건이 개념을 필요로 하고, 개념에서 규칙이
> 나온다. 계약이 갱신되면 이 분류를 다시 본다.
---
TOPIC
커밋 모호성 — "모른다"를 결과로 유지하기
commit-ambiguity-as-a-result
├── CONCEPT
│ ├── 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지
│ └── 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문
├── CASE
│ ├── `pg_terminate_backend``57P01`로 도착하고 커밋 레코드는 이미 WAL에 있었다
│ ├── high-water mark가 "본 위치"를 뜻해서 재전달된 변경이 영구히 사라졌다
│ ├── 번역되지 않은 경합 예외가 재시도 코디네이터의 catch를 통째로 비껴갔다
│ └── 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다
├── REFERENCE
│ ├── 모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다
│ ├── 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다
│ ├── 커밋 모호성 판정은 넓혀도 좁혀도 해롭다
│ └── 실패 번역 사슬의 순서는 계약이다
├── OPEN QUESTION
│ └── 커밋 모호성 계약 레인이 이 리비전에서 통과하는지 실행으로 확인되지 않았다
└── DECISION
├── completion-unknown은 자동으로도 수동으로도 재시도하지 않는다
├── 전송·업무·스트림 증거는 세 축이고 서로를 함의하지 않는다
└── 재시도 단위는 statement가 아니라 유스케이스 전체다
---
TOPIC
조립 소유권 — 통제와 그 의존을 같은 곳이 소유하기
assembly-ownership
├── CONCEPT
│ ├── Spring 조립의 세 경로와 각각이 결정하는 것
│ └── 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
├── CASE
│ ├── 스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다
│ ├── outbox 체인 전체가 만족될 수 없는 `@ConditionalOnBean` 뒤에 있었다
│ ├── 관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다
│ ├── 이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다
│ ├── `@ConditionalOnBean(DataSource.class)`가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다
│ ├── 넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다
│ ├── 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
│ └── subsystem 전체가 미배선인데 그것을 켜는 flag는 startup 검사를 수행한다
├── REFERENCE
│ ├── `@Bean`이 있다는 것은 조립 증거가 아니다
│ ├── `@ConditionalOnBean`은 조건이 만족될 수 있는지까지 확인해야 한다
│ ├── 시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다
│ ├── 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다
│ ├── "꺼짐"은 조건의 반복이 아니라 구조여야 한다
│ └── 프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다
├── OPEN QUESTION
│ ├── 부팅 한 번으로 확증 가능한 두 건이 아직 정적 추론으로만 남아 있다
│ └── `@ConditionalOnBean` 사슬의 실제 평가 순서를 확인하지 않았다
└── DECISION
├── 마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다
└── 풀이 필요한지는 "JPA가 켜졌나"가 아니라 "커넥션이 필요한 capability가 있나"로 묻는다
---
TOPIC
게이트가 증명하지 않는 것
what-a-gate-does-not-prove
├── CONCEPT
│ ├── strict test lane — 발견하지 못하면 실패하는 레인
│ └── 증거 등급과 provenance — R1과 R2를 가르는 것
├── CASE
│ ├── "certified"라 불리던 레인이 threshold를 하나도 비교하지 않고 있었다
│ ├── 문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다
│ ├── 릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다
│ ├── 패키지 카탈로그가 트리보다 아홉 개 적어서 사이클이 통과했다
│ ├── 릴리스 레인이 매트릭스 세 버전 중 첫 번째만 돌리고 세 개를 커버로 기록했다
│ └── 다중 타깃 검증을 확인한다는 테스트가 다른 가드에 걸려 통과했다
├── REFERENCE
│ ├── 빠뜨림이 통과가 되는 게이트는 게이트가 아니다
│ ├── 문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다
│ ├── 아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다
│ ├── 계약 테스트는 어댑터가 실제로 돌리는 statement를 실행해야 한다
│ └── 아무것도 발견하지 못한 레인은 성공이 아니라 실패여야 한다
├── OPEN QUESTION
│ └── 컨테이너가 필요한 레인의 실제 결과를 실행으로 확인하지 않았다
└── DECISION
├── 인증 레인만 Docker 가드를 달지 않는다
├── 성능 측정은 릴리스 게이트에 넣지 않는다
└── 후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다
---
TOPIC
타입으로 카디널리티와 개인정보를 막기
bounding-by-type
├── CONCEPT
│ ├── 서명된 커서의 구조와 검증 순서
│ └── 카디널리티 경계를 타입으로 표현하기
├── CASE
│ ├── 시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다
│ ├── 진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다
│ └── 커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유
├── REFERENCE
│ ├── 이름은 값이 아니라 registry key다
│ ├── 관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다
│ ├── path·identifier는 등록하고 value는 바인딩한다
│ └── sanitize가 아니라 reject가 기본이다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
├── tenant id는 메트릭 태그가 되지 않는다
└── 커서에 서명하는 이유는 기밀성이 아니라 무결성이다
---
TOPIC
중복 장치 — 조립된 쪽이 약한 쪽일 때
duplicate-mechanisms
├── CASE
│ ├── 클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다
│ ├── 요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다
│ ├── JPA 플랫폼 capability 대부분에 production 소비자가 없다
│ ├── forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
│ └── 재시도 구현이 둘이고 정교한 쪽을 아무도 호출하지 않는다
├── REFERENCE
│ ├── 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다
│ ├── `Atomic*` 타입의 존재는 원자성의 증거가 아니다
│ └── 같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── 감사 메커니즘은 엔티티당 정확히 하나여야 한다
---
TOPIC
문서 드리프트의 방향
drift-direction
├── CASE
│ ├── 지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다
│ ├── README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다
│ ├── 출하 default 조합이 첫 write에서 예외를 던진다
│ ├── 문서가 UUIDv7이라 말하고 생성되는 것은 v4다
│ ├── 선택할 수 없는 브로커가 지원 매트릭스에 기능 목록과 함께 실려 있다
│ └── 다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다
├── REFERENCE
│ ├── 과대 진술 문서를 과소보다 먼저 고친다
│ └── 문서의 수치는 세지 말고 파생하거나 게이트로 붙든다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
├── 문서 계약 테스트의 단언 범위를 capability 표까지 넓힐 것인가
└── 지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다
---
TOPIC
플랫폼 가족 사이의 학습 전이
learning-transfer-between-families
├── CASE
│ ├── 두 번째 플랫폼이 첫 번째의 bridge 부재는 막고 게이트 배선은 옮기지 않았다
│ └── 같은 자격 증명 회전 결함이 한 가족에서 닫히고 다른 가족에서 재현됐다
├── REFERENCE
│ └── 레지스트리로 표현된 규칙은 전이되고 CI로 표현된 규칙은 전이되지 않는다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── gRPC 플랫폼은 build-only로 두고 애플리케이션 도달 경로를 먼저 정한다
---
TOPIC
owner-safe 상태 기계 — 소유권을 SQL에 적기
owner-safe-state-machines
├── CONCEPT
│ ├── fenced lease — 만료 시각만으로는 부족한 이유
│ ├── CAS 튜플과 update count가 답이 되는 구조
│ └── capability_schema_registry — 스키마 적용과 사용 승인의 분리
├── CASE
│ ├── lease가 만료 시각만 기록하고 소유자를 기록하지 않아 terminal state가 되돌려졌다
│ ├── transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
│ ├── 활성 트랜잭션 검사가 data source를 묻지 않아 다른 커넥션에서 커밋됐다
│ ├── 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
│ └── native claim이 `@Version`을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다
├── REFERENCE
│ ├── CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
│ ├── 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
│ ├── 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
│ └── digest는 길이 프레이밍하고 버전을 붙인다
├── OPEN QUESTION
│ └── V2 상태 기계 넷의 컨테이너 레인이 실행되지 않았다
└── DECISION
├── capability는 스키마 적용과 사용 승인을 분리한다
└── 상태 기계 구현은 Spring stereotype을 갖지 않는다
---
TOPIC
트랜잭션 데드라인과 커넥션 예산
transaction-deadline-and-pool
├── CONCEPT
│ ├── 호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파
│ └── `REQUIRES_NEW`의 커넥션 비용과 풀 사이징 제약
├── CASE
│ ├── `connection-timeout: 5s`가 모든 prod 배포를 시작 실패시켰고 local만 통과했다
│ ├── validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다
│ └── `REQUIRES_NEW`가 바깥 커넥션을 핀한 채 새 커넥션을 딴다
├── REFERENCE
│ ├── 데드라인은 호출 예산에서 시작해 세 단계로 좁힌다
│ ├── 쓰기 트랜잭션에는 유한 타임아웃이 필수다
│ └── 세션 스코프 설정은 풀로 돌아간 커넥션에 남는다
├── OPEN QUESTION
│ └── 풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다
└── DECISION
├── 트랜잭션 템플릿은 모드별로 미리 만들어 둔다
└── `inRootWrite`는 suspend하지 않고 fail-fast한다
---
TOPIC
스키마 소유권과 capability 스트림
schema-ownership-and-capability-streams
├── CONCEPT
│ └── 독립 Flyway 스트림과 baseline version 0
├── CASE
│ ├── 두 트리가 다 V1부터 번호를 매겨 공유 history가 하나를 건너뛸 수 있었다
│ ├── `char(64)``varchar(64)` 불일치를 H2가 가리고 있었다
│ ├── 레지스트리 컬럼이 38자 경로에서 짧아 "더 짧은 경로를 적는" 우회를 유혹했다
│ ├── Flyway location customizer가 운영자가 바인딩한 값을 덮어썼다
│ └── messaging 마이그레이션 두 leaf가 같은 디렉터리에서 `V2`를 둘 만들었다
├── REFERENCE
│ ├── 마이그레이션 스트림은 자기 history 테이블을 갖는다
│ ├── 로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다
│ └── 적용된 마이그레이션의 checksum은 그것을 돌린 모든 배포에 대한 약속이다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
├── Flyway가 스키마를 소유하고 런타임 롤은 DDL 권한을 갖지 않는다
└── Repair는 모드가 아니라 운영자가 호출하는 작업이다
---
TOPIC
Redis 명령 admission과 카탈로그
redis-command-admission
├── CONCEPT
│ └── 명령 카탈로그와 admission 아홉 단계
├── CASE
│ ├── "build gate"라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다
│ ├── 의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다
│ ├── NOSCRIPT 복구가 다섯 벌이고 넷은 스크립트 레지스트리를 지나지 않는다
│ └── startup probe가 production에서 한 번도 실행되지 않는다
├── REFERENCE
│ ├── 서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다
│ └── 단일 admission point는 우회 경로를 세어야 성립한다
├── OPEN QUESTION
│ └── Redis 토폴로지 레인이 실행되지 않아 key spec 드리프트가 확인되지 않았다
└── DECISION
└── 분류되지 않은 명령은 fail-closed로 거부한다
---
TOPIC
HTTP 실패 분류와 재시도 안전성
http-failure-classification
├── CONCEPT
│ └── 전송 실패의 단계와 범주 — `AttemptStage``FailureCategory`
├── CASE
│ ├── 영구 TLS 실패가 재시도 가능한 CONNECT로 분류된다
│ └── 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
├── REFERENCE
│ ├── 원인 사슬은 가장 구체적인 분류가 이기도록 순회한다
│ └── 재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── 재시도 안전성은 증거에 기반해 판정한다
---
TOPIC
파일 상태 기계와 물리 정리의 seam
fileserver-state-and-fencing
├── CONCEPT
│ └── 파일 상태 기계와 READY가 뜻하는 것
├── CASE
│ ├── scriptable 콘텐츠 탐지가 BOM·NUL·주석으로 우회된다
│ ├── cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
│ ├── claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
│ └── README가 "노출된 setting도 bean도 없다"고 적은 능력에 production bean 여덟이 있다
├── REFERENCE
│ ├── 공개 읽기 가능한 상태는 완전한 identity를 DB 제약으로 요구한다
│ ├── 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다
│ └── 접두사 시작 매칭은 시그니처에는 맞고 스니핑 패턴에는 맞지 않는다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── 물리 경로와 원본 파일명을 저장하지 않는다
---
TOPIC
객체 스토리지의 단계적 수명주기와 권한 분리
objectstorage-staged-lifecycle
├── CONCEPT
│ └── staged lifecycle과 서명된 grant
├── CASE
│ ├── APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없다
│ ├── 직접 multipart의 마지막 part는 grant를 받을 수 없다
│ └── 서명된 grant의 endpoint 검증이 upload 경로에만 있다
├── REFERENCE
│ ├── 권한이 센 절반이 설정 한 줄로 켜지면 안 된다
│ └── 이진 승인 코덱은 decode 후 재인코딩이 원본과 같아야 한다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── legacy 채택은 서로 다른 두 승인자의 서명을 요구한다
---
TOPIC
자기 공시 — 등급을 스스로 낮춰 적기
self-disclosure-grading
├── CONCEPT
│ └── 네 단계 공시 등급 — modelled에서 production-verified까지
├── CASE
│ ├── 등급표 13행 중 일곱을 스스로 강등하고 한 행만 관측과 어긋났다
│ ├── 시작 검증기가 커서 서명 키를 요구하는데 그 키로 서명하는 코드가 없다
│ └── build-only 등급이 90개 파일의 미조립을 오늘의 사고에서 면제한다
├── REFERENCE
│ ├── 등급은 네 단계로 나누고 관측보다 높게 적지 않는다
│ └── `runtime_memberships`를 먼저 읽고 심각도를 정한다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── 능력 등급은 코드가 아니라 실행된 증거에서 파생한다
---
TOPIC
멀티테넌시 격리가 조용히 무력화되는 방법
multitenancy-isolation
├── CONCEPT
│ ├── RLS가 성립하기 위한 세 전제
│ └── 네 가지 멀티테넌시 전략과 각각의 격리 경계
├── CASE
│ ├── RLS가 아무것도 하지 않는 세 가지 방법
│ ├── `search_path`가 풀로 돌아간 커넥션에 남아 다음 tenant가 상속한다
│ └── tenant별 풀이 개별적으로 합리적이고 그 합이 서버 상한을 넘긴다
├── REFERENCE
│ ├── Hibernate filter는 보안 경계가 아니다
│ ├── tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다
│ └── 격리 설정은 트랜잭션 로컬이어야 한다
├── OPEN QUESTION
│ └── (없음)
└── DECISION
└── 클래스패스에 있는 것은 실행 동의가 아니다
---
# Node Specifications
각 노드는 계약(`root-tree-contract.md`)이 요구하는 필드를 갖는다. `evidence`가 "없음"인 노드는 그
이유를 함께 적는다 — 명령 출력이 아닌 근거(코드·javadoc·ADR)로 충분한 경우와, 실행이 필요한데
하지 못한 경우를 구별한다. 후자는 `missing-verification`에 남고 대응하는 Open Question을 갖는다.
---
## TOPIC 1 — commit-ambiguity-as-a-result
### CASE — `pg_terminate_backend`가 `57P01`로 도착하고 커밋 레코드는 이미 WAL에 있었다
- slug: `commit-ambiguity-is-not-only-sqlstate-08`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §3.5
- code: `.../persistence-jpa/.../transaction/CommitFailureClassifier.java` · `docs/jpa/repository-adaptation.md` §4
- evidence: 없음 — 저장소가 기록한 컨테이너 레인 관측을 인용한다. 이 분석에서 재실행하지 않았다
- classification: 설계가 "커밋 모호성은 SQLSTATE class 08뿐"이라고 적었고, in-flight 커밋 중인 백엔드를 `pg_terminate_backend`로 끊었을 때 `57P01`(admin_shutdown)이 도착하며 그 시점에 커밋 레코드가 이미 WAL에 있을 수 있음을 컨테이너 레인이 보였다. 규칙이 `57P01/57P02/57P03`까지 넓어졌고 `CommitAmbiguityContractTest`가 SQLSTATE를 직접 assert해 다시 좁아지지 못하게 고정했다.
- missing-verification: `jpaPlatformFailureTest`를 이 리비전에서 실행하지 않았다. 확인한 것은 규칙의 현재 코드 형태와 그 근거 문장이다.
- relations: `reference:unknown-is-a-third-result` · `reference:ambiguity-rule-hurts-both-ways` · `decision:completion-unknown-is-never-retried` · `open-question:commit-ambiguity-lane-not-executed`
### CASE — high-water mark가 "본 위치"를 뜻해서 재전달된 변경이 영구히 사라졌다
- slug: `high-water-mark-swallowed-a-redelivered-change`
- readiness: `READY`
- source: `final/document.md#4-2` · `analysis/06` §67
- code: `.../persistence-mongo/.../MongoChangeStreamPipeline.java:49,58-63` · `.../ReactiveMongoChangeStreamConsumer.java:199`
- evidence: `evidence/raw/135a-mongo-change-stream-probe.java` · `evidence/raw/135-mongo-change-stream-probe.txt`
- classification: mark가 이벤트 수신 즉시 전진하므로 "완료된 위치"가 아니라 "본 위치"를 뜻하고, resume이 같은 pipeline 인스턴스를 재사용해 mark가 남는다. probe 세 개(A/B/C)가 worker 하나·평범한 failover에서 checkpoint 없이 지나간 이벤트를 다음 이벤트의 checkpoint가 추월하고 그 사이 재전달분을 pipeline이 삼키는 것을 관측했다. 구독은 `RUNNING`, runbook 비어 있음, caller의 `Flux`는 정상 완료. 세 테스트가 각각 절반씩만 보고 "본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 없다.
- missing-verification: probe는 in-memory 구성이다. 실제 replica set failover에서 같은 순서가 재현되는지는 확인하지 않았다.
- relations: `reference:unknown-is-a-third-result` · `case:outbox-chain-behind-an-unsatisfiable-condition`
### CASE — 번역되지 않은 경합 예외가 재시도 코디네이터의 catch를 통째로 비껴갔다
- slug: `untranslated-contention-bypassed-the-retry-catch`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §3.6
- code: `.../persistence-jpa/.../transaction/PersistenceFailureTranslatorChain.java` · `.../OptimisticConflictTranslator.java`
- evidence: 없음 — 저장소의 javadoc이 사후 기록으로 남긴 회귀다
- classification: 재시도 코디네이터가 `JpaPersistenceException`만 catch하는데 executor는 아무것도 번역하지 않고 템플릿을 돌렸다. 그래서 경합이 실제로 만들어내는 실패 — Hibernate `OptimisticLockException`, Spring `OptimisticLockingFailureException`, raw 직렬화/데드락 `DataAccessException` — 이 번역되지 않은 채 executor를 떠나 catch를 완전히 비껴갔고 **production 경합은 재시도되지 않았다.** 단위 픽스처는 이미 번역된 예외를 던져서 초록불이었다.
- missing-verification: 현재 사슬이 네 단계 순서를 지키는지는 코드로 확인했으나, 경합을 실제로 일으켜 재시도를 관측하지 않았다.
- relations: `reference:translation-chain-order-is-a-contract` · `reference:unknown-is-a-third-result` · `case:a-certifying-lane-that-compared-nothing`
### CASE — 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다
- slug: `two-owners-popped-the-evidence-frame`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §3.4
- code: `.../persistence-jpa/.../transaction/TransactionEvidenceContext.java` · `.../TransactionEvidenceScope.java`
- evidence: 없음 — javadoc의 사후 기록
- classification: 증거 프레임을 pop하는 주인이 둘이었다 — 트랜잭션 매니저가 commit/rollback에서, executor의 `finally`가 operation·attempt 일치 시. 바깥과 같은 operation·같은 attempt를 가진 `REQUIRES_NEW` 안쪽 트랜잭션(기본 경로에서는 둘 다 attempt 1이라 **항상** 그렇다)에서 안쪽 매니저가 안쪽을 pop하고 executor가 바깥을 자기 것으로 오인해 pop했다. 이후 바깥의 커밋 실패는 operation 없이, attempt 1로, reconciliation key 없이 보고됐다. 해결은 깊이로 소유권을 식별하고 자기 프레임이 top일 때만 pop하는 것이다.
- missing-verification: 없음 — 수정된 형태와 그 테스트(`hasRawThreadLocalValue`)를 코드로 확인했다.
- relations: `reference:unknown-is-a-third-result` · `decision:retry-unit-is-the-use-case`
### REFERENCE — 모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다
- slug: `unknown-is-a-third-result`
- readiness: `READY`
- source: `final/document.md#9` 규칙 13 · `analysis/05` §16 · `analysis/19` §3.2
- classification: 완료 여부를 알 수 없는 상태를 성공이나 실패로 접으면 그 정보가 영원히 사라진다. 기준은 "결과 타입에 세 번째 변형이 있는가"이고, 그것이 있을 때만 caller가 재시도와 조정을 구별한다.
- scope: 커밋·발행·전달처럼 관측이 결과를 확정하지 못할 수 있는 모든 경계. 구현 예: `RetryDisposition.RECONCILE` · `TransactionResult.Indeterminate` · `WriteDisposition.UNDETERMINED` · `PublishCompletion.AMBIGUOUS` · `GrpcBusinessEvidence.COMMIT_UNKNOWN` · `ReplicaLagMonitor.replayedThrough(): Optional`
- exceptions: 아무것도 프로세스를 떠나지 않은 실패는 확정적이므로 세 번째 변형이 아니다. 남발하면 조정 큐가 커지고 운영자가 읽지 않고 비우게 된다.
- relations: `case:commit-ambiguity-is-not-only-sqlstate-08` · `case:high-water-mark-swallowed-a-redelivered-change` · `decision:completion-unknown-is-never-retried`
### REFERENCE — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다
- slug: `make-the-unsafe-state-unrepresentable`
- readiness: `READY`
- source: `final/document.md#9` 규칙 14 · `analysis/05` §2.2 · `analysis/19` §3.2
- classification: "이 조합은 하면 안 된다"를 리뷰 규칙이나 정책 코드로 두면 정책 버그가 그것을 만들 수 있다. 생성자가 거부하면 그 값이 존재할 수 없다.
- scope: 두 필드의 조합이 의미상 불가능한 모든 값 타입. 예: `JpaFailureContext``completionUnknown && retryable` 거부 + 예외가 안전 팩토리로 컨텍스트 재구성(이중 방어) · `PublishEvidence`의 두 거부 · `GrpcExecutionEvidence`가 "unary call with stream evidence" 거부
- exceptions: 생성자 검증은 값 타입에서만 싸다. 유효성이 다른 객체의 상태에 의존하면 조립 지점의 시작 검증기가 자리다.
- relations: `reference:unknown-is-a-third-result` · `decision:completion-unknown-is-never-retried` · `decision:three-axes-of-evidence`
### REFERENCE — 커밋 모호성 판정은 넓혀도 좁혀도 해롭다
- slug: `ambiguity-rule-hurts-both-ways`
- readiness: `READY`
- source: `analysis/05` §3.5 · `docs/adr/ADR-JPA-003-completion-unknown.md`
- classification: 모호성 규칙을 좁게 두면 실제로 모호한 실패가 확정 롤백으로 오인돼 재시도된다. 넓게 두면 평범한 풀 고갈과 서버 재시작이 조정 큐로 밀려들고 운영자가 그 큐를 읽지 않고 비우는 습관을 배운다. 기준은 "이 SQLSTATE가 커밋 **단계에서** 발생했고 드라이버가 어느 쪽인지 말해주지 못하는가" 두 조건의 교집합이다.
- scope: 커밋 실패 분류를 갖는 모든 데이터 계층. 판정 재료는 관측된 트랜잭션 phase와 SQLSTATE·예외 타입 두 가지이며 어느 하나만으로는 부족하다.
- exceptions: 커넥션 유실이 커밋을 불명으로 남겼는지는 phase에 달렸으므로 `08*`을 그 자체로 completion-unknown으로 두면 안 된다 — 벤더 분류기가 `CONNECTION_UNAVAILABLE`로만 두고 phase를 아는 커밋 분류기가 따로 판정하는 이유다.
- relations: `case:commit-ambiguity-is-not-only-sqlstate-08` · `decision:completion-unknown-is-never-retried`
### REFERENCE — 실패 번역 사슬의 순서는 계약이다
- slug: `translation-chain-order-is-a-contract`
- readiness: `READY`
- source: `analysis/05` §3.6
- classification: 번역 사슬은 (1) 이미 분류된 실패를 그대로 통과, (2) provider 고유 예외(SQLSTATE 없음), (3) 벤더 SQLSTATE, (4) 나머지는 손대지 않고 반환의 순서를 지켜야 한다. 재번역은 이미 들고 있는 attempt·key·completion 판정을 잃고, 무분류 포장은 프로그래밍 에러를 retryable로 보이게 한다.
- scope: 예외를 계층 간에 옮기는 모든 어댑터. 정책의 화이트리스트는 어떤 **범주**가 재시도될 수 있는지를 넓히지, **이 실패**에 대한 분류기의 판정을 뒤집지 않는다.
- exceptions: 도메인 예외·assertion 실패·`NullPointerException`은 퍼시스턴스 실패가 아니므로 번역 대상이 아니다.
- relations: `case:untranslated-contention-bypassed-the-retry-catch` · `reference:unknown-is-a-third-result`
### OPEN QUESTION — 커밋 모호성 계약 레인이 이 리비전에서 통과하는지 실행으로 확인되지 않았다
- slug: `commit-ambiguity-lane-not-executed`
- readiness: `OPEN`
- source: `final/document.md#6-2` · `analysis/05` §3.5, §15
- known: `CommitFailureClassifier`의 현재 규칙 형태(`40003` · `08*` · `57P0x` · transport break 4종)를 코드로 확인 · `CommitAmbiguityContractTest`가 SQLSTATE를 직접 assert함 · 해당 레인은 Docker 부재 시 skip이 아니라 실패한다
- unknown: 이 리비전에서 실제로 통과하는지 · `57P01` 관측이 PostgreSQL 16/17/18 전부에서 재현되는지
- next-verification: `./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain`, 매트릭스는 `-Pjpa.matrix.versions=16` 식으로 major당 한 번(다중 선택은 거부된다)
- decision-criterion: 세 major 통과 시 이 질문을 닫고 해당 Case의 `missing-verification`을 지운다. 실패하면 그 실패가 새 Case가 된다
- relations: `case:commit-ambiguity-is-not-only-sqlstate-08` · `open-question:container-lanes-not-executed`
### DECISION — completion-unknown은 자동으로도 수동으로도 재시도하지 않는다
- slug: `completion-unknown-is-never-retried`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-2` · `analysis/05` §2.2, §3.5
- decision-evidence: `docs/adr/ADR-JPA-003-completion-unknown.md` — Status: Accepted, Date: 2026-08-11, Design §17
- grounds: `case:commit-ambiguity-is-not-only-sqlstate-08` · `reference:unknown-is-a-third-result` · `reference:make-the-unsafe-state-unrepresentable`
- classification: ADR이 `TransactionCompletionUnknownException`을 자동으로도 수동으로도 재시도하지 않으며 `COMMITTING` 단계 관측 실패에 한해, `40003`·`08*`·transport break에 한해 생성한다고 명시한다. 근거는 "Retrying a possibly-committed write is the most damaging thing this platform could do... the failure is **epistemic rather than transient**"이고, 불변식을 정책이 아니라 타입 수준에서 강제한다.
- relations: `case:commit-ambiguity-is-not-only-sqlstate-08` · `decision:three-axes-of-evidence` · `reference:ambiguity-rule-hurts-both-ways`
### DECISION — 전송·업무·스트림 증거는 세 축이고 서로를 함의하지 않는다
- slug: `three-axes-of-evidence`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-5` · `analysis/20` §2.4
- decision-evidence: `docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md` — Status: accepted, Date: 2026-08-30
- grounds: `reference:unknown-is-a-third-result` · `reference:make-the-unsafe-state-unrepresentable`
- classification: 상태 코드가 caller의 실제 질문에 대한 답이 아니라는 관찰에서 출발한다. `GrpcTransportEvidence``NOT_SENT``UNOBSERVED`를 구별하고, `GrpcBusinessEvidence``COMMIT_UNKNOWN`을 실제 상태로 두며, `GrpcExecutionEvidence`가 관측 불가능한 조합을 거부한다. 응답 헤더를 확정 커밋으로 승격하는 것은 한 메서드를 고쳐야만 가능하다.
- relations: `decision:completion-unknown-is-never-retried` · `reference:unknown-is-a-third-result`
### DECISION — 재시도 단위는 statement가 아니라 유스케이스 전체다
- slug: `retry-unit-is-the-use-case`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/05` §3.6
- decision-evidence: `docs/adr/ADR-JPA-002-full-transaction-retry.md` · `.../FullTransactionRetryCoordinator.java`의 근거 javadoc
- grounds: `case:untranslated-contention-bypassed-the-retry-catch` · `reference:unknown-is-a-third-result`
- classification: 낙관적 충돌·직렬화 실패에서 시도가 실패한 이유는 "계산의 기준이 된 상태가 더 이상 커밋된 상태가 아니라서"이므로, 같은 statement를 다시 쏘면 같은 틀린 답을 계산한다. executor에 다시 들어가면 새 트랜잭션과 새 Persistence Context를 받고 도메인 규칙이 reload된 데이터에 대해 다시 돈다. 예산은 attempt 수와 elapsed 두 상한을 모두 갖는다 — "타임아웃에 10초 걸리는 락에 3번 시도하면 30초짜리 호출이고 상류 호출자는 한참 전에 포기했다."
- relations: `case:a-retry-implementation-nobody-calls` · `reference:unknown-is-a-third-result`
### CONCEPT — 커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지
- slug: `commit-evidence-phases`
- readiness: `READY`
- source: `final/document.md#3-2` · `analysis/05` §3.4, §2.3
- code: `.../persistence-jpa/.../api/transaction/TransactionCompletionEvidence.java` · `.../transaction/EvidenceAwareJpaTransactionManager.java` · `.../TransactionEvidenceContext.java`
- classification: 트랜잭션이 어디까지 갔는지를 여섯 단계로 기록하는 메커니즘의 설명이다. `NOT_STARTED → ACTIVE → COMMITTING → COMMITTED | ROLLED_BACK | UNKNOWN`이고, **`COMMITTING` 단계에서 관측된 실패만** completion-unknown이 될 수 있다는 것이 전체 모델의 핵심이다. `EvidenceAwareJpaTransactionManager``mark(COMMITTING)`을 provider commit **직전**에 찍는 이유(그 안에서 프로세스가 죽으면 마지막 기록이 "물어봤고 모른다"여야 한다), 그리고 컨텍스트가 단일 슬롯이 아니라 `ArrayDeque` 스택인 이유(`REQUIRES_NEW`가 같은 스레드에서 바깥을 suspend한다)를 함께 설명한다. `ThreadLocal.withInitial`을 쓰지 않는 이유도 이 개념의 일부다 — 읽을 때마다 값을 설치하면 `clear()`가 방금 제거한 것을 다시 등록한다.
- missing-verification: 없음 — 구조와 근거를 코드로 확인했다
- relations: `case:two-owners-popped-the-evidence-frame` · `case:commit-ambiguity-is-not-only-sqlstate-08` · `reference:unknown-is-a-third-result` · `decision:completion-unknown-is-never-retried`
### CONCEPT — 트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문
- slug: `transaction-result-algebra`
- readiness: `READY`
- source: `final/document.md#3-2` · `analysis/05` §3.2
- code: `.../persistence-jpa/.../api/transaction/TransactionResult.java` · `.../transaction/SpringPolicyTransactionPort.java`
- classification: `TransactionResult<T>`가 sealed interface로 다섯 변형을 갖는 이유의 설명이다. `Committed`(물리 커밋 확인) · `Participating`(바깥 트랜잭션에 참여 — **커밋을 주장하지 않는다**) · `DeterminateRollback` · `Indeterminate`(**replay 권한을 주지 않는다**) · `CommittedWithPostCommitFailure`. 각 변형이 caller에게 허용하는 행동이 다르고, 특히 `Participating``Indeterminate`는 boolean이나 예외로는 표현할 수 없는 상태다. `PhaseSentinel``Ordered.HIGHEST_PRECEDENCE`로 등록되어 물리 소유자일 때만 phase를 기록하는 구조도 함께 다룬다.
- missing-verification: 없음
- relations: `concept:commit-evidence-phases` · `reference:unknown-is-a-third-result` · `reference:make-the-unsafe-state-unrepresentable`
---
## TOPIC 2 — assembly-ownership
### CONCEPT — Spring 조립의 세 경로와 각각이 결정하는 것
- slug: `three-assembly-paths`
- readiness: `READY`
- source: `final/document.md#1-4`, `#7-1` · `analysis/18` · `analysis/14` §7.1
- code: `.../app-bootstrap/.../CaSkeletonApplication.java` · 8개 `AutoConfiguration.imports` · `.../app-bootstrap/src/main/resources/META-INF/spring.factories`
- evidence: `evidence/raw/tl-web-six-unowned-components.txt`
- classification: 이 저장소에서 빈이 컨텍스트에 들어오는 경로가 셋이고 각각 다른 질문에 답한다. **컴포넌트 스캔**(`@ComponentScan` + `AUTO_CONFIGURED_PACKAGES` 제외 정규식) · **`.imports`**(8개 파일 / 13개 클래스가 전부) · **`spring.factories`**(EnvironmentPostProcessor 6 · SpringBootExceptionReporter · AutoConfigurationImportFilter · ApplicationListener). 조립 결함을 판정할 때 세 경로를 다 읽어야 하는 이유와, main Java 4,614개 중 스테레오타입 보유가 206개(4.5%)뿐이라 조립 표면이 매우 좁다는 사실을 함께 설명한다.
- missing-verification: 없음 — 세 경로의 내용을 전수 판독했다
- relations: `case:scan-exclusion-without-an-owner` · `reference:read-the-assembling-side-first` · `reference:a-bean-is-not-composition-evidence`
### CONCEPT — 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
- slug: `when-conditions-are-evaluated`
- readiness: `READY`
- source: `analysis/05` §14.4
- code: `.../app-bootstrap/.../persistencejpa/JpaPlatformRuntimeAutoConfiguration.java`의 근거 javadoc · `.../PersistenceJpaRootAutoConfiguration.java``jpaResolvedDataSourceCheck`
- classification: `@ConditionalOnBean`은 그 클래스가 **언제 평가되는가**에 따라 답이 달라진다. `@AutoConfiguration`으로 등록되면 다른 자동설정 이후에 평가되지만, plain `@Configuration`이 `@Import`로 들어오면 **클래스가 파싱되는 동안 — 대상 빈 정의가 존재하기 전에** 평가된다. 이 저장소가 그 함정을 실제로 밟았고(여덟 빈이 조용히 사라짐), 회피 방법 두 가지를 남겼다 — 검증기를 주입받지 않고 직접 생성하기, 그리고 조건을 루트로 올리기.
- missing-verification: 현재 리비전의 각 조건부 빈이 어느 시점에 평가되는지는 `ConditionEvaluationReport`로 확인하지 않았다
- relations: `case:conditionalonbean-evaluated-at-parse-time` · `reference:conditionalonbean-must-be-satisfiable` · `open-question:conditional-evaluation-order-unverified`
### CASE — 스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다
- slug: `scan-exclusion-without-an-owner`
- readiness: `READY`
- source: `final/document.md#3-1` · `analysis/14` §8.1, §51
- code: `.../CaSkeletonApplication.java` `AUTO_CONFIGURED_PACKAGES` · `.../mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java` · `.../webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java`
- evidence: `evidence/raw/tl-web-six-unowned-components.txt` · `evidence/meta/tl-web-six-unowned-components.json` · `evidence/terminal/tl-web-six-unowned-components.svg`
- classification: 합성 루트가 다섯 web 패키지를 스캔에서 제외하고 근거를 "Ownership by auto-configuration is what ties a control's presence to its dependency's"로 적었다. 두 자동설정은 실제로 존재하고 `.imports`에 있고 출하 컨텍스트에 도달하는데, 등록하는 `@Bean` 13개·10개가 전부 협력자이고 제외된 패키지의 컴포넌트 여섯은 어느 쪽도 소유하지 않는다(전부 main 참조 0). `ProblemCatalog``WebProblemFactory`는 빈이고 그것을 쓰는 `@RestControllerAdvice`는 빈이 아니다.
- missing-verification: `/actuator/beans`로 여섯 타입의 부재를 직접 확인하지 않았다
- relations: `concept:three-assembly-paths` · `reference:a-bean-is-not-composition-evidence` · `reference:read-the-assembling-side-first` · `case:observation-downgraded-by-the-composition`
### CASE — outbox 체인 전체가 만족될 수 없는 `@ConditionalOnBean` 뒤에 있었다
- slug: `outbox-chain-behind-an-unsatisfiable-condition`
- readiness: `READY`
- source: `final/document.md#4-3` · `analysis/19` §7.1
- code: `.../messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java` · `.../messaging-outbox-jdbc-postgresql/.../JdbcOutboxRepository.java` · `.../messaging-inbox-jdbc-postgresql/.../JdbcInboxRepository.java`
- evidence: `evidence/raw/tl-outbox-unsatisfiable-condition.txt` · `evidence/meta/…json` · `evidence/terminal/…svg`
- classification: 조건 사슬의 뿌리인 두 port의 유일한 구현이 main 참조 0이다 — 구현은 저장소 안에 있는데 만드는 자동설정이 없다. relay·worker·lifecycle·cleanup·transactional inbox handler 19개 파일 / 2,818 LOC가 조립되지 않고, Spring이 이것을 오류로 보고하지 않으므로 "꺼져 있음"과 "조립될 수 없음"이 런타임에서 구별되지 않는다. 같은 starter가 `MessageCodecRegistry`에는 `@ConditionalOnMissingBean` 기본 구현을 준다는 점이 비일관을 결함으로 만든다.
- missing-verification: `ConditionEvaluationReport`로 미충족 사유를 확인하지 않았다
- relations: `reference:conditionalonbean-must-be-satisfiable` · `concept:when-conditions-are-evaluated` · `open-question:one-boot-would-settle-two-findings`
### CASE — 관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다
- slug: `observation-downgraded-by-the-composition`
- readiness: `READY`
- source: `final/document.md#5-2` · `analysis/19` §5.1
- code: `.../messaging-runtime-core/.../DefaultMessagePublisher.java` (`NO_OBSERVATION` 필드 javadoc) · `.../MessagingCoreAutoConfiguration.java:445`
- evidence: `evidence/raw/tl-messaging-observation-noop.txt` · `evidence/meta/…json` · `evidence/terminal/…svg`
- classification: 관측을 선택적 데코레이터가 아니라 필수 생성자 인자로 만든 수정이 runtime-core에 있고 그 javadoc이 "an unobserved publish path is how 'the dashboards were empty during the incident' happens"로 이유를 적는다. 자동설정은 6인자 생성자를 골라 `NO_OBSERVATION`(다섯 메서드 전부 빈 본문)을 주입하고, 방출자 넷은 main 참조 0이며 등록되는 것은 협력자 둘뿐이다. 같은 경로가 예외 메시지를 의도적으로 버리므로 둘이 합쳐지면 진단 흔적이 남지 않는다.
- missing-verification: 부팅 후 `/actuator/metrics``messaging.*` 시리즈 부재를 확인하지 않았다
- relations: `reference:a-bean-is-not-composition-evidence` · `case:scan-exclusion-without-an-owner` · `open-question:one-boot-would-settle-two-findings`
### CASE — 이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다
- slug: `autoconfiguration-in-name-only`
- readiness: `READY`
- source: `analysis/05` §14.4
- code: `.../app-bootstrap/.../persistencejpa/JpaPlatformRuntimeAutoConfiguration.java`의 javadoc
- evidence: 없음 — javadoc의 사후 기록
- classification: 세 클래스가 `...AutoConfiguration`으로 이름 붙었고 plain factory였다 — `@AutoConfiguration`도, `@Bean`도, `.imports` 엔트리도 없었고 합성 루트는 그 패키지를 스캔에서 제외한다. 그래서 capability 리포트는 transaction retry·completion evidence·observability를 Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.** 개발자가 재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.
- missing-verification: 없음 — 수정 후 형태를 코드로 확인했다
- relations: `reference:a-bean-is-not-composition-evidence` · `case:a-retry-implementation-nobody-calls` · `decision:capability-grade-is-declared-not-inferred`
### CASE — `@ConditionalOnBean(DataSource.class)`가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다
- slug: `conditionalonbean-evaluated-at-parse-time`
- readiness: `READY`
- source: `analysis/05` §14.4
- code: `.../persistencejpa/JpaPlatformRuntimeAutoConfiguration.java` · `.../PersistenceJpaRootAutoConfiguration.java`
- evidence: 없음 — javadoc의 사후 기록
- classification: 이 클래스는 루트가 **import**하지 auto-configure하지 않으므로, 그 조건이 클래스 파싱 중 — datasource 빈 정의가 존재하기 전에 — 평가됐고 따라서 **모든 실제 배포에서 false**였다. 아래 여덟 빈이 조용히 사라졌고 아무것도 그중 어느 것에도 의존하지 않아 아무것도 보고하지 않았다. datasource validator가 caller에 배선되고 Compose 레인이 "No qualifying bean"이라고 답했을 때에야 드러났다. 같은 함정을 피하려고 루트의 검사가 validator를 주입받지 않고 직접 생성한다.
- missing-verification: 현재 리비전에서 재발하지 않는지 `ConditionEvaluationReport`로 확인하지 않았다
- relations: `concept:when-conditions-are-evaluated` · `open-question:conditional-evaluation-order-unverified`
### CASE — 넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다
- slug: `narrowing-the-scan-orphaned-eight-components`
- readiness: `READY`
- source: `analysis/05` §14.2
- code: `.../persistence-jpa/.../config/JpaAdapterComponentsConfig.java`의 javadoc
- evidence: 없음 — javadoc의 사후 기록
- classification: 합성 루트의 스캔이 persistence 트리를 정규식으로 제외했고 **그 제외는 옳다** — 그것이 optional capability를 optional하게 만든다. 빠진 것은 나머지 절반이다. 이 leaf의 여덟 클래스가 scanned component로 쓰여 있는데(`SpringTransactionPort`·`PersistenceExceptionTranslator`·`StandardSqlStateErrorMapping`·`DomainContextAuditContextPort`·idempotency store와 reaper·outbox store와 reaper) 넓은 스캔이 멈추자 **아무것도 도달하지 않았다.** 특히 `TransactionPort`는 구현이 전혀 없어서 트랜잭션을 여는 모든 유스케이스가 열 포트를 갖지 못했고, 단위 테스트는 각 클래스를 직접 생성하므로 볼 수 있는 것이 없었다.
- missing-verification: 없음 — 수정된 `@ComponentScan` 대상 6개를 코드로 확인했다
- relations: `concept:three-assembly-paths` · `reference:read-the-assembling-side-first` · `reference:off-must-be-structural`
### CASE — 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
- slug: `thirteen-startup-rules-never-run`
- readiness: `READY`
- source: `final/document.md#5-3` · `analysis/20` §3.1
- code: `.../grpc-spring-boot-starter/.../GrpcPlatformStartupValidator.java`(188줄) · `.../GrpcPlatformAutoConfiguration.java`(106줄, `@Bean` 9)
- evidence: `evidence/raw/267-grpc-family-reachability.txt`
- classification: validator가 5개 그룹 13개 규칙을 갖고(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1) javadoc이 그 13개를 고른 기준을 "None of them fails a smoke test"로 적는다. 유일한 조립 지점인 자동설정은 `@Bean` 9개를 만들면서 이 validator를 부르지 않고, static 메서드라 빈이 될 수도 없다. CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 startup을 거부한다"와 §2.2의 runtime 강제가 둘 다 이 validator를 통해서만 성립하므로 둘 다 실행되지 않는다.
- missing-verification: build-only 가족이라 부팅 확인이 불가능하다 — 채택 시점에만 관측 가능
- relations: `reference:the-startup-validator-follows-the-autoconfiguration-root` · `reference:a-bean-is-not-composition-evidence` · `case:a-release-gate-with-no-evidence-producer`
### CASE — subsystem 전체가 미배선인데 그것을 켜는 flag는 startup 검사를 수행한다
- slug: `a-flag-that-validates-an-unwired-subsystem`
- readiness: `READY`
- source: `final/document.md#4-2` · `analysis/06` §49
- code: `.../persistence-mongo/.../MongoRootAutoConfiguration.java` 및 그 조건
- evidence: 없음 — 도달성 전수 확인
- classification: mongo leaf(497 파일 / main 351)가 배선돼 있지 않은데, 그것을 켜는 flag는 startup 검사를 수행한다. 즉 운영자가 스위치를 켜면 검사가 돌고 통과하지만 그 뒤에 아무 능력도 조립되지 않는다 — "켰고 검사도 통과했다"가 "작동한다"로 읽히는 조합이다.
- missing-verification: 부팅으로 빈 목록을 확인하지 않았다
- relations: `reference:off-must-be-structural` · `reference:a-bean-is-not-composition-evidence` · `case:mongo-default-throws-on-first-write`
### REFERENCE — `@Bean`이 있다는 것은 조립 증거가 아니다
- slug: `a-bean-is-not-composition-evidence`
- readiness: `READY`
- source: `final/document.md#9` 규칙 2·3 · `analysis/99` §2
- classification: 타입이 로드된다는 것은 jar가 클래스패스에 있다는 뜻이고, `@Bean`이 있다는 것은 그 값이 컨텍스트에 있다는 뜻이다. 둘 다 그것이 **실행 경로에 놓였다**는 뜻이 아니다. 기준은 "그 빈을 주입받아 호출하는 코드가 있는가"다.
- scope: 프레임워크가 조립하는 모든 통제 — 검증기·인터셉터·필터·어드바이스·정책 객체. 판정은 선언 파일을 제외한 main 참조 수를 세는 것이다.
- exceptions: build-only leaf의 **공개 API 표면**은 예외다(채택자가 부를 타입). 구분 기준은 "가족 내부의 다른 코드가 불러야 하는 조립·기계 타입인가". `@ConfigurationProperties`처럼 프레임워크가 소비하는 타입도 참조 0이 정상이다.
- relations: `case:scan-exclusion-without-an-owner` · `case:observation-downgraded-by-the-composition` · `case:thirteen-startup-rules-never-run` · `reference:conditionalonbean-must-be-satisfiable`
### REFERENCE — `@ConditionalOnBean`은 조건이 만족될 수 있는지까지 확인해야 한다
- slug: `conditionalonbean-must-be-satisfiable`
- readiness: `READY`
- source: `final/document.md#9` 규칙 4 · `analysis/19` §7.1
- classification: 조건이 만족되지 않으면 조용히 아무것도 만들어지지 않고 프레임워크는 이것을 오류로 보고하지 않는다. 기준은 두 질문이다 — "X의 구현이 저장소 안에 있는가", "그것을 만드는 자동설정이 있는가". 사슬이면 뿌리까지 따라간다.
- scope: 조건부 조립을 쓰는 모든 지점. 평가 시점 문제와 뿌리 부재 문제가 같은 증상을 내므로 `ConditionEvaluationReport`가 둘을 가른다.
- exceptions: 애플리케이션이 직접 등록하기를 기대하는 확장점이면 정당하다. 다만 그때는 저장소가 그 port의 구현을 제공하지 않거나, 제공한다면 `@ConditionalOnMissingBean` 기본값으로 함께 등록하는 것이 일관된다.
- relations: `case:outbox-chain-behind-an-unsatisfiable-condition` · `concept:when-conditions-are-evaluated` · `open-question:conditional-evaluation-order-unverified`
### REFERENCE — 시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다
- slug: `the-startup-validator-follows-the-autoconfiguration-root`
- readiness: `READY`
- source: `final/document.md#5-3` · `analysis/18` (SRC-158)
- classification: 루트가 있으면 검증기를 매달 자리가 있고, 없으면 검증기는 컴포넌트 스캔이 닿기를 기대하는데 그 스캔이 그 패키지를 제외하고 있을 수 있다. 기준은 "이 검증기를 부르는 조립 지점이 어디인가"를 능력 단위로 묻는 것이다.
- scope: 자동설정과 컴포넌트 스캔을 함께 쓰는 조합. 확인 결과: app-bootstrap 12종 배선(고아 0), messaging 6종 배선, web·websocket 미배선.
- exceptions: **루트가 있는데도 부르지 않는 경우**가 둘 있다(`WebPlatformStartupValidator`, `GrpcPlatformStartupValidator`). 필요조건이지 충분조건이 아니다.
- relations: `case:thirteen-startup-rules-never-run` · `case:scan-exclusion-without-an-owner` · `reference:a-bean-is-not-composition-evidence`
### REFERENCE — 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다
- slug: `read-the-assembling-side-first`
- readiness: `READY`
- source: `final/document.md#13` 항목 5 · `analysis/99` §8
- classification: 통제가 작동하지 않는다는 판정은 그 통제의 코드만 읽어서는 내릴 수 없다. 조립하는 쪽을 먼저 읽어야 원인이 "루트 없음" / "루트는 있는데 소유하지 않음" / "조건 만족 불가"로 갈리고, 세 경우의 조치가 다르다.
- scope: 프레임워크 조립을 쓰는 모든 분석. 순서는 (1) 레지스트리의 runtime membership, (2) `.imports`·`spring.factories` 전량, (3) 합성 루트의 스캔 경계와 import filter, (4) 그 다음 개별 통제.
- exceptions: 없음. 이 분석의 정정 네 건이 전부 이 순서를 어겨서 생겼다.
- relations: `concept:three-assembly-paths` · `case:scan-exclusion-without-an-owner` · `reference:a-bean-is-not-composition-evidence`
### REFERENCE — "꺼짐"은 조건의 반복이 아니라 구조여야 한다
- slug: `off-must-be-structural`
- readiness: `READY`
- source: `final/document.md#10-3` · `analysis/05` §14.1 · `analysis/19` §6.1
- classification: 능력이 꺼졌다는 것은 빈이 없고, 소켓·풀·스레드가 없고, 설정이 바인딩되지 않고, 스키마 기대치도 없다는 뜻이어야 한다. 조건을 빈마다 반복하면 다음 달에 추가된 빈이 게이트 밖에 남는다. 기준은 "조건이 몇 곳에 적혀 있는가"이고 답이 하나여야 한다.
- scope: optional capability를 갖는 모든 조립. 구현 형태는 루트 하나가 조건을 갖고 자식을 `@Import`하는 것이다.
- exceptions: 기본 켜짐인 능력(`matchIfMissing=true`)은 이 모델에 그대로 들어가지 않는다 — web의 네 스위치가 그 경우이고, 그것을 활성화 모델에 넣으려면 조립 소유권을 먼저 정해야 한다.
- relations: `case:narrowing-the-scan-orphaned-eight-components` · `case:a-flag-that-validates-an-unwired-subsystem` · `decision:one-root-owns-the-master-switch`
### REFERENCE — 프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다
- slug: `count-the-frameworks-own-autoconfigurations`
- readiness: `READY`
- source: `analysis/05` §14.1
- classification: 프로젝트 자신의 설정만 조건화하는 것으로는 부족하다 — starter가 프레임워크의 import metadata를 통해 자기 것을 기여하므로, 평범한 `@EnableAutoConfiguration` 애플리케이션은 프로젝트 조건이 무엇이라 하든 풀을 열고 마이그레이션을 돌린다. 기준은 "이 능력이 꺼졌을 때 프레임워크가 여전히 무엇을 만드는가"다.
- scope: starter를 클래스패스에 두는 모든 optional capability. 이 저장소의 형태는 `AutoConfigurationImportFilter`가 10종을 막는 것이다.
- exceptions: 필터 목록의 오타는 **조용히 fail-open**한다(매치하지 않을 뿐). 그래서 테스트가 필터의 반환값이 아니라 **빈 부재**로 assert해야 한다.
- relations: `case:narrowing-the-scan-orphaned-eight-components` · `reference:off-must-be-structural` · `decision:pool-need-is-a-capability-question`
### OPEN QUESTION — 부팅 한 번으로 확증 가능한 두 건이 아직 정적 추론으로만 남아 있다
- slug: `one-boot-would-settle-two-findings`
- readiness: `OPEN`
- source: `final/document.md#11` 항목 3 · `analysis/19` §5.1, §7.1
- known: `messagingPublisher`가 6인자 생성자를 호출하고 그것이 `NO_OBSERVATION`을 넘긴다 · 방출자 넷의 main 참조 0 · 두 repository 구현의 main 참조 0
- unknown: `/actuator/metrics``messaging.*` 시리즈가 실제로 없는지 · `ConditionEvaluationReport`가 outbox 사슬 미충족을 어떤 사유로 보고하는지
- next-verification: `app.messaging.enabled=true`, `app.messaging.broker=kafka`, `debug=true`로 한 번 부팅해 (1) `/actuator/metrics` 시리즈 목록, (2) 시작 로그의 `Negative matches``MessagingReliabilityAutoConfiguration` 항목을 캡처. 브로커가 없어도 둘 다 가능하다
- decision-criterion: 두 관측이 정적 판정과 일치하면 두 Case의 `missing-verification`을 지우고 닫는다. 어긋나면 모듈 분석을 먼저 갱신한다
- relations: `case:observation-downgraded-by-the-composition` · `case:outbox-chain-behind-an-unsatisfiable-condition`
### OPEN QUESTION — `@ConditionalOnBean` 사슬의 실제 평가 순서를 확인하지 않았다
- slug: `conditional-evaluation-order-unverified`
- readiness: `OPEN`
- source: `final/document.md#11` 항목 4 · `analysis/05` §14.4
- known: 이 저장소에 그 함정의 실제 사례가 기록돼 있다(파싱 시점 평가로 여덟 빈 소실) · 회피를 위해 루트가 validator를 직접 생성한다
- unknown: 현재 리비전의 조건부 빈들이 각각 어느 시점에 평가되는지 · outbox 사슬이 "뿌리 부재"인지 "평가 시점"인지 — 정적으로는 전자로 보이지만 둘은 같은 증상을 낸다
- next-verification: `debug=true` 부팅의 `Negative matches` 사유 문자열을 읽는다. `OnBeanCondition`이 남기는 사유가 뿌리 타입 부재를 지목하는지가 갈림점이다
- decision-criterion: 사유가 뿌리 부재를 지목하면 해당 Case의 원인 분류가 확정된다. 평가 시점 문제면 분류와 조치가 바뀐다
- relations: `case:outbox-chain-behind-an-unsatisfiable-condition` · `case:conditionalonbean-evaluated-at-parse-time` · `concept:when-conditions-are-evaluated` · `open-question:one-boot-would-settle-two-findings`
### DECISION — 마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다
- slug: `one-root-owns-the-master-switch`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-3` · `analysis/19` §6.1 · `analysis/05` §14.1
- decision-evidence: `.../MessagingPlatformRootAutoConfiguration.java`의 조건과 javadoc · `.../PersistenceJpaRootAutoConfiguration.java` + `JpaOffAutoConfigurationImportFilter` · `.../GrpcPlatformAutoConfiguration.java`(`matchIfMissing=false`)
- grounds: `reference:off-must-be-structural` · `reference:count-the-frameworks-own-autoconfigurations`
- classification: 다섯 어댑터가 같은 형태로 교정된 결정이고 근거가 코드에 있다 — "one root owning the condition, importing children that carry none, so a bean added to any child next month is gated without anyone remembering to repeat a condition."
- relations: `case:scan-exclusion-without-an-owner` · `reference:off-must-be-structural` · `decision:pool-need-is-a-capability-question`
### DECISION — 풀이 필요한지는 "JPA가 켜졌나"가 아니라 "커넥션이 필요한 capability가 있나"로 묻는다
- slug: `pool-need-is-a-capability-question`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-3` · `analysis/05` §14.1
- decision-evidence: `.../app-bootstrap/.../persistencejpa/DataSourceRequirement.java``reasons(environment)` 6개 조건과 javadoc
- grounds: `reference:count-the-frameworks-own-autoconfigurations` · `reference:off-must-be-structural`
- classification: 풀은 JPA의 사유물이 아니다 — outbox·JDBC idempotency store·multi-instance lock·notification store·Fileserver 트랜잭션 경로가 전부 필요로 한다. "JPA 꺼졌나?"만 물으면 아무도 안 쓰는 풀을 열거나 정당하게 쓰던 capability를 조용히 망가뜨린다. 그래서 여섯 조건의 OR로 판정한다.
- relations: `decision:one-root-owns-the-master-switch` · `reference:count-the-frameworks-own-autoconfigurations`
---
## TOPIC 3 — what-a-gate-does-not-prove
### CONCEPT — strict test lane — 발견하지 못하면 실패하는 레인
- slug: `strict-test-lane`
- readiness: `READY`
- source: `final/document.md#6-3` · `analysis/05` §15.1 · `analysis/19` §2.3 · `analysis/20` §2.5
- code: `src/build-logic/src/main/groovy/ca.strict-test-lane.gradle` · 각 leaf의 `strictTestLanes { lane(...) }`
- classification: 이 저장소가 "레인"을 정의하는 방식의 설명이다. `failOnNoDiscoveredTests = true`(발견 0은 성공이 아니라 실패), `outputs.upToDateWhen { false }`(이전 실행 결과를 서빙하지 않음), 태그 기반 선택, 그리고 태그 필터가 discovery **이후**에 적용되므로 `failOnNoDiscoveredTests`만으로는 부족해 executed-selector 검사가 따로 있다는 것까지. Docker 부재를 skip이 아니라 에러로 두는 결정과, 반대로 랩톱 빌드를 깨지 않으려 `test`에서 태그를 제외하는 결정이 어떻게 공존하는지 설명한다.
- missing-verification: 플러그인이 `check`에 레인을 연결하지 않는다는 것은 확인했으나, TestKit 픽스처가 검증하는 범위는 읽지 않았다
- relations: `reference:a-lane-that-discovers-nothing-must-fail` · `reference:a-gate-nobody-runs-reports-the-last-run` · `decision:only-the-certification-lane-carries-no-docker-guard`
### CONCEPT — 증거 등급과 provenance — R1과 R2를 가르는 것
- slug: `evidence-grades-and-provenance`
- readiness: `READY`
- source: `final/document.md#6-5` · `analysis/05` §15.6
- code: `gradle/jpa-evidence.gradle`(917줄) · `config/jpa/readiness-cards.yaml`(718줄)
- classification: 증거가 "돌았다"에서 "믿을 수 있다"로 올라가는 조건의 설명이다. 후보 검증(R1)은 JUnit XML에서 exact selector와 executed/skipped/failure 수를 읽고 canonical JSON SHA-256으로 manifest를 만들지만, candidate profile·dirty source·아직 R2가 아닌 prerequisite를 `readinessBlockers`에 **보존**해서 통과해도 R1에 머문다. R2는 clean revision + CI provenance(`JPA_EVIDENCE_CI_JOB`, artifact location) + immutable PostgreSQL image digest + prerequisite DAG를 요구한다. 로컬 dirty worktree에서 `worktree-is-dirty`로 실패하는 것이 정식 동작이다.
- missing-verification: 두 프로파일 모두 실행하지 않았다 — 컨테이너 필요
- relations: `decision:candidate-evidence-stays-at-r1` · `reference:agreement-between-documents-proves-nothing` · `open-question:container-lanes-not-executed`
### CASE — "certified"라 불리던 레인이 threshold를 하나도 비교하지 않고 있었다
- slug: `a-certifying-lane-that-compared-nothing`
- readiness: `READY`
- source: `final/document.md#6-6` · `analysis/05` §15.3
- code: `.../persistence-jpa/build.gradle` (`jpaPlatformPoolContractTest` 등록부 주석)
- evidence: 없음 — build.gradle 주석이 자기 이력을 기록한다
- classification: 레인 이름이 `jpaPlatformPerformanceTest`였고 "certify"한다고 기술됐으며 나타나는 모든 곳에서 off가 기본인 boolean 뒤에 있었다 — 이 파일에서도, 명시적으로 off로 설정한 nightly workflow에서도. 릴리스 게이트가 "유일한 threshold assertion이 'threshold를 assert하지 않고 있다'인 레인"에 의존했다. 수정은 이름을 약속하지 않는 것으로 바꾸고 프로퍼티를 제거하는 것이었고, 주석이 그 이름을 반복하지 않는 이유까지 적는다.
- missing-verification: 현재 레인을 실행하지 않았다(실 PostgreSQL 필요)
- relations: `concept:strict-test-lane` · `reference:a-gate-nobody-runs-reports-the-last-run` · `decision:performance-measurement-is-not-a-release-gate`
### CASE — 문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다
- slug: `doc-contract-test-boundary-predicted-the-drift`
- readiness: `READY`
- source: `final/document.md#7-4` · `analysis/19` §9.3
- code: `.../messaging-testkit/src/test/.../MessagingDocumentationContractTest.java` · `docs/messaging/support-matrix.md`
- evidence: `evidence/raw/261-messaging-documentation-contract-test-coverage.txt` · `evidence/raw/255-messaging-capability-doc-vs-code-drift.txt`
- classification: doc rot를 막기 위해 존재하는 계약 테스트의 단언 여덟 개가 붙드는 것(등급 이름·Kafka 버전 문자열·존재하지 않는 두 enum 상수)은 전부 정확하고, 붙들지 않는 것(capability 표 60칸·runtime membership 문장·브로커 등급표의 "제한" 칸)에 드리프트 세 건이 전부 있다. 테스트 javadoc은 좁은 단언을 고른 이유까지 옳게 적는다. 결함은 좁게 고른 것이 아니라 그 경계가 어디에도 적혀 있지 않다는 점이다.
- missing-verification: 없음 — 단언 목록과 문서를 전수 대조했다
- relations: `reference:agreement-between-documents-proves-nothing` · `case:support-matrix-said-the-opposite-of-the-code` · `decision:widen-doc-contract-assertions`
### CASE — 릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다
- slug: `a-release-gate-with-no-evidence-producer`
- readiness: `READY`
- source: `final/document.md#7-6` · `analysis/20` §3.2
- code: `.../grpc-testkit/.../release/GrpcStableReleaseGate.java` · `.../GrpcReleaseEvidence.java` · `docs/compatibility/grpc-support-matrix.md`
- evidence: `evidence/raw/tl-grpc-release-gate-no-producer.txt` · `evidence/meta/…json` · `evidence/terminal/…svg`
- classification: 지원 매트릭스가 "게이트가 릴리스를 막는다"고 현재 시제로 적는다. 게이트 설계는 정교하고 문서 부재를 차단 사유로 두는 근거까지 적지만, `GrpcReleaseEvidence`의 다섯 성분이 전부 호출자가 넘기는 값이고 `runbookPresent`는 파일 시스템을 보지 않으며 유일한 생성 지점 넷이 자기 단위 테스트다. Gradle 태스크 0, CI 워크플로 0/28.
- missing-verification: 없음 — 생성 지점·태스크·워크플로를 전수 확인했다
- relations: `reference:a-gate-nobody-runs-reports-the-last-run` · `case:the-second-platform-carried-the-design-not-the-wiring` · `concept:evidence-grades-and-provenance`
### CASE — 패키지 카탈로그가 트리보다 아홉 개 적어서 사이클이 통과했다
- slug: `a-catalog-nine-entries-short`
- readiness: `READY`
- source: `analysis/05` §1
- code: `.../persistence-jpa/src/testkit/.../JpaModuleBoundaryTest.java`
- evidence: 없음 — 테스트 javadoc의 사후 기록
- classification: 경계 테스트가 production root의 직계 자식 패키지 닫힌 카탈로그를 들고 실제 트리와 비교하는데, 카탈로그에 13개가 적혀 있고 트리에는 22개가 있었다. 그래서 아홉 패키지가 **아무 규칙의 지배도 받지 않았고** `transaction → postgresql` / `postgresql → transaction` 사이클이 통과했다. 수정은 "카탈로그와 트리의 **정확한 동등성**" 검사를 추가하는 것이었다.
- missing-verification: 없음
- relations: `reference:omission-that-passes-is-not-a-gate` · `concept:strict-test-lane`
### CASE — 릴리스 레인이 매트릭스 세 버전 중 첫 번째만 돌리고 세 개를 커버로 기록했다
- slug: `three-versions-declared-one-executed`
- readiness: `READY`
- source: `analysis/05` §15.4
- code: `config/jpa/release-registry.json` · `.../JpaPlatformContractSupport.start()` · `.github/workflows/jpa-release.yml`
- evidence: 없음 — 레지스트리 `_comment`와 javadoc의 사후 기록
- classification: 릴리스 레인이 `-Pjpa.matrix.versions=16,17,18``selectedVersions().get(0)`을 쓰는 지원 클래스에 넘겼고 **통합 suite 전체가 PostgreSQL 16에 대해 돌았으며**, 지원 표는 3개 assertion짜리 smoke test의 힘으로 17과 18을 완전 커버로 기록했다. 수정은 `start()`가 다중 선택을 아예 거부하고, workflow가 major당 job으로 fan-out하며, promotion job이 세 major의 증거가 **같은 commit SHA**를 담기를 요구하는 것이다.
- missing-verification: 현재 릴리스 워크플로를 실행하지 않았다
- relations: `reference:agreement-between-documents-proves-nothing` · `concept:evidence-grades-and-provenance` · `decision:capability-grade-is-declared-not-inferred`
### CASE — 다중 타깃 검증을 확인한다는 테스트가 다른 가드에 걸려 통과했다
- slug: `a-test-that-passed-on-the-wrong-guard`
- readiness: `READY`
- source: `final/document.md#3-5` · `analysis/13` §9
- code: `.../notification/.../NotificationBindingCompiler.java:141,159-184`
- evidence: 없음 — 코드 경로와 테스트 단언 비교
- classification: `SINGLE` 전용 가드가 먼저 던져 다중 타깃 검증 전체(순환 탐지 포함)가 도달 불가인데, 그것을 검증한다는 테스트가 `hasMessageContaining("strategy")`로 단언한다. 그 문자열은 **먼저 던지는 다른 가드**의 메시지에도 있어서 테스트는 통과하고, 검증하려던 코드는 한 번도 실행되지 않는다.
- missing-verification: 없음
- relations: `reference:omission-that-passes-is-not-a-gate` · `reference:a-contract-test-must-run-the-adapters-statement`
### REFERENCE — 빠뜨림이 통과가 되는 게이트는 게이트가 아니다
- slug: `omission-that-passes-is-not-a-gate`
- readiness: `READY`
- source: `final/document.md#9` 규칙 12 · `analysis/05` §1, §16
- classification: 목록·카탈로그·selector로 대상을 고르는 게이트는 새 항목 추가를 잊었을 때 초록불이 된다. 기준은 "대상 집합을 어떻게 정하는가"이고, 답이 "손으로 유지하는 목록"이면 그 목록과 실제 트리의 정확한 동등성을 별도로 검사해야 한다.
- scope: 아키텍처 규칙·entity scan·문서 수치 진술·테스트 레인 selector. 구현 예: `JpaModuleBoundaryTest` 동등성 · `PersistenceEntityScanCoverageTest` · `failOnNoDiscoveredTests` · `verifyDocumentedLeafCount`
- exceptions: 대상 집합을 **파생**하는 게이트에는 필요 없다(`CertifiedEvidence.knownGaps``all() covered`). 파생이 가능하면 목록을 두지 않는 것이 항상 낫다. 그리고 walk 범위 자체가 목록이면 그 범위가 같은 문제를 갖는다.
- relations: `case:a-catalog-nine-entries-short` · `case:a-build-gate-that-is-not-in-the-build` · `reference:numbers-in-docs-should-be-derived`
### REFERENCE — 문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다
- slug: `agreement-between-documents-proves-nothing`
- readiness: `READY`
- source: `final/document.md#9` 규칙 5 · `analysis/19` §6.6
- classification: 지원 문서와 코드 상수를 대조하는 검사는 둘이 같이 틀렸을 때 통과한다. 기준은 "이 값이 실행된 무언가에서 왔는가"다. 원문: "the matrix and the support document agreed with each other and with nothing that had executed."
- scope: 지원 매트릭스·capability 선언·버전 표·커버리지 표. 파생 형태의 예: `hasLiveBrokerCertification()`이 레인 증거에서 계산되고, 증거는 레인이 쓴 manifest이며, Gradle이 양방향 대조하고 CI가 돌린다.
- exceptions: 실행이 불가능하거나 지나치게 비싼 주장은 파생할 수 없다. 그때는 **부재를 명시적으로 이름 붙이는 것**이 대안이다(`knownGaps`).
- relations: `case:doc-contract-test-boundary-predicted-the-drift` · `case:three-versions-declared-one-executed` · `case:a-release-gate-with-no-evidence-producer`
### REFERENCE — 아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다
- slug: `a-gate-nobody-runs-reports-the-last-run`
- readiness: `READY`
- source: `final/document.md#9` 규칙 6 · `analysis/18` §4.1c · `analysis/20` §3.3
- classification: 게이트의 신뢰도는 단언이 아니라 도는 경로가 정한다. 두 질문 — "`check`에 연결돼 있는가", "이것을 이름으로 부르는 CI 워크플로가 있는가". 둘 다 아니면 상태는 마지막으로 명령을 입력한 사람이 본 것이다.
- scope: 별도 레인·소스셋·태그로 분리된 모든 검증. 판정은 `check` 의존 그래프와 CI YAML의 명령 문자열을 직접 읽는 것이다.
- exceptions: 의도적으로 `test`에서 뺀 레인은 그 자체로 결함이 아니다. 구분 기준은 대체 경로의 존재다 — messaging 인증 레인은 전용 워크플로가 있고, gRPC의 네 레인은 없다.
- relations: `case:a-certifying-lane-that-compared-nothing` · `case:a-release-gate-with-no-evidence-producer` · `case:a-build-gate-that-is-not-in-the-build` · `decision:only-the-certification-lane-carries-no-docker-guard`
### REFERENCE — 계약 테스트는 어댑터가 실제로 돌리는 statement를 실행해야 한다
- slug: `a-contract-test-must-run-the-adapters-statement`
- readiness: `READY`
- source: `final/document.md#9` 규칙 19 · `analysis/05` §11.2, §17 P2
- classification: SQL을 테스트가 재타이핑하면 증명되는 것은 "테스트 작성자와 어댑터 작성자가 쿼리에 대해 합의했다"이고, 그것은 아무도 필요로 하지 않는 속성이다. 기준은 "이 테스트가 실행하는 statement가 production 상수에서 왔는가"다.
- scope: native SQL·스크립트·와이어 포맷을 갖는 모든 어댑터. 구현 형태는 SQL을 production 상수로 뽑고 테스트가 그 상수를 실행하는 것이다(`RecipientClaimSql`).
- exceptions: statement가 아니라 **정책 판정**을 검증하는 테스트는 재타이핑 문제가 없다. 다만 그때도 판정 대상이 production 타입이어야 한다 — 이 저장소에는 production 타입을 하나도 실행하지 않는 계약 테스트가 여섯 개 있다.
- relations: `case:a-test-that-passed-on-the-wrong-guard` · `reference:omission-that-passes-is-not-a-gate` · `case:jpa-platform-capabilities-have-no-consumer`
### REFERENCE — 아무것도 발견하지 못한 레인은 성공이 아니라 실패여야 한다
- slug: `a-lane-that-discovers-nothing-must-fail`
- readiness: `READY`
- source: `final/document.md#6-3` · `analysis/05` §15.1
- classification: 선택된 레인이 테스트를 하나도 발견하지 못하면 기본 동작은 성공 보고다. 조용히 돌기를 멈춘 계약 suite는 통과하는 것과 구별되지 않는다. 기준은 "이 레인이 0건을 발견했을 때 무엇을 보고하는가"다.
- scope: 태그·소스셋·selector로 좁힌 모든 테스트 태스크. 두 층이 필요하다 — 발견 단계의 `failOnNoDiscoveredTests`와, 태그 필터가 discovery 이후에 적용되므로 executed-selector 검사가 추가로 필요하다.
- exceptions: 환경 부재로 skip하는 것이 정당한 레인(컨테이너 없는 랩톱)은 예외이되, **인증처럼 부재가 곧 거짓 성공이 되는 레인은 예외의 예외**다 — 그런 레인은 가드를 달지 않고 실패한다.
- relations: `concept:strict-test-lane` · `decision:only-the-certification-lane-carries-no-docker-guard`
### OPEN QUESTION — 컨테이너가 필요한 레인의 실제 결과를 실행으로 확인하지 않았다
- slug: `container-lanes-not-executed`
- readiness: `OPEN`
- source: `final/document.md#6-2` · `#11` 항목 1
- known: 실행한 레인과 결과가 기록돼 있다(messaging 851 · grpc 579 · 증거 레인 7·9·9 · graphql 1,603 · websocket 720 · grpc adapter 48 · app-bootstrap 1,016 중 1 환경 실패) · 미실행 레인의 목록과 각각의 요구사항 · messaging 인증만 CI가 강제한다
- unknown: JPA 6개 레인 + readiness task 15종, mongo/redis/fileserver/objectstorage/httpclient Testcontainers, websocket 커스텀 4종, `grpcPerformanceTest`의 실제 결과 · 그중 어느 것이 지금 빨간지
- next-verification: `./gradlew jpaPlatformReleaseGate``./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence` 두 개를 먼저 돌린다
- decision-criterion: 두 명령의 exit code와 실패 목록이 나오면 닫는다. 실패가 나오면 각각이 새 Case 후보가 되고 `a-gate-nobody-runs-reports-the-last-run`의 예측과 대조한다
- relations: `open-question:commit-ambiguity-lane-not-executed` · `open-question:redis-topology-lane-not-executed` · `open-question:v2-state-machine-lanes-not-executed` · `reference:a-gate-nobody-runs-reports-the-last-run`
### DECISION — 인증 레인만 Docker 가드를 달지 않는다
- slug: `only-the-certification-lane-carries-no-docker-guard`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#6-4` · `analysis/19` §2.3
- decision-evidence: `.../messaging-kafka/build.gradle`의 등록부 주석과 `excludeTags` · `.github/workflows/messaging-certification.yml` 헤더 주석
- grounds: `reference:a-lane-that-discovers-nothing-must-fail` · `reference:a-gate-nobody-runs-reports-the-last-run`
- classification: 모든 컨테이너 suite가 Docker 부재 시 이유를 적고 skip하는데 인증 레인만 예외다 — "a lane that skipped would report success for a broker nobody started." 대신 `test`에서 태그로 제외해 랩톱 빌드를 깨지 않고, 전용 워크플로가 레인이 아니라 **게이트**를 돌리며 게이트가 레인에 의존한다.
- relations: `case:a-release-gate-with-no-evidence-producer` · `concept:strict-test-lane`
### DECISION — 성능 측정은 릴리스 게이트에 넣지 않는다
- slug: `performance-measurement-is-not-a-release-gate`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-5` · `analysis/20` §2.5 · `analysis/05` §15.3
- decision-evidence: `.../grpc-testkit/build.gradle``excludeTags 'grpc-performance'`와 주석 · `.../persistence-jpa/build.gradle`의 개명 기록
- grounds: `case:a-certifying-lane-that-compared-nothing` · `reference:a-gate-nobody-runs-reports-the-last-run`
- classification: 두 가족이 같은 결론에 다른 경로로 도달했다. gRPC는 처음부터 빼고 이유를 적었고("flaky gates get turned off"), JPA는 사고로 배웠다. 두 경우 모두 진짜 성능 게이트는 전용 러너·warmup/sample 수·기록된 baseline이 필요하고 그때는 별도 레인이어야 한다는 조건을 남겼다.
- relations: `case:a-certifying-lane-that-compared-nothing` · `open-question:container-lanes-not-executed`
### DECISION — 후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다
- slug: `candidate-evidence-stays-at-r1`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#6-5` · `analysis/05` §15.6
- decision-evidence: `gradle/jpa-evidence.gradle`의 두 태스크와 `readinessBlockers` 구현 · `config/jpa/readiness-cards.yaml``state` 필드
- grounds: `concept:evidence-grades-and-provenance` · `reference:agreement-between-documents-proves-nothing`
- classification: 후보 검증이 zero-skip·schema·content hash·prerequisite link를 전부 만족해도 `attainedReadiness=R1`을 유지하고, 각 manifest가 candidate profile·dirty source·비R2 prerequisite를 blocker로 보존해 **후보 통과를 R2로 오인할 수 없게** 한다. R2는 clean revision과 CI provenance와 immutable image digest를 요구하며, 로컬 dirty worktree에서 실패하는 것이 정식 동작이다.
- relations: `concept:evidence-grades-and-provenance` · `decision:capability-grade-is-declared-not-inferred`
---
## TOPIC 4 — bounding-by-type
### CONCEPT — 서명된 커서의 구조와 검증 순서
- slug: `signed-cursor-structure`
- readiness: `READY`
- source: `final/document.md#10-4` · `analysis/05` §2.4 · `analysis/19` §4.x
- code: `.../persistence-jpa/.../api/query/SignedJsonCursorCodec.java` · `.../grpc-policy/.../streaming/GrpcResumeTokenCodec.java` · `.../GrpcResumeToken.java`
- classification: 이 저장소에 서명 커서 구현이 둘 있고 같은 다섯 단계를 지킨다. (1) 길이 검사가 substring/decode/MAC **이전 첫 줄**에 온다 — 페이징 엔드포인트는 public이고 그 아래 모든 코드가 caller가 보낸 크기에 비례해 할당한다. (2) base64 확장률로 decode 후 크기를 할당 전에 bound한다. (3) MAC 길이를 먼저 확인한다 — `MessageDigest.isEqual`은 같은 길이 입력에 대해서만 상수 시간이다. (4) 상수 시간 비교. (5) **서명 검증 후에야** payload를 파싱한다. gRPC 쪽은 여기에 두 가지를 더한다 — 알 수 없는 key id를 현재 키로 폴백하지 않고 거부(폴백은 rotation을 "탈취된 키가 여전히 검증되는 창"으로 만든다), 그리고 malformed·unknown key·verify 실패를 **구별 불가능하게** 반환(구별은 probing oracle이다).
- missing-verification: 없음 — 두 구현을 코드로 확인했다
- relations: `case:cursor-verification-order` · `decision:cursors-are-signed-for-integrity` · `reference:names-are-registry-keys-not-values`
### CONCEPT — 카디널리티 경계를 타입으로 표현하기
- slug: `cardinality-bounds-as-types`
- readiness: `READY`
- source: `final/document.md#4-1`, `#5-2` · `analysis/05` §2.1, §12.2 · `analysis/02`
- code: `.../api/PersistenceOperationName.java` 계열 · `.../observation/JpaMetricTags.java` · `.../shared-contract/.../ForbiddenMetricTags.java` · `.../CardinalityBounds.java`
- classification: metric tag·trace·retry policy의 키가 되는 문자열을 값 타입으로 만들고 정규식을 생성자에 두는 패턴의 설명이다. `PersistenceOperationName`·`QueryName`·`ConstraintCode`·`FetchPlanName`·`WorkQueueName`·`JsonPathName`·`TenantId`가 전부 같은 모양이고, 목적은 엔티티 id·tenant id·SQL 조각·요청 스코프 값이 그 자리에 올 수 없게 하는 것이다. 검증이 registry가 아니라 생성자에 있는 이유(대시보드가 안 뜰 때까지 살아남지 않게)와 sanitize가 아니라 reject인 이유(sanitize하면 caller가 계속 넘기고 눈치채지 못한다)를 함께 다룬다.
- missing-verification: 없음
- relations: `reference:names-are-registry-keys-not-values` · `reference:reject-rather-than-sanitize` · `decision:tenant-id-is-never-a-metric-tag`
### CASE — 시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다
- slug: `pii-through-an-exception-message`
- readiness: `READY`
- source: `final/document.md#8-1` · `analysis/04` §4
- code: `.../support/.../FailOpenDependencyLogger.java` (`logFailure`) · `.../app-bootstrap/.../LogMaskingPatterns.java`
- evidence: `evidence/raw/021a-support-logger-pii-probe.java` · `evidence/raw/021-support-logger-pii-probe.txt`
- classification: source와 README가 "logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다"고 주장하고 테스트도 그것을 검사하는데, fixture의 예외가 `"connection refused"`라 marker가 어떤 argument에도 없다 — 테스트는 payload object가 직접 전달되지 않는다는 것만 확인한다. PII를 담은 예외를 넣는 probe를 실행하니 formatted WARN에 그대로 남았다. 소비자 SPI 넷은 arbitrary `Exception`을 던질 수 있고, 전역 masking은 password/token 계열만 덮으며 README 스스로 "보증이 아니라 defence-in-depth"라고 적는다.
- missing-verification: 실제 provider SDK가 recipient/body를 예외 메시지에 넣는지는 확인하지 않았다. probe는 그것이 가능할 때 logger가 막지 못한다는 것만 보인다
- relations: `concept:cardinality-bounds-as-types` · `reference:telemetry-can-be-more-dangerous-than-its-subject` · `reference:reject-rather-than-sanitize`
### CASE — 진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다
- slug: `a-report-that-cannot-carry-a-datasource`
- readiness: `READY`
- source: `analysis/05` §2.5, §12.1
- code: `.../api/capability/CapabilitySupport.java` · `.../security/DatabasePrivilegeReport.java`
- evidence: 없음 — 값 타입 정의와 그 javadoc
- classification: `CapabilitySupport`가 provider 객체(`DataSource`·`EntityManagerFactory`·`SessionFactory`)를 절대 담지 않고, `DatabasePrivilegeReport`가 JDBC URL·패스워드·호스트를 담지 않는다. 이유가 같다 — 이 값들은 리포트로 직렬화되고 actuator로 publish될 수 있어야 하는데, 살아 있는 리소스를 값 타입에 끌고 들어가면 리포트가 자격증명을 흘린다. "publish 전에 마스킹해야 할 것은 애초에 들어가지 않는다"가 설계 문장이다.
- missing-verification: 없음
- relations: `concept:cardinality-bounds-as-types` · `reference:telemetry-can-be-more-dangerous-than-its-subject`
### CASE — 커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유
- slug: `cursor-verification-order`
- readiness: `READY`
- source: `analysis/05` §2.4
- code: `.../api/query/SignedJsonCursorCodec.java`
- evidence: 없음 — 구현과 그 javadoc
- classification: 다섯 방어가 각각 다른 공격을 막고 순서가 계약이다. `MAX_ENCODED_LENGTH(4096)` 검사가 첫 줄에 없으면 decode가 caller가 보낸 크기만큼 할당하고, MAC 길이 확인이 없으면 `MessageDigest.isEqual`의 상수 시간 보장이 깨지며, 서명 검증 전에 파싱하면 서명 없는 토큰이 애플리케이션 JSON 파서에 도달한다. 그리고 MAC이 **버전과 payload를 함께** 덮어 prefix 재작성으로 옛 포맷으로 다운그레이드하는 것을 막는다.
- missing-verification: 없음
- relations: `concept:signed-cursor-structure` · `decision:cursors-are-signed-for-integrity`
### REFERENCE — 이름은 값이 아니라 registry key다
- slug: `names-are-registry-keys-not-values`
- readiness: `READY`
- source: `final/document.md#9` 규칙 15 · `analysis/05` §2.1, §5, §7
- classification: 쿼리·큐·업서트·JSON path·정렬 필드처럼 파라미터로 바인딩할 수 없는 것을 caller가 문자열로 주게 하면 caller가 statement의 일부를 쓰는 것이다. 기준은 "이 값이 statement를 **선택**하는가, statement의 **일부가 되는가**"다.
- scope: SQL/JSON path/정렬/스키마 이름 등 바인딩 불가능한 위치 전부, 그리고 metric tag·trace·retry policy 키가 되는 모든 문자열.
- exceptions: 값(비교 대상·payload)은 반대다 — 바인딩할 수 있으므로 registry에 넣으면 유연성만 잃는다.
- relations: `concept:cardinality-bounds-as-types` · `reference:register-paths-bind-values` · `decision:tenant-id-is-never-a-metric-tag`
### REFERENCE — 관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다
- slug: `telemetry-can-be-more-dangerous-than-its-subject`
- readiness: `READY`
- source: `final/document.md#5-2` · `analysis/05` §12.2 · `analysis/02`
- classification: 메트릭 태그·로그·트레이스·예외 메시지는 원본보다 오래 살고 더 널리 퍼지는 사본이다. 기준은 "이 진단 경로에 들어가는 값의 도메인을 누가 정하는가"이고, 답이 "외부 라이브러리" 또는 "요청 내용"이면 그 경로는 개인정보 경계다.
- scope: metric tag·structured log field·trace attribute·exception message·actuator 응답. 구현: `JpaMetricTags` 다섯 개 · `ForbiddenMetricTags` · `SqlDiagnosticRedactor`(문자열 리터럴·숫자·이메일 통째 치환, 512자 절단) · `sanitized(Throwable)`가 타입만 남김.
- exceptions: 이미 외부에서 들어온 값(드라이버 예외 메시지)은 거부할 수 없으므로 그때만 redaction이 자리이고, 그것을 "보증"이 아니라 defence-in-depth로 부르는 것이 정확하다.
- relations: `case:pii-through-an-exception-message` · `case:a-report-that-cannot-carry-a-datasource` · `decision:tenant-id-is-never-a-metric-tag`
### REFERENCE — path·identifier는 등록하고 value는 바인딩한다
- slug: `register-paths-bind-values`
- readiness: `READY`
- source: `final/document.md#9` 규칙 16 · `analysis/05` §7.5
- classification: JSON path·정렬 필드·스키마 이름·conflict 컬럼은 파라미터로 바인딩할 수 없으므로 registry로 고정하고, 비교 값은 바인딩할 수 있으므로 항상 bound parameter로 넘긴다. 원문: "path는 registry에서 오고 고정 statement의 일부다. value는 caller에서 오고 항상 bound parameter다."
- scope: native SQL·JSONB 질의·동적 정렬·업서트 대상 컬럼. 등록 항목도 정규식으로 검증한다(예: unquoted PostgreSQL identifier `[a-z_][a-z0-9_]{0,62}`) — 등록 실수조차 함수 호출이나 두 번째 절을 넣지 못하게.
- exceptions: 스키마 이름처럼 statement의 일부이면서 tenant에서 유도되는 값은 registry 사전 등록이 유일한 안전 형태다 — 유도하면 외부 영향 데이터로 SQL을 만드는 것이다.
- relations: `reference:names-are-registry-keys-not-values` · `case:jpa-platform-capabilities-have-no-consumer`
### REFERENCE — sanitize가 아니라 reject가 기본이다
- slug: `reject-rather-than-sanitize`
- readiness: `READY`
- source: `analysis/05` §12.2
- classification: 경계를 넘는 값을 조용히 자르거나 치환하면 caller가 계속 그 값을 넘기고 절대 눈치채지 못한다. 거부하면 도입된 자리에서 실패한다. 기준은 "이 값의 생산자를 우리가 고칠 수 있는가"이고, 답이 예면 reject다.
- scope: metric tag·이름 값 타입·설정 키·헤더 이름. `LowCardinality.REGISTERED`가 정규식을 통과하지 못하면 던지는 것이 그 형태다.
- exceptions: 생산자를 고칠 수 없는 경우(외부 드라이버의 예외 메시지, 서드파티 응답 본문)는 redaction이 맞고, 그때는 그것이 보증이 아님을 문서에 적어야 한다.
- relations: `case:pii-through-an-exception-message` · `reference:telemetry-can-be-more-dangerous-than-its-subject` · `concept:cardinality-bounds-as-types`
### DECISION — tenant id는 메트릭 태그가 되지 않는다
- slug: `tenant-id-is-never-a-metric-tag`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-4` · `analysis/05` §13.2 · `analysis/02`
- decision-evidence: `.../experimental/multitenancy/TenantId.java`의 정규식과 근거 javadoc · `.../shared-contract/.../ForbiddenMetricTags.java` · `.../observation/JpaMetricTags.java`
- grounds: `reference:telemetry-can-be-more-dangerous-than-its-subject` · `concept:cardinality-bounds-as-types`
- classification: tenant 카디널리티는 정의상 unbounded이고 텔레메트리 안의 tenant id는 그렇게 취급되지 않는 시스템 안의 고객 데이터다. 두 층으로 강제된다 — `TenantId``[a-z0-9][a-z0-9_-]{1,62}`로 스키마 이름·`set_config` 값·라우팅 키에 들어갈 수 있는 형태만 허용하고, 메트릭 태그 집합이 다섯 개로 닫혀 있으며 검증이 생성자에 있다. `request_id`가 baggage에는 허용되고 metric label에는 금지되는 비대칭이 테스트에서 pin돼 있다.
- relations: `reference:telemetry-can-be-more-dangerous-than-its-subject` · `case:pii-through-an-exception-message`
### DECISION — 커서에 서명하는 이유는 기밀성이 아니라 무결성이다
- slug: `cursors-are-signed-for-integrity`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-4` · `analysis/05` §2.4
- decision-evidence: `.../api/query/SignedJsonCursorCodec.java`의 클래스 javadoc이 결정과 근거를 적는다
- grounds: `concept:signed-cursor-structure` · `case:cursor-verification-order`
- classification: payload는 읽을 수 있고 그것이 의도다. 막는 것은 **무결성 침해**다 — "서명 없는 커서는 클라이언트가 제어하는 정렬 상태이고, 그걸 고쳐 쓰면 임의의 키로 seek할 수 있다. 스캔이 어디서 시작했는지에 predicate가 의존하는 곳이라면 이건 **접근 제어 우회**다." gRPC의 resume token이 같은 결정을 다른 필드 집합(caller·filter fingerprint·snapshot version·expiry·key id)으로 반복한다.
- relations: `concept:signed-cursor-structure` · `case:cursor-verification-order`
---
## TOPIC 5 — duplicate-mechanisms
### CASE — 클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다
- slug: `a-weaker-private-copy-on-the-wired-path`
- readiness: `READY`
- source: `final/document.md#7-3` · `analysis/13` §25.1
- code: `.../notification/.../WebPushSubscriptionValue.java:27-28,50-57`
- evidence: 없음 — 두 검사의 코드 비교와 호출 경로 확인
- classification: Web Push 구독 엔드포인트는 클라이언트가 제공하는 URL이고, 저장소에 SSRF 가드가 존재하는데 이 경로는 그것을 지나지 않고 같은 클래스 안의 약한 검사 private 사본을 쓴다. 중복 장치 중 조립된 쪽이 약한 쪽인 패턴에서 결과가 보안 경계에 닿는 사례다.
- missing-verification: 실제 SSRF 시도를 재현하지 않았다
- relations: `reference:check-which-duplicate-is-wired` · `case:a-policy-reversed-by-a-later-filter`
### CASE — 요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다
- slug: `a-policy-reversed-by-a-later-filter`
- readiness: `READY`
- source: `final/document.md#7-3` · `analysis/14` §32.1
- code: `.../adapter/inbound/web/.../WebMvcRequestIdFilter.java`
- evidence: 없음 — 등록 지점과 두 구현 비교
- classification: 정책이 한 필터에 구현돼 있고 뒤에 도는 다른 배선 필터가 클라이언트 제공 값을 채택한다. 둘 다 조립돼 있어서 정책 쪽이 먼저 실행되고 나중 것이 덮는다.
- missing-verification: 부팅해서 실제 필터 체인 순서를 확인하지 않았다
- relations: `reference:check-which-duplicate-is-wired` · `case:a-weaker-private-copy-on-the-wired-path`
### CASE — JPA 플랫폼 capability 대부분에 production 소비자가 없다
- slug: `jpa-platform-capabilities-have-no-consumer`
- readiness: `READY`
- source: `final/document.md#8-2` 항목 8 · `analysis/05` §17 P8, §11.0
- code: `.../persistence-jpa/.../springdata/**` · `.../hibernate/**` · `.../postgresql/{write,lock,json,array,range,copy}/**` · `.../cache/**` · `.../envers/**` · `.../querydsl/**`
- evidence: 없음 — leaf 밖 참조 전수 계수
- classification: `JpaKeysetQuerySupport`·`SafeSortMapper`·`JpaStreamExecutor`·`FetchPlanApplier`·batch/bulk/stateless executor·업서트·work claim·JSON/range/array·COPY·`CacheRegionCatalog`·`EntityGraphCatalog`가 전부 leaf 밖 참조 0이다. 결정적 증거는 **같은 leaf 안의 두 스토어**다 — `fileserver`(25파일)와 `notification`(53파일)이 합쳐 350KB이고 매일 PostgreSQL에 쓰는데 JPA 플랫폼 타입을 하나도 import하지 않고, 같은 문제(SKIP LOCKED 큐 클레임·안전 정렬·충돌 판정)를 각자 다시 만들었다. 즉 "아직 소비자가 없어서"가 아니라 **소비자가 있는데도 안 쓴다.** `support-matrix.md`가 대부분을 Advanced로 표기하므로 거짓말은 아니지만, 이 조합은 §P2(계약 테스트가 production 타입을 실행하지 않음)와 겹쳐 "구현됐고, 안 쓰이고, 테스트도 없다"가 된다.
- missing-verification: 파생 프로젝트가 실제로 이 capability들을 가져다 쓰는지 확인할 방법이 이 저장소 안에 없다 — 그것이 세 선택지(채택 / 명시적 라이브러리 선언 / 제거) 중 무엇이 맞는지를 정한다
- relations: `reference:check-which-duplicate-is-wired` · `reference:a-contract-test-must-run-the-adapters-statement` · `reference:a-bean-is-not-composition-evidence` · `decision:capability-grade-is-declared-not-inferred`
### CASE — forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다
- slug: `trust-policy-lives-in-nginx-not-in-the-code`
- readiness: `READY`
- source: `final/document.md#3-1` · `analysis/14` §32.2
- code: `.../adapter/inbound/web/...`의 forwarded 헤더 정책(421 LOC) · `infra/nginx` 설정
- evidence: 없음 — 도달성 확인과 설정 파일 대조
- classification: forwarded 헤더를 어디까지 믿을지 판정하는 Java 정책이 421 LOC 작성돼 있고 배선되지 않는다. 실제 판정은 Nginx 설정이 한다. 두 곳이 어긋나면 코드 리뷰가 잡을 수 없고, Java 쪽을 고쳐도 동작이 바뀌지 않는다.
- missing-verification: Nginx 설정이 실제로 어떤 hop을 신뢰하는지 런타임에서 확인하지 않았다
- relations: `reference:check-which-duplicate-is-wired` · `reference:a-bean-is-not-composition-evidence`
### CASE — 재시도 구현이 둘이고 정교한 쪽을 아무도 호출하지 않는다
- slug: `a-retry-implementation-nobody-calls`
- readiness: `READY`
- source: `final/document.md#8-2` 항목 7 · `analysis/05` §17 P1
- code: `.../transaction/FullTransactionRetryCoordinator.java` · `.../SpringPolicyTransactionPort.java` · `.../DefaultJpaRetryPolicy.java`
- evidence: 없음 — 참조 전수 확인
- classification: `FullTransactionRetryCoordinator`는 빈으로 등록되지만 production 코드 어디에서도 주입되지 않는다(참조는 app-bootstrap의 자기 config 둘과 테스트뿐). 실제로 도는 재시도는 `SpringPolicyTransactionPort` + `TransactionRetryBackoff`이고 `COMMAND_SERIALIZABLE_REPLAY_SAFE` 정책에만 적용된다. 둘이 다른 설정을 읽고(전자는 하드코딩 3회/20~500ms, 후자는 `ca-skeleton.jpa.transaction.retry-*` 기본 2회), `DefaultJpaRetryPolicy`의 6단계 순서·`IrreversibleSideEffectContext` 확인·`RetryBudget`의 elapsed 상한이 전부 호출되지 않는 경로에 있다. `support-matrix.md`는 "Full-transaction retry | Stable"이라고 선언한다.
- missing-verification: 없음 — 참조 전수 확인
- relations: `reference:check-which-duplicate-is-wired` · `case:autoconfiguration-in-name-only` · `decision:retry-unit-is-the-use-case`
### REFERENCE — 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다
- slug: `check-which-duplicate-is-wired`
- readiness: `READY`
- source: `final/document.md#7-3` · `analysis/99` §4
- classification: 같은 문제를 푸는 장치가 둘이면 "하나를 지우자"가 아니라 "요청 경로에 놓인 쪽이 어느 쪽인가"를 먼저 묻는다. 이 저장소의 13건 중 11건에서 조립된 쪽이 더 약했다. 이유는 잘 만든 정책 객체가 프레임워크 표면 밖(95.5%)에 있고 실제로 경로에 놓이는 것은 좁은 표면에서 손으로 배선한 것이기 때문이다.
- scope: 검증·가드·정책·복구 로직의 중복. 판정은 두 구현의 호출 경로를 각각 따라가는 것이고 코드 품질 비교보다 먼저 한다.
- exceptions: 방향이 반대인 경우도 있다 — 자격 증명 회전은 조립된 쪽이 동시성 계약 테스트까지 갖춘 더 나은 구현이고 죽은 쪽이 참조 0이다. 그때 조치는 "약한 쪽을 조립"이 아니라 "죽은 쪽을 제거"다.
- relations: `case:a-weaker-private-copy-on-the-wired-path` · `case:a-retry-implementation-nobody-calls` · `reference:two-vocabularies-for-one-concept`
### REFERENCE — `Atomic*` 타입의 존재는 원자성의 증거가 아니다
- slug: `atomic-type-is-not-atomicity`
- readiness: `READY`
- source: `final/document.md#5-5` · `analysis/20` §7
- classification: `get()`으로 비교하고 별도로 `incrementAndGet()`을 부르는 것은 원자적 타입을 쓴 check-then-act다. 기준은 "읽은 값과 쓰는 값 사이에 다른 스레드가 끼어들 수 있는가"이고, 판정은 `compareAndSet`/`updateAndGet`/`synchronized`의 존재를 세는 것이다.
- scope: 카운터·레지스트리·상태 홀더. 특히 **경계를 강제하는 카운터**에서 결정적이다 — 경계는 동시성이 높을 때 필요하고 check-then-act는 정확히 그때 샌다.
- exceptions: 단일 스레드 전용임이 문서화됐거나 구조적으로 보장되는 경우. 다만 그 보장이 javadoc에 없으면 다음 사람이 알 수 없다.
- relations: `case:the-same-rotation-defect-closed-once-and-reproduced` · `reference:check-which-duplicate-is-wired`
### REFERENCE — 같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다
- slug: `two-vocabularies-for-one-concept`
- readiness: `READY`
- source: `analysis/19` §5.4 · `analysis/06` §58, §69 · `analysis/05` §12.5
- classification: 같은 개념을 두 타입이 표현하고 하나만 조립돼 있으면, 남은 쪽은 다음 사람이 어느 것을 써야 할지 알 수 없게 만든다. 기준은 "이 둘이 같은 질문에 답하는가"이고, 그렇다면 조립된 쪽을 정본으로 표시하고 나머지를 제거 대상으로 명시한다.
- scope: 감사 메타데이터·자격 증명 회전·TTL 선언·recovery 어휘. 표시 방법은 support matrix의 상태 컬럼("Candidate, not composed")과 ArchUnit 규칙(엔티티가 둘 다 쓰는 것을 금지)이다.
- exceptions: 두 어휘가 다른 계층에 속하고 각각 소비자가 있으면 중복이 아니다 — `api.error`의 안정 예외 계층과 `failure`의 웹 표면 매핑이 그 경우다.
- relations: `reference:check-which-duplicate-is-wired` · `decision:one-audit-mechanism-per-entity`
### DECISION — 감사 메커니즘은 엔티티당 정확히 하나여야 한다
- slug: `one-audit-mechanism-per-entity`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/05` §12.5
- decision-evidence: `.../testkit/arch/JpaArchitectureRules.java``entitiesUseExactlyOneAuditMechanism` · `docs/jpa/support-matrix.md`의 두 메커니즘 상태 표기
- grounds: `reference:two-vocabularies-for-one-concept` · `reference:check-which-duplicate-is-wired`
- classification: `audit/AuditableEntity`(canonical, `@MappedSuperclass`, `created_at/by`·`updated_at/by`, actor 256)와 `auditing/AuditMetadata`(candidate, `@Embeddable`, `modified_*`, actor 64)가 공존하고 ArchUnit이 한 엔티티가 둘 다 쓰는 것을 막는다. 근거: "둘 다 고른 엔티티는 하나의 의미에 두 writer, 하나의 사실에 두 컬럼 계열, 그리고 어느 엔티티가 뭘 골랐는지 알아야 하는 마이그레이션을 얻는다." 어느 메커니즘도 bulk/native update에 도달하지 않으므로 `bulkUpdatesOfAuditedEntitiesStampAudit`가 별도로 그것을 강제한다.
- relations: `reference:two-vocabularies-for-one-concept`
---
## TOPIC 6 — drift-direction
### CASE — 지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다
- slug: `support-matrix-said-the-opposite-of-the-code`
- readiness: `READY`
- source: `final/document.md#8-1` · `analysis/19` §6.3
- code: `.../messaging-kafka/.../KafkaMessagingTransport.java:62-64`(`CAPABILITIES`와 javadoc) · `docs/messaging/support-matrix.md`
- evidence: `evidence/raw/tl-kafka-dedup-drift.txt` · `evidence/meta/…json` · `evidence/terminal/…svg` · `evidence/raw/255-messaging-capability-doc-vs-code-drift.txt`
- classification: capability 표 60칸을 코드 배열과 전수 대조한 결과 불일치가 정확히 한 칸이다 — Kafka `deduplicatedPublish`가 문서 `O`, 코드 `false`. 그 플래그가 12개 중 유일하게 실제 거부를 발생시키는 것이고, 코드 javadoc이 `true``false` 변경 이력과 피해를 직접 이름 붙인다 — "the caller believes the broker is deduplicating and skips the idempotency it would otherwise build." 나머지 48칸은 일치하고, Pulsar의 `keyedOrdering`은 문서가 두 배열 차이까지 반영해 코드보다 정밀하다.
- missing-verification: 없음 — 60칸 전수 대조
- relations: `reference:fix-overstatement-before-understatement` · `case:doc-contract-test-boundary-predicted-the-drift` · `decision:widen-doc-contract-assertions`
### CASE — README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다
- slug: `the-readme-recipe-does-not-start`
- readiness: `READY`
- source: `final/document.md#7-4` · `analysis/06` §4, §23
- code: `.../persistence-mongo/README.md` · `.../MongoMappingConfiguration.java` · `.../PolicyAwareMongoTypeMapper.java`
- evidence: `evidence/raw/129-mongo-empty-type-registry-write-probe.txt` · `evidence/raw/129a-…java`
- classification: README가 제시하는 활성화 절차를 그대로 따르면 시작은 하고 첫 write에서 예외가 난다. 빈 registry가 기본 bean이고 policy-aware mapper가 모든 converter에 무조건 설치되며 미등록 타입 write가 `IllegalStateException`을 던진다. probe가 shipped default 조합을 실제로 구성해 확인했고, 같은 converter에 Spring 기본 mapper를 두면 같은 write가 성공한다 — 실패는 문서·엔티티 형태가 아니라 이 leaf가 설치한 mapper에서 온다.
- missing-verification: 실제 MongoDB에 붙이지 않았다(probe는 converter 수준)
- relations: `reference:fix-overstatement-before-understatement` · `case:mongo-default-throws-on-first-write`
### CASE — 출하 default 조합이 첫 write에서 예외를 던진다
- slug: `mongo-default-throws-on-first-write`
- readiness: `READY`
- source: `final/document.md#4-2` · `analysis/06` §23
- code: `.../persistence-mongo/.../MongoMappingConfiguration.java` · `.../MongoTypeMetadataConfigurer.java` · `.../PolicyAwareMongoTypeMapper.java:75,134` · `.../MongoTypeMetadataRegistry.java`
- evidence: `evidence/raw/129-mongo-empty-type-registry-write-probe.txt` · `evidence/raw/129a-…java`
- classification: 세 사실이 겹친다 — (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`을 던진다. 그래서 module을 켜고 type metadata를 등록하지 않은 배포는 **시작은 하고 첫 write에서 실패한다.** probe가 shipped default 조합을 실제로 구성해 확인했고 Spring 기본 mapper로 바꾸면 같은 write가 성공한다. 그리고 같은 컴포넌트가 같은 질문에 세 가지로 답한다 — 미등록 타입의 정책은 `CLASS_METADATA_ALLOWED`, type-restricted **query**는 class name을 predicate에 쓰고, **write**는 예외다. 읽기와 쓰기가 정반대로 답하고 어느 쪽도 registry가 문서화한 기본값과 일치하지 않는다.
- missing-verification: 실제 MongoDB에 붙이지 않았다 — probe는 converter 수준이다
- relations: `case:the-readme-recipe-does-not-start` · `case:a-flag-that-validates-an-unwired-subsystem` · `reference:fix-overstatement-before-understatement`
### CASE — 문서가 UUIDv7이라 말하고 생성되는 것은 v4다
- slug: `documented-uuidv7-generates-v4`
- readiness: `READY`
- source: `final/document.md#7-4` · `analysis/07` §5, §6, §7, §8 · `analysis/01` §11
- code: `.../adapter/outbound/identifier/...` · 해당 leaf의 `CLAUDE.md`·`README.md`
- evidence: 없음 — 문서 문장과 구현 대조
- classification: 문서는 UUIDv7을 말하고 구현은 v4를 만든다. 같은 leaf에서 `CLAUDE.md`의 의존성 서술 **세 항목이 모두** 틀렸고, README에 사실 오류가 셋 있으며, `CLAUDE.md`가 근거로 대는 두 가드 중 하나는 저장소에 없다. 10 파일짜리 leaf에서 문서 오류가 이만큼 나오는 것은 이 leaf에 production 소비자가 없다는 사실(§7 §3)과 함께 읽어야 한다 — 아무도 쓰지 않으면 문서도 검증되지 않는다.
- missing-verification: 없음
- relations: `reference:fix-overstatement-before-understatement` · `reference:numbers-in-docs-should-be-derived`
### CASE — 선택할 수 없는 브로커가 지원 매트릭스에 기능 목록과 함께 실려 있다
- slug: `an-unselectable-broker-listed-with-features`
- readiness: `READY`
- source: `final/document.md#2-4`, `#3-3` · `analysis/19` §6.2
- code: `.../messaging-spring-boot-starter/.../MessagingProviderSelection.java`(`BROKERS_WITHOUT_A_TRANSPORT`) · `docs/messaging/support-matrix.md` RabbitMQ 행
- evidence: `evidence/raw/256-messaging-provider-selection-and-rabbit-transport.txt`
- classification: `app.messaging.broker=rabbit`은 startup 오류다 — `RabbitChannelPublisher` 구현이 저장소에 없고 선택기가 그것을 이름으로 거부한다. 코드는 fail-closed로 정직하고 근거까지 적는다. 그런데 지원 매트릭스의 RabbitMQ 행은 "제한" 칸에 "장애 시나리오 미실행"만 적고 기능 칸에 publisher confirm·quorum queue·DLQ를 나열한다. 읽는 사람은 "검증은 덜 됐지만 쓸 수는 있는 옵션"으로 이해하고, 20 main / 2,443 LOC가 출하 아티팩트에 들어 있다.
- missing-verification: 없음
- relations: `reference:fix-overstatement-before-understatement` · `case:support-matrix-said-the-opposite-of-the-code`
### CASE — 다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다
- slug: `five-documents-say-nineteen-leaves`
- readiness: `READY`
- source: `final/document.md#7-4` · `analysis/05` §17 P3
- code: `docs/jpa/repository-adaptation.md` · `docs/mongodb/…` · `docs/httpclient/…` · `docs/notification/module-mapping.md` · `docs/adr/ADR-MONGO-001-platform-boundary.md` · `src/settings.gradle`(16줄, 수 검사 없음)
- evidence: 없음 — 문서 문장과 레지스트리 대조
- classification: 다섯 문서가 "exactly 19 leaf identities"와 "`src/settings.gradle` throws when the registry does not contain exactly 19 modules"를 적는데, 레지스트리는 62개이고 `settings.gradle`에는 `19`도 수 검사도 없다(검증은 플러그인에 위임됐다). `verifyDocumentedLeafCount`가 존재하지만 `CLAUDE.md`·`AGENTS.md`·leaf `build.gradle`만 walk하고 `docs/**`는 대상이 아니다 — 그 게이트 자신의 주석이 "이름이 적힌 목록은 다섯 개의 모듈 CLAUDE.md와 네 개의 leaf build.gradle을 놓쳤다"고 같은 형태의 사고를 기록한다.
- missing-verification: 없음
- relations: `reference:numbers-in-docs-should-be-derived` · `reference:omission-that-passes-is-not-a-gate`
### REFERENCE — 과대 진술 문서를 과소보다 먼저 고친다
- slug: `fix-overstatement-before-understatement`
- readiness: `READY`
- source: `final/document.md#7-4` · `analysis/19` §6.3
- classification: 문서 드리프트에는 방향이 있고 위험이 대칭이 아니다. 과대 진술은 독자가 자기 안전장치를 생략하게 만들고, 과소 진술은 능력을 못 찾거나 잘못 고르게 만들지만 있는 방어를 제거하게 만들지는 않는다. 기준은 "이 문장을 믿은 팀이 무엇을 **하지 않게** 되는가"다.
- scope: 지원 매트릭스·capability 표·README의 보장 진술·javadoc의 현재 시제 서술. 이 저장소의 문서 드리프트 14건 중 P1은 하나뿐이고 그것이 과대 방향이다.
- exceptions: 과소 진술이라도 **선택을 바꾸는** 경우는 같은 우선순위다 — 선택 불가 브로커가 기능 목록과 함께 실린 경우가 그것이다.
- relations: `case:support-matrix-said-the-opposite-of-the-code` · `case:an-unselectable-broker-listed-with-features` · `case:the-readme-recipe-does-not-start`
### REFERENCE — 문서의 수치는 세지 말고 파생하거나 게이트로 붙든다
- slug: `numbers-in-docs-should-be-derived`
- readiness: `READY`
- source: `analysis/19` §6.4 · `analysis/05` §17 P3
- classification: 산문에 적은 수치는 다음 변경에서 드리프트한다. 기준은 "이 수치의 정본이 어디인가"이고, 정본이 있으면 문서는 세지 말고 가리켜야 한다. messaging 가족 문서가 자기 오류를 고치며 남긴 문장이 그 규칙이다 — "정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다."
- scope: leaf 수·버전 목록·커버리지 수치·capability 개수. 대안은 (1) 세지 않고 SSOT를 가리키기, (2) 세야 한다면 그 수치를 검사하는 게이트를 두고 그 게이트의 walk 범위를 문서까지 넓히기.
- exceptions: 스냅샷임을 명시한 문서는 갱신하지 않는 것이 오히려 정확하다 — `00-project-overview.md`가 초기 sizing을 그대로 두고 헤더에 그 사실을 적는 형태다.
- relations: `case:five-documents-say-nineteen-leaves` · `reference:omission-that-passes-is-not-a-gate`
### DECISION — 문서 계약 테스트의 단언 범위를 capability 표까지 넓힐 것인가
- slug: `widen-doc-contract-assertions`
- readiness: `NEEDS_DECISION` · decision-status: `NOT_DECIDED`
- source: `final/document.md#7-4` · `analysis/19` §9.3
- decision-evidence: 없음 — 분석의 권고이고 프로젝트가 선택한 기록이 없다
- grounds: `case:doc-contract-test-boundary-predicted-the-drift` · `case:support-matrix-said-the-opposite-of-the-code` · `reference:agreement-between-documents-proves-nothing`
- classification: capability 표는 기계로 검증 가능하다 — `CompatibilityMatrix.entries()`가 어댑터 이름을 갖고, 각 어댑터의 `MessagingCapabilities`는 코드 상수이며, 표는 마크다운 파이프 테이블이다. 그러나 프로젝트가 이 확장을 선택한 기록이 없고, 테스트 javadoc은 오히려 좁게 유지하는 근거를 적는다("Asserting on wording would make every edit a test failure and the check would be deleted"). 결정이 필요한 지점이며 이 노드는 생성되지 않는다.
- relations: `case:doc-contract-test-boundary-predicted-the-drift` · `reference:fix-overstatement-before-understatement`
### DECISION — 지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다
- slug: `capability-grade-is-declared-not-inferred`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-5` · `analysis/05` §2.5 · `analysis/19` §6.6 · `analysis/20` §2.6
- decision-evidence: `.../api/capability/CapabilitySupport.java`의 javadoc("evidence suite가 돌지 않은 capability는 컴파일이 된다는 이유로 STABLE이 되지 않는다") · `CompatibilityMatrix.Entry.hasLiveBrokerCertification()`의 파생 구현 · `docs/adr/ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md`
- grounds: `reference:grades-may-understate-never-overstate` · `reference:agreement-between-documents-proves-nothing`
- classification: 세 가족이 같은 결정을 각자 구현했다. JPA는 `SupportLevel`을 선언으로 두고 evidence card로 승격을 게이트하며, messaging은 등급을 boolean 필드에서 레인 증거 파생으로 바꿨고(그 전에는 Rabbit이 `true`를 달고 있었는데 fault scenario가 한 번도 실행된 적 없었다), gRPC는 capability별 promotion gate를 ADR로 고정했다.
- relations: `reference:grades-may-understate-never-overstate` · `case:three-versions-declared-one-executed` · `decision:candidate-evidence-stays-at-r1`
---
## TOPIC 7 — learning-transfer-between-families
### CASE — 두 번째 플랫폼이 첫 번째의 bridge 부재는 막고 게이트 배선은 옮기지 않았다
- slug: `the-second-platform-carried-the-design-not-the-wiring`
- readiness: `READY`
- source: `final/document.md#7-6` · `analysis/20` §2, §3 · `analysis/99` §7.6
- code: `src/grpc/CLAUDE.md` · `src/config/architecture/modules.json` · `.../messaging-kafka/build.gradle` · `.github/workflows/messaging-certification.yml`
- evidence: `evidence/raw/tl-platform-suite-results.txt` · `evidence/raw/266-grpc-family-claims-verification.txt` · `evidence/raw/250-messaging-certification-gate-chain.txt`
- classification: gRPC 가족이 messaging을 명시적으로 참조하며 만들어졌고 목표를 문서에 적었다. 옮겨진 셋(bridge를 먼저 정함·framework-free 계약 leaf·속성 기반 전송 선택)은 전부 레지스트리와 `build.gradle`로 표현되는 규칙이고, 옮겨지지 않은 셋(증거 파생 등급·CI가 돌리는 게이트·조립에 연결된 시작 검증기)은 전부 Gradle 태스크와 CI YAML로 표현되는 규칙이다.
- missing-verification: 이 분류가 인과인지 상관인지는 확인할 수 없다 — 작성자가 왜 후자를 옮기지 않았는지에 대한 기록이 없다
- relations: `reference:registry-rules-transfer-ci-rules-do-not` · `case:a-release-gate-with-no-evidence-producer` · `case:the-same-rotation-defect-closed-once-and-reproduced` · `decision:grpc-stays-build-only-until-the-bridge-is-decided`
### CASE — 같은 자격 증명 회전 결함이 한 가족에서 닫히고 다른 가족에서 재현됐다
- slug: `the-same-rotation-defect-closed-once-and-reproduced`
- readiness: `READY`
- source: `final/document.md#5-5` · `analysis/19` §5.4 · `analysis/20` §7.4
- code: `.../messaging-security/.../CredentialRuntimeRegistry.java` · `.../src/test/.../CredentialRotationContractTest.java` · `.../grpc-policy/.../security/GrpcCredentialRotationManager.java:89,103,119,120`
- evidence: `evidence/raw/tl-rotation-defect-reproduced.txt` · `evidence/meta/…json` · `evidence/terminal/…svg`
- classification: messaging이 get → fetch → put → clear를 동기화 없이 하던 결함을 닫고 이력을 계약 테스트 javadoc에 남겼다 — "one replacement was dropped from the map without ever being cleared — a secret left in memory that nothing owns." gRPC의 회전 매니저는 `AtomicReference`를 쓰면서 `compareAndSet`을 한 번도 쓰지 않고 `get()``set()`으로만 다룬다(`synchronized`도 0). javadoc이 그 경합의 존재를 이미 알고 있다 — "the usual reason for one is two rotators racing". `grpc-policy`의 테스트 16개 중 동시성을 다루는 것이 없다.
- missing-verification: 경합을 재현하는 동시성 테스트를 작성하지 않았다
- relations: `reference:atomic-type-is-not-atomicity` · `reference:registry-rules-transfer-ci-rules-do-not`
### REFERENCE — 레지스트리로 표현된 규칙은 전이되고 CI로 표현된 규칙은 전이되지 않는다
- slug: `registry-rules-transfer-ci-rules-do-not`
- readiness: `READY`
- source: `final/document.md#7-6` · `analysis/99` §7.6
- classification: 한 팀이 배운 규칙을 다음 팀이 이어받는지는 그 규칙이 어디에 적혀 있는가로 갈린다. 레지스트리 항목·`build.gradle` 선언·타입 시그니처로 표현된 규칙은 다음 사람이 같은 파일을 편집하면서 마주치고 따라 한다. Gradle 태스크와 CI YAML로 표현된 규칙은 마주치지 않는다.
- scope: 플랫폼 가족·모듈 템플릿·복제되는 구조. 관측: gRPC가 messaging에서 옮겨온 셋은 전부 레지스트리/`build.gradle` 표현이고 옮기지 않은 셋은 전부 Gradle 태스크/CI YAML 표현이다.
- exceptions: 표본이 두 가족뿐이라 인과인지 상관인지 확정할 수 없다. 안전한 형태는 "전이되지 않는다"가 아니라 **"CI로만 표현된 규칙은 복제 시 명시적으로 옮겨야 한다"**는 체크리스트 항목이다.
- relations: `case:the-second-platform-carried-the-design-not-the-wiring` · `case:a-release-gate-with-no-evidence-producer`
### DECISION — gRPC 플랫폼은 build-only로 두고 애플리케이션 도달 경로를 먼저 정한다
- slug: `grpc-stays-build-only-until-the-bridge-is-decided`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#2-3` · `analysis/20` §2.3, §2.6
- decision-evidence: `docs/adr/ADR-GRPC-001-platform-family-and-registry-shape.md` · `modules.json`(18 leaf 전부 `runtime_memberships: []`, `adapter-inbound-grpc``allowed_dependencies`에 이 가족 없음) · `src/grpc/CLAUDE.md` · `docs/compatibility/grpc-support-matrix.md`("Not released … build-only")
- grounds: `case:the-second-platform-carried-the-design-not-the-wiring` · `reference:registry-rules-transfer-ci-rules-do-not`
- classification: messaging이 starter를 `app-bootstrap` 의존으로 넣으면서 18 leaf가 출하 아티팩트에 실렸고 MSG-015가 실재 문제가 됐다. gRPC 가족은 그 선을 넘지 않기로 하고 레지스트리로 강제하며, 배선하려면 `runtime_memberships`를 먼저 바꾸고 `verifyRuntimeModuleMembership`을 통과시켜야 한다. 운영 문서가 그 상태를 정확히 공시한다는 점에서 messaging의 지원 매트릭스와 대비된다.
- relations: `case:the-second-platform-carried-the-design-not-the-wiring` · `case:a-release-gate-with-no-evidence-producer` · `reference:runtime-membership-decides-severity`
---
## TOPIC 8 — owner-safe-state-machines
### CONCEPT — fenced lease — 만료 시각만으로는 부족한 이유
- slug: `fenced-lease`
- readiness: `READY`
- source: `final/document.md#4-1`, `#4-3` · `analysis/05` §10 · `analysis/19` §7.3
- code: `db/migration/jpa/…``db/migration/messaging/V2__messaging_outbox_lease_fencing.sql` 헤더 · `.../fileserver/…` V3 마이그레이션
- classification: lease가 "언제 끝나는가"만 기록하고 "누가 들고 있는가"를 기록하지 않으면 만료를 지난 worker가 여전히 쓸 수 있다. V2 마이그레이션 헤더가 그 시나리오를 3단계로 적는다 — relay A가 claim하고 브로커를 부름 / lease 만료, relay B가 재claim하고 발행하고 PUBLISHED 기록 / relay A가 타임아웃 후 그 위에 AMBIGUOUS를 씀. 그리고 결정적 문장 — "**Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, a scheduler stall**..." 해법은 소유자와 fencing token을 행에 기록하고 terminal write가 그 튜플로 매칭하는 것이다. 같은 결함이 이 저장소에서 최소 세 곳(messaging outbox·fileserver cleanup·notification dispatcher)에 나타났다.
- missing-verification: 없음 — 마이그레이션과 claim SQL을 코드로 확인했다
- relations: `case:a-lease-without-an-owner` · `reference:cas-tuple-in-the-where-clause` · `reference:expired-claim-and-expired-execution-differ`
### CONCEPT — CAS 튜플과 update count가 답이 되는 구조
- slug: `cas-tuple-and-update-count`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §10.1, §10.3
- code: `.../postgresql/idempotency/IdempotencyTransitionGateway.java`의 다섯 statement · `.../outbox/PostgreSqlPollingDeliveryAdapter.java`의 completion CAS 셋
- classification: 상태 전이를 "읽고 → 판단하고 → PK로 update"하면 그 사이에 takeover한 worker의 상태를 덮어쓴다. 이 저장소의 형태는 소유권 튜플 전체(scope · owner token · attempt · claim operation id · state revision)를 where 절에 반복하고 **update count 자체를 답으로 쓰는** 것이다 — 1이면 이 owner가 그 revision에서 여전히 owner였고, 0이면 다른 무언가가 record를 움직였으니 caller는 자기 view를 현재로 취급하면 안 된다. polling delivery는 여기에 authority `EXISTS` 서브쿼리를 더해 cutover를 가로지르지 못하게 한다.
- missing-verification: 컨테이너 레인 미실행 — 동시 claim에서 실제로 0행이 나오는지 관측하지 않았다
- relations: `reference:cas-tuple-in-the-where-clause` · `case:native-claim-did-not-bump-the-version` · `open-question:v2-state-machine-lanes-not-executed`
### CONCEPT — capability_schema_registry — 스키마 적용과 사용 승인의 분리
- slug: `capability-schema-registry`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §8.4, §11.3
- code: `db/migration/postgresql/V6__capability_schema_registry_adoption.sql` · 각 capability 스트림의 V1 · `.../notification/NotificationSchemaActivation.java`
- classification: 독립 Flyway 스트림 설계의 접착제다. 각 capability 스트림의 V1이 (1) 선행조건 검사(`DO $$ ... RAISE EXCEPTION`으로 core epoch가 ACTIVE인지), (2) 테이블 생성, (3) **자기를 `INSTALLED_INACTIVE`로 등록**의 세 단계를 밟고, 어댑터가 런타임에 `capability_id` + `core_epoch` + `feature_revision` + `lifecycle_state='ACTIVE'`를 조회해 확인한다. 그래서 "스키마가 적용됐다"와 "capability를 써도 된다"가 분리된다. 확인은 startup에서만 하고 호출마다 하지 않는 이유("승격되지 않은 스트림은 배포 상태이고, 매 호출마다 묻는 것은 프로세스가 도는 동안 바뀔 수 없는 질문에 round trip을 넣는 것")도 이 개념의 일부다.
- missing-verification: 없음
- relations: `decision:capability-separates-installation-from-activation` · `concept:independent-flyway-streams` · `case:registry-column-too-short-for-its-own-path`
### CASE — lease가 만료 시각만 기록하고 소유자를 기록하지 않아 terminal state가 되돌려졌다
- slug: `a-lease-without-an-owner`
- readiness: `READY`
- source: `final/document.md#4-3` · `analysis/19` §7.3 · `analysis/08` §V3
- code: `db/migration/messaging/V2__messaging_outbox_lease_fencing.sql` · fileserver V3 마이그레이션
- evidence: 없음 — 마이그레이션 헤더가 사후 기록이다
- classification: V1이 `lease_expires_at`만 기록해 claim이 "언제 끝나는가"만 말하고 "누가"를 말하지 않았고, relay의 terminal write가 `message_id`만으로 매칭했다. 그래서 lease를 지나 멈춰 있던 worker가 다른 worker가 이미 PUBLISHED로 옮긴 행에 AMBIGUOUS를 덮어썼고 행이 다시 claim 가능해져 메시지가 두 번 발행됐다. 같은 결함이 fileserver cleanup(claim이 owner·token·expiry를 기록하지 않아 죽은 worker의 항목이 영영 IN_PROGRESS로 남음)과 notification dispatcher에도 있었다.
- missing-verification: 컨테이너 레인 미실행
- relations: `concept:fenced-lease` · `reference:cas-tuple-in-the-where-clause` · `open-question:v2-state-machine-lanes-not-executed`
### CASE — transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
- slug: `a-digest-that-covered-who-but-not-what`
- readiness: `READY`
- source: `analysis/05` §10.1
- code: `.../postgresql/idempotency/IdempotencyDigestPolicy.java`
- evidence: 없음 — javadoc의 사후 기록
- classification: digest가 `transition|operationId|ownerToken|attempt|stateRevision`이었고 **무엇을 했는지**를 전혀 덮지 않았다. retryable로 기록된 `FAIL`과 abandoned로 기록된 `FAIL`이 같은 digest를 냈고, 서로 다른 응답이나 서로 다른 retention을 가진 두 completion도 그랬다. digest를 비교하는 replay는 "차이 전체가 중요한 부분인 두 전이"를 같다고 결론지었다. 수정은 `semanticArguments`(disposition·retention·response digest·codec identity)를 포함하고 **길이 프레이밍**으로 구성하는 것 — 모든 구성요소가 가변 폭 텍스트이고 최소 하나(owner token)는 플랫폼이 제약할 것이 아니므로 delimiter join은 서로 다른 목록을 같은 문자열로 렌더링할 수 있다. `VERSION`을 붙여 구성이 바뀌면 저장된 digest가 가로질러 비교되지 않게 한다.
- missing-verification: 없음
- relations: `reference:digest-must-be-length-framed-and-versioned` · `concept:cas-tuple-and-update-count`
### CASE — 활성 트랜잭션 검사가 data source를 묻지 않아 다른 커넥션에서 커밋됐다
- slug: `an-active-transaction-check-that-asked-the-wrong-question`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §10.1
- code: `.../postgresql/idempotency/IdempotencyCapabilityGuard.java`
- evidence: 없음 — javadoc의 사후 기록
- classification: store가 스레드에 활성 read-write 트랜잭션이 있는지 확인했는데 그건 **어떤 data source에서든 어떤 트랜잭션이든** 열려 있으면 참이다. 형제 outbox/inbox 어댑터는 `hasResource(dataSource)`를 확인하는데 그것이 실제로 중요한 질문이다. 차이는 data source가 둘인 애플리케이션에서 드러난다 — **다른** 쪽의 트랜잭션 안에서 발행된 mutation이 옛 검사를 통과하고, 이 store의 커넥션에서 트랜잭션 밖으로 돌고, **원자적이어야 했던 작업과 독립적으로 커밋됐다.**
- missing-verification: 두 data source 구성으로 재현하지 않았다
- relations: `reference:cas-tuple-in-the-where-clause` · `concept:cas-tuple-and-update-count`
### CASE — 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
- slug: `expired-claim-versus-expired-execution`
- readiness: `READY`
- source: `analysis/05` §10.1, §10.4
- code: `.../postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java`의 claim 결정 트리 · `.../inbox/PostgreSqlSameStoreInboxAdapter.java`
- evidence: 없음 — 결정 트리와 그 javadoc
- classification: 만료된 lease를 일률적으로 takeover하면 이미 실행이 시작된 작업을 blind retry하게 된다. 이 저장소는 상태로 나눈다 — 만료된 `CLAIMED``resetClaim`으로 takeover하고, 만료된 `EXECUTING``abandonExpiredExecution`으로 `ABANDONED`에 넣고 `RecoveryRequired`를 반환한다. inbox도 같은 축을 쓰되 `RECEIVED`(takeover 가능)와 `PROCESSING`(→ DEAD, recovery-required)로 나눈다. 즉 "claim만 했다"와 "실행에 들어갔다"가 만료 시 다른 결론을 낳는다.
- missing-verification: 컨테이너 레인 미실행
- relations: `reference:expired-claim-and-expired-execution-differ` · `concept:fenced-lease` · `open-question:v2-state-machine-lanes-not-executed`
### CASE — native claim이 `@Version`을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다
- slug: `native-claim-did-not-bump-the-version`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §11.2
- code: `.../notification/RecipientClaimSql.java`의 claim statement
- evidence: 없음 — statement와 그 주석
- classification: claim이 native `UPDATE`인데 JPA `@Version` 컬럼을 올리지 않으면, claim 전에 로드된 managed 엔티티가 여전히 옛 version을 들고 있고 그 flush가 **성공하면서 lease와 state를 pre-claim 값으로 덮어쓴다.** native statement가 충돌이 있었다고 말해주지 않았기 때문에 **충돌을 보고하지 않는 낙관적 잠금**이 된다. 수정은 statement에 `version = d.version + 1`을 넣는 것이고, 그 한 줄에 주석이 붙어 있다.
- missing-verification: 컨테이너 레인 미실행
- relations: `concept:cas-tuple-and-update-count` · `reference:cas-tuple-in-the-where-clause` · `reference:a-contract-test-must-run-the-adapters-statement`
### REFERENCE — CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
- slug: `cas-tuple-in-the-where-clause`
- readiness: `READY`
- source: `final/document.md#9` 규칙 17 · `analysis/05` §10, §16
- classification: 읽고 나서 PK만으로 update하면 그 사이 takeover한 worker의 상태를 덮어쓴다. 소유권을 이루는 모든 값을 where 절에 반복하고 update count를 판정에 쓰면, 1은 "여전히 내 것"이고 0은 "누군가 움직였다"를 뜻한다.
- scope: lease·claim·상태 전이를 갖는 모든 관계형 어댑터. 튜플 구성은 보통 scope·owner token·attempt·claim operation id·state revision이고, 권한 경계가 있으면 authority `EXISTS`를 더한다.
- exceptions: 단일 writer가 보장되는 경로(마이그레이션·관리 작업)는 필요 없다. 다만 그 보장이 배포 형태(단일 인스턴스)에서 오면 다중 인스턴스로 가는 날 깨지므로, 보장의 출처를 적어야 한다.
- relations: `concept:cas-tuple-and-update-count` · `case:a-lease-without-an-owner` · `case:native-claim-did-not-bump-the-version`
### REFERENCE — 시간은 DB에서, 그리고 행을 잠근 다음에 읽는다
- slug: `read-the-clock-after-the-lock`
- readiness: `READY`
- source: `final/document.md#9` 규칙 17 · `analysis/05` §10, §16
- classification: 애플리케이션 시계로 lease 만료를 판단하면 lease를 쓴 머신이 아닌 곳에서 판단하는 것이고, 락 전에 DB 시계를 읽으면 행이 바뀔 수 있는 시점 이전의 순간으로 판단하는 것이다. 순서는 `for update``clock_timestamp()`다.
- scope: lease·claim·만료 판정을 갖는 모든 SQL. `now()`가 아니라 `clock_timestamp()`인 이유도 같은 축이다 — 전자는 트랜잭션 시작 시각으로 고정된다.
- exceptions: 읽기 전용 조회에서 만료 여부를 **보고만** 하는 경우는 잠글 필요가 없다. 다만 그 결과로 행동하면 안 된다.
- relations: `reference:cas-tuple-in-the-where-clause` · `concept:cas-tuple-and-update-count`
### REFERENCE — 만료된 claim과 만료된 실행은 다르게 다뤄야 한다
- slug: `expired-claim-and-expired-execution-differ`
- readiness: `READY`
- source: `analysis/05` §10.1, §10.4
- classification: lease 만료는 "누가 들고 있었는가"만 말하고 "무엇까지 했는가"를 말하지 않는다. claim만 한 상태의 만료는 안전하게 takeover할 수 있지만, 실행에 들어간 상태의 만료는 외부 부수효과가 이미 발생했을 수 있으므로 blind retry가 아니라 조정으로 보내야 한다.
- scope: 실행 전 예약과 실행 자체를 구별하는 모든 작업 큐·멱등성 저장소. 구현은 상태를 둘로 나누는 것이다(`CLAIMED`/`EXECUTING`, `RECEIVED`/`PROCESSING`).
- exceptions: 외부 부수효과가 없는 순수 계산 작업은 구별이 필요 없다. 다만 "부수효과 없음"이 유지되는지는 시간이 지나며 바뀌므로 그 전제를 적어야 한다.
- relations: `case:expired-claim-versus-expired-execution` · `concept:fenced-lease` · `reference:unknown-is-a-third-result`
### REFERENCE — digest는 길이 프레이밍하고 버전을 붙인다
- slug: `digest-must-be-length-framed-and-versioned`
- readiness: `READY`
- source: `analysis/05` §10.1
- classification: 가변 폭 문자열을 delimiter로 이어 digest를 만들면 서로 다른 구성요소 목록이 같은 문자열로 렌더링될 수 있다. 길이 프레이밍(`len:value`)이 그것을 막고, 버전 번호가 구성 변경 전후의 digest를 가로질러 비교하지 못하게 한다. 그리고 digest는 "누가·언제"뿐 아니라 **"무엇을"**까지 덮어야 한다.
- scope: replay 판정·중복 탐지·전이 동일성 비교에 쓰는 모든 digest. 구성요소 중 하나라도 플랫폼이 제약하지 않는 값(외부 토큰)이면 프레이밍이 필수다.
- exceptions: 모든 구성요소가 고정 길이이거나 플랫폼이 문법을 강제하는 값이면 delimiter로 충분하다. 다만 그 강제가 어디 있는지 적어야 한다.
- relations: `case:a-digest-that-covered-who-but-not-what` · `reference:names-are-registry-keys-not-values`
### OPEN QUESTION — V2 상태 기계 넷의 컨테이너 레인이 실행되지 않았다
- slug: `v2-state-machine-lanes-not-executed`
- readiness: `OPEN`
- source: `final/document.md#6-2` · `analysis/05` §15.5
- known: 네 상태 기계(idempotency·outbox-storage·outbox-polling·inbox)가 `readiness-cards.yaml`에서 `implemented-candidate` 상태다 · 각 card의 `evidence.scenarios`가 exact selector를 고정한다 · 후보 검증은 R1에 머문다
- unknown: 네 레인이 이 리비전에서 통과하는지 · 동시 claim에서 CAS가 실제로 0행을 내는지 · trigger 기반 fencing(`trg_fence_legacy_outbox_writer`)이 예상대로 발화하는지
- next-verification: `./gradlew :adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest postgresqlOutboxStorageIntegrationTest postgresqlOutboxPollingIntegrationTest postgresqlInboxIntegrationTest` — 각각 readiness card의 producer다
- decision-criterion: 넷 다 zero-skip으로 통과하면 네 Case의 `missing-verification`을 지운다. 하나라도 실패하면 그 실패가 새 Case가 되고 해당 card의 state를 재검토한다
- relations: `case:a-lease-without-an-owner` · `case:expired-claim-versus-expired-execution` · `case:native-claim-did-not-bump-the-version` · `open-question:container-lanes-not-executed`
### DECISION — capability는 스키마 적용과 사용 승인을 분리한다
- slug: `capability-separates-installation-from-activation`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#4-1` · `analysis/05` §8.4, §11.3
- decision-evidence: `db/migration/postgresql/V6__capability_schema_registry_adoption.sql` · 각 capability 스트림 V1의 3단계 구조 · `.../NotificationSchemaActivation.java`의 startup 확인과 javadoc
- grounds: `concept:capability-schema-registry` · `concept:independent-flyway-streams`
- classification: 스키마가 적용된 것과 capability를 써도 되는 것을 분리하고, 후자를 `lifecycle_state='ACTIVE'`로 표시한다. activation 검증기의 javadoc이 이 결정이 없을 때의 두 실패 모양을 적는다 — `ddl-auto=validate`에서는 기능을 **끈** 배포가 테이블 부재로 boot 실패하고, `ddl-auto=none`이면 boot는 성공하고 **첫 발송이 raw `relation does not exist`를** 처음 보낸 사람에게 반환한다.
- relations: `concept:capability-schema-registry` · `decision:flyway-owns-the-schema`
### DECISION — 상태 기계 구현은 Spring stereotype을 갖지 않는다
- slug: `state-machines-carry-no-stereotype`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/05` §10
- decision-evidence: 네 V2 구현의 클래스 선언과 그 javadoc("두 composition root가 `dev.caskeleton.adapter`를 component-scan하므로 `@Repository`를 붙이면 선택하지 않은 배포에서도 빈이 된다")
- grounds: `reference:off-must-be-structural` · `reference:a-bean-is-not-composition-evidence`
- classification: 스테레오타입을 붙이면 두 합성 루트의 스캔이 그것을 잡아, capability를 선택하지 않은 배포에서도 빈이 된다. 그래서 네 구현이 전부 plain class이고 협력자를 주입이 아니라 생성자에서 조립한다 — "이건 이 store의 부품이지 애플리케이션이 조립하거나 교체하는 서비스가 아니고, 주입하면 public bean 표면이 1개에서 6개로 넓어진다."
- relations: `reference:off-must-be-structural` · `decision:one-root-owns-the-master-switch`
---
## TOPIC 9 — transaction-deadline-and-pool
### CONCEPT — 호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파
- slug: `deadline-propagation`
- readiness: `READY`
- source: `final/document.md#3-2` · `analysis/05` §3.3
- code: `.../transaction/TransactionDeadlineCalculator.java` · `.../postgresql/PostgreSqlLocalTimeoutConfigurer.java` · `.../h2/H2LocalTimeoutConfigurer.java`
- classification: 호출자의 남은 예산이 세 단계로 좁혀져 DB 세션 설정에 도달하는 구조의 설명이다. 획득 전에는 `connectionTimeout + beginBudget + minimumActionWindow + completionMargin`을 요구하고 Spring의 초 단위 타임아웃이 1초 미만이면 시작하지 않는다. begin 이후에는 statement/lock/idle 셋을 각각 유도하고 하나라도 1ms 미만이면 거부한다. DB에 미는 방법이 `SET`이 아니라 `set_config(..., true)`인 이유가 둘이다 — `SET`은 파라미터 바인딩 전에 파싱되어 syntax error가 나고, 함수 호출은 값이 statement text에서 빠진다. 세 번째 인자 `true`가 transaction-local을 뜻한다. H2 구현이 두 가지에서 다른데(세션 스코프, idle 가드 없음) 그것이 H2의 성질이지 선택이 아니라는 점도 함께 적혀 있다.
- missing-verification: 실제 세션에서 `SHOW statement_timeout`으로 적용을 확인하지 않았다
- relations: `reference:deadline-narrows-in-three-stages` · `reference:session-scoped-settings-outlive-the-transaction` · `case:a-five-second-string-that-broke-every-prod-deploy`
### CONCEPT — `REQUIRES_NEW`의 커넥션 비용과 풀 사이징 제약
- slug: `requires-new-connection-cost`
- readiness: `READY`
- source: `final/document.md#3-2` · `analysis/05` §3.1, §13.2
- code: `.../transaction/SpringTransactionPort.java`(`inNew`) · `.../experimental/multitenancy/TenantPoolBudget.java`
- classification: `REQUIRES_NEW`는 바깥 트랜잭션의 커넥션을 **핀한 채로** 새 물리 JDBC 커넥션을 딴다. 그래서 풀 사이징 제약이 곱셈이 된다 — `maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1`. 레코드마다 `inNew`를 도는 루프가 금지인 이유(풀 고갈 + 데드락)와, 같은 곱셈 함정이 database-per-tenant에서 어떻게 반복되는지(각 tenant 풀은 개별적으로 합리적이고 그 합이 아니다 — 50 tenant × 10 = 서버 `max_connections` 100에 500 커넥션, 실패는 idle이던 것 포함 모든 tenant에 동시에 도착)를 함께 다룬다.
- missing-verification: 풀 계약 레인 미실행
- relations: `case:requires-new-pins-the-outer-connection` · `case:tenant-pools-summed-past-the-server-ceiling` · `open-question:pool-contract-lane-not-executed`
### CASE — `connection-timeout: 5s`가 모든 prod 배포를 시작 실패시켰고 local만 통과했다
- slug: `a-five-second-string-that-broke-every-prod-deploy`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §14.6
- code: `.../app-bootstrap/src/main/resources/application.yml`의 hikari 블록 주석 · `.../HikariPoolConstraintValidator.java`
- evidence: 없음 — application.yml 주석의 사후 기록
- classification: `connection-timeout``HikariConfig#setConnectionTimeout(long)`에 바인딩되므로 binder가 `"5s"`를 즉시 거부한다. `"5s"`로 출시됐고 **모든 prod/dev 배포가 시작에 실패했는데** local은 `application-local.yml`이 값을 override해서 통과했다 — **prod 레인만이 그것을 찾을 수 있었다.** 그리고 `HikariPoolConstraintValidator`는 이 키를 `DurationStyle`로 읽어서 `"5s"`를 기꺼이 받아들인다 — **그 관대함이 잘못된 기본값을 지원되는 것처럼 보이게 만들었다.**
- missing-verification: 없음
- relations: `concept:deadline-propagation` · `reference:local-with-a-different-db-is-a-different-system` · `case:a-validator-checking-the-wrong-datasource`
### CASE — validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다
- slug: `a-validator-checking-the-wrong-datasource`
- readiness: `READY`
- source: `analysis/05` §14.5
- code: `.../app-bootstrap/.../persistencejpa/JpaDataSourceProfileValidator.java`의 javadoc
- evidence: 없음 — javadoc의 사후 기록
- classification: validator가 `app.jpa-platform.datasource.*`에 바인딩된 settings를 읽었는데 **요청을 서비스하는 풀은 `spring.datasource.hikari.*`에서** 만들어진다 — 하나의 풀에 두 개의 기술, 그리고 validator는 사용되지 않는 기술에 대해 통과할 수 있다. 더 나쁜 건 그 평행 네임스페이스가 어떤 shipped YAML에도 env-key 레지스트리의 어떤 행에도 없어서 두 필드가 항상 null이었고 `requirePoolBounds`가 모든 배포에서 던졌을 것이라는 점이다 — 아무것도 그것을 호출하지 않아서 아무것도 실패하지 않았다. **서로를 상쇄한 두 결함이고, 애플리케이션이 시작한 이유는 두 번째가 첫 번째를 숨겼기 때문이다.**
- missing-verification: 없음
- relations: `reference:a-bean-is-not-composition-evidence` · `case:a-five-second-string-that-broke-every-prod-deploy`
### CASE — `REQUIRES_NEW`가 바깥 커넥션을 핀한 채 새 커넥션을 딴다
- slug: `requires-new-pins-the-outer-connection`
- readiness: `READY`
- source: `final/document.md#3-2` · `analysis/05` §3.1
- code: `.../transaction/SpringTransactionPort.java``inNew`와 그 javadoc(application-port D12)
- evidence: 없음 — 제약이 javadoc에 수식으로 적혀 있다
- classification: `inNew`가 편해 보이지만 커넥션 비용이 곱셈이라는 것을 문서화한 제약이다. 풀 사이징 부등식이 명시돼 있고, 레코드마다 `inNew`를 도는 루프가 금지이며(풀 고갈 + 데드락), 배치로 묶거나 루프를 트랜잭션 밖으로 빼야 한다. 같은 이유로 `inRootWrite``REQUIRES_NEW`로 suspend해서 "root인 척"하지 않고 fail-fast한다.
- missing-verification: 풀 계약 레인 미실행 — 포화 시 pending count 보고와 대기 동작을 관측하지 않았다
- relations: `concept:requires-new-connection-cost` · `decision:in-root-write-fails-fast` · `open-question:pool-contract-lane-not-executed`
### REFERENCE — 데드라인은 호출 예산에서 시작해 세 단계로 좁힌다
- slug: `deadline-narrows-in-three-stages`
- readiness: `READY`
- source: `analysis/05` §3.3
- classification: 상류 호출자의 남은 시간이 트랜잭션·statement·lock 순으로 좁혀져야 하고, 각 단계에서 남은 시간이 다음 단계의 최소 요구를 못 채우면 **시작하지 않는 것**이 옳다. 시작해서 중간에 잘리면 completion-unknown을 만들지만 시작하지 않으면 확정 거부다.
- scope: 호출 예산을 전파하는 모든 계층. 각 단계에 여유(margin)를 두어 마지막에 결과를 기록할 시간을 남긴다.
- exceptions: 예산을 모르는 진입점(스케줄러·부팅 작업)은 자기 상한을 갖되 그것이 무한이 아니어야 한다.
- relations: `concept:deadline-propagation` · `reference:write-transactions-need-a-finite-timeout`
### REFERENCE — 쓰기 트랜잭션에는 유한 타임아웃이 필수다
- slug: `write-transactions-need-a-finite-timeout`
- readiness: `READY`
- source: `analysis/05` §2.3
- classification: 무제한 write 트랜잭션은 statement 하나가 막히면 커넥션·락·row version을 무한정 잡는다. 기준은 "이 프로파일이 read-only가 아닌데 타임아웃이 없는가"이고, 있으면 생성자가 거부해야 한다.
- scope: 트랜잭션 프로파일·정책 값 타입. `TransactionProfile`의 생성자가 `!readOnly && timeout이 null/0/음수`를 거부하는 형태다.
- exceptions: 읽기 전용 트랜잭션은 상한이 있으면 좋지만 필수는 아니다 — 락을 잡지 않기 때문이다. 다만 락을 잡는 read(`FOR UPDATE`)는 쓰기와 같이 취급한다.
- relations: `reference:deadline-narrows-in-three-stages` · `reference:make-the-unsafe-state-unrepresentable`
### REFERENCE — 세션 스코프 설정은 풀로 돌아간 커넥션에 남는다
- slug: `session-scoped-settings-outlive-the-transaction`
- readiness: `READY`
- source: `analysis/05` §3.3, §13.2
- classification: `SET`으로 건 설정은 커넥션이 풀로 돌아가도 살아남아 다음 borrower가 상속한다. 다음 borrower가 다른 tenant이거나 tenant 없는 백그라운드 job이면 그 설정이 격리 경계를 무너뜨린다. 기준은 "이 설정이 트랜잭션과 함께 되돌아가는가"이고, PostgreSQL에서는 `set_config(..., true)`가 그것을 보장한다.
- scope: `statement_timeout`·`lock_timeout`·`search_path`·`app.tenant_id` 등 세션 상태 전부. RLS의 tenant 바인딩이 transaction-local이어야 하는 이유가 같다.
- exceptions: 대응물이 없는 벤더(H2의 idle 가드)에서는 세션 스코프를 인정하되 **그 사실을 적고** caller-side deadline에 맡긴다 — "적용했다"고 거짓 보고하지 않는다.
- relations: `concept:deadline-propagation` · `concept:rls-three-preconditions` · `case:search-path-survived-the-return-to-the-pool`
### OPEN QUESTION — 풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다
- slug: `pool-contract-lane-not-executed`
- readiness: `OPEN`
- source: `final/document.md#6-2` · `analysis/05` §15.3
- known: `jpaPlatformPoolContractTest`가 threshold를 약속하지 않고 **행동 계약**만 검증하도록 개명됐다 · 검증 항목은 `REQUIRES_NEW` depth 1이 동시 스레드당 커넥션 2개를 요구하는 것, 포화된 풀이 pending count를 보고하는 것, caller가 커넥션 없이 진행하지 않고 기다리는 것
- unknown: 이 리비전에서 그 세 계약이 실제로 성립하는지 · 풀 사이징 부등식이 현재 기본값(`maximumPoolSize` 10, `minimum-idle` 2)에서 어떤 동시성까지 견디는지
- next-verification: `./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest --console=plain`
- decision-criterion: 세 계약이 통과하면 `case:requires-new-pins-the-outer-connection``missing-verification`을 지운다. 실패하면 그 실패와 현재 기본값의 관계를 새 Case로 기록한다
- relations: `case:requires-new-pins-the-outer-connection` · `concept:requires-new-connection-cost` · `open-question:container-lanes-not-executed`
### DECISION — 트랜잭션 템플릿은 모드별로 미리 만들어 둔다
- slug: `templates-are-built-once-per-mode`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#3-2` · `analysis/05` §3.1
- decision-evidence: `.../transaction/SpringTransactionPort.java`의 생성자와 javadoc
- grounds: `concept:requires-new-connection-cost` · `reference:write-transactions-need-a-finite-timeout`
- classification: `TransactionTemplate`은 문서상 thread-safe지만 **mutable**이다. 호출마다 propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다. 그래서 네 모드에 대해 템플릿 셋을 생성 시점에 만들어 두고(`inWrite``inRootWrite`는 실행 속성이 같아 공유하되 precondition만 다르다) 이후 변경하지 않는다.
- relations: `case:requires-new-pins-the-outer-connection` · `decision:in-root-write-fails-fast`
### DECISION — `inRootWrite`는 suspend하지 않고 fail-fast한다
- slug: `in-root-write-fails-fast`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#3-2` · `analysis/05` §3.1
- decision-evidence: `.../transaction/SpringTransactionPort.java``inRootWrite` 구현과 `NestedRootTransactionRejectedException`
- grounds: `decision:templates-are-built-once-per-mode` · `concept:requires-new-connection-cost`
- classification: "root 트랜잭션이어야 한다"를 `REQUIRES_NEW`로 구현하면 호출자 트랜잭션과 독립 커밋되는 silent 의미 변경이 생긴다. 대신 활성 트랜잭션이 있으면 즉시 거부한다. 그리고 `TransactionTemplate.execute`가 커밋 후에 값을 반환하므로 `inRootWrite`의 결과는 **post-commit에만** 호출자에게 보이고 커밋 실패는 값 대신 예외로 전파된다.
- relations: `decision:templates-are-built-once-per-mode` · `reference:unknown-is-a-third-result`
---
## TOPIC 10 — schema-ownership-and-capability-streams
### CONCEPT — 독립 Flyway 스트림과 baseline version 0
- slug: `independent-flyway-streams`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §8.3
- code: `.../persistence-jpa/.../notification/NotificationSchemaStream.java` · `.../postgresql/PostgreSqlPersistenceConfig.java:115` · `db/migration/**`
- classification: 이 저장소에 Flyway 스트림이 여덟 개이고 각각 자기 history 테이블을 갖는 이유의 설명이다. 뻔한 해법(디렉터리를 primary location 목록에 추가)이 틀린 이유는 두 트리가 다 V1부터 번호를 매기고 공유 history가 두 V1을 같은 버전으로 만들어 하나를 거부하거나 resolution 순서에 따라 건너뛰기 때문이다. `baselineVersion("0")` + `baselineOnMigrate(true)`가 필요한 이유(core가 이미 채운 스키마 위에 자기 history를 만들어야 하므로 Flyway가 "history 없는 비어있지 않은 스키마"라며 거부한다)와, version 0이어야 하는 이유(더 높은 baseline은 그 스트림의 마이그레이션을 건너뛴다 — 이 설정이 보통 두려워하는 실패 모드)를 함께 다룬다.
- missing-verification: 마이그레이션 레인 미실행
- relations: `case:two-trees-both-numbered-from-v1` · `concept:capability-schema-registry` · `decision:flyway-owns-the-schema`
### CASE — 두 트리가 다 V1부터 번호를 매겨 공유 history가 하나를 건너뛸 수 있었다
- slug: `two-trees-both-numbered-from-v1`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §8.3
- code: `.../notification/NotificationSchemaStream.java`의 javadoc
- evidence: 없음 — javadoc의 사후 기록
- classification: notification 마이그레이션이 `db/migration/jpa/notification-platform`에 있는데 primary location은 `db/migration/postgresql`이라 **아무것도 적용하지 않았다.** 디렉터리를 목록에 추가하는 해법이 틀린 이유는 `V1__notification_platform_core``V1__initial_schema`가 같은 버전이 되기 때문이다. 해법은 독립 스트림 + 자기 history 테이블이고, 그것이 capability를 진짜로 optional하게 만든다.
- missing-verification: 없음
- relations: `concept:independent-flyway-streams` · `case:messaging-migrations-collide-at-v2`
### CASE — `char(64)`와 `varchar(64)` 불일치를 H2가 가리고 있었다
- slug: `h2-hid-a-column-type-mismatch`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §8.5
- code: `db/migration/postgresql/V10__idempotency_request_hash_varchar.sql` 헤더 · `db/migration/jpa/idempotency/V2` · `.../IdempotencyRecordEntity.java`
- evidence: 없음 — 마이그레이션 헤더가 실패 메시지 전문을 담는다
- classification: V1이 `request_hash``char(64)`로 선언했고 엔티티는 `length = 64`로 매핑해 Hibernate가 `varchar(64)`로 읽는다. 실 PostgreSQL에 `ddl-auto=validate`로 시작하면 실패했다 — `found [bpchar (Types#CHAR)], but expecting [varchar(64)]`. **로컬이 H2로 도는 한 보이지 않았다** — H2의 create-drop은 엔티티에서 스키마를 만들므로 절대 엔티티와 불일치할 수 없다. 수정이 엔티티가 아니라 마이그레이션 쪽인 이유와, V1을 in-place로 고치지 않은 이유("적용된 마이그레이션의 checksum은 이미 그것을 돌린 모든 배포에 대한 약속")가 함께 적혀 있다. 이 사건이 `application-local.yml`을 PostgreSQL 기본으로 바꿨다.
- missing-verification: 없음
- relations: `reference:local-with-a-different-db-is-a-different-system` · `reference:an-applied-checksum-is-a-promise`
### CASE — 레지스트리 컬럼이 38자 경로에서 짧아 "더 짧은 경로를 적는" 우회를 유혹했다
- slug: `registry-column-too-short-for-its-own-path`
- readiness: `READY`
- source: `analysis/05` §8.4
- code: `db/migration/postgresql/V9__widen_capability_schema_stream.sql` · `db/migration/jpa/idempotency/V2`
- evidence: 없음 — 마이그레이션 헤더의 사후 기록
- classification: `schema_stream``varchar(32)`였고 작성 당시 모든 스트림에 맞았으며 `'db/migration/jpa/notification-platform'`(38자)에서 안 맞기 시작했다. 실패 모드가 나쁜 종류다 — 모든 면에서 올바른 등록이 `value too long`으로 마이그레이션 타임에 실패하고, **뻔한 우회책은 스트림의 실제 경로가 아닌 더 짧은 경로를 기록하는 것**이며, 스키마가 어디서 왔는지에 대해 거짓말하는 레지스트리는 없는 것보다 나쁘다. 128로 넓힌 이유도 적혀 있다 — `capability_id`가 이미 `varchar(128)`이고 **하나의 bound가 두 개보다 추론하기 쉽다.**
- missing-verification: 없음
- relations: `concept:capability-schema-registry` · `reference:an-applied-checksum-is-a-promise`
### CASE — Flyway location customizer가 운영자가 바인딩한 값을 덮어썼다
- slug: `a-customizer-that-discarded-the-bound-property`
- readiness: `READY`
- source: `analysis/05` §7.7
- code: `.../postgresql/PostgreSqlPersistenceConfig.java:94-115`의 javadoc
- evidence: 없음 — javadoc의 사후 기록
- classification: customizer가 무조건 `locations(...)`를 호출했는데 그것은 Spring이 `spring.flyway.locations`에서 바인딩한 것을 **대체**한다. 그래서 운영자가 `SPRING_FLYWAY_LOCATIONS`로 capability 스트림을 추가하고 Flyway가 성공적 마이그레이션을 보고하는 것을 보고도 **벤더 스트림만** 얻을 수 있었다 — 프로퍼티는 읽히고 바인딩되고 그 뒤에 도는 customizer가 버렸다. `local-notification-ingest` 레인은 7개 location을 세팅하고 1개를 적용했다. 지금은 "아무도 고르지 않았을 때만 기여하고, 누군가 골랐으면 비켜선다".
- missing-verification: 없음
- relations: `concept:independent-flyway-streams` · `reference:a-bean-is-not-composition-evidence`
### CASE — messaging 마이그레이션 두 leaf가 같은 디렉터리에서 `V2`를 둘 만들었다
- slug: `messaging-migrations-collide-at-v2`
- readiness: `READY`
- source: `final/document.md#4-3` · `analysis/19` §7.2
- code: `src/messaging/messaging-inbox-jdbc-postgresql/.../db/migration/messaging/V2__messaging_inbox.sql` · `src/messaging/messaging-outbox-jdbc-postgresql/.../db/migration/messaging/V2__messaging_outbox_lease_fencing.sql`
- evidence: `evidence/raw/259-messaging-flyway-migration-namespace.txt`
- classification: 두 leaf가 같은 classpath 리소스 디렉터리 `db/migration/messaging/`에 각자 번호를 매겨 `V2`가 둘이다. 그 위치를 Flyway에 주는 순간 duplicate version으로 부팅이 실패한다. 지금 실패하지 않는 유일한 이유는 **그 위치를 아무도 Flyway에 주지 않기 때문**이고(main 코드에서 `db/migration/messaging`을 부르는 곳 0건), 각 leaf의 IT는 자기 jar 리소스만 보므로 재현하지 못한다. 원 구현 계획서는 분리된 위치를 지정했었다.
- missing-verification: 실제로 그 위치를 Flyway에 추가해 duplicate version 실패를 관측하지 않았다
- relations: `concept:independent-flyway-streams` · `case:two-trees-both-numbered-from-v1` · `case:outbox-chain-behind-an-unsatisfiable-condition`
### REFERENCE — 마이그레이션 스트림은 자기 history 테이블을 갖는다
- slug: `each-stream-owns-its-history-table`
- readiness: `READY`
- source: `analysis/05` §8.3
- classification: 두 마이그레이션 트리가 하나의 history를 공유하면 버전 공간을 공유하게 되고, 각 트리가 독립적으로 번호를 매기는 한 충돌은 시간 문제다. 기준은 "이 트리의 버전 번호를 누가 정하는가"이고, 답이 둘 이상이면 스트림을 나누고 history를 분리한다.
- scope: capability별·모듈별로 나뉜 모든 마이그레이션. 분리 시 `baselineVersion("0")` + `baselineOnMigrate(true)`가 필요하고, 0이어야 그 스트림의 마이그레이션이 전부 돈다.
- exceptions: 하나의 팀이 하나의 트리를 소유하고 그 안에서 번호를 조정할 수 있으면 분리가 불필요하다. sample composition이 두 location을 하나의 목록으로 합쳐 버전 공간을 공유하는 경우가 그 예이고, 그때는 번호 충돌을 사람이 피해야 한다.
- relations: `concept:independent-flyway-streams` · `case:two-trees-both-numbered-from-v1` · `case:messaging-migrations-collide-at-v2`
### REFERENCE — 로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다
- slug: `local-with-a-different-db-is-a-different-system`
- readiness: `READY`
- source: `final/document.md#9` 규칙 18 · `analysis/05` §8.5
- classification: 로컬이 in-memory DB로 돌고 스키마를 엔티티에서 생성하면 **엔티티와 마이그레이션의 불일치가 구조적으로 발견 불가능**하다. 기준은 "로컬과 prod가 같은 벤더·같은 마이그레이션 모드·같은 스키마 소유자를 쓰는가"이고, 아니면 "로컬에서는 됩니다"가 다른 시스템에 대한 진술이다.
- scope: 개발 환경 구성 전반. 이 저장소는 두 사건(`char(64)`, `fs_cleanup_item`) 이후 local을 PostgreSQL + Flyway + `ddl-auto=validate`로 바꾸고 H2를 별도 개발자 task로 밀었다.
- exceptions: 빠른 단위 테스트용 in-memory는 유지해도 된다. 다만 그 레인이 **스키마 계약을 증명한다고 주장하면 안 된다** — H2가 `FOR UPDATE SKIP LOCKED`를 실제로 지키는지 같은 벤더 차이는 측정하고 근거를 적어야 한다.
- relations: `case:h2-hid-a-column-type-mismatch` · `reference:an-applied-checksum-is-a-promise`
### REFERENCE — 적용된 마이그레이션의 checksum은 그것을 돌린 모든 배포에 대한 약속이다
- slug: `an-applied-checksum-is-a-promise`
- readiness: `READY`
- source: `analysis/05` §8.1, §8.5
- classification: 이미 적용된 마이그레이션을 in-place로 고치면 그것을 돌린 배포들의 history와 어긋난다. 기준은 "이 파일이 어딘가에 적용된 적이 있는가"이고, 있으면 새 버전을 추가하고 가드된 변환(`DO $$ ... IF EXISTS ... THEN ALTER`)을 쓴다. 같은 이유로 Flyway `repair`는 모드가 아니다 — history를 지금 디스크에 맞게 다시 써서 **증거를 지워 증상을 해결**한다.
- scope: 모든 forward-only 마이그레이션. 스트림이 둘 이상이고 상대 순서가 고정되지 않았으면 같은 가드된 변환을 양쪽에 둔다.
- exceptions: 어디에도 적용된 적 없는 마이그레이션(방금 작성한 것)은 고쳐도 된다. 판정 근거는 개발자의 기억이 아니라 history 테이블이다.
- relations: `case:h2-hid-a-column-type-mismatch` · `case:registry-column-too-short-for-its-own-path` · `decision:repair-is-not-a-mode`
### DECISION — Flyway가 스키마를 소유하고 런타임 롤은 DDL 권한을 갖지 않는다
- slug: `flyway-owns-the-schema`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#4-1`, `#10-3` · `analysis/05` §8.1, §12.1
- decision-evidence: `docs/adr/ADR-JPA-004-flyway-schema-source-of-truth.md` · `.../security/PostgreSqlRuntimeRoleVerifier.java`의 startup 질의 · `.../migration/FlywaySchemaPolicy.java`의 프로필별 모드
- grounds: `reference:an-applied-checksum-is-a-promise` · `reference:local-with-a-different-db-is-a-different-system`
- classification: 스키마를 Flyway가 소유하고 ORM은 확인만 한다(`ddl-auto``none`/`validate`만 허용). 그리고 그것을 **강제 가능**하게 만드는 것이 런타임 롤에서 DDL 권한을 뺀 것이다 — 서버에 `has_schema_privilege`로 직접 물어 fail-closed로 거부한다. "애플리케이션 자신의 자격증명이 DDL을 실행할 수 없으면, 어떤 코드 경로도 어떤 라이브러리도 어떤 주입된 statement도 런타임에 스키마를 바꿀 수 없다." staging/prod는 배포 파이프라인이 마이그레이션을 소유하고 애플리케이션은 `VALIDATE_ONLY`다.
- relations: `decision:repair-is-not-a-mode` · `decision:capability-separates-installation-from-activation`
### DECISION — Repair는 모드가 아니라 운영자가 호출하는 작업이다
- slug: `repair-is-not-a-mode`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/05` §8.1
- decision-evidence: `.../migration/FlywaySchemaPolicy.java`(모드 enum에 repair 없음) · `.../FlywayValidationGate.java`의 javadoc
- grounds: `reference:an-applied-checksum-is-a-promise` · `decision:flyway-owns-the-schema`
- classification: Flyway의 `repair`는 schema history를 지금 디스크에 있는 스크립트에 맞게 다시 쓴다 — **증거를 지워서 증상을 해결**한다. checksum mismatch는 배포된 스크립트가 적용된 것과 다르다는 뜻이고 흥미로운 질문은 "어떤 변경이 이 DB에 빠졌는가"인데, repair는 그 질문을 물을 수 없게 만들어 답한다. 그래서 startup 동작이 아니라 운영자가 의도적으로 호출하는 operation descriptor로만 존재한다.
- relations: `decision:flyway-owns-the-schema` · `reference:an-applied-checksum-is-a-promise`
---
## TOPIC 11 — redis-command-admission
### CONCEPT — 명령 카탈로그와 admission 아홉 단계
- slug: `redis-admission-stages`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/10` §64
- code: `.../cache-redis/.../CommandPolicyGuard.java` · `.../RedisCommandCatalog.java` · `.../RedisCommandGateway.java`
- classification: 이 SDK가 명령 하나를 내보내기 전에 지나는 단계의 설명이다 — 카탈로그 분류(BLOCKED·R3·R4 거부) · capability/최소 버전 확인 · permit provenance 검증 · 네임스페이스 검사 · Cluster 동일 슬롯 검사 · 요청 예산 · 정책 기반 레인·타임아웃 유도 · 실패 번역 · 관측. 그리고 그 위에 얹힌 계약 — gateway는 "everything routed through it has already passed `CommandPolicyGuard`"를 전제하므로 정책·permit·예산·타임아웃·관측을 자기 관심사로 두지 않는다. 카탈로그가 미분류 명령을 fail-closed로 거부하는 것이 이 구조의 바닥이다.
- missing-verification: 토폴로지 레인 미실행 — 실제 서버에서 각 단계가 도는지 관측하지 않았다
- relations: `case:five-adapters-bypass-the-single-admission-point` · `reference:a-single-admission-point-must-count-its-bypasses` · `decision:unclassified-commands-are-refused`
### CASE — "build gate"라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다
- slug: `a-build-gate-that-is-not-in-the-build`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/10` §47
- code: `.../cache-redis/.../RedisCommandMetadataDiff.java` · 명령 정책 파일 머리 주석
- evidence: `evidence/raw/163-redis-*-probes.txt` §8.1
- classification: 정책 파일이 "The catalog drift gate compares the two and fails the build when the server grows a command this file has not judged"라고 적고 비교 로직과 다섯 버킷 테스트가 완성돼 있다. 그런데 실제 서버 메타데이터를 넣는 곳이 없다 — main 참조 0, 토폴로지 레인 사용 0, 실행 태스크 없음. 빌드를 깨는 gate는 존재하지 않고 gate가 쓸 비교 함수와 그 단위 테스트만 있다. 위험은 fail-closed 카탈로그가 잡지 못하는 셋이다 — key spec 이동(네임스페이스·슬롯 검사가 잘못된 인자를 키로 봄) · ACL 카테고리 확장(계정 분리 가정이 약해짐) · deprecation(타입 있는 API가 사라질 명령을 계속 노출).
- missing-verification: 토폴로지 레인 미실행
- relations: `reference:a-gate-nobody-runs-reports-the-last-run` · `reference:server-metadata-defines-the-command` · `open-question:redis-topology-lane-not-executed`
### CASE — 의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다
- slug: `five-adapters-bypass-the-single-admission-point`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/10` §64
- code: `.../CommandPolicyGuard.java` · cache·idempotency·lease·ratelimit·realtime 어댑터의 `lease.gateway()` 호출부
- evidence: `evidence/raw/165-redis-*-probes.txt` §8.1
- classification: 두 javadoc이 "The single admission point every command passes through"와 "everything routed through it has already passed `CommandPolicyGuard`"를 주장하는데, 다섯 어댑터가 `RedisRuntimeOwner``RedisLease``lease.gateway()`를 직접 부른다(30곳). `SyncRedisCommandExecutor`·`ReactiveRedisCommandExecutor`·`CommandPolicyGuard`·`CommandRequest`를 참조하는 파일이 0이고 타입 있는 API 참조도 0이다. 아홉 단계 중 네임스페이스만 `CapabilityKeyspace`가 같은 `RedisNamespace`에서 키를 조립해 구성으로 유지된다.
- missing-verification: 실제 Redis에 붙여 우회 경로의 명령이 정책 없이 나가는지 관측하지 않았다
- relations: `concept:redis-admission-stages` · `reference:a-single-admission-point-must-count-its-bypasses` · `reference:check-which-duplicate-is-wired`
### CASE — NOSCRIPT 복구가 다섯 벌이고 넷은 스크립트 레지스트리를 지나지 않는다
- slug: `five-copies-of-noscript-recovery`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/10` §56
- code: `.../cache-redis/...`의 NOSCRIPT 복구 다섯 지점
- evidence: 없음 — 복구 경로 전수 확인
- classification: Redis가 `NOSCRIPT`를 반환했을 때의 복구(스크립트 재등록 후 재시도)가 다섯 벌로 구현돼 있고 그중 넷이 스크립트 레지스트리를 지나지 않는다. 레지스트리를 지나는 한 벌만이 스크립트 SHA와 소스의 대응을 유지하므로, 나머지 넷은 재등록하는 스크립트가 레지스트리가 아는 것과 같다는 보장이 없다.
- missing-verification: 실제 `SCRIPT FLUSH` 이후 다섯 경로의 동작 차이를 관측하지 않았다
- relations: `reference:check-which-duplicate-is-wired` · `concept:redis-admission-stages`
### CASE — startup probe가 production에서 한 번도 실행되지 않는다
- slug: `a-startup-probe-that-never-runs`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/10` §6
- code: `.../cache-redis/...`의 startup probe와 그 조건
- evidence: 없음 — 도달성 확인
- classification: Redis 연결·버전·capability를 확인하는 startup probe가 존재하는데 production 경로에서 실행되지 않는다. 그래서 "이 배포의 Redis가 이 SDK가 요구하는 최소 버전을 만족하는가"가 부팅 시점에 답해지지 않고, 첫 명령에서 capability 예외로 나타난다.
- missing-verification: 부팅해서 probe 미실행을 직접 확인하지 않았다
- relations: `reference:the-startup-validator-follows-the-autoconfiguration-root` · `reference:a-bean-is-not-composition-evidence`
### REFERENCE — 서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다
- slug: `server-metadata-defines-the-command`
- readiness: `READY`
- source: `analysis/10` §47
- classification: 명령이 **무엇인가**는 서버의 `COMMAND DOCS`/`COMMAND INFO`/`COMMAND GETKEYSANDFLAGS`가 정하고, 이 SDK가 그것으로 **무엇을 할 용의가 있는가**는 정책 파일이 정한다. 둘을 대조하는 게이트가 없으면 서버가 명령을 늘리거나 key spec을 옮겨도 알 수 없다.
- scope: 벤더 프로토콜을 감싸는 모든 SDK. 대조해야 할 다섯 버킷 — 미분류 신규 명령 · 사라진 명령 · key spec 이동 · ACL 카테고리 변경 · deprecation.
- exceptions: 미분류 명령은 fail-closed 카탈로그가 이미 막으므로 그 버킷만은 게이트 없이도 안전하다. 나머지 넷은 카탈로그가 잡지 못한다.
- relations: `case:a-build-gate-that-is-not-in-the-build` · `decision:unclassified-commands-are-refused`
### REFERENCE — 단일 admission point는 우회 경로를 세어야 성립한다
- slug: `a-single-admission-point-must-count-its-bypasses`
- readiness: `READY`
- source: `analysis/10` §64
- classification: "모든 것이 여기를 지난다"는 주장은 그 지점의 코드가 아니라 **그것을 지나지 않는 경로의 수**로 검증된다. 기준은 하류 실행기를 직접 부르는 호출자를 세는 것이고, 0이 아니면 그 주장은 성립하지 않는다.
- scope: guard·interceptor·gateway처럼 "유일한 통로"를 주장하는 모든 컴포넌트. 판정 방법은 하류 타입(gateway·executor)의 참조를 전수로 세고 guard를 지나는 것과 아닌 것을 나누는 것이다.
- exceptions: 우회 경로가 있어도 그 경로가 **같은 보장을 다른 방식으로** 유지하면 부분적으로 정당하다 — 이 저장소에서 네임스페이스가 그런 경우다. 다만 그 사실을 주장 옆에 적어야 하고, 나머지 여덟 단계는 그렇지 않다.
- relations: `case:five-adapters-bypass-the-single-admission-point` · `concept:redis-admission-stages` · `reference:check-which-duplicate-is-wired`
### OPEN QUESTION — Redis 토폴로지 레인이 실행되지 않아 key spec 드리프트가 확인되지 않았다
- slug: `redis-topology-lane-not-executed`
- readiness: `OPEN`
- source: `final/document.md#6-2` · `analysis/10` §0, §47
- known: `redisTopologyTest` 레인이 실제 서버에 붙는다 · `RedisCommandMetadataDiff`의 비교 함수가 완성돼 있고 다섯 버킷 테스트가 있다 · 그 둘을 잇는 실행 경로가 없다
- unknown: 현재 대상 Redis 버전의 실제 `COMMAND DOCS` 출력이 정책 파일과 어떤 차이를 보이는지 · 특히 key spec 이동과 ACL 카테고리 변경이 이미 있는지
- next-verification: 토폴로지 레인에서 `COMMAND DOCS`/`COMMAND INFO`를 읽어 `RedisCommandMetadataDiff.compare(...)`를 돌리고 결과를 출력한다 — 레인은 이미 서버에 붙어 있고 비교 함수도 있으므로 없는 것은 연결 한 줄이다
- decision-criterion: diff가 비어 있으면 현재 정책이 서버와 일치함이 확인되고 그 연결을 게이트로 승격할 근거가 된다. 비어 있지 않으면 각 버킷이 새 Case 후보다
- relations: `case:a-build-gate-that-is-not-in-the-build` · `open-question:container-lanes-not-executed`
### DECISION — 분류되지 않은 명령은 fail-closed로 거부한다
- slug: `unclassified-commands-are-refused`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/10` §47
- decision-evidence: `.../RedisCommandCatalog.java``require` 구현과 테스트 `theCatalogFailsClosedForAnUnclassifiedCommand`
- grounds: `reference:server-metadata-defines-the-command` · `concept:redis-admission-stages`
- classification: 카탈로그에 분류가 없는 명령은 허용이 아니라 거부다. 그래서 서버가 새 명령을 추가해도 이 SDK를 통해 조용히 나가지 못한다. 이것이 catalog drift 게이트가 없는 상태에서도 한 버킷(미분류 신규 명령)만은 안전한 이유이고, 동시에 나머지 네 버킷은 이 fail-closed가 잡지 못한다는 사실의 근거이기도 하다.
- relations: `reference:server-metadata-defines-the-command` · `case:a-build-gate-that-is-not-in-the-build`
---
## TOPIC 12 — http-failure-classification
### CONCEPT — 전송 실패의 단계와 범주 — `AttemptStage`와 `FailureCategory`
- slug: `transport-failure-stage-and-category`
- readiness: `READY`
- source: `final/document.md#8-1` · `analysis/11` §51
- code: `.../httpclient/.../ApacheFailureClassifier.java` · `.../TransportFailure.java` · `.../DefaultRetryEligibilityEngine.java`
- classification: HTTP 전송 실패를 "어디까지 갔는가"(`AttemptStage`: pool·DNS·CONNECT·TLS·전송·응답)와 "무엇이 실패했는가"(`FailureCategory`)의 두 축으로 모델링하는 구조의 설명이다. 두 축이 함께 `notSent`/`maybeSent` 판정을 만들고, 그것이 재시도 결정표의 입력이 된다 — `TLS_PERMANENT``permanent()`로 절대 차단되고 `CONNECT`는 멱등성과 무관하게 재시도된다. 그래서 **분류가 곧 재시도 안전성**이고, 원인 사슬 순회 순서가 그 분류를 정한다.
- missing-verification: 없음 — 분류기와 결정표를 코드로 확인했다
- relations: `case:permanent-tls-classified-as-retryable-connect` · `reference:walk-the-cause-chain-most-specific-wins` · `decision:retry-safety-is-decided-by-evidence`
### CASE — 영구 TLS 실패가 재시도 가능한 CONNECT로 분류된다
- slug: `permanent-tls-classified-as-retryable-connect`
- readiness: `READY`
- source: `final/document.md#8-1` 항목 11 · `analysis/11` §51
- code: `.../httpclient/.../ApacheFailureClassifier.java``recognize` 분기 순서
- evidence: 없음 — **저장소 자신의 테스트 3건이 실패로 잡고 있다**(`:adapter:outbound:httpclient:test` 283 중 3)
- classification: `recognize`의 분기 순서가 pool → DNS → **CONNECT** → TLS인데, Apache HttpClient 5는 TLS 핸드셰이크 실패를 연결 단계 실패로 감싼다(`HttpHostConnectException``SSLHandshakeException`). 바깥 것이 CONNECT 분기에 먼저 걸려 안쪽 `SSLHandshakeException`은 검사되지 않는다. 그래서 신뢰할 수 없는 CA·만료된 인증서·호스트명 불일치 셋 다 영구 실패인데 일시적 연결 실패로 취급되어 재시도된다 — 매번 같은 이유로 실패하므로 예산을 전부 소모하고, 호출자와 관측 모두 인증서 문제가 아니라 연결 문제를 본다. **판정 근거 셋**: 잘못된 방향(영구를 일시적으로 낮춤) · 보안 신호 손실 · 저장소 자신의 테스트가 이미 실패로 잡고 있음.
- missing-verification: 없음 — 실패하는 세 테스트의 이름과 기대/실제 값을 확인했다
- relations: `concept:transport-failure-stage-and-category` · `reference:walk-the-cause-chain-most-specific-wins` · `decision:retry-safety-is-decided-by-evidence`
### CASE — 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
- slug: `a-circuit-breaker-permit-that-leaks-on-local-rejection`
- readiness: `READY`
- source: `analysis/11` §22
- code: `.../httpclient/...`의 회로 브레이커 permit 획득/반환 경로
- evidence: 없음 — 경로 확인
- classification: 회로 브레이커 permit을 얻은 뒤 로컬 검증에서 요청이 거부되면 그 permit이 반환되지 않는다. 실패 한 번에 하나씩 줄어드는 형태이고, 같은 저장소의 messaging publish 경로가 명시적으로 막은 것("실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다")과 같은 결함이다.
- missing-verification: 반복 호출로 permit 고갈을 재현하지 않았다
- relations: `concept:transport-failure-stage-and-category` · `reference:check-which-duplicate-is-wired`
### REFERENCE — 원인 사슬은 가장 구체적인 분류가 이기도록 순회한다
- slug: `walk-the-cause-chain-most-specific-wins`
- readiness: `READY`
- source: `analysis/11` §51
- classification: 예외 사슬을 바깥에서 안쪽으로 훑으며 처음 인식되는 것을 돌려주면, 라이브러리가 구체적 원인을 일반적 예외로 감쌌을 때 잘못된 분류가 나온다. 기준은 "이 사슬에서 가장 구체적인 분류가 이기는가"이고, 구현은 (a) 구체적 분기를 앞으로 옮기거나 (b) 사슬 전체를 훑어 최선의 매치를 고르는 것이다.
- scope: 드라이버·클라이언트 예외를 자기 범주로 번역하는 모든 분류기. 특히 전송 계층은 감싸기가 흔하다. `IdentityHashMap` + 최대 깊이로 사이클 안전을 확보하고, `SQLException.getNextException()` 같은 벤더별 곁가지도 따라간다.
- exceptions: 바깥 예외가 실제로 더 구체적인 경우가 있다 — 그때는 순서가 아니라 우선순위 표가 필요하고, 그 표를 테스트로 고정해야 한다.
- relations: `case:permanent-tls-classified-as-retryable-connect` · `reference:translation-chain-order-is-a-contract`
### REFERENCE — 재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다
- slug: `retryability-needs-both-idempotency-and-category`
- readiness: `READY`
- source: `analysis/11` §51 · `analysis/05` §3.6
- classification: 실패 범주만으로 재시도를 정하면 비멱등 요청을 재시도하고, 멱등성만으로 정하면 영구 실패를 반복한다. 기준은 두 입력의 곱이다 — 범주가 재시도 가능하고 **동시에** 요청이 재시도 안전할 때만 재시도한다. 그리고 그 판정에 "아무것도 전송되지 않았다"는 증거가 있으면 멱등성 요구가 완화된다.
- scope: HTTP·gRPC·메시징 클라이언트의 재시도 결정. 이 저장소는 `notSent` 증거를 별도 축으로 두어 그 완화를 표현한다.
- exceptions: 분류기가 terminal로 표시한 실패는 정책의 화이트리스트로 되살릴 수 없다 — 화이트리스트는 어떤 **범주**가 재시도될 수 있는지를 넓히지 **이 실패**에 대한 판정을 뒤집지 않는다.
- relations: `concept:transport-failure-stage-and-category` · `reference:translation-chain-order-is-a-contract` · `decision:retry-safety-is-decided-by-evidence`
### DECISION — 재시도 안전성은 증거에 기반해 판정한다
- slug: `retry-safety-is-decided-by-evidence`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-2` · `analysis/11` §51 · `analysis/20` §2.4
- decision-evidence: `.../httpclient/.../TransportFailure.java``notSent`/`maybeSent` 팩토리와 그 javadoc · `docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md`(같은 원칙의 gRPC판)
- grounds: `concept:transport-failure-stage-and-category` · `reference:retryability-needs-both-idempotency-and-category`
- classification: 이 모듈의 전체 설계가 "증거에 기반해 재시도 안전성을 판정한다"에 서 있다 — 무엇이 실패했는지가 아니라 **무엇이 관측됐는지**가 판정 입력이다. `notSent`는 클라이언트가 자기 전송 실패를 본 경우이고 그 외는 전부 `maybeSent`다. §51의 결함이 무거운 이유가 여기 있다 — 그 증거가 틀리면 설계 전체가 틀린 입력 위에 선다.
- relations: `case:permanent-tls-classified-as-retryable-connect` · `decision:three-axes-of-evidence` · `decision:completion-unknown-is-never-retried`
---
## TOPIC 13 — fileserver-state-and-fencing
### CONCEPT — 파일 상태 기계와 READY가 뜻하는 것
- slug: `file-state-machine-and-ready`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/08` §V1
- code: `db/migration/jpa/fileserver/V1__create_fileserver_metadata.sql` · `.../persistence/fileserver/FileTransitionRepository.java`
- classification: 파일이 11개 상태를 지나고 **공개 읽기 가능한 상태는 `READY` 하나뿐**이라는 설계의 설명이다. V1 헤더가 원칙을 적는다 — "파일시스템이 아니라 관계형 레코드가 파일이 공개적으로 읽을 수 있는지 결정한다." 그래서 모든 전이가 `state``version` 둘 다로 가드되고, `READY`의 완전성이 DB CHECK 제약으로 강제된다(`content_key`·`actual_size`·`sha256`·`strong_etag`·`published_at`이 전부 NOT NULL). 그리고 물리 경로·마운트·원본 물리 파일명을 저장하지 않는다 — `content_key`는 서버 생성 opaque key이고 `original_name`은 신뢰할 수 없는 표시용 텍스트다.
- missing-verification: 컨테이너 레인 미실행
- relations: `reference:a-publicly-readable-state-must-be-complete-by-constraint` · `decision:no-physical-paths-in-metadata`
### CASE — scriptable 콘텐츠 탐지가 BOM·NUL·주석으로 우회된다
- slug: `scriptable-detection-bypassed-by-a-bom`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/08` §40
- code: `.../fileserver/.../ScriptableContentPolicy.java`
- evidence: `evidence/raw/146-fileserver-*-probes.txt`(EXECUTION PROBE 블록)
- classification: javadoc이 목적을 "Detection is on content, not on the claimed type or the extension, because both are attacker controlled"로 적는데, 구현은 1,024바이트 접두사를 소문자화·`stripLeading()`한 뒤 여섯 마커로 **시작하는지**만 본다. hermetic probe로 실제 판정을 측정하니 셋이 통과한다 — UTF-8 BOM + `<html>` · 선행 HTML 주석 후 `<script>` · NUL 바이트 후 `<html>`. `String.stripLeading()``Character.isWhitespace`만 제거하므로 BOM(U+FEFF)도 NUL도 지우지 않는다. 셋 다 브라우저는 HTML로 렌더링하고, BOM 접두 HTML은 여러 편집기의 기본 출력이다. 형제 검증기와의 대비가 판정을 굳힌다 — `MediaTypeVerifier`의 매직바이트 선두 매칭은 시그니처의 정의가 파일 선두이므로 옳지만, scriptable 마커는 시그니처가 아니라 브라우저가 스니핑하는 패턴이다.
- missing-verification: 실제 브라우저가 세 입력을 HTML로 렌더링하는지 확인하지 않았다(문서화된 스니핑 동작에 근거)
- relations: `reference:prefix-matching-fits-signatures-not-sniffing` · `concept:file-state-machine-and-ready`
### CASE — cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
- slug: `a-read-then-delete-race-on-the-upload-lease`
- readiness: `READY`
- source: `analysis/08` §V4
- code: `db/migration/jpa/fileserver/V4__fileserver_upload_terminal_state.sql` 헤더
- evidence: 없음 — 마이그레이션 헤더의 사후 기록
- classification: cleanup이 writer lease를 읽고, 없는 것을 확인하고, staging 바이트를 삭제했다. 그 read와 delete 사이에 writer가 바로 그 lease를 획득할 수 있다 — **업로드가 끝났다고 말하는 게 DB에 아무것도 없었으니까** — 그리고 cleanup이 제거한 객체는 업로드가 활발히 append 중이던 것이었다. 해법은 cancel/failed finalize가 cleanup을 큐잉하는 **같은 트랜잭션 안에서** 세션을 `TERMINAL`로 옮기고, acquire/renew/offset commit이 `ACTIVE`를 요구하며, cleanup이 읽은 값으로 판단하는 대신 **조건부 update로 행을 claim**하는 것이다.
- missing-verification: 컨테이너 레인 미실행
- relations: `reference:claim-with-a-conditional-update-not-a-read` · `concept:fenced-lease`
### CASE — claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
- slug: `a-cleanup-claim-without-fencing`
- readiness: `READY`
- source: `analysis/08` §V3
- code: `db/migration/jpa/fileserver/V3__fileserver_fenced_cleanup_lease.sql` 헤더
- evidence: 없음 — 마이그레이션 헤더의 사후 기록
- classification: claim이 항목을 `IN_PROGRESS`로 옮기고 **다른 건 아무것도 기록하지 않았다** — owner도, token도, lease expiry도. 두 결과가 나왔다. (1) 물리 delete를 수행하고 DB를 정리하기 전에 죽은 worker가 행을 영원히 `IN_PROGRESS`로 남겼고, 어떤 쿼리도 그것을 살아있는 worker가 활발히 삭제 중인 것과 구별할 수 없어 아무것도 회수하지 않았다. (2) 완료 update가 `cleanup_id`만으로 매칭해, 합리적 lease를 한참 지나 멈춰 있던 worker가 다른 worker가 반쯤 진행한 항목 위에 DONE을 쓸 수 있었다. `NULL` lease를 reaper가 자동 회수하지 않고 운영자에게 넘기는 결정도 함께 기록돼 있다 — "자동 takeover는 아무도 상태를 기록하지 않은 작업에 대해 추측하는 것이다."
- missing-verification: 컨테이너 레인 미실행
- relations: `concept:fenced-lease` · `case:a-lease-without-an-owner` · `reference:claim-with-a-conditional-update-not-a-read`
### CASE — README가 "노출된 setting도 bean도 없다"고 적은 능력에 production bean 여덟이 있다
- slug: `readme-said-no-beans-there-are-eight`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/08` §4, §39
- code: `.../fileserver/README.md` · 해당 능력의 port 구현 여덟과 그 bean 정의
- evidence: 없음 — README 문장과 bean 목록 대조
- classification: README가 특정 능력들에 대해 "노출된 setting도 bean도 없다"고 적는데, 여덟 개의 port 구현과 여덟 개의 bean이 있다. 방향이 **과소 진술**이라 위험이 과대보다 낮지만, 이 문서를 읽고 "그 능력은 아직 없다"고 판단한 팀이 같은 것을 다시 만들 수 있다.
- missing-verification: 없음
- relations: `reference:fix-overstatement-before-understatement` · `reference:numbers-in-docs-should-be-derived`
### REFERENCE — 공개 읽기 가능한 상태는 완전한 identity를 DB 제약으로 요구한다
- slug: `a-publicly-readable-state-must-be-complete-by-constraint`
- readiness: `READY`
- source: `analysis/08` §V1
- classification: "이 상태의 행은 반드시 이 필드들을 갖는다"를 애플리케이션 코드로만 두면 어떤 경로가 그것을 어겼는지 알 수 없다. 부분 CHECK 제약(`state <> 'READY' OR (… IS NOT NULL …)`)으로 두면 그 상태에 도달하는 모든 경로가 강제된다. 기준은 "이 상태가 외부에 무엇을 약속하는가"이고, 약속이 있으면 제약으로 적는다.
- scope: 공개·발행·활성 같은 terminal 상태를 갖는 모든 상태 기계. 같은 형태가 messaging outbox delivery의 `ck_..._state_shape`/`ck_..._terminal_shape`에도 있다.
- exceptions: 제약이 마이그레이션 시점에 기존 행을 재검증하므로, 이미 데이터가 있는 테이블에는 `NOT VALID`로 추가해 앞으로의 쓰기에만 적용하는 선택지가 있다 — 그때는 기존 행의 정합성을 별도로 확인해야 한다.
- relations: `concept:file-state-machine-and-ready` · `reference:cas-tuple-in-the-where-clause`
### REFERENCE — 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다
- slug: `claim-with-a-conditional-update-not-a-read`
- readiness: `READY`
- source: `analysis/08` §V4 · `analysis/05` §10
- classification: 읽어서 확인하고 행동하면 그 사이에 상태가 바뀔 수 있다. 조건부 update가 행을 claim하면 update count가 곧 "내가 얻었는가"의 답이 된다. 기준은 "이 판단과 그에 따른 행동 사이에 다른 참여자가 끼어들 수 있는가"다.
- scope: cleanup·relay·worker처럼 경쟁 참여자가 있는 모든 작업. 물리 자원(파일·객체)을 지우는 경로에서 특히 결정적이다 — 되돌릴 수 없기 때문이다.
- exceptions: 단일 참여자가 구조적으로 보장되면 불필요하다. 다만 그 보장이 배포 형태에서 오면 다중 인스턴스로 가는 날 깨진다.
- relations: `case:a-read-then-delete-race-on-the-upload-lease` · `case:a-cleanup-claim-without-fencing` · `reference:cas-tuple-in-the-where-clause`
### REFERENCE — 접두사 시작 매칭은 시그니처에는 맞고 스니핑 패턴에는 맞지 않는다
- slug: `prefix-matching-fits-signatures-not-sniffing`
- readiness: `READY`
- source: `analysis/08` §40
- classification: 매직바이트는 정의상 파일 선두에 있으므로 접두사 시작 비교가 정확하다. 브라우저가 스니핑하는 패턴은 선두 고정이 아니므로 같은 비교가 우회 가능하다. 기준은 "이 패턴의 정의가 위치를 포함하는가"이고, 아니면 정규화(BOM·NUL·주석 제거) 후 탐색이거나 범위 스캔이어야 한다.
- scope: 콘텐츠 기반 탐지 전반 — 스크립트 탐지·MIME 스니핑 방어·서명 확인. 정규화 대상은 최소한 BOM(U+FEFF)·NUL·선행 주석이다.
- exceptions: 시그니처 검증(매직바이트)은 선두 고정이 옳고 정규화하면 오히려 틀린다.
- relations: `case:scriptable-detection-bypassed-by-a-bom` · `reference:reject-rather-than-sanitize`
### DECISION — 물리 경로와 원본 파일명을 저장하지 않는다
- slug: `no-physical-paths-in-metadata`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/08` §V1
- decision-evidence: `db/migration/jpa/fileserver/V1__create_fileserver_metadata.sql` 헤더가 결정과 근거를 적는다
- grounds: `concept:file-state-machine-and-ready` · `reference:a-publicly-readable-state-must-be-complete-by-constraint`
- classification: 물리 경로·마운트·원본 물리 파일명을 저장하지 않고 `content_key`(서버 생성 opaque)와 `original_name`(신뢰할 수 없는 표시용 텍스트)만 둔다. 그래서 저장 위치를 바꿔도 메타데이터가 바뀌지 않고, 클라이언트가 준 이름이 경로로 해석될 여지가 없다.
- relations: `concept:file-state-machine-and-ready`
---
## TOPIC 14 — objectstorage-staged-lifecycle
### CONCEPT — staged lifecycle과 서명된 grant
- slug: `staged-lifecycle-and-signed-grants`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/09` · `analysis/03` §10
- code: `.../objectstorage/...`의 staged 업로드 경로와 grant 서명·검증
- classification: 객체가 staging에서 관리 네임스페이스로 옮겨지는 단계와, 각 단계의 권한이 서명된 grant로 표현되는 구조의 설명이다. opaque identity(클라이언트가 물리 위치를 알 수 없음), privilege separation(업로드 자격과 발행 자격이 다름), 그리고 grant가 endpoint·유효창·operation을 함께 바인딩하는 이유를 다룬다. `application-core`가 이 수명주기를 port로 소유하고 어댑터가 벤더별 구현만 갖는 경계도 함께 설명한다.
- missing-verification: 컨테이너 레인 미실행
- relations: `case:apply-is-a-setting-approval-is-not` · `case:the-last-part-cannot-get-a-grant` · `reference:the-powerful-half-must-not-be-one-setting-away`
### CASE — APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없다
- slug: `apply-is-a-setting-approval-is-not`
- readiness: `READY`
- source: `final/document.md#4-4` · `analysis/09` §49
- code: `.../objectstorage/.../ObjectStorageLegacyMigrationConfig.java:30` · `.../Ed25519LegacyAdoptionApprovalVerifier.java` · `.../LegacyObjectAdoptionService.requireApproval`
- evidence: `evidence/raw/155-objectstorage-*-probes.txt` §8.1
- classification: legacy 채택은 이 leaf에서 가장 권한이 센 동작이다(원시 locator로 legacy를 읽어 관리 네임스페이스에 **발행**). 설계가 detached 2인 승인을 요구하고 검증기 구현은 촘촘하다 — 서로 다른 두 승인자의 Ed25519 서명, destination/epoch/operationId/manifest 다이제스트 일치, 유효창 7일 이내, 그리고 코덱이 **decode 후 재인코딩해 원본 바이트와 같아야만** 통과(비정규 인코딩 거부). 그런데 조립이 비대칭이다 — APPLY 실행 경로는 설정 두 줄로 켜지고 bean이 만들어지는데, 검증기를 `main`에서 생성하는 코드가 저장소 전체에 0이고 신뢰 승인자 키·키 id·유효기간에 해당하는 설정 항목이 하나도 없다. 어댑터 쪽 `requireApproval`**필드 동등성만** 검사하고 서명은 보지 않는다.
- missing-verification: APPLY 모드를 실제로 켜서 어떤 실패가 나는지 관측하지 않았다
- relations: `reference:the-powerful-half-must-not-be-one-setting-away` · `reference:a-binary-approval-codec-must-round-trip` · `reference:a-bean-is-not-composition-evidence`
### CASE — 직접 multipart의 마지막 part는 grant를 받을 수 없다
- slug: `the-last-part-cannot-get-a-grant`
- readiness: `READY`
- source: `analysis/09` §38
- code: `.../objectstorage/...`의 multipart grant 발급 경로
- evidence: 없음 — 경로 확인
- classification: 직접 multipart 업로드에서 마지막 part가 grant를 받을 수 없는 경로 결함이다. grant가 업로드 단계별로 발급되는 구조인데 마지막 part의 조건이 그 발급 경로를 벗어난다.
- missing-verification: 실제 multipart 업로드를 수행해 재현하지 않았다
- relations: `concept:staged-lifecycle-and-signed-grants` · `case:endpoint-check-only-on-upload`
### CASE — 서명된 grant의 endpoint 검증이 upload 경로에만 있다
- slug: `endpoint-check-only-on-upload`
- readiness: `READY`
- source: `analysis/09` §39
- code: `.../objectstorage/...`의 grant 검증 지점들
- evidence: 없음 — 검증 지점 전수 확인
- classification: grant가 endpoint를 바인딩하는데 그 검증이 upload 경로에만 있고 다른 경로에는 없다. grant를 발급받은 주체가 다른 endpoint로 그것을 쓸 수 있다는 뜻이고, 서명이 endpoint를 덮는 이유가 무력화된다.
- missing-verification: 다른 endpoint로 grant를 제시해 재현하지 않았다
- relations: `concept:staged-lifecycle-and-signed-grants` · `case:the-last-part-cannot-get-a-grant`
### REFERENCE — 권한이 센 절반이 설정 한 줄로 켜지면 안 된다
- slug: `the-powerful-half-must-not-be-one-setting-away`
- readiness: `READY`
- source: `analysis/09` §49
- classification: 한 능력이 "실행하는 절반"과 "승인하는 절반"으로 나뉠 때, 실행 쪽이 설정으로 켜지고 승인 쪽이 손으로 배선해야 하면 비대칭이 위험 방향으로 기운다. 기준은 "이 설정을 켠 배포에서 승인 검증이 없으면 무엇이 일어나는가"이고, 답이 "실행된다"면 그 설정이 승인 bean의 부재를 startup 실패로 만들어야 한다.
- scope: 승인·서명·2인 통제를 요구하는 모든 관리 작업. 이 저장소의 admin plane이 반대 사례다 — 파괴적 작업의 bean을 **아예 자동설정하지 않고** 그 부재를 javadoc으로 문서화한다.
- exceptions: 승인 검증을 애플리케이션 계층이 소유하는 설계면 어댑터에 bean이 없는 것이 옳다. 다만 그때는 어댑터가 승인 없이 실행되는 경로를 갖지 않아야 하고, 이 저장소에서는 어댑터의 검사가 필드 동등성뿐이다.
- relations: `case:apply-is-a-setting-approval-is-not` · `reference:a-bean-is-not-composition-evidence`
### REFERENCE — 이진 승인 코덱은 decode 후 재인코딩이 원본과 같아야 한다
- slug: `a-binary-approval-codec-must-round-trip`
- readiness: `READY`
- source: `analysis/09` §49
- classification: 서명 대상이 이진 인코딩이면 같은 논리 값에 대해 여러 바이트 표현이 가능할 때 서명 우회가 생긴다. decode한 뒤 재인코딩해 원본 바이트와 동일한지 확인하면 비정규 인코딩이 거부된다. 기준은 "이 인코딩이 canonical인가"이고, 보장이 없으면 round-trip 검사가 그 자리다.
- scope: 서명·MAC의 대상이 되는 모든 이진·텍스트 인코딩. 길이 프레이밍이 함께 필요한 이유도 같은 축이다.
- exceptions: 인코딩이 정의상 canonical이면(고정 길이 필드만) 불필요하다. 다만 필드가 하나라도 가변이면 성립하지 않는다.
- relations: `case:apply-is-a-setting-approval-is-not` · `reference:digest-must-be-length-framed-and-versioned` · `concept:signed-cursor-structure`
### DECISION — legacy 채택은 서로 다른 두 승인자의 서명을 요구한다
- slug: `legacy-adoption-requires-two-approvers`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `analysis/09` §49
- decision-evidence: `.../Ed25519LegacyAdoptionApprovalVerifier.java`의 구현과 그 테스트(`verifiesCanonicalExactBindingWithTwoDistinctTrustedApprovers`, `rejectsDuplicateApproverAndAnyBindingTamper`) · `sample-portfolio``AdoptLegacyPosterImageUseCase.authorizeApply`가 보여 주는 의도된 조립
- grounds: `concept:staged-lifecycle-and-signed-grants` · `reference:the-powerful-half-must-not-be-one-setting-away`
- classification: 원시 locator로 legacy 네임스페이스를 읽어 관리 네임스페이스에 발행하는 동작에 detached 2인 승인을 요구한다. 검증기가 서로 다른 두 승인자를 요구하고(중복 승인자 거부), destination·epoch·operationId·manifest·네임스페이스 다이제스트를 요청과 대조하며, 유효창을 최대 7일로 제한한다. 의도된 조립은 use case가 **호출자가 준 approval을 버리고** 검증 결과로 요청을 다시 만드는 것 — "application에서 검증하고 adapter에서 적용한다". 그 use case가 어디에도 배선돼 있지 않다는 점이 `case:apply-is-a-setting-approval-is-not`이다.
- relations: `case:apply-is-a-setting-approval-is-not` · `reference:a-binary-approval-codec-must-round-trip`
---
## TOPIC 15 — self-disclosure-grading
### CONCEPT — 네 단계 공시 등급 — modelled에서 production-verified까지
- slug: `four-grade-disclosure`
- readiness: `READY`
- source: `final/document.md#7-5` · `analysis/16` §45.2, §45.3
- code: `src/adapter/inbound/graphql/CLAUDE.md`의 등급 정의와 13행 표
- classification: graphql leaf가 자기 능력을 네 등급으로 공시하는 체계의 설명이다 — `modelled`(타입은 있고 조립 없음) · `wired`(조립됨) · `integration-verified`(통합 테스트로 확인) · `production-verified`(운영 관측). 그리고 규칙 하나 — "현재 등급보다 높게 표현하지 않는다". 이 체계가 왜 작동했는지가 핵심이다 — 13행 중 일곱을 **스스로 강등**했고, 이 분석이 파일 단위 배선 데이터와 전수 대조했을 때 12행이 일치했다. 타입 이름이 등급을 인코딩하는 관행(`*Admission` — "Spring transport handler는 없다, 그래서 타입 이름도 `*Admission`이다")도 이 개념의 일부다.
- missing-verification: 없음 — 13행 전수 대조
- relations: `case:seven-rows-downgraded-by-the-family-itself` · `reference:grades-may-understate-never-overstate` · `decision:capability-grade-is-declared-not-inferred`
### CASE — 등급표 13행 중 일곱을 스스로 강등하고 한 행만 관측과 어긋났다
- slug: `seven-rows-downgraded-by-the-family-itself`
- readiness: `READY`
- source: `final/document.md#7-5` · `analysis/16` §20.1, §29.1, §45.2
- code: `src/adapter/inbound/graphql/CLAUDE.md` 등급표 · `.../graphql/http/...`(해당 행의 대상 타입)
- evidence: `evidence/raw/213-inbound-graphql-release-probes.txt`
- classification: `CLAUDE.md`가 4등급을 정의하고 "현재 등급보다 높게 표현하지 않는다"를 규칙으로 선언한 뒤 13행 중 일곱을 스스로 `modelled`로 강등했다. sub-scope 02~06의 파일 단위 배선 데이터와 13행을 전수 대조한 결과 **12행 일치**, `요청 크기/Accept 협상 (http/)` 한 행만 불일치이고 **어긋나는 방향이 표가 금지한 "높게 표현" 쪽**이다 — 인용된 두 증거가 endpoint 테스트가 아닌 순수 단위 테스트이고 대상 타입은 미배선이다. 그리고 기계 매니페스트(`GrpcStableCapabilityManifest`에 해당하는 graphql 쪽)와 사람이 읽는 등급표가 커서에 대해 서로 다른 답을 준다.
- missing-verification: 없음 — 13행 전수 대조
- relations: `concept:four-grade-disclosure` · `reference:grades-may-understate-never-overstate` · `case:a-startup-validator-that-requires-a-key-nothing-signs-with`
### CASE — 시작 검증기가 커서 서명 키를 요구하는데 그 키로 서명하는 코드가 없다
- slug: `a-startup-validator-that-requires-a-key-nothing-signs-with`
- readiness: `READY`
- source: `final/document.md#7-1` · `analysis/16` §24.1
- code: `.../graphql/.../HmacGraphQlCursorCodec.java` · `.../GraphQlCursorKeyRing.java` · 시작 검증기
- evidence: `evidence/raw/212-inbound-graphql-data-probes.txt`
- classification: 시작 검증기가 "unsigned cursors are client-editable"로 프로덕션을 거부하며 커서 서명 키를 요구하고, 설정이 그 키를 받고, 액추에이터가 그것을 확인해 준다 — **세 신호가 전부 긍정**이다. 그런데 그 키로 서명하는 코드가 없다(`HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 배선 0). 저장소 자신의 테스트가 이 상태를 알고 있다 — `GraphQlPolicyRequestPathTest`(GQL-INT-003)가 javadoc으로 기록하고 "this test fails the moment somebody wires one half without the other"로 고정한다. 현재 어떤 feature에도 페이지네이션이 붙지 않아 조작 대상 커서 자체가 없다는 것이 위험을 낮춘다.
- missing-verification: 부팅해서 세 신호를 직접 관측하지 않았다
- relations: `concept:four-grade-disclosure` · `reference:a-bean-is-not-composition-evidence` · `concept:signed-cursor-structure`
### CASE — build-only 등급이 90개 파일의 미조립을 오늘의 사고에서 면제한다
- slug: `build-only-exempts-ninety-files-from-todays-incident`
- readiness: `READY`
- source: `final/document.md#8-3` · `analysis/17` §4.1, §26.6
- code: `src/config/architecture/modules.json`(`adapter-inbound-websocket``runtime_memberships: []`) · `.../ConditionalTransportCompositionContractTest.java``BUILD_ONLY_TRANSPORTS`
- evidence: `evidence/raw/228-app-bootstrap-activation-probes.txt`
- classification: websocket leaf의 `backend.websocket` 플랫폼 약 90개 main 파일에 조립 지점이 없고 세 안전 장치(`WebSocketPlatformStartupValidator` 125줄 · `WebSocketStackExclusivity` 78줄 · settings의 safe-default 규약)가 전부 호출자 0이다. 처음에 P1로 기록했다가 `runtime_memberships=[]`가 기계로 강제되는 build-only 등급임을 확인하고 P2로 내렸다 — 그 실패 시나리오가 현재 출하되는 두 조합 어디에서도 발생할 수 없다. 등급이 심각도를 바꾸되 발견을 없애지는 않는다는 것이 이 Case의 요점이다.
- missing-verification: 없음
- relations: `reference:runtime-membership-decides-severity` · `reference:grades-may-understate-never-overstate` · `decision:grpc-stays-build-only-until-the-bridge-is-decided`
### REFERENCE — 등급은 네 단계로 나누고 관측보다 높게 적지 않는다
- slug: `grades-may-understate-never-overstate`
- readiness: `READY`
- source: `final/document.md#7-5` · `analysis/16` §45.3 · `analysis/20` §2.6
- classification: 능력 등급표의 각 행은 "이 능력이 어디까지 검증됐는가"를 말한다. 낮게 적는 것은 보수적이고 안전하지만 높게 적으면 그 행을 읽은 팀이 검증되지 않은 것에 의존한다. 네 단계(`modelled`/`wired`/`integration-verified`/`production-verified`)가 유용한 이유는 "구현됐다"와 "조립됐다"와 "검증됐다"가 다른 사실이기 때문이다.
- scope: capability 등급표·지원 매트릭스·readiness card. 기계가 소비하는 매니페스트가 따로 있으면 둘 사이의 불일치도 같은 규칙으로 판정한다.
- exceptions: build-only 가족은 전부 `modelled` 근처에 머무는 것이 정상이고 그 자체가 결함이 아니다 — 다만 그 사실을 운영 문서가 말해야 한다.
- relations: `concept:four-grade-disclosure` · `case:seven-rows-downgraded-by-the-family-itself` · `reference:runtime-membership-decides-severity`
### REFERENCE — `runtime_memberships`를 먼저 읽고 심각도를 정한다
- slug: `runtime-membership-decides-severity`
- readiness: `READY`
- source: `final/document.md#9` 규칙 1 · `analysis/17` §26.6 · `analysis/19` §1.1 · `analysis/20` §1.1
- classification: 같은 미조립도 그 leaf가 런타임에 오르는지에 따라 "오늘의 사고"와 "채택 시점의 부채"로 갈린다. 기준은 레지스트리의 `runtime_memberships`이고, 그것이 기계로 강제되면(`verifyRuntimeModuleMembership`, 전송별 contract test) 판정의 근거가 된다.
- scope: 모듈 레지스트리를 갖는 모든 멀티모듈 프로젝트. 이 저장소는 62 leaf 중 27이 build-only(729 파일 / main 479)다.
- exceptions: build-only leaf라도 **조립되는 소수의 bean**이 있으면 그 부분은 오늘의 문제다 — gRPC의 `GrpcAdmissionController`가 9개 bean 중 하나인 경우가 그것이다.
- relations: `case:build-only-exempts-ninety-files-from-todays-incident` · `decision:grpc-stays-build-only-until-the-bridge-is-decided` · `reference:grades-may-understate-never-overstate`
### DECISION — 능력 등급은 코드가 아니라 실행된 증거에서 파생한다
- slug: `grades-derive-from-executed-evidence`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-5` · `analysis/19` §6.6 · `analysis/16` §45.3
- decision-evidence: `CompatibilityMatrix.Entry.hasLiveBrokerCertification()`의 파생 구현과 javadoc · graphql `CLAUDE.md`의 등급 규칙 · `docs/adr/ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md`
- grounds: `concept:four-grade-disclosure` · `reference:agreement-between-documents-proves-nothing`
- classification: 등급을 선언된 boolean이 아니라 레인이 낸 증거에서 계산한다. messaging이 그 전환을 명시적으로 기록한다 — "As a field it was a boolean an author set next to the tier, and RabbitMQ carried `true` while no fault scenario had ever been executed against it." graphql은 사람이 읽는 표에 같은 규칙을 적용해 일곱 행을 강등했고, gRPC는 capability별 promotion을 ADR로 고정했다.
- relations: `decision:capability-grade-is-declared-not-inferred` · `concept:four-grade-disclosure`
---
## TOPIC 16 — multitenancy-isolation
### CONCEPT — RLS가 성립하기 위한 세 전제
- slug: `rls-three-preconditions`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §13.1
- code: `db/experimental-rls/V1__tenant_rls.sql` · `.../experimental/multitenancy/RlsPolicyVerifier.java` · `.../RlsTenantSessionBinder.java`
- classification: PostgreSQL RLS가 실제로 격리하려면 세 가지가 동시에 참이어야 한다는 설명이다. (1) `ENABLE ROW LEVEL SECURITY` — 테이블에 policy를 켠다. (2) `FORCE ROW LEVEL SECURITY`**테이블 OWNER에게도** 적용한다. 없으면 owner는 자기 policy에서 면제되고 **owner는 흔히 마이그레이션 롤이며 그것이 사람들이 테스트하는 롤이다.** (3) 런타임 롤이 `BYPASSRLS`를 갖지 않는다 — 이것은 테이블 속성이 아니라 롤 속성이라 startup에서 assert해야 한다. policy가 `current_setting('app.tenant_id', true)`를 쓰는 이유(두 번째 인자 `true`가 미설정 시 raise 대신 NULL을 반환하고, NULL은 절대 `tenant_id`와 같지 않으므로 fail-closed)도 함께 다룬다.
- missing-verification: 실제 RLS 환경을 세워 세 전제를 하나씩 깨보지 않았다
- relations: `case:three-ways-rls-does-nothing` · `reference:hibernate-filter-is-not-a-security-boundary` · `reference:isolation-settings-must-be-transaction-local`
### CONCEPT — 네 가지 멀티테넌시 전략과 각각의 격리 경계
- slug: `four-multitenancy-strategies`
- readiness: `READY`
- source: `analysis/05` §13.2
- code: `.../experimental/multitenancy/`의 column·rls·schema·database 구현
- classification: 이 저장소가 네 전략(discriminator column · RLS · schema-per-tenant · database-per-tenant)을 각각 플래그 뒤에 두고 구현한 구조의 설명이다. 각 전략의 격리 경계가 다르고 실패 모드도 다르다 — column은 predicate 누락이 곧 유출, RLS는 세 전제(§concept:rls-three-preconditions), schema는 `search_path` 잔존, database는 커넥션 예산의 곱셈. `TenantId`의 정규식이 네 전략 전부의 공통 기반인 이유(스키마 이름·`set_config` 값·라우팅 키에 들어가므로 `../public` 같은 값이 path-traversal이 된다)도 다룬다.
- missing-verification: 네 전략 중 어느 것도 실제로 켜서 관측하지 않았다 — 전부 experimental 플래그 뒤에 있다
- relations: `concept:rls-three-preconditions` · `case:tenant-pools-summed-past-the-server-ceiling` · `decision:classpath-presence-is-not-consent`
### CASE — RLS가 아무것도 하지 않는 세 가지 방법
- slug: `three-ways-rls-does-nothing`
- readiness: `READY`
- source: `final/document.md#4-1` · `analysis/05` §13.1, §17 P8
- code: `.../experimental/multitenancy/RlsPolicyVerifier.java` · `db/experimental-rls/V1__tenant_rls.sql`
- evidence: 없음 — verifier가 검사하는 세 조건과 마이그레이션 주석
- classification: `RlsPolicyVerifier`가 RLS가 조용히 무력화되는 세 경로를 전부 확인한다 — policy 없음/RLS 미활성 · 런타임 롤이 `BYPASSRLS` 보유 · **런타임 롤이 테이블을 소유**(FORCE 없으면 면제). 세 번째가 가장 놓치기 쉽다. 그리고 unique index 하나에도 같은 축의 주석이 있다 — tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요구**에 그 컬럼이 들어가야 하고, `(value)`만의 unique index는 다른 tenant가 그 값을 썼다는 이유로 insert를 실패시켜 **버그이자 정보 유출**이 된다. 다만 `analysis/05` §97이 기록하듯 verifier 자신이 "반드시 보호돼야 하는 table"의 부재를 성공으로 인정하는 결함을 갖는다.
- missing-verification: 세 조건을 하나씩 깨서 verifier가 실제로 잡는지 확인하지 않았다
- relations: `concept:rls-three-preconditions` · `reference:hibernate-filter-is-not-a-security-boundary` · `reference:tenant-column-belongs-in-every-unique-constraint`
### CASE — `search_path`가 풀로 돌아간 커넥션에 남아 다음 tenant가 상속한다
- slug: `search-path-survived-the-return-to-the-pool`
- readiness: `READY`
- source: `analysis/05` §13.2
- code: `.../experimental/multitenancy/SchemaMultiTenantConnectionProvider.java`의 reset 구현과 javadoc
- evidence: 없음 — 구현과 javadoc
- classification: `search_path`는 세션 설정이라 풀로 돌아간 커넥션이 여전히 마지막 tenant의 스키마를 들고 있다. 다음 borrower — 아마 다른 tenant, 또는 tenant 없는 백그라운드 job — 가 **어떤 statement도 틀리지 않은 채로** 거기서 읽고 쓴다. 해법은 반환 시 `NEUTRAL_SCHEMA = "pg_catalog"`로 되돌리는 것이다. 같은 성질이 H2의 로컬 타임아웃 설정에도 있고(세션 스코프라 트랜잭션이 끝나도 남는다) 그쪽은 매 트랜잭션 전 재적용으로 실무상 가려진다.
- missing-verification: 풀 재사용에서 잔존을 실제로 관측하지 않았다
- relations: `reference:isolation-settings-must-be-transaction-local` · `reference:session-scoped-settings-outlive-the-transaction` · `concept:four-multitenancy-strategies`
### CASE — tenant별 풀이 개별적으로 합리적이고 그 합이 서버 상한을 넘긴다
- slug: `tenant-pools-summed-past-the-server-ceiling`
- readiness: `READY`
- source: `analysis/05` §13.2
- code: `.../experimental/multitenancy/TenantPoolBudget.java`의 두 ceiling과 javadoc
- evidence: 없음 — 구현과 javadoc
- classification: database-per-tenant는 특정한 방식으로 실패한다 — 각 tenant의 풀은 개별적으로 합리적이고 **그 합이 아니다.** 50 tenant × 10 커넥션 = `max_connections`가 100인 서버에 500 커넥션이고, 실패는 **idle이던 것 포함 모든 tenant에 동시에** connection refusal로 도착한다. `TenantPoolBudget`이 두 ceiling(풀 개수와 커넥션 총합)을 갖는 이유도 적혀 있다 — pool count만으로는 풀 크기 차이를 무시하고, connection total만으로는 각자 스레드와 모니터링을 가진 무한한 수의 작은 풀을 허용한다. 다만 `analysis/05` §98이 기록하듯 예산이 **새 pool 크기를 계산하지 않아** ceiling을 넘길 수 있다.
- missing-verification: 다중 tenant 풀을 실제로 세워 상한 초과를 재현하지 않았다
- relations: `concept:requires-new-connection-cost` · `concept:four-multitenancy-strategies` · `reference:deadline-narrows-in-three-stages`
### REFERENCE — Hibernate filter는 보안 경계가 아니다
- slug: `hibernate-filter-is-not-a-security-boundary`
- readiness: `READY`
- source: `analysis/05` §13.2
- classification: Hibernate filter는 엔티티 쿼리에 적용되고 native SQL·bulk DML·`getReference`·L2 캐시를 통해 도달하는 것에는 적용되지 않는다. 그래서 filter만 믿는 설계는 다른 tenant의 행으로 가는 경로 여러 개를 열어 둔다. 기준은 "이 격리를 우회하는 경로가 몇 개인가"이고, ORM 기능은 그 답이 항상 0이 아니다.
- scope: tenant·소유자·가시성 격리 전반. 실제 경계는 DB(RLS)나 별도 가드(`TenantAwareRepositoryGuard`)여야 하고, filter는 편의로만 쓴다.
- exceptions: 읽기 경로만 있고 native·bulk가 구조적으로 금지된 좁은 컨텍스트면 filter로 충분할 수 있다. 다만 그 금지를 ArchUnit 같은 것이 강제해야 하고, 강제가 없으면 전제가 유지되지 않는다.
- relations: `concept:rls-three-preconditions` · `case:three-ways-rls-does-nothing` · `reference:tenant-column-belongs-in-every-unique-constraint`
### REFERENCE — tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다
- slug: `tenant-column-belongs-in-every-unique-constraint`
- readiness: `READY`
- source: `analysis/05` §13.1
- classification: tenant 격리가 컬럼에 의존하면 그 테이블의 모든 uniqueness 요구가 그 컬럼을 포함해야 한다. `(value)`만의 unique index는 다른 tenant가 그 값을 이미 썼다는 이유로 한 tenant의 insert를 실패시키는데, 그것은 **버그이자 정보 유출**이다 — 존재하지 않아야 할 행의 존재를 알려준다.
- scope: discriminator column 전략을 쓰는 모든 테이블. 같은 논리가 partial unique index와 exclusion constraint에도 적용된다.
- exceptions: 전역적으로 유일해야 하는 값(외부 시스템의 식별자)은 tenant를 포함하지 않는 것이 맞다. 다만 그때는 그 값이 tenant 간에 노출되는 것이 의도임을 적어야 한다.
- relations: `case:three-ways-rls-does-nothing` · `reference:hibernate-filter-is-not-a-security-boundary`
### REFERENCE — 격리 설정은 트랜잭션 로컬이어야 한다
- slug: `isolation-settings-must-be-transaction-local`
- readiness: `READY`
- source: `analysis/05` §13.1, §13.2
- classification: tenant 바인딩·`search_path`처럼 격리를 결정하는 세션 설정은 트랜잭션과 함께 되돌아가야 한다. 세션 스코프면 풀로 돌아간 커넥션이 그것을 들고 있고 다음 borrower가 상속한다. PostgreSQL에서는 `set_config(name, value, true)`의 세 번째 인자가 그것을 보장한다.
- scope: RLS tenant 바인딩·schema 라우팅·타임아웃 등 세션 상태로 표현되는 모든 격리. 대응물이 없는 벤더에서는 반환 시 명시적 reset이 대안이다.
- exceptions: 커넥션이 tenant에 고정 할당되는 database-per-tenant에서는 세션 스코프가 문제가 되지 않는다. 다만 그때는 풀 예산이 새 문제가 된다.
- relations: `case:search-path-survived-the-return-to-the-pool` · `reference:session-scoped-settings-outlive-the-transaction` · `concept:rls-three-preconditions`
### DECISION — 클래스패스에 있는 것은 실행 동의가 아니다
- slug: `classpath-presence-is-not-consent`
- readiness: `READY` · decision-status: `ADOPTED`
- source: `final/document.md#10-3` · `analysis/05` §13
- decision-evidence: `.../experimental/ExperimentalFeatureGate.java`의 javadoc · 세 클래스(`RlsTenantSessionBinder`·`ConsistencyAwareDataSourceRouter`·`SchemaTenantMigrationOrchestrator`)의 package-private 생성자와 `enabledBy` 정적 팩토리
- grounds: `concept:four-multitenancy-strategies` · `reference:hibernate-filter-is-not-a-security-boundary`
- classification: experimental 모듈은 transitive하게 도착할 수 있고, jar가 있다는 이유로 스스로 켜지는 tenant-isolation이나 replica-routing 기능은 가능한 최악의 기본값이다. 게이트가 **결정의 부재를 활성화가 아니라 에러**로 만든다. 그리고 그것이 실제로 강제되도록 세 클래스가 생성자를 package-private으로 바꾸고 `enabledBy` 팩토리만 남겼다 — "게이트를 static이 아니라 **파라미터로** 받는 게 요구사항을 시그니처의 일부로 만든다: caller는 컴파일러가 요구하는 인자를 잊을 수 없다."
- relations: `concept:four-multitenancy-strategies` · `reference:runtime-membership-decides-severity` · `decision:one-root-owns-the-master-switch`
---
# Decomposition checklist
## Source integrity
- [x] 문서 해시(`10eabd7a…`)와 리비전(`21234e38…`)을 헤더에 기록
- [x] 모든 노드에 source 앵커 하나 이상
- [x] source 앵커가 제목이 함의하는 내용을 실제로 담고 있음
- [x] 런타임 의존 주장은 evidence를 명명하거나 `missing-verification`에 적었고, 실행이 필요한 것은 대응하는 Open Question을 갖는다
## Topic quality
- [x] 16개 Topic 전부 공학 문제 공간이며 디렉터리 이름이 아니다 — 각각 여러 leaf/모듈을 가로지른다
- [x] 두 Topic이 같은 인과 사슬을 임의로 쪼개지 않음. 인접한 쌍의 구분 근거:
- `assembly-ownership`(조립이 안 됨) ↔ `what-a-gate-does-not-prove`(검증이 그것을 못 잡음) — 다른 질문이다
- `owner-safe-state-machines`(소유권을 SQL에 적기) ↔ `transaction-deadline-and-pool`(시간과 커넥션 예산) — 같은 leaf지만 다른 실패 축이다
- `bounding-by-type`(값의 도메인) ↔ `drift-direction`(문서의 진술) — 전자는 코드, 후자는 문서다
- `duplicate-mechanisms`(둘 중 약한 쪽이 조립됨) ↔ `redis-command-admission`(우회 경로의 수) — 후자는 한 leaf의 구체적 admission 구조다
- [x] 가장 큰 Topic(`assembly-ownership` 18노드)이 전부 같은 판정 기준을 공유
## Concept
- [x] 각 Concept이 사건이 아니라 메커니즘의 설명이다
- [x] Concept이 Case의 요약이 아니다 — 각 Concept은 여러 Case가 참조하는 구조를 다룬다
- [x] 본문에 넣을 코드·상태도·표의 출처가 source 앵커에 있다
- [x] 계약 파일이 이 종류를 아직 기술하지 않는다는 사실을 트리 머리말에 적었다
## Case
- [x] 각 Case에 구체적 사건·실험·실패·진단·검증 시퀀스가 있다
- [x] 제목이 역사를 지어내지 않아도 이해된다
- [x] 결론이 실제 증거로 경계 지어졌고, 증거 없는 추론은 `missing-verification`에 적었다
- [x] 저장소의 javadoc·마이그레이션 헤더가 사후 기록인 경우 그 사실을 evidence 항목에 적었다
## Reference
- [x] 각 규칙이 원 사건 밖에서 재사용 가능 — 프로젝트 이름을 지워도 규칙이 남는지 확인
- [x] Case 요약을 선언형으로 바꾼 것이 아님 — `scope``exceptions`가 사건 밖의 조건을 말한다
- [x] `exceptions`를 전부 source에서 진술. 특히 다음 넷은 반대 사례가 실재한다:
- `a-bean-is-not-composition-evidence` — build-only leaf의 공개 API 표면
- `a-gate-nobody-runs-reports-the-last-run` — 대체 경로가 있는 messaging 인증 레인
- `check-which-duplicate-is-wired` — 조립된 쪽이 더 나은 자격 증명 회전
- `the-startup-validator-follows-the-autoconfiguration-root` — 루트가 있는데도 부르지 않는 둘
## Open Question
- [x] 답이 분석 안에 이미 있지 않음 — 여덟 개 전부 실행 또는 부팅이 필요하다
- [x] known / unknown / next-verification이 분리됨
- [x] 후보 선택지를 지어내지 않음
- [x] `container-lanes-not-executed`가 나머지 네 개(commit-ambiguity · v2-state-machine · redis-topology · pool-contract)의 상위 집합임을 relations로 표시
## Decision
- [x] 프로젝트 선택이 명시적으로 기록됨 — ADR 5건(`ADR-JPA-002`·`ADR-JPA-003`·`ADR-JPA-004`·`ADR-GRPC-001`·`ADR-GRPC-003`·`ADR-GRPC-ADV-001`) + 코드/`build.gradle`/마이그레이션 헤더의 근거 주석
- [x] "기술이 있다"를 근거로 쓰지 않음
- [x] 권고에 그치는 것은 `NEEDS_DECISION``widen-doc-contract-assertions` 하나
## Duplication
- [x] 두 노드가 같은 일차 목적을 갖지 않음
- [x] 내용 복사 대신 relations 사용
- [x] Topic을 가로지르는 참조가 있는 노드는 양쪽에서 relations로 연결했다 — 예: `a-release-gate-with-no-evidence-producer``what-a-gate-does-not-prove`에 속하고 `learning-transfer-between-families`가 참조한다
---
# Generation summary
| kind | 총계 | 생성 가능 | 생성 차단 |
|---|---|---|---|
| CONCEPT | 21 | 21 | 0 |
| CASE | 66 | 66 | 0 |
| REFERENCE | 49 | 49 | 0 |
| OPEN QUESTION | 7 | 7 (`OPEN`) | 0 |
| DECISION | 26 | 25 (`READY`) | 1 (`NEEDS_DECISION`) |
| **합계** | **169** | **168** | **1** |
## Topic별 분포
| # | Topic | CON | CASE | REF | OQ | DEC | 계 |
|---|---|---|---|---|---|---|---|
| 1 | `commit-ambiguity-as-a-result` | 2 | 4 | 4 | 1 | 3 | 14 |
| 2 | `assembly-ownership` | 2 | 8 | 6 | 2 | 2 | 20 |
| 3 | `what-a-gate-does-not-prove` | 2 | 6 | 5 | 1 | 3 | 17 |
| 4 | `bounding-by-type` | 2 | 3 | 4 | 0 | 2 | 11 |
| 5 | `duplicate-mechanisms` | 0 | 5 | 3 | 0 | 1 | 9 |
| 6 | `drift-direction` | 0 | 6 | 2 | 0 | 2 | 10 |
| 7 | `learning-transfer-between-families` | 0 | 2 | 1 | 0 | 1 | 4 |
| 8 | `owner-safe-state-machines` | 3 | 5 | 4 | 1 | 2 | 15 |
| 9 | `transaction-deadline-and-pool` | 2 | 3 | 3 | 1 | 2 | 11 |
| 10 | `schema-ownership-and-capability-streams` | 1 | 5 | 3 | 0 | 2 | 11 |
| 11 | `redis-command-admission` | 1 | 4 | 2 | 1 | 1 | 9 |
| 12 | `http-failure-classification` | 1 | 2 | 2 | 0 | 1 | 6 |
| 13 | `fileserver-state-and-fencing` | 1 | 4 | 3 | 0 | 1 | 9 |
| 14 | `objectstorage-staged-lifecycle` | 1 | 3 | 2 | 0 | 1 | 7 |
| 15 | `self-disclosure-grading` | 1 | 3 | 2 | 0 | 1 | 7 |
| 16 | `multitenancy-isolation` | 2 | 3 | 3 | 0 | 1 | 9 |
| | **합계** | **21** | **66** | **49** | **7** | **26** | **169** |
빈 브랜치는 채우지 않았다 — `duplicate-mechanisms`·`drift-direction`·`learning-transfer-between-families`
CONCEPT, 그리고 열 개 Topic의 OPEN QUESTION. 대칭은 품질 목표가 아니라는 계약 조항을 따랐다.
## 모듈 문서별 기여
| 모듈 문서 | 줄 | 기여 노드 |
|---|---|---|
| `05` persistence-jpa | 4,674 | **48** |
| `19` messaging-platform | 1,284 | 21 |
| `20` grpc-platform | 716 | 13 |
| `14` adapter-inbound-web | 1,702 | 9 |
| `06` persistence-mongo | 1,496 | 6 |
| `10` cache-redis | 933 | 9 |
| `08` fileserver | 656 | 8 |
| `16` graphql | 1,122 | 5 |
| `09` objectstorage | 793 | 6 |
| `11` httpclient | 716 | 5 |
| `13` notification | 1,062 | 4 |
| `18` app-bootstrap | 571 | 5 |
| `17` websocket | 546 | 3 |
| `02` shared-contract · `04` support · `07` identifier · `01` domain-core | 1,209 | 6 |
| `99` cross-scope | 364 | (전 Topic의 relations 기반) |
`03` application-core와 `12` adapter-outbound-messaging은 독립 노드를 갖지 않는다 — 전자는 port 계약이
다른 Topic의 Case 안에서 다뤄지고, 후자는 MSG-015가 `assembly-ownership`
`learning-transfer-between-families`에 나뉘어 들어갔다.
---
# 터미널 증거 자산
**정본은 `raw`이고 SVG는 표현이다**(도구 README의 Evidence rule). 각 `meta/*.json`
command · cwd · executedAt · exitCode · sourceRevision을 담는다. 8종 전부 exit 0이고 출력에
secret이 없어 redaction은 발생하지 않았다.
| 자산 | 연결된 노드 | raw | 한 화면에 보이는 것 |
|---|---|---|---|
| `tl-kafka-dedup-drift` | `case:support-matrix-said-the-opposite-of-the-code` | 10줄 | 코드 배열 10번째 `false` · 문서 표 같은 행 `O` · 그 플래그를 강제하는 유일한 지점 |
| `tl-web-six-unowned-components` | `case:scan-exclusion-without-an-owner` · `concept:three-assembly-paths` | 29줄 | 여섯 컴포넌트 애노테이션과 mainRefs=0 · 두 자동설정이 실제로 도달함 · `@Bean` 13/10 · `@Import` 0 · 13개가 전부 협력자 |
| `tl-grpc-release-gate-no-producer` | `case:a-release-gate-with-no-evidence-producer` | 15줄 | 생성 지점 4곳이 전부 자기 테스트 · Gradle 태스크 0 · CI 0/28 · 대조군 messaging |
| `tl-outbox-unsatisfiable-condition` | `case:outbox-chain-behind-an-unsatisfiable-condition` | 26줄 | 조건 사슬 · 두 뿌리 port 구현 mainRefs=0 · 같은 starter의 기본 구현 대조 |
| `tl-messaging-observation-noop` | `case:observation-downgraded-by-the-composition` | 36줄 | 6인자 생성자 선택 · `NO_OBSERVATION` 위임 · 방출자 넷 0 대 협력자 둘 |
| `tl-rotation-defect-reproduced` | `case:the-same-rotation-defect-closed-once-and-reproduced` | 19줄 | messaging 계약 테스트 문장 · gRPC의 `get()`/`set()` 지점 · CAS 0건 · 경합을 아는 javadoc |
| `tl-platform-suite-results` | `case:the-second-platform-carried-the-design-not-the-wiring` · `open-question:container-lanes-not-executed` | 13줄 | 두 가족 스위트 집계(851/579, 0 실패 0 skip) · 증거 레인 3종 · **미실행 레인 목록** |
| `tl-grpc-evidence-lanes` | 여러 Case 공통 | 63줄 | 증거 레인 3종 Gradle 실행 원문 — Netty가 실제 소켓을 열고 통과 |
## 기존 probe에서 재캡처가 필요한 자산
아래는 원문이 `evidence/raw/`에 있으나 4줄 헤더(`# evidence NNN — slug` 등)를 포함한 형식이다.
렌더러는 명령 출력 원문을 기대하므로 record 작성 시 헤더 없는 clean raw로 재캡처한다.
| 노드 | 기존 raw |
|---|---|
| `case:high-water-mark-swallowed-a-redelivered-change` | `135-mongo-change-stream-probe.txt` · `135a-…java` |
| `case:pii-through-an-exception-message` | `021-support-logger-pii-probe.txt` · `021a-…java` |
| `case:scriptable-detection-bypassed-by-a-bom` | `146-fileserver-*-probes.txt`의 EXECUTION PROBE 블록 |
| `case:the-readme-recipe-does-not-start` | `129-mongo-empty-type-registry-write-probe.txt` · `129a-…java` |
| `case:a-build-gate-that-is-not-in-the-build` | `163-redis-*-probes.txt` §8.1 |
| `case:five-adapters-bypass-the-single-admission-point` | `165-redis-*-probes.txt` §8.1 |
| `case:apply-is-a-setting-approval-is-not` | `155-objectstorage-*-probes.txt` §8.1 |
| `case:seven-rows-downgraded-by-the-family-itself` | `213-inbound-graphql-release-probes.txt` |
## 터미널 증거가 맞지 않는 노드
근거가 명령 출력이 아닌 것들이다. record 작성 시 코드 인용·마이그레이션 헤더 인용·diff로 대체한다.
- 저장소의 javadoc·마이그레이션 헤더가 사후 기록인 Case — `two-owners-popped-the-evidence-frame`,
`autoconfiguration-in-name-only`, `a-lease-without-an-owner`, `h2-hid-a-column-type-mismatch`,
`a-customizer-that-discarded-the-bound-property`, `a-five-second-string-that-broke-every-prod-deploy`
- 코드 경로 비교가 근거인 Case — `a-weaker-private-copy-on-the-wired-path`,
`a-policy-reversed-by-a-later-filter`, `endpoint-check-only-on-upload`
- 컨테이너가 필요해 아직 실행하지 못한 것 — `commit-ambiguity-is-not-only-sqlstate-08`(대응 Open Question 있음)
`evidence/svg/`는 비워 뒀다. 다이어그램은 관계를 설명할 수 있으나 사건의 발생을 증명하지 못한다는
규칙(`writing-tech-log-from-analysis` §Evidence)에 따라, 필요한 Case에서 개별 판단한다.