{ "schemaVersion": 4, "project": "clean-architecture-backend-template", "ssot": "final/document.md", "sourceRepository": { "path": "/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template", "revision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "verified": "git rev-parse HEAD 가 이 값과 같다 (2026-09-07 확인)" }, "ssotSha256": "74b4986fd8621f6fd61791232777dc0e6c8ef999cbc2343df050bd5921ea03ac", "sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "generatedAt": "2026-09-08", "candidateScope": { "document": "final/document.md", "sections": [ "§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11" ], "excluded": [ "제2부 — 모듈 분석 전문", "제3부 — 분석 재료" ], "note": "제1부(§0~§14 + 부록)가 통합 분석이고 그 가운데 §3~§11 이 후보를 찾는 범위다. 제2부의 모듈 분석 65편과 제3부의 분석 재료는 근거이지 후보 자리가 아니다. 글감의 source 는 제1부 앵커를 적어도 하나 갖고, 제2부 앵커는 그 주장을 상세히 확인하는 자리로만 붙인다", "excludedAnchorPattern": "#`?(?:a\\d\\d|a\\d\\d-|부록)" }, "note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸은 사람이 적고 file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py clean-architecture-backend-template", "contract": { "decomposition": [ "**분해 기준.** Topic은 공학 문제 공간이고 디렉터리가 아니다. 한 Topic 안의 Case들은 서로 다른", "메커니즘을 갖되 같은 판정 기준과 어휘를 공유한다. Reference는 그 Topic의 Case를 지우고도 남는", "규칙만 올린다. 빈 브랜치는 채우지 않는다.", "노드 수가 많은 이유는 분모가 크기 때문이다 — 62 leaf · main Java 4,614 파일 · 320,318 LOC ·", "모듈 분석 65편(최상위 22 · messaging 25 · grpc 18)이 `final/document.md` 제2부에 들어 있다.", "`final/document.md#a05`(persistence-jpa) 하나가 4,950줄이고 그 안에 P1급만 열 건이 넘는다.", "**CONCEPT 브랜치에 대하여.** Studio/project contract가 explanatory body record를 지원하므로", "Concept를 정식 kind로 사용한다. `root-tree-contract.md`, `record-kinds.md`, review checklist와", "writing template도 동일 기준으로 맞췄다.", "- **CASE** — 재현하고 검증한 하나의 사건. \"이때 이런 일이 일어났고 이렇게 확인했다.\"", "- **CONCEPT** — 이 코드베이스에 실제 구현된 메커니즘·상태 모델·경계의 설명. 일반 튜토리얼이 아니다.", "- **REFERENCE** — 그 메커니즘에서 뽑은 재사용 기준. 규칙·적용조건·예외로 나뉜다.", "셋의 관계는 보통 CONCEPT ← CASE → REFERENCE다. 같은 근거에서 여러 kind가 필요하면 한 기록에", "섞지 않고 관계로 연결한다." ], "readinessValues": [ "READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED" ], "dispositionValues": { "PROMOTE": "독립 Tech Log 로 쓴다", "MERGE_INTO": "다른 기록의 한 절로 흡수한다", "KEEP_IN_SSOT": "분석에는 남기고 독립 기록으로 만들지 않는다 — 정상적인 성공 결과다", "NEEDS_EVIDENCE": "주장에 아직 검증이 없다", "NEEDS_DECISION": "방향이 그럴듯하지만 프로젝트가 정하지 않았다", "BLOCKED": "원본이 불완전하거나 서로 어긋난다" } }, "counts": { "topics": 16, "nodes": 123, "written": 112, "unwritten": 11, "unlisted": 0, "candidates": 1088 }, "topics": { "commit-ambiguity-as-a-result": { "topic": "commit-ambiguity-as-a-result", "title": "커밋 모호성 — 「모른다」를 결과로 유지하기", "readerQuestion": "커밋 결과를 확정할 수 없을 때 무엇을 기록하고 어떤 후속 행동을 허용할 것인가?", "kinds": { "case": [ { "title": "pg_terminate_backend 가 57P01 로 도착하고 커밋 레코드는 이미 WAL 에 있었다", "kind": "case", "slug": "commit-ambiguity-is-not-only-sqlstate-08", "readiness": "READY", "source": [ "final/document.md#4-1", "final/document.md#a05 §136", "final/document.md#a05 §139" ], "code": [ "CommitFailureClassifier", "CommitAmbiguityContractTest" ], "evidence": [ "evidence/raw/050-jpa-commit-ambiguity-probe.txt", "evidence/raw/115-integration-lane-original-verification.txt" ], "classification": "규칙이 틀렸다는 관측, 왜 틀렸는지의 진단, 규칙을 넓히고 계약 테스트로 고정한 결론이 한 사건 안에서 닫힌다", "missing-verification": "HEAD(21234e38)에서 레인을 다시 돌리지 않았다. 57P01 이 PostgreSQL 16·17·18 매트릭스 전부에서 같게 도착하는지도 재지 않았다", "relations": [ "concept:transaction-result-algebra", "reference:unknown-is-a-third-result", "decision:completion-unknown-is-never-retried", "question:commit-ambiguity-lane-not-rerun-at-head" ], "publication": "초안", "file": "commit-ambiguity-as-a-result/case/case-commit-ambiguity-is-not-only-sqlstate-08.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [ "../../../final/evidence/raw/050-jpa-commit-ambiguity-probe.txt", "../../../final/evidence/raw/115-integration-lane-original-verification.txt" ] } ], "concept": [ { "title": "트랜잭션 결과 대수 — 다섯 변형이 각각 답하는 질문", "kind": "concept", "slug": "transaction-result-algebra", "readiness": "READY", "source": [ "final/document.md#3-2", "final/document.md#a05" ], "basis-version": "Spring Boot 4.0.8 · Java 21 · 리비전 21234e38", "classification": "Indeterminate 가 무엇을 주장하지 않는 값인지 먼저 알아야 57P01 사례의 결론을 읽을 수 있다. 커밋 증거 단계는 이 개념의 한 절로 들어간다", "relations": [ "case:commit-ambiguity-is-not-only-sqlstate-08", "decision:completion-unknown-is-never-retried" ], "publication": "초안", "file": "commit-ambiguity-as-a-result/concept/concept-transaction-result-algebra.md", "status": "게시 전", "studioId": "", "assets": [ "commit-evidence-phase-machine" ], "assetFiles": [ "commit-evidence-phase-machine" ], "evidenceFiles": [] }, { "title": "발행 증거와 완료 판정이 따로 있는 이유", "slug": "publish-evidence-and-completion", "readiness": "READY", "source": [ "final/document.md#3-3", "final/document.md#a19 §3.2" ], "code": [ "PublishEvidence", "PublishCompletion", "TransmissionEvidence" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · messaging 플랫폼의 3축 실패 어휘", "classification": "아무것도 프로세스를 떠나지 않은 실패와 wire 위에 있던 실패가 다른 결론을 받아야 하는 이유를, compact constructor 가 표현 불가능한 조합을 거부하는 구조로 설명한다", "relations": [ "decision:record-the-evidence-first-choose-the-conclusion-later", "reference:unknown-is-a-third-result", "concept:transaction-result-algebra" ], "kind": "concept", "publication": "미작성" } ], "reference": [ { "title": "모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다", "kind": "reference", "slug": "unknown-is-a-third-result", "readiness": "READY", "source": [ "final/document.md#9", "final/document.md#3-3" ], "classification": "저장소 안에서 다섯 타입이 같은 형태로 구현돼 있고 다음 프로젝트에도 그대로 적용된다", "scope": "호출자가 재시도할지 정해야 하는 결과를 돌려주는 경계 — 트랜잭션 실행기, 발행 경로, 복제 지연 조회", "exceptions": "아무것도 프로세스를 떠나지 않은 실패는 확정이다. access 거부와 encode 실패는 REJECTED 이고 ambiguous 가 아니다", "relations": [ "case:commit-ambiguity-is-not-only-sqlstate-08", "decision:completion-unknown-is-never-retried" ], "publication": "초안", "file": "commit-ambiguity-as-a-result/reference/reference-unknown-is-a-third-result.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [ { "title": "커밋 모호성 레인이 HEAD 에서도 통과하는지는 실행이 아니라 드리프트 0 으로 답했다", "kind": "question", "slug": "commit-ambiguity-lane-not-rerun-at-head", "readiness": "OPEN", "source": [ "final/document.md#6-2", "final/document.md#11", "final/document.md#13" ], "known": "다섯 태그 레인이 a24ece9c 에서 244 tests · 0 failures 로 통과했다. jpaPlatformFailureTest 는 2 클래스 6 tests 이고 CommitAmbiguityContractTest 가 그 레인에 있다", "unknown": "HEAD(21234e38)에서 같은 레인을 돌린 결과. 지금 답은 실행이 아니라 리프 경로 변경 파일 0 이라는 드리프트 측정에서 나온다", "next-verification": "Docker 가 있는 환경에서 HEAD 를 체크아웃하고 :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --rerun-tasks 를 돌린다", "decision-criterion": "0 failures 면 닫는다. 실패하면 드리프트 0 근거를 철회하고 §6.2 정정을 다시 쓴다", "relations": [ "case:commit-ambiguity-is-not-only-sqlstate-08" ], "publication": "초안", "file": "commit-ambiguity-as-a-result/question/question-commit-ambiguity-lane-not-rerun-at-head.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [ "../../../final/evidence/raw/115-integration-lane-original-verification.txt" ] } ], "decision": [ { "title": "completion-unknown 은 자동으로도 수동으로도 재시도하지 않는다", "kind": "decision", "slug": "completion-unknown-is-never-retried", "readiness": "READY", "decision-status": "ADOPTED", "source": [ "final/document.md#10-2", "final/document.md#4-1" ], "decision-evidence": [ "case:commit-ambiguity-is-not-only-sqlstate-08" ], "grounds": "JpaFailureContext 생성자가 completionUnknown && retryable 조합을 던지고, TransactionCompletionUnknownException 과 RetryProfile 이 같은 조합을 각각 다시 막는다", "classification": "대안(모호성을 실패로 접고 재시도)을 두고 프로젝트가 방향을 정했고 감수한 비용이 기록돼 있다", "relations": [ "case:commit-ambiguity-is-not-only-sqlstate-08", "reference:unknown-is-a-third-result", "concept:transaction-result-algebra" ], "publication": "초안", "file": "commit-ambiguity-as-a-result/decision/decision-completion-unknown-is-never-retried.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "증거를 먼저 기록하고 결론은 나중에 고른다", "slug": "record-the-evidence-first-choose-the-conclusion-later", "readiness": "READY", "source": [ "final/document.md#10-2", "final/document.md#3-3", "final/document.md#a19 §3.2" ], "decision-status": "ADOPTED", "decision-evidence": "`PublishEvidence` 가 brokerAccepted·confirmationLevel·transmission 을 따로 담고 compact constructor 가 표현 불가능한 조합을 거부한다 (final/document.md#3-3)", "grounds": "저장된 결과만으로 운영자가 「브로커가 이 메시지를 들고 있을 수 있나」에 답할 수 있어야 한다. 결론만 남기면 그 질문에 답할 수 없고, ambiguous 로 뭉뚱그리면 어떤 브로커도 보지 못한 메시지에 대해 caller 를 reconciliation 으로 보낸다. 감수한 비용은 저장할 것이 늘어난다는 것이다", "classification": "프로젝트가 증거와 결론을 분리하는 쪽을 골랐고 타입이 그 분리를 강제한다", "relations": [ "concept:publish-evidence-and-completion", "decision:completion-unknown-is-never-retried", "reference:unknown-is-a-third-result" ], "kind": "decision", "publication": "미작성" } ] } }, "assembly-ownership": { "topic": "assembly-ownership", "title": "조립 소유권 — 통제와 그 의존을 같은 곳이 소유하기", "readerQuestion": "선언된 통제가 실제 출하 경로에 설치되었는지를 무엇으로 판정하는가?", "kinds": { "case": [ { "title": "스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다", "kind": "case", "slug": "scan-exclusion-without-an-owner", "readiness": "READY", "source": [ "`final/document.md#3-1`", "`final/document.md#a14` §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`" ], "publication": "초안", "file": "assembly-ownership/case/case-scan-exclusion-without-an-owner.md", "status": "게시 전", "studioId": "", "assets": [ "scan-exclusion-without-an-owner" ], "assetFiles": [ "scan-exclusion-without-an-owner" ], "evidenceFiles": [ "../../../final/evidence/raw/scan-exclusion-without-an-owner.txt", "../../../final/evidence/raw/tl-web-six-unowned-components.txt" ] }, { "title": "outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다", "kind": "case", "slug": "outbox-chain-behind-an-unsatisfiable-condition", "readiness": "READY", "source": [ "`final/document.md#4-3`", "`final/document.md#a19` §7.1" ], "code": [ "`.../messaging-spring-boot-starter/.../MessagingReliabilityAutoConfiguration.java`", "`.../messaging-outbox-jdbc-postgresql/.../JdbcOutboxRepository.java`", "`.../messaging-inbox-jdbc-postgresql/.../JdbcInboxRepository.java`" ], "evidence": [ "`EVD-335` (`evidence/raw/335-two-outboxes-one-wired.txt`)", "`evidence/raw/tl-outbox-unsatisfiable-condition.txt`(일부 구획에 셸 변수가 전개되지 않은 채 저장돼 있어 수치는 재측정했다)" ], "classification": "이 저장소에는 서로를 모르는 outbox 구현이 둘 있다. 스택 A는 `application-core/.../outbox/` 의 포트와 `persistence-jpa` 의 `@Repository OutboxStoreAdapter`, 그리고 `app-bootstrap` 의 `OutboxConfig` 가 `@Scheduled` 로 구동하는 `PublishPendingOutboxEventsUseCase` 다 — 배선되어 출하된다. 스택 B는 `messaging-reliability-api` 의 `OutboxRepository` 와 `JdbcOutboxRepository`(2,276 LOC)와 `OutboxRelay` 계열이며, `MessagingReliabilityAutoConfiguration:81` 의 `@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})` 뒤에 있다. `JdbcOutboxRepository` 에는 스프링 스테레오타입이 없고 그것을 만드는 `@Bean` 도 main 에 없으므로 조건은 프로덕션에서 참이 되지 않는다. 스타터의 javadoc 은 이 조건들을 결함이 아니라 계약으로 서술하고 저장소·팩토리 제공을 애플리케이션 책임으로 둔다 — 따라서 이것은 조건 결함이 아니라 **정본이 정해지지 않은 중복**이다. 두 스택은 저장 모델과 발행 경로가 다르므로 동시에 켜면 같은 이벤트가 두 번 적히거나 두 번 발행될 수 있다.", "missing-verification": "`ConditionEvaluationReport`로 미충족 사유를 확인하지 않았다", "relations": [ "`reference:conditionalonbean-must-be-satisfiable`", "`concept:when-conditions-are-evaluated`", "`open-question:one-boot-would-settle-two-findings`" ], "publication": "초안", "file": "assembly-ownership/case/case-outbox-chain-behind-an-unsatisfiable-condition.md", "status": "게시 전", "studioId": "", "assets": [ "outbox-chain-behind-an-unsatisfiable-condition" ], "assetFiles": [ "outbox-chain-behind-an-unsatisfiable-condition" ], "evidenceFiles": [ "../../../final/evidence/raw/outbox-chain-behind-an-unsatisfiable-condition.txt", "../../../final/evidence/raw/tl-outbox-unsatisfiable-condition.txt" ] }, { "title": "관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다", "kind": "case", "slug": "observation-downgraded-by-the-composition", "readiness": "READY", "source": [ "`final/document.md#5-2`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "assembly-ownership/case/case-observation-downgraded-by-the-composition.md", "status": "게시 전", "studioId": "", "assets": [ "observation-downgraded-by-the-composition" ], "assetFiles": [ "observation-downgraded-by-the-composition" ], "evidenceFiles": [ "../../../final/evidence/raw/observation-downgraded-by-the-composition.txt", "../../../final/evidence/raw/tl-messaging-observation-noop.txt" ] }, { "title": "시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다", "kind": "case", "slug": "thirteen-startup-rules-never-run", "readiness": "READY", "source": [ "`final/document.md#5-3`", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "assembly-ownership/case/case-thirteen-startup-rules-never-run.md", "status": "게시 전", "studioId": "", "assets": [ "thirteen-startup-rules-never-run" ], "assetFiles": [ "thirteen-startup-rules-never-run" ], "evidenceFiles": [ "../../../final/evidence/raw/thirteen-startup-rules-never-run.txt" ] }, { "title": "하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다", "kind": "case", "slug": "a-flag-that-validates-an-unwired-subsystem", "readiness": "READY", "source": [ "`final/document.md#4-2`", "`final/document.md#a06` §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`" ], "publication": "초안", "file": "assembly-ownership/case/case-a-flag-that-validates-an-unwired-subsystem.md", "status": "게시 전", "studioId": "", "assets": [ "a-flag-that-validates-an-unwired-subsystem" ], "assetFiles": [ "a-flag-that-validates-an-unwired-subsystem" ], "evidenceFiles": [ "../../../final/evidence/raw/a-flag-that-validates-an-unwired-subsystem.txt" ] }, { "title": "검증기가 운영에 TLS를 요구하고, 실제로 조립되는 생산자에는 그 설정이 없다", "kind": "case", "slug": "a-validator-that-demands-tls-and-an-assembly-that-omits-it", "readiness": "READY", "source": [ "`final/document.md#a19-messaging-spring-boot-starter` §17.1", "`final/document.md#14-2`" ], "code": [ "`.../messaging-kafka/.../KafkaProfileValidator.java`", "`.../messaging-spring-boot-starter/.../KafkaProducerFactory`(생산자 조립부)", "`.../messaging-security/.../KafkaSecurityConfigurer.java`" ], "evidence": [ "없음 — 조립 코드와 `KafkaSecurityConfigurer` 호출자 전수 대조로 판정했다" ], "classification": "`KafkaProfileValidator` 가 운영 프로파일이 전송 보안 없이 뜨는 것을 거부하고, 테스트 `aProductionKafkaBrokerWithoutTransportSecurityFailsStartup` 이 그 거부를 지킨다. 그런데 실제로 만들어지는 `KafkaProducer` 의 설정 맵에는 `bootstrap.servers`·직렬화기 둘·`acks=all`·`enable.idempotence` 만 있고 `security.protocol` 이 없다 — Kafka 기본값은 `PLAINTEXT` 다. `security.protocol`·`ssl.enabled.protocols`·`ssl.endpoint.identification.algorithm`·`sasl.mechanism`·`sasl.jaas.config` 를 만드는 `KafkaSecurityConfigurer` 는 저장소 전역에서 production 호출자가 0 이다. 즉 검증기는 \"TLS 있음\" 선언을 요구하고 통과시키며, 연결은 평문으로 열린다. 이 리프는 `app-bootstrap` 소속이므로 미배선 블록 안쪽 이야기가 아니다.", "missing-verification": "브로커를 띄워 실제 핸드셰이크를 관측하지 않았다 — 조립되는 설정 맵의 키 집합으로 판정했다", "relations": [ "`reference:a-validator-is-enforced-by-injection`", "`reference:read-the-assembling-side-first`", "`reference:off-must-be-structural`" ], "listedInTree": false, "publication": "초안", "file": "assembly-ownership/case/case-a-validator-that-demands-tls-and-an-assembly-that-omits-it.md", "status": "게시 전", "studioId": "", "assets": [ "a-validator-that-demands-tls-and-an-assembly-that-omits-it", "a-validator-that-demands-tls-and-an-assembly-that-omits-it-run" ], "assetFiles": [ "a-validator-that-demands-tls-and-an-assembly-that-omits-it", "a-validator-that-demands-tls-and-an-assembly-that-omits-it-run" ], "evidenceFiles": [ "../../../final/evidence/raw/a-validator-that-demands-tls-and-an-assembly-that-omits-it.txt", "../../../final/evidence/raw/a-validator-that-demands-tls-and-an-assembly-that-omits-it-run.txt" ] }, { "title": "가드와 journal 과 durability 검증기는 켜지고, 부를 서비스가 없었다", "slug": "the-guard-is-on-and-the-service-is-not", "readiness": "READY", "source": [ "final/document.md#5-4", "final/document.md#a19 §8.1", "final/document.md#a19 §8.2" ], "code": [ "DestructiveOperationGuard", "MessagingAdminService", "AdminOperationJournal" ], "classification": "이 저장소에서 가장 잘 조립된 게이트가 동시에 형태 A 의 사례이기도 하다는 것을 부재 네 건으로 확정했고, 그중 하나만 문서화돼 있다는 데서 닫힌다", "missing-verification": "실제로 admin 을 켜고 부팅해 호출 대상이 없다는 것을 확인하지는 않았다. 참조 0 과 조건 판정으로만 확정했다", "relations": [ "decision:destructive-admin-operations-are-not-autoconfigured", "reference:a-bean-is-not-composition-evidence", "case:scan-exclusion-without-an-owner" ], "kind": "case", "publication": "미작성" } ], "concept": [ { "title": "Spring 조립의 세 경로와 각각이 결정하는 것", "kind": "concept", "slug": "three-assembly-paths", "readiness": "READY", "source": [ "`final/document.md#1-4`, `#7-1`", "`final/document.md#a18`", "`final/document.md#a14` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · Spring Boot 자동설정(.imports) · 컴포넌트 스캔 · @Bean 손 배선 셋을 쓰는 app-bootstrap 과 sample-portfolio 두 합성 루트", "publication": "초안", "file": "assembly-ownership/concept/concept-three-assembly-paths.md", "status": "게시 전", "studioId": "", "assets": [ "three-assembly-paths", "three-assembly-paths-diagram" ], "assetFiles": [ "three-assembly-paths", "three-assembly-paths" ], "evidenceFiles": [ "../../../final/evidence/raw/three-assembly-paths.txt", "../../../final/evidence/raw/tl-web-six-unowned-components.txt" ] } ], "reference": [ { "title": "@Bean이 있다는 것은 조립 증거가 아니다", "kind": "reference", "slug": "a-bean-is-not-composition-evidence", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 2·3", "`final/document.md#a99` §3.1" ], "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`" ], "publication": "초안", "file": "assembly-ownership/reference/reference-a-bean-is-not-composition-evidence.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다", "kind": "reference", "slug": "conditionalonbean-must-be-satisfiable", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 4", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "assembly-ownership/reference/reference-conditionalonbean-must-be-satisfiable.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다", "kind": "reference", "slug": "the-startup-validator-follows-the-autoconfiguration-root", "readiness": "READY", "source": [ "`final/document.md#5-3`", "`final/document.md#a18` (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`" ], "publication": "초안", "file": "assembly-ownership/reference/reference-the-startup-validator-follows-the-autoconfiguration-root.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다", "kind": "reference", "slug": "read-the-assembling-side-first", "readiness": "READY", "source": [ "`final/document.md#13` 항목 5", "`final/document.md#a99` §5" ], "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`" ], "publication": "초안", "file": "assembly-ownership/reference/reference-read-the-assembling-side-first.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "\"꺼짐\"은 조건의 반복이 아니라 구조여야 한다", "kind": "reference", "slug": "off-must-be-structural", "readiness": "READY", "source": [ "`final/document.md#10-3`", "`final/document.md#a05` §14.1", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "assembly-ownership/reference/reference-off-must-be-structural.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [ { "title": "부팅 한 번으로 확증 가능한 두 건이 아직 정적 추론으로만 남아 있다", "kind": "question", "slug": "one-boot-would-settle-two-findings", "readiness": "OPEN", "source": [ "`final/document.md#11` 항목 3", "`final/document.md#a19` §5.1, §7.1", "`final/document.md#a99` §6" ], "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`" ], "publication": "초안", "file": "assembly-ownership/question/openquestion-one-boot-would-settle-two-findings.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "@ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다", "kind": "question", "slug": "conditional-evaluation-order-unverified", "readiness": "OPEN", "source": [ "`final/document.md#11` 항목 4", "`final/document.md#a05` §14.4", "`final/document.md#a99` §6" ], "known": [ "이 저장소에 그 함정의 실제 사례가 기록돼 있다(파싱 시점 평가로 여덟 빈 소실)", "회피를 위해 루트가 validator를 직접 생성한다", "**사이클 2 보강(`EVD-335`)**: outbox 사슬의 경우 `JdbcOutboxRepository` 에 스프링 스테레오타입이 없고 `new` 하는 main 코드도 0이며 `OutboxEnvelopeFactory` 를 만드는 `@Bean` 은 test 하나뿐이므로, 정적으로는 뿌리 부재가 거의 확실하다. 다만 런타임 사유 문자열로 확인한 것은 아니다" ], "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`" ], "publication": "초안", "file": "assembly-ownership/question/openquestion-conditional-evaluation-order-unverified.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "decision": [ { "title": "마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다", "kind": "decision", "slug": "one-root-owns-the-master-switch", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-3`", "`final/document.md#a19` §6.1", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "assembly-ownership/decision/decision-one-root-owns-the-master-switch.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "풀이 필요한지는 \"JPA가 켜졌나\"가 아니라 \"커넥션이 필요한 capability가 있나\"로 묻는다", "kind": "decision", "slug": "pool-need-is-a-capability-question", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-3`", "`final/document.md#a05` §14.1", "`final/document.md#a18` §5" ], "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`" ], "publication": "초안", "file": "assembly-ownership/decision/decision-pool-need-is-a-capability-question.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "파괴적 admin 작업은 자동설정하지 않는다", "slug": "destructive-admin-operations-are-not-autoconfigured", "readiness": "READY", "source": [ "final/document.md#10-3", "final/document.md#5-4", "final/document.md#a19 §8.1" ], "decision-status": "ADOPTED", "decision-evidence": "`DestructiveOperationGuard(false)` 가 기본이고 `DestructiveMessagingAdmin` 이 의도적으로 bean 이 아니며, 그 부재를 javadoc 이 명시한다 (final/document.md#5-4)", "grounds": "애플리케이션 런타임은 admin 자격증명을 들고 있지 않으므로, 그 자격증명이 필요한 작업을 가드가 거부한다. purge 나 delete 가 필요한 운영 도구는 자기 자격증명으로 스스로 등록한다. 감수한 비용은 사고 대응 중에 그 도구가 없다는 것을 발견할 수 있다는 것이고, 그래서 부재를 문서화하는 것이 짝이다", "classification": "프로젝트가 이 방향을 실제로 골랐고 기본값·가드·javadoc 셋으로 표현했다", "relations": [ "case:the-guard-is-on-and-the-service-is-not", "decision:one-root-owns-the-master-switch", "reference:off-must-be-structural" ], "kind": "decision", "publication": "미작성" } ] } }, "what-a-gate-does-not-prove": { "topic": "what-a-gate-does-not-prove", "title": "게이트가 증명하지 않는 것", "readerQuestion": "게이트가 초록불인데 아무것도 증명하지 않을 수 있다면, 무엇을 대신 확인해야 하는가?", "kinds": { "case": [ { "title": "\"certified\"라 불리던 레인이 threshold를 하나도 비교하지 않고 있었다", "kind": "case", "slug": "a-certifying-lane-that-compared-nothing", "readiness": "READY", "source": [ "`final/document.md#6-6`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-a-certifying-lane-that-compared-nothing.md", "status": "게시 전", "studioId": "", "assets": [ "a-certifying-lane-that-compared-nothing" ], "assetFiles": [ "a-certifying-lane-that-compared-nothing" ], "evidenceFiles": [ "../../../final/evidence/raw/a-certifying-lane-that-compared-nothing.txt" ] }, { "title": "문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다", "kind": "case", "slug": "doc-contract-test-boundary-predicted-the-drift", "readiness": "READY", "source": [ "`final/document.md#7-4`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-doc-contract-test-boundary-predicted-the-drift.md", "status": "게시 전", "studioId": "", "assets": [ "doc-contract-test-boundary-predicted-the-drift" ], "assetFiles": [ "doc-contract-test-boundary-predicted-the-drift" ], "evidenceFiles": [ "../../../final/evidence/raw/doc-contract-test-boundary-predicted-the-drift.txt" ] }, { "title": "릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다", "kind": "case", "slug": "a-release-gate-with-no-evidence-producer", "readiness": "READY", "source": [ "`final/document.md#7-6`", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-a-release-gate-with-no-evidence-producer.md", "status": "게시 전", "studioId": "", "assets": [ "a-release-gate-with-no-evidence-producer" ], "assetFiles": [ "a-release-gate-with-no-evidence-producer" ], "evidenceFiles": [ "../../../final/evidence/raw/a-release-gate-with-no-evidence-producer.txt", "../../../final/evidence/raw/tl-grpc-release-gate-no-producer.txt" ] }, { "title": "다중 타깃 검증을 확인한다는 테스트가 다른 가드에 걸려 통과했다", "kind": "case", "slug": "a-test-that-passed-on-the-wrong-guard", "readiness": "READY", "source": [ "`final/document.md#3-5`", "`final/document.md#a13` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-a-test-that-passed-on-the-wrong-guard.md", "status": "게시 전", "studioId": "", "assets": [ "a-test-that-passed-on-the-wrong-guard" ], "assetFiles": [ "a-test-that-passed-on-the-wrong-guard" ], "evidenceFiles": [ "../../../final/evidence/raw/a-test-that-passed-on-the-wrong-guard.txt" ] } ], "concept": [ { "title": "strict test lane — 발견하지 못하면 실패하는 레인", "kind": "concept", "slug": "strict-test-lane", "readiness": "READY", "source": [ "`final/document.md#6-3`", "`final/document.md#a05` §15.1", "`final/document.md#a19` §2.3", "`final/document.md#a20` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · Gradle Test 태스크의 failOnNoDiscoveredTests = true · outputs.upToDateWhen { false } 규약", "publication": "초안", "file": "what-a-gate-does-not-prove/concept/concept-strict-test-lane.md", "status": "게시 전", "studioId": "", "assets": [ "strict-test-lane" ], "assetFiles": [ "strict-test-lane" ], "evidenceFiles": [ "../../../final/evidence/raw/strict-test-lane.txt" ] }, { "title": "증거 등급과 provenance — R1과 R2를 가르는 것", "kind": "concept", "slug": "evidence-grades-and-provenance", "readiness": "READY", "source": [ "`final/document.md#6-5`", "`final/document.md#a05` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · gradle/jpa-evidence.gradle 917줄 · active card 11개 · R1/R2 분리", "publication": "초안", "file": "what-a-gate-does-not-prove/concept/concept-evidence-grades-and-provenance.md", "status": "게시 전", "studioId": "", "assets": [ "evidence-grades-and-provenance", "evidence-grades-and-provenance-diagram" ], "assetFiles": [ "evidence-grades-and-provenance", "evidence-grades-and-provenance" ], "evidenceFiles": [ "../../../final/evidence/raw/evidence-grades-and-provenance.txt" ] } ], "reference": [ { "title": "빠뜨림이 통과가 되는 게이트는 게이트가 아니다", "kind": "reference", "slug": "omission-that-passes-is-not-a-gate", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 12", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/reference/reference-omission-that-passes-is-not-a-gate.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다", "kind": "reference", "slug": "agreement-between-documents-proves-nothing", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 5", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/reference/reference-agreement-between-documents-proves-nothing.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다", "kind": "reference", "slug": "a-gate-nobody-runs-reports-the-last-run", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 6", "`final/document.md#a18` §4.1c", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/reference/reference-a-gate-nobody-runs-reports-the-last-run.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "계약 테스트는 어댑터가 실제로 돌리는 statement를 실행해야 한다", "kind": "reference", "slug": "a-contract-test-must-run-the-adapters-statement", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 19", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/reference/reference-a-contract-test-must-run-the-adapters-statement.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "아무것도 발견하지 못한 레인은 성공이 아니라 실패여야 한다", "kind": "reference", "slug": "a-lane-that-discovers-nothing-must-fail", "readiness": "READY", "source": [ "`final/document.md#6-3`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/reference/reference-a-lane-that-discovers-nothing-must-fail.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "산문이 선언한 게이트는 빌드에 있는 게이트가 아니다", "kind": "reference", "slug": "a-gate-declared-in-prose-is-not-in-the-build", "readiness": "READY", "source": [ "`final/document.md#a99` §3.5", "`final/document.md#14-2`" ], "classification": "`a-gate-nobody-runs-reports-the-last-run` 이 \"레인이 도는가\" 를 묻는다면, 이 규칙은 그 앞 단계를 묻는다 — **게이트가 존재한다고 말하는 문장이 어디에 근거하는가.** 주석·javadoc·설정 파일 헤더가 \"이것이 빌드를 실패시킨다\" 고 적는 것은 관찰이 아니라 의도다. 판정은 한 줄이다 — 그 심볼 이름으로 빌드 스크립트와 CI 정의를 grep 했을 때 나오는가. 나오지 않으면 그 문장은 지금 사실이 아니고, 주석 밀도가 높은 저장소일수록 비싸다 — 읽는 사람이 근거 있게 믿기 때문이다.", "scope": [ "\"fails the build\"", "\"refuses at startup\"", "\"is checked too\" 처럼 실행을 단언하는 모든 주석. 이 저장소에서 다섯 곳이 걸렸다 — `GrpcProtoContractValidator`, `GrpcBufPolicy` 의 네 Buf 태스크, `GrpcAdvancedModuleGuard.requireStableStarterIsClean`, `NatsJetStreamProfileValidator`, `PulsarProfileValidator`." ], "exceptions": [ "의도적으로 미래를 서술하는 선언은 이 규칙의 대상이 아니다 — 다만 그때는 시제가 달라야 한다. `GrpcCodegenManifest` 가 좋은 예다: \"It does not run in this build yet — adaptation D6 records why, and this manifest is what a future decision to turn it on has to satisfy rather than replace.\"" ], "relations": [ "`case:two-files-name-a-build-gate-that-no-build-runs`", "`reference:a-gate-nobody-runs-reports-the-last-run`", "`case:startup-validator-is-the-only-reader-of-four-keys`", "`reference:a-validator-is-enforced-by-injection`" ], "listedInTree": false, "publication": "초안", "file": "what-a-gate-does-not-prove/reference/reference-a-gate-declared-in-prose-is-not-in-the-build.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [ { "title": "컨테이너가 필요한 레인의 실제 결과를 실행으로 확인하지 않았다", "kind": "question", "slug": "container-lanes-not-executed", "readiness": "OPEN", "source": [ "`final/document.md#6-2`", "`#11` 항목 1", "`final/document.md#a99` §6" ], "known": [ "실행한 레인과 결과가 기록돼 있다(messaging 851", "grpc 579", "증거 레인 7·9·9", "graphql 1,603", "websocket 720", "grpc adapter 48", "app-bootstrap 1,016 중 1 환경 실패)", "미실행 레인의 목록과 각각의 요구사항", "messaging 인증만 CI가 강제한다", "**사이클 2 보강(`EVD-334`)**: 이 컨테이너에 Docker 가 있고(client 29.1.3 / server 29.6.1) Testcontainers 기반 `test` 레인을 실제로 돌렸다 — persistence-jpa 477", "persistence-mongo 72", "cache-redis 435", "fileserver 398(1 로케일)", "objectstorage 140", "notification 327", "httpclient 283(3 듀얼스택) 전부 실행됨" ], "unknown": [ "JPA 의 6개 특수 레인(`jpaPlatformFailureTest` 포함)과 readiness task 15종", "websocket 커스텀 4종", "`grpcPerformanceTest` 의 실제 결과", "그중 어느 것이 지금 빨간지. 기본 `test` 레인은 사이클 2에서 전부 돌렸으므로 남은 것은 **특수 레인과 게이트 태스크**다" ], "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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/question/openquestion-container-lanes-not-executed.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [ "../../../final/evidence/raw/tl-platform-suite-results.txt" ] } ], "decision": [ { "title": "인증 레인만 Docker 가드를 달지 않는다", "kind": "decision", "slug": "only-the-certification-lane-carries-no-docker-guard", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#6-4`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/decision/decision-only-the-certification-lane-carries-no-docker-guard.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "성능 측정은 릴리스 게이트에 넣지 않는다", "kind": "decision", "slug": "performance-measurement-is-not-a-release-gate", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-5`", "`final/document.md#a20` §2.5", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/decision/decision-performance-measurement-is-not-a-release-gate.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다", "kind": "decision", "slug": "candidate-evidence-stays-at-r1", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#6-5`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/decision/decision-candidate-evidence-stays-at-r1.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ] } }, "bounding-by-type": { "topic": "bounding-by-type", "title": "타입으로 카디널리티와 개인정보를 막기", "readerQuestion": "값이 담을 수 있는 것을 타입이 미리 줄이면 무엇을 막을 수 있는가?", "kinds": { "case": [ { "title": "시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다", "kind": "case", "slug": "pii-through-an-exception-message", "readiness": "READY", "source": [ "`final/document.md#8-1`", "`final/document.md#a04` §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`" ], "publication": "초안", "file": "bounding-by-type/case/case-pii-through-an-exception-message.md", "status": "게시 전", "studioId": "", "assets": [ "pii-through-an-exception-message" ], "assetFiles": [ "pii-through-an-exception-message" ], "evidenceFiles": [ "../../../final/evidence/raw/pii-through-an-exception-message.txt" ] } ], "concept": [ { "title": "서명된 커서의 구조와 검증 순서", "kind": "concept", "slug": "signed-cursor-structure", "readiness": "READY", "source": [ "`final/document.md#10-4`", "`final/document.md#a05` §2.4", "`final/document.md#a19` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · persistence-jpa 의 keyset 커서 계약 · messaging 의 같은 계열 커서", "publication": "초안", "file": "bounding-by-type/concept/concept-signed-cursor-structure.md", "status": "게시 전", "studioId": "", "assets": [ "signed-cursor-structure", "signed-cursor-structure-diagram" ], "assetFiles": [ "signed-cursor-structure", "signed-cursor-structure" ], "evidenceFiles": [ "../../../final/evidence/raw/signed-cursor-structure.txt" ] }, { "title": "카디널리티 경계를 타입으로 표현하기", "kind": "concept", "slug": "cardinality-bounds-as-types", "readiness": "READY", "source": [ "`final/document.md#4-1`, `#5-2`", "`final/document.md#a05` §2.1, §12.2", "`final/document.md#a02`" ], "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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · PersistenceOperationName 등 이름 타입 여덟 · JpaMetricTags 다섯 태그 · shared-contract 의 ForbiddenMetricTags", "publication": "초안", "file": "bounding-by-type/concept/concept-cardinality-bounds-as-types.md", "status": "게시 전", "studioId": "", "assets": [ "cardinality-bounds-as-types", "cardinality-bounds-as-types-diagram" ], "assetFiles": [ "cardinality-bounds-as-types", "cardinality-bounds-as-types" ], "evidenceFiles": [ "../../../final/evidence/raw/cardinality-bounds-as-types.txt" ] } ], "reference": [ { "title": "이름은 값이 아니라 registry key다", "kind": "reference", "slug": "names-are-registry-keys-not-values", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 15", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "bounding-by-type/reference/reference-names-are-registry-keys-not-values.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다", "kind": "reference", "slug": "telemetry-can-be-more-dangerous-than-its-subject", "readiness": "READY", "source": [ "`final/document.md#5-2`", "`final/document.md#a05` §12.2", "`final/document.md#a02`" ], "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`" ], "publication": "초안", "file": "bounding-by-type/reference/reference-telemetry-can-be-more-dangerous-than-its-subject.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "path·identifier는 등록하고 value는 바인딩한다", "kind": "reference", "slug": "register-paths-bind-values", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 16", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "bounding-by-type/reference/reference-register-paths-bind-values.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [], "decision": [ { "title": "tenant id는 메트릭 태그가 되지 않는다", "kind": "decision", "slug": "tenant-id-is-never-a-metric-tag", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-4`", "`final/document.md#a05` §13.2", "`final/document.md#a02`" ], "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`" ], "publication": "초안", "file": "bounding-by-type/decision/decision-tenant-id-is-never-a-metric-tag.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "커서에 서명하는 이유는 기밀성이 아니라 무결성이다", "kind": "decision", "slug": "cursors-are-signed-for-integrity", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-4`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "bounding-by-type/decision/decision-cursors-are-signed-for-integrity.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "keyset 페이지에 offset 필드를 두지 않는다", "slug": "a-keyset-page-has-no-offset-field", "readiness": "READY", "source": [ "final/document.md#10-4", "final/document.md#a05 §2.4" ], "decision-status": "ADOPTED", "decision-evidence": "페이지 계약에 offset·total count·page number 칸이 없고, 그 부재를 javadoc 이 이유와 함께 적는다 (final/document.md#10-4)", "grounds": "필드의 부재가 나중에 하나 추가되는 것을 막는다. total count 는 같은 predicate 에 두 번째 집계 쿼리를 요구하고, 움직이는 데이터셋에서 그 숫자는 클라이언트에 닿기 전에 이미 낡았다. 감수한 비용은 「몇 쪽 중 몇 쪽」을 그릴 수 없다는 것이다", "classification": "프로젝트가 칸을 두지 않는 쪽을 골랐고 그 부재 자체가 가드다", "relations": [ "decision:cursors-are-signed-for-integrity", "concept:signed-cursor-structure", "reference:register-paths-bind-values" ], "kind": "decision", "publication": "미작성" }, { "title": "JSONB 문서 안에 타입 메타데이터를 넣지 않는다", "slug": "no-type-metadata-inside-a-jsonb-document", "readiness": "READY", "source": [ "final/document.md#10-4", "final/document.md#a05 §7.5" ], "decision-status": "ADOPTED", "decision-evidence": "JSONB 컬럼에 타입 메타데이터를 쓰지 않기로 하고 그 이유를 javadoc 이 적는다 (final/document.md#10-4)", "grounds": "문서 안의 타입 메타데이터는 JSONB 컬럼을 역직렬화 가젯으로 만든다. 감수한 비용은 다형 문서를 저장할 때 타입을 컬럼으로 따로 들어야 한다는 것이다", "classification": "프로젝트가 이 방향을 골랐고 같은 판단이 mongo 쪽 `_class` 정책과 대비된다", "relations": [ "decision:a-keyset-page-has-no-offset-field", "reference:register-paths-bind-values", "case:mongo-default-throws-on-first-write" ], "kind": "decision", "publication": "미작성" } ] } }, "duplicate-mechanisms": { "topic": "duplicate-mechanisms", "title": "중복 장치 — 조립된 쪽이 약한 쪽일 때", "readerQuestion": "같은 일을 하는 장치가 둘일 때 실제 요청이 지나는 것은 어느 쪽인가?", "kinds": { "case": [ { "title": "강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다", "kind": "case", "slug": "a-weaker-private-copy-on-the-wired-path", "readiness": "READY", "source": [ "`final/document.md#7-3`", "`final/document.md#a13` §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`" ], "publication": "초안", "file": "duplicate-mechanisms/case/case-a-weaker-private-copy-on-the-wired-path.md", "status": "게시 전", "studioId": "", "assets": [ "a-weaker-private-copy-on-the-wired-path", "a-weaker-private-copy-on-the-wired-path-probe" ], "assetFiles": [ "a-weaker-private-copy-on-the-wired-path", "a-weaker-private-copy-on-the-wired-path-probe" ], "evidenceFiles": [ "../../../final/evidence/raw/a-weaker-private-copy-on-the-wired-path.txt", "../../../final/evidence/raw/a-weaker-private-copy-on-the-wired-path-probe.txt" ] }, { "title": "요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다", "kind": "case", "slug": "a-policy-reversed-by-a-later-filter", "readiness": "READY", "source": [ "`final/document.md#7-3`", "`final/document.md#a14` §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`" ], "publication": "초안", "file": "duplicate-mechanisms/case/case-a-policy-reversed-by-a-later-filter.md", "status": "게시 전", "studioId": "", "assets": [ "a-policy-reversed-by-a-later-filter" ], "assetFiles": [ "a-policy-reversed-by-a-later-filter" ], "evidenceFiles": [ "../../../final/evidence/raw/a-policy-reversed-by-a-later-filter.txt" ] }, { "title": "JPA 플랫폼 capability 대부분에 production 소비자가 없다", "kind": "case", "slug": "jpa-platform-capabilities-have-no-consumer", "readiness": "READY", "source": [ "`final/document.md#8-2` 항목 8", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "duplicate-mechanisms/case/case-jpa-platform-capabilities-have-no-consumer.md", "status": "게시 전", "studioId": "", "assets": [ "jpa-platform-capabilities-have-no-consumer" ], "assetFiles": [ "jpa-platform-capabilities-have-no-consumer" ], "evidenceFiles": [ "../../../final/evidence/raw/jpa-platform-capabilities-have-no-consumer.txt" ] }, { "title": "forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다", "kind": "case", "slug": "trust-policy-lives-in-nginx-not-in-the-code", "readiness": "READY", "source": [ "`final/document.md#3-1`", "`final/document.md#a14` §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`" ], "publication": "초안", "file": "duplicate-mechanisms/case/case-trust-policy-lives-in-nginx-not-in-the-code.md", "status": "게시 전", "studioId": "", "assets": [ "trust-policy-lives-in-nginx-not-in-the-code" ], "assetFiles": [ "trust-policy-lives-in-nginx-not-in-the-code" ], "evidenceFiles": [ "../../../final/evidence/raw/trust-policy-lives-in-nginx-not-in-the-code.txt" ] }, { "title": "정식 경계라고 적은 클래스가 그것을 구현하지 않는다", "kind": "case", "slug": "a-retry-implementation-nobody-calls", "readiness": "READY", "source": [ "`final/document.md#8-2` 항목 7", "`final/document.md#a05` §17 P1" ], "code": [ "`.../transaction/FullTransactionRetryCoordinator.java`", "`.../SpringPolicyTransactionPort.java`", "`.../DefaultJpaRetryPolicy.java`" ], "evidence": [ "`EVD-336` (`evidence/raw/336-retry-coordinator-has-no-advice.txt`) — 참조 전수 계수", "AOP 어드바이저 0", "ADR 이 지목한 인터셉터 부재" ], "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`" ], "publication": "초안", "file": "duplicate-mechanisms/case/case-a-retry-implementation-nobody-calls.md", "status": "게시 전", "studioId": "", "assets": [ "a-retry-implementation-nobody-calls" ], "assetFiles": [ "a-retry-implementation-nobody-calls" ], "evidenceFiles": [ "../../../final/evidence/raw/a-retry-implementation-nobody-calls.txt" ] }, { "title": "부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다", "slug": "a-boundary-that-leaks-only-under-load", "readiness": "READY", "source": [ "final/document.md#5-5", "final/document.md#8-2 항목 6", "final/document.md#a20 §7" ], "code": [ "GrpcAdmissionController", "inFlight", "AtomicInteger" ], "classification": "조립되는 9개 bean 중 하나가 검사와 증가 사이를 열어 두었고, 같은 가족 안에 CAS 루프로 정확히 쓴 참조 구현이 함께 있다는 데서 원인이 지식의 부재가 아니라 적용의 불균일임이 닫힌다", "missing-verification": "경계를 실제로 넘기는 부하를 걸어 재현하지 않았다. 코드 읽기와 같은 가족의 올바른 구현 대조로만 확정했다", "relations": [ "reference:atomic-type-is-not-atomicity", "case:the-same-rotation-defect-closed-once-and-reproduced", "reference:check-which-duplicate-is-wired" ], "kind": "case", "publication": "미작성" } ], "concept": [], "reference": [ { "title": "중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다", "kind": "reference", "slug": "check-which-duplicate-is-wired", "readiness": "READY", "source": [ "`final/document.md#7-3`", "`final/document.md#a99` §3.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`" ], "publication": "초안", "file": "duplicate-mechanisms/reference/reference-check-which-duplicate-is-wired.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "Atomic* 타입의 존재는 원자성의 증거가 아니다", "kind": "reference", "slug": "atomic-type-is-not-atomicity", "readiness": "READY", "source": [ "`final/document.md#5-5`", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "duplicate-mechanisms/reference/reference-atomic-type-is-not-atomicity.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [], "decision": [] } }, "drift-direction": { "topic": "drift-direction", "title": "문서 드리프트의 방향", "readerQuestion": "문서가 코드와 어긋났을 때 어느 방향의 어긋남을 먼저 고쳐야 하는가?", "kinds": { "case": [ { "title": "지원 매트릭스가 코드와 반대를 적었고, 그 오해가 소비자에게 자기 멱등성을 생략하게 한다", "kind": "case", "slug": "support-matrix-said-the-opposite-of-the-code", "readiness": "READY", "source": [ "`final/document.md#8-1`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "drift-direction/case/case-support-matrix-said-the-opposite-of-the-code.md", "status": "게시 전", "studioId": "", "assets": [ "support-matrix-said-the-opposite-of-the-code" ], "assetFiles": [ "support-matrix-said-the-opposite-of-the-code" ], "evidenceFiles": [ "../../../final/evidence/raw/support-matrix-said-the-opposite-of-the-code.txt", "../../../final/evidence/raw/tl-kafka-dedup-drift.txt" ] }, { "title": "README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다", "kind": "case", "slug": "the-readme-recipe-does-not-start", "readiness": "READY", "source": [ "`final/document.md#7-4`", "`final/document.md#a06` §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-mongo-empty-type-registry-write-probe.java…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`" ], "publication": "초안", "file": "drift-direction/case/case-the-readme-recipe-does-not-start.md", "status": "게시 전", "studioId": "", "assets": [ "the-readme-recipe-does-not-start" ], "assetFiles": [ "the-readme-recipe-does-not-start" ], "evidenceFiles": [ "../../../final/evidence/raw/the-readme-recipe-does-not-start.txt" ] }, { "title": "출하 default 조합이 첫 write에서 예외를 던진다", "kind": "case", "slug": "mongo-default-throws-on-first-write", "readiness": "READY", "source": [ "`final/document.md#4-2`", "`final/document.md#a06` §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-mongo-empty-type-registry-write-probe.java…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`" ], "publication": "초안", "file": "drift-direction/case/case-mongo-default-throws-on-first-write.md", "status": "게시 전", "studioId": "", "assets": [ "mongo-default-throws-on-first-write" ], "assetFiles": [ "mongo-default-throws-on-first-write" ], "evidenceFiles": [ "../../../final/evidence/raw/mongo-default-throws-on-first-write.txt" ] }, { "title": "문서가 UUIDv7이라 말하고 생성되는 것은 v4다", "kind": "case", "slug": "documented-uuidv7-generates-v4", "readiness": "READY", "source": [ "`final/document.md#7-4`", "`final/document.md#a07` §5, §6, §7, §8", "`final/document.md#a01` §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`" ], "publication": "초안", "file": "drift-direction/case/case-documented-uuidv7-generates-v4.md", "status": "게시 전", "studioId": "", "assets": [ "documented-uuidv7-generates-v4" ], "assetFiles": [ "documented-uuidv7-generates-v4" ], "evidenceFiles": [ "../../../final/evidence/raw/documented-uuidv7-generates-v4.txt" ] }, { "title": "실 브로커로 증명된 어댑터가 선택하면 기동이 실패하는 이름으로 등록돼 있다", "kind": "case", "slug": "an-unselectable-broker-listed-with-features", "readiness": "READY", "source": [ "`final/document.md#2-4`, `#3-3`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "drift-direction/case/case-an-unselectable-broker-listed-with-features.md", "status": "게시 전", "studioId": "", "assets": [ "an-unselectable-broker-listed-with-features" ], "assetFiles": [ "an-unselectable-broker-listed-with-features" ], "evidenceFiles": [ "../../../final/evidence/raw/an-unselectable-broker-listed-with-features.txt" ] }, { "title": "다섯 문서가 \"exactly 19 leaf\"라고 적고 레지스트리는 62다", "kind": "case", "slug": "five-documents-say-nineteen-leaves", "readiness": "READY", "source": [ "`final/document.md#7-4`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "drift-direction/case/case-five-documents-say-nineteen-leaves.md", "status": "게시 전", "studioId": "", "assets": [ "five-documents-say-nineteen-leaves" ], "assetFiles": [ "five-documents-say-nineteen-leaves" ], "evidenceFiles": [ "../../../final/evidence/raw/five-documents-say-nineteen-leaves.txt" ] } ], "concept": [], "reference": [ { "title": "과대 진술 문서를 과소보다 먼저 고친다", "kind": "reference", "slug": "fix-overstatement-before-understatement", "readiness": "READY", "source": [ "`final/document.md#7-4`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "drift-direction/reference/reference-fix-overstatement-before-understatement.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [ { "title": "문서 계약 테스트의 단언 범위를 capability 표까지 넓힐 것인가", "kind": "question", "slug": "widen-doc-contract-assertions", "readiness": "OPEN", "source": [ "`final/document.md#7-4`", "`final/document.md#a19` §9.3" ], "known": [ "capability 표는 기계로 검증 가능하다 — 어댑터 이름과 능력 상수와 마크다운 표가 전부 소스에 있다. 그리고 현재 계약 테스트의 좁은 단언 범위 밖에 발견된 드리프트 세 건이 전부 있다." ], "unknown": [ "프로젝트가 이 확장을 채택할 것인지. 테스트 javadoc 은 오히려 좁게 유지하는 근거를 적는다 — \"Asserting on wording would make every edit a test failure and the check would be deleted.\"" ], "next-verification": "capability 표 60칸을 코드 상수에서 파생시키는 검사를 시제품으로 만들어 문구 변경에 대한 취약성을 실측한다.", "decision-criterion": "파생 검사가 문구 변경에 취약하지 않다는 것이 실측되면 확장을 채택한다. 그렇지 않으면 좁은 단언을 유지하고 경계를 문서에 적는다.", "relations": [ "`case:doc-contract-test-boundary-predicted-the-drift`", "`reference:fix-overstatement-before-understatement`" ], "classification": "capability 표는 기계로 검증 가능하다 — `CompatibilityMatrix.entries()`가 어댑터 이름을 갖고, 각 어댑터의 `MessagingCapabilities`는 코드 상수이며, 표는 마크다운 파이프 테이블이다. 그러나 프로젝트가 이 확장을 선택한 기록이 없고, 테스트 javadoc은 오히려 좁게 유지하는 근거를 적는다(\"Asserting on wording would make every edit a test failure and the check would be deleted\"). 결정이 필요한 지점이며 이 노드는 생성되지 않는다.", "listedInTree": false, "publication": "초안", "file": "drift-direction/question/openquestion-widen-doc-contract-assertions.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "decision": [ { "title": "지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다", "kind": "decision", "slug": "capability-grade-is-declared-not-inferred", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-5`", "`final/document.md#a05` §2.5", "`final/document.md#a19` §6.6", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "drift-direction/decision/decision-capability-grade-is-declared-not-inferred.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ] } }, "learning-transfer-between-families": { "topic": "learning-transfer-between-families", "title": "플랫폼 가족 사이의 학습 전이", "readerQuestion": "한 플랫폼에서 배운 것이 다음 플랫폼으로 옮겨질 때 무엇이 따라가고 무엇이 남는가?", "kinds": { "case": [ { "title": "두 번째 플랫폼이 첫 번째의 bridge 부재는 막고 게이트 배선은 옮기지 않았다", "kind": "case", "slug": "the-second-platform-carried-the-design-not-the-wiring", "readiness": "READY", "source": [ "`final/document.md#7-6`", "`final/document.md#a20` §2, §3", "`final/document.md#a99` §2, §4" ], "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`" ], "publication": "초안", "file": "learning-transfer-between-families/case/case-the-second-platform-carried-the-design-not-the-wiring.md", "status": "게시 전", "studioId": "", "assets": [ "the-second-platform-carried-the-design-not-the-wiring" ], "assetFiles": [ "the-second-platform-carried-the-design-not-the-wiring" ], "evidenceFiles": [ "../../../final/evidence/raw/the-second-platform-carried-the-design-not-the-wiring.txt", "../../../final/evidence/raw/tl-platform-suite-results.txt", "../../../final/evidence/raw/tl-grpc-evidence-lanes.txt" ] }, { "title": "같은 자격 증명 회전 결함이 한 가족에서 닫히고 다른 가족에서 재현됐다", "kind": "case", "slug": "the-same-rotation-defect-closed-once-and-reproduced", "readiness": "READY", "source": [ "`final/document.md#5-5`", "`final/document.md#a19` §5.4", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "learning-transfer-between-families/case/case-the-same-rotation-defect-closed-once-and-reproduced.md", "status": "게시 전", "studioId": "", "assets": [ "the-same-rotation-defect-closed-once-and-reproduced" ], "assetFiles": [ "the-same-rotation-defect-closed-once-and-reproduced" ], "evidenceFiles": [ "../../../final/evidence/raw/the-same-rotation-defect-closed-once-and-reproduced.txt", "../../../final/evidence/raw/tl-rotation-defect-reproduced.txt" ] } ], "concept": [], "reference": [ { "title": "레지스트리로 표현된 규칙은 전이되고 CI로 표현된 규칙은 전이되지 않는다", "kind": "reference", "slug": "registry-rules-transfer-ci-rules-do-not", "readiness": "READY", "source": [ "`final/document.md#7-6`", "`final/document.md#a99` §2, §4" ], "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`" ], "publication": "초안", "file": "learning-transfer-between-families/reference/reference-registry-rules-transfer-ci-rules-do-not.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [], "decision": [ { "title": "gRPC 플랫폼은 build-only로 두고 애플리케이션 도달 경로를 먼저 정한다", "kind": "decision", "slug": "grpc-stays-build-only-until-the-bridge-is-decided", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#2-3`", "`final/document.md#a20` §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`" ], "publication": "초안", "file": "learning-transfer-between-families/decision/decision-grpc-stays-build-only-until-the-bridge-is-decided.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ] } }, "owner-safe-state-machines": { "topic": "owner-safe-state-machines", "title": "owner-safe 상태 기계 — 소유권을 SQL에 적기", "readerQuestion": "동시에 도는 두 일꾼이 같은 행과 같은 진행 위치를 두고 다투지 않게 하려면 무엇을 어디에 적어야 하는가?", "kinds": { "case": [ { "title": "리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다", "kind": "case", "slug": "a-lease-without-an-owner", "readiness": "READY", "source": [ "`final/document.md#4-3`", "`final/document.md#a19` §7.3", "`final/document.md#a08` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/case/case-a-lease-without-an-owner.md", "status": "게시 전", "studioId": "", "assets": [ "a-lease-without-an-owner" ], "assetFiles": [ "a-lease-without-an-owner" ], "evidenceFiles": [ "../../../final/evidence/raw/a-lease-without-an-owner.txt" ] }, { "title": "활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다", "kind": "case", "slug": "an-active-transaction-check-that-asked-the-wrong-question", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/case/case-an-active-transaction-check-that-asked-the-wrong-question.md", "status": "게시 전", "studioId": "", "assets": [ "an-active-transaction-check-that-asked-the-wrong-question" ], "assetFiles": [ "an-active-transaction-check-that-asked-the-wrong-question" ], "evidenceFiles": [ "../../../final/evidence/raw/an-active-transaction-check-that-asked-the-wrong-question.txt" ] }, { "title": "native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다", "kind": "case", "slug": "native-claim-did-not-bump-the-version", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/case/case-native-claim-did-not-bump-the-version.md", "status": "게시 전", "studioId": "", "assets": [ "native-claim-did-not-bump-the-version" ], "assetFiles": [ "native-claim-did-not-bump-the-version" ], "evidenceFiles": [ "../../../final/evidence/raw/native-claim-did-not-bump-the-version.txt" ] }, { "title": "「본 적 있는 위치」를 「투영이 끝난 위치」로 쓴 mark 가 재전달된 이벤트를 삼켰다", "slug": "a-mark-that-meant-seen-not-projected", "readiness": "READY", "source": [ "final/document.md#4-2", "final/document.md#8-1 항목 9", "final/document.md#a06 §67" ], "code": [ "MongoChangeStreamPipeline", "highWaterMark", "NoOpCheckpoints" ], "classification": "failover 한 번으로 변경이 영구히 사라지는 경로를 probe 로 재현했고, mark 가 전진하는 시점이 원인임을 확정했다. 세 테스트가 각각 절반씩만 보아 「본 적 있지만 완료되지 않은 위치」라는 제3의 상태가 어디에도 없었다는 것까지 닫힌다", "missing-verification": "probe C 는 worker 하나에 평범한 failover 한 번이다. worker 가 여럿이거나 resume 이 반복될 때의 손실 폭은 재지 않았다", "relations": [ "concept:fenced-lease", "case:a-lease-without-an-owner", "reference:read-the-clock-after-the-lock" ], "kind": "case", "publication": "미작성" } ], "concept": [ { "title": "fenced lease — 만료 시각만으로는 부족한 이유", "kind": "concept", "slug": "fenced-lease", "readiness": "READY", "source": [ "`final/document.md#4-1`, `#4-3`", "`final/document.md#a05` §10", "`final/document.md#a19` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · owner-safe 상태 기계 넷(idempotency · outbox-storage · outbox-polling · inbox) · V2__messaging_outbox_lease_fencing.sql", "publication": "초안", "file": "owner-safe-state-machines/concept/concept-fenced-lease.md", "status": "게시 전", "studioId": "", "assets": [ "fenced-lease" ], "assetFiles": [ "fenced-lease" ], "evidenceFiles": [ "../../../final/evidence/raw/fenced-lease.txt" ] }, { "title": "CAS 튜플과 update count가 답이 되는 구조", "kind": "concept", "slug": "cas-tuple-and-update-count", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · owner-safe 상태 기계 넷이 공유하는 여섯 단계 패턴 · PostgreSQL 16/17/18", "publication": "초안", "file": "owner-safe-state-machines/concept/concept-cas-tuple-and-update-count.md", "status": "게시 전", "studioId": "", "assets": [ "cas-tuple-and-update-count", "cas-tuple-and-update-count-diagram" ], "assetFiles": [ "cas-tuple-and-update-count", "cas-tuple-and-update-count" ], "evidenceFiles": [ "../../../final/evidence/raw/cas-tuple-and-update-count.txt" ] }, { "title": "capability_schema_registry — 스키마 적용과 사용 승인의 분리", "kind": "concept", "slug": "capability-schema-registry", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · capability_schema_registry · INSTALLED_INACTIVE 와 ACTIVE 두 상태 · notification 과 fileserver 두 capability 스트림", "publication": "초안", "file": "owner-safe-state-machines/concept/concept-capability-schema-registry.md", "status": "게시 전", "studioId": "", "assets": [ "capability-schema-registry", "capability-schema-registry-diagram" ], "assetFiles": [ "capability-schema-registry", "capability-schema-registry" ], "evidenceFiles": [ "../../../final/evidence/raw/capability-schema-registry.txt" ] } ], "reference": [ { "title": "CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다", "kind": "reference", "slug": "cas-tuple-in-the-where-clause", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 17", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/reference/reference-cas-tuple-in-the-where-clause.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "시간은 DB에서, 그리고 행을 잠근 다음에 읽는다", "kind": "reference", "slug": "read-the-clock-after-the-lock", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 17", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/reference/reference-read-the-clock-after-the-lock.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [ { "title": "V2 상태 기계 넷의 컨테이너 레인이 실행되지 않았다", "kind": "question", "slug": "v2-state-machine-lanes-not-executed", "readiness": "OPEN", "source": [ "`final/document.md#6-2`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/question/openquestion-v2-state-machine-lanes-not-executed.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "decision": [ { "title": "capability는 스키마 적용과 사용 승인을 분리한다", "kind": "decision", "slug": "capability-separates-installation-from-activation", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "owner-safe-state-machines/decision/decision-capability-separates-installation-from-activation.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ] } }, "transaction-deadline-and-pool": { "topic": "transaction-deadline-and-pool", "title": "트랜잭션 데드라인과 커넥션 예산", "readerQuestion": "호출자에게 남은 시간이 DB 의 타임아웃까지 어떻게 내려가고, 그 사이에 커넥션은 몇 개가 필요한가?", "kinds": { "case": [ { "title": "connection-timeout: 5s가 모든 prod 배포를 시작 실패시켰고 local만 통과했다", "kind": "case", "slug": "a-five-second-string-that-broke-every-prod-deploy", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/case/case-a-five-second-string-that-broke-every-prod-deploy.md", "status": "게시 전", "studioId": "", "assets": [ "a-five-second-string-that-broke-every-prod-deploy-bind", "a-five-second-string-that-broke-every-prod-deploy" ], "assetFiles": [ "a-five-second-string-that-broke-every-prod-deploy-bind", "a-five-second-string-that-broke-every-prod-deploy" ], "evidenceFiles": [ "../../../final/evidence/raw/a-five-second-string-that-broke-every-prod-deploy-bind.txt", "../../../final/evidence/raw/a-five-second-string-that-broke-every-prod-deploy.txt" ] }, { "title": "REQUIRES_NEW가 바깥 커넥션을 핀한 채 새 커넥션을 딴다", "kind": "case", "slug": "requires-new-pins-the-outer-connection", "readiness": "READY", "source": [ "`final/document.md#3-2`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/case/case-requires-new-pins-the-outer-connection.md", "status": "게시 전", "studioId": "", "assets": [ "requires-new-pins-the-outer-connection" ], "assetFiles": [ "requires-new-pins-the-outer-connection" ], "evidenceFiles": [ "../../../final/evidence/raw/requires-new-pins-the-outer-connection.txt" ] } ], "concept": [ { "title": "호출 예산에서 DB 로컬 타임아웃까지의 데드라인 전파", "kind": "concept", "slug": "deadline-propagation", "readiness": "READY", "source": [ "`final/document.md#3-2`", "`final/document.md#a05` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · SpringTransactionPort · PostgreSQL 의 statement_timeout · lock_timeout · idle_in_transaction_session_timeout 을 set_config 로 transaction-local 지정", "publication": "초안", "file": "transaction-deadline-and-pool/concept/concept-deadline-propagation.md", "status": "게시 전", "studioId": "", "assets": [ "deadline-propagation", "deadline-propagation-diagram" ], "assetFiles": [ "deadline-propagation", "deadline-propagation" ], "evidenceFiles": [ "../../../final/evidence/raw/deadline-propagation.txt" ] }, { "title": "REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약", "kind": "concept", "slug": "requires-new-connection-cost", "readiness": "READY", "source": [ "`final/document.md#3-2`", "`final/document.md#a05` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · Spring 의 REQUIRES_NEW 전파 · HikariCP 풀 제약 검증기", "publication": "초안", "file": "transaction-deadline-and-pool/concept/concept-requires-new-connection-cost.md", "status": "게시 전", "studioId": "", "assets": [ "requires-new-connection-cost" ], "assetFiles": [ "requires-new-connection-cost" ], "evidenceFiles": [ "../../../final/evidence/raw/requires-new-connection-cost.txt" ] } ], "reference": [], "question": [ { "title": "풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다", "kind": "question", "slug": "pool-contract-lane-not-executed", "readiness": "OPEN", "source": [ "`final/document.md#6-2`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/question/openquestion-pool-contract-lane-not-executed.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "decision": [ { "title": "트랜잭션 템플릿은 모드별로 미리 만들어 둔다", "kind": "decision", "slug": "templates-are-built-once-per-mode", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#3-2`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/decision/decision-templates-are-built-once-per-mode.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "inRootWrite는 suspend하지 않고 fail-fast한다", "kind": "decision", "slug": "in-root-write-fails-fast", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#3-2`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/decision/decision-in-root-write-fails-fast.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ] } }, "schema-ownership-and-capability-streams": { "topic": "schema-ownership-and-capability-streams", "title": "스키마 소유권과 capability 스트림", "readerQuestion": "스키마를 누가 소유하고, 마이그레이션이 여러 갈래로 갈릴 때 무엇이 충돌하는가?", "kinds": { "case": [ { "title": "두 트리가 다 V1부터 번호를 매겨 공유 history가 하나를 건너뛸 수 있었다", "kind": "case", "slug": "two-trees-both-numbered-from-v1", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/case/case-two-trees-both-numbered-from-v1.md", "status": "게시 전", "studioId": "", "assets": [ "two-trees-both-numbered-from-v1" ], "assetFiles": [ "two-trees-both-numbered-from-v1" ], "evidenceFiles": [ "../../../final/evidence/raw/two-trees-both-numbered-from-v1.txt" ] }, { "title": "char(64)와 varchar(64) 불일치를 H2가 가리고 있었다", "kind": "case", "slug": "h2-hid-a-column-type-mismatch", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/case/case-h2-hid-a-column-type-mismatch.md", "status": "게시 전", "studioId": "", "assets": [ "h2-hid-a-column-type-mismatch" ], "assetFiles": [ "h2-hid-a-column-type-mismatch" ], "evidenceFiles": [ "../../../final/evidence/raw/h2-hid-a-column-type-mismatch.txt" ] }, { "title": "messaging 마이그레이션 두 leaf가 같은 디렉터리에서 V2를 둘 만들었다", "kind": "case", "slug": "messaging-migrations-collide-at-v2", "readiness": "READY", "source": [ "`final/document.md#4-3`", "`final/document.md#a19` §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`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/case/case-messaging-migrations-collide-at-v2.md", "status": "게시 전", "studioId": "", "assets": [ "messaging-migrations-collide-at-v2" ], "assetFiles": [ "messaging-migrations-collide-at-v2" ], "evidenceFiles": [ "../../../final/evidence/raw/messaging-migrations-collide-at-v2.txt" ] } ], "concept": [ { "title": "독립 Flyway 스트림과 baseline version 0", "kind": "concept", "slug": "independent-flyway-streams", "readiness": "READY", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · Flyway 마이그레이션 스트림 여덟 갈래와 각자의 history 테이블 · baseline version 0", "publication": "초안", "file": "schema-ownership-and-capability-streams/concept/concept-independent-flyway-streams.md", "status": "게시 전", "studioId": "", "assets": [ "independent-flyway-streams" ], "assetFiles": [ "independent-flyway-streams" ], "evidenceFiles": [ "../../../final/evidence/raw/independent-flyway-streams.txt" ] } ], "reference": [ { "title": "로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다", "kind": "reference", "slug": "local-with-a-different-db-is-a-different-system", "readiness": "READY", "source": [ "`final/document.md#9` 규칙 18", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/reference/reference-local-with-a-different-db-is-a-different-system.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "question": [], "decision": [ { "title": "Flyway가 스키마를 소유하고 런타임 롤은 DDL 권한을 갖지 않는다", "kind": "decision", "slug": "flyway-owns-the-schema", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#4-1`", "`final/document.md#a05` §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`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/decision/decision-flyway-owns-the-schema.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ] } }, "redis-command-admission": { "topic": "redis-command-admission", "title": "Redis 명령 admission과 카탈로그", "readerQuestion": "명령 하나가 Redis 에 닿기까지 무엇을 지나야 하고, 그 관문을 건너뛰는 경로는 어디에 있는가?", "kinds": { "case": [ { "title": "\"build gate\"라 불리는 catalog drift 검사가 어디에서도 실행되지 않는다", "kind": "case", "slug": "a-build-gate-that-is-not-in-the-build", "readiness": "READY", "source": [ "`final/document.md#4-4`", "`final/document.md#a10` §47" ], "code": [ "`.../cache-redis/.../RedisCommandMetadataDiff.java`", "명령 정책 파일 머리 주석" ], "evidence": [ "`evidence/raw/163-cache-redis-guard-connection-codec-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`" ], "publication": "초안", "file": "redis-command-admission/case/case-a-build-gate-that-is-not-in-the-build.md", "status": "게시 전", "studioId": "", "assets": [ "a-build-gate-that-is-not-in-the-build" ], "assetFiles": [ "a-build-gate-that-is-not-in-the-build" ], "evidenceFiles": [ "../../../final/evidence/raw/a-build-gate-that-is-not-in-the-build.txt" ] }, { "title": "의미 어댑터 다섯이 gateway를 직접 불러 admission 아홉 단계를 건너뛴다", "kind": "case", "slug": "five-adapters-bypass-the-single-admission-point", "readiness": "READY", "source": [ "`final/document.md#4-4`", "`final/document.md#a10` §64" ], "code": [ "`.../CommandPolicyGuard.java`", "cache·idempotency·lease·ratelimit·realtime 어댑터의 `lease.gateway()` 호출부" ], "evidence": [ "`evidence/raw/165-cache-redis-semantic-adapters-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`" ], "publication": "초안", "file": "redis-command-admission/case/case-five-adapters-bypass-the-single-admission-point.md", "status": "게시 전", "studioId": "", "assets": [ "five-adapters-bypass-the-single-admission-point" ], "assetFiles": [ "five-adapters-bypass-the-single-admission-point" ], "evidenceFiles": [ "../../../final/evidence/raw/five-adapters-bypass-the-single-admission-point.txt" ] }, { "title": "NOSCRIPT 복구가 다섯 벌이고 넷은 스크립트 레지스트리를 지나지 않는다", "kind": "case", "slug": "five-copies-of-noscript-recovery", "readiness": "READY", "source": [ "`final/document.md#4-4`", "`final/document.md#a10` §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`" ], "publication": "초안", "file": "redis-command-admission/case/case-five-copies-of-noscript-recovery.md", "status": "게시 전", "studioId": "", "assets": [ "five-copies-of-noscript-recovery" ], "assetFiles": [ "five-copies-of-noscript-recovery" ], "evidenceFiles": [ "../../../final/evidence/raw/five-copies-of-noscript-recovery.txt" ] }, { "title": "startup probe가 production에서 한 번도 실행되지 않는다", "kind": "case", "slug": "a-startup-probe-that-never-runs", "readiness": "READY", "source": [ "`final/document.md#4-4`", "`final/document.md#a10` §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`" ], "publication": "초안", "file": "redis-command-admission/case/case-a-startup-probe-that-never-runs.md", "status": "게시 전", "studioId": "", "assets": [ "a-startup-probe-that-never-runs" ], "assetFiles": [ "a-startup-probe-that-never-runs" ], "evidenceFiles": [ "../../../final/evidence/raw/a-startup-probe-that-never-runs.txt" ] } ], "concept": [ { "title": "명령 카탈로그와 admission 아홉 단계", "kind": "concept", "slug": "redis-admission-stages", "readiness": "READY", "source": [ "`final/document.md#4-4`", "`final/document.md#a10` §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`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · adapter-outbound-cache-redis 390 파일 · 명령 카탈로그와 CommandPolicyGuard", "publication": "초안", "file": "redis-command-admission/concept/concept-redis-admission-stages.md", "status": "게시 전", "studioId": "", "assets": [ "redis-admission-stages", "redis-admission-stages-diagram" ], "assetFiles": [ "redis-admission-stages", "redis-admission-stages" ], "evidenceFiles": [ "../../../final/evidence/raw/redis-admission-stages.txt" ] } ], "reference": [], "question": [ { "title": "Redis 토폴로지 레인이 실행되지 않아 key spec 드리프트가 확인되지 않았다", "kind": "question", "slug": "redis-topology-lane-not-executed", "readiness": "OPEN", "source": [ "`final/document.md#6-2`", "`final/document.md#a10` §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`" ], "publication": "초안", "file": "redis-command-admission/question/openquestion-redis-topology-lane-not-executed.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] } ], "decision": [] } }, "http-failure-classification": { "topic": "http-failure-classification", "title": "HTTP 실패 분류와 재시도 안전성", "readerQuestion": "전송 실패를 어떤 어휘로 적고, 그 실패를 다시 시도해도 되는지는 무엇이 답하는가?", "kinds": { "case": [ { "title": "붉은 테스트를 제품 결함으로 읽은 오진 — 듀얼스택 localhost가 TLS 실패를 버린다", "kind": "case", "slug": "a-red-test-misread-as-a-product-defect", "readiness": "READY", "source": [ "`final/document.md#14`", "`final/document.md#a11` §51", "`final/document.md#a99` §1, §5" ], "code": [ "`.../httpclient/.../ApacheFailureClassifier.java`", "`.../testkit/MockHttpServer.java:70-72`", "`.../security/MutualTlsHandshakeContractTest.java:128-171`" ], "evidence": [ "`EVD-332` — 예외 사슬 실측", "접속 호스트만 바꾼 대조 실험", "`/etc/hosts` 듀얼스택 확인" ], "classification": "`:adapter:outbound:httpclient:test`가 283 중 3건 실패한다(신뢰불가 CA·만료 인증서·호스트명 불일치). 셋 다 `expected: TLS_HANDSHAKE but was: CONNECT`다. 사이클 1은 `recognize`의 분기 순서(pool → DNS → CONNECT → TLS)를 읽고 \"Apache가 TLS 실패를 `HttpHostConnectException`으로 감싸므로 바깥 CONNECT 분기가 안쪽 `SSLHandshakeException`을 가린다\"고 결론하고 **P1 제품 결함**으로 올렸다. 사이클 2가 예외 사슬을 실제로 출력하니 사슬은 `ResourceAccessException → HttpHostConnectException(\"Connection refused\")` 둘뿐이고 `SSLHandshakeException`이 아예 없다. 진짜 원인은 `MockHttpServer.uri()`가 호스트명 `localhost`를 돌려주는데 이 컨테이너의 `/etc/hosts`가 `localhost`를 `127.0.0.1`과 `::1` 양쪽에 주고 `MockWebServer`는 IPv4에만 바인딩한다는 것이다. Apache의 다중 주소 연결 루프는 마지막이 아닌 주소의 실패를 삼키므로, 127.0.0.1에서 난 진짜 TLS 실패가 버려지고 `::1`의 연결 거부만 승격된다. 접속 호스트를 `127.0.0.1`로 바꾸면 세 건 모두 `SSLHandshakeException` → `TLS_PERMANENT`가 된다. 핸드셰이크가 성공하는 테스트가 통과하는 이유도 같다 — 첫 주소에서 성공하면 루프가 즉시 반환한다. **판정은 둘로 갈린다.** 픽스처가 호스트명을 쓰는 것은 P3이고, 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 `CONNECT`로 강등된다는 성질은 P2/기록이며 이 모듈에서는 고칠 수 없다 — 분류기가 볼 수 있는 정보를 엔진이 이미 버렸기 때문이다.", "missing-verification": "`ReactorFailureClassifier`·`JdkFailureClassifier`는 같은 방식으로 실행해 보지 않았다", "relations": [ "`concept:transport-failure-stage-and-category`", "`reference:a-classifier-sees-only-what-the-engine-kept`", "`reference:verify-runtime-shape-at-runtime`", "`decision:retry-safety-is-decided-by-evidence`" ], "publication": "초안", "file": "http-failure-classification/case/case-a-red-test-misread-as-a-product-defect.md", "status": "게시 전", "studioId": "", "assets": [ "a-red-test-misread-as-a-product-defect", "a-red-test-misread-as-a-product-defect-host-control" ], "assetFiles": [ "a-red-test-misread-as-a-product-defect", "a-red-test-misread-as-a-product-defect-host-control" ], "evidenceFiles": [ "../../../final/evidence/raw/a-red-test-misread-as-a-product-defect.txt", "../../../final/evidence/raw/a-red-test-misread-as-a-product-defect-host-control.txt" ] } ], "concept": [ { "title": "전송 실패의 단계와 범주 — AttemptStage와 FailureCategory", "kind": "concept", "slug": "transport-failure-stage-and-category", "readiness": "READY", "source": [ "`final/document.md#8-1`", "`final/document.md#a11` §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:a-red-test-misread-as-a-product-defect`", "`reference:walk-the-cause-chain-most-specific-wins`", "`decision:retry-safety-is-decided-by-evidence`" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · adapter-outbound-httpclient 의 AttemptStage 와 FailureCategory", "publication": "초안", "file": "http-failure-classification/concept/concept-transport-failure-stage-and-category.md", "status": "게시 전", "studioId": "", "assets": [ "transport-failure-stage-and-category", "transport-failure-stage-and-category-diagram" ], "assetFiles": [ "transport-failure-stage-and-category", "transport-failure-stage-and-category" ], "evidenceFiles": [ "../../../final/evidence/raw/transport-failure-stage-and-category.txt" ] }, { "title": "실패 어휘 세 층과 그 사이를 잇는 SQLState 매트릭스", "slug": "three-failure-vocabularies", "readiness": "READY", "source": [ "final/document.md#5-1", "final/document.md#a05 §7.1", "final/document.md#a02" ], "code": [ "Category", "FailureCategory", "PublishCompletion", "TransmissionEvidence" ], "basis-version": "clean-architecture-backend-template @ 21234e38 · shared-contract Category 10값 · persistence-jpa FailureCategory 16값", "classification": "세 어휘가 각각 다른 질문에 답하고 그 사이를 매트릭스 하나가 잇는다는 것을 알아야, 어떤 실패가 어느 층에서 이름을 얻는지 읽을 수 있다", "relations": [ "reference:one-sqlstate-with-two-contributors-fails-startup", "decision:an-unrecognised-sqlstate-is-not-guessed", "concept:transport-failure-stage-and-category" ], "kind": "concept", "publication": "미작성" } ], "reference": [ { "title": "같은 SQLState 를 둘이 등록하면 값이 같아도 시작을 실패시킨다", "slug": "one-sqlstate-with-two-contributors-fails-startup", "readiness": "READY", "source": [ "final/document.md#5-1", "final/document.md#a05 §7.1" ], "classification": "last-writer-wins merge 가 매핑 소유권 드리프트를 숨긴다는 판단이 매트릭스 병합 규칙 하나로 굳었고, 여러 기여자가 같은 표를 채우는 어떤 레지스트리에도 적용된다", "scope": "여러 모듈이 같은 표에 항목을 등록하고 그 표가 런타임 판정을 결정하는 자리", "exceptions": "기여자가 하나뿐이거나 중복이 의미상 같다는 것을 검사가 확인할 수 있으면 실패시키지 않아도 된다", "relations": [ "concept:three-failure-vocabularies", "reference:omission-that-passes-is-not-a-gate", "decision:an-unrecognised-sqlstate-is-not-guessed" ], "kind": "reference", "publication": "미작성" } ], "question": [], "decision": [ { "title": "재시도 안전성은 증거에 기반해 판정한다", "kind": "decision", "slug": "retry-safety-is-decided-by-evidence", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#10-2`", "`final/document.md#a11` §51", "`final/document.md#a20-grpc-core-api` §1" ], "decision-evidence": [ "`.../httpclient/.../TransportFailure.java`의 `notSent`/`sentNoResponse` 팩토리와 그 javadoc", "`.../api/operation/ExecutionEvidence.java`의 네 값과 \"A generic engine I/O failure is never upgraded to NOT_SENT\"", "`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": "이 모듈의 전체 설계가 \"증거에 기반해 재시도 안전성을 판정한다\"에 서 있다 — 무엇이 실패했는지가 아니라 **무엇이 관측됐는지**가 판정 입력이다. `NOT_SENT`은 단계 실패가 요청이 서버에 닿지 않았음을 증명할 때만 쓰고, 일반적인 엔진 I/O 실패는 결코 `NOT_SENT`으로 승격되지 않는다. 사이클 2가 §51에서 확인한 것이 이 판정의 취약점을 보여 준다 — 증거를 만드는 계층이 정보를 버리면 이 설계 전체가 틀린 입력 위에 선다.", "relations": [ "`case:a-red-test-misread-as-a-product-defect`", "`decision:three-axes-of-evidence`", "`decision:completion-unknown-is-never-retried`" ], "publication": "초안", "file": "http-failure-classification/decision/decision-retry-safety-is-decided-by-evidence.md", "status": "게시 전", "studioId": "", "assets": [], "assetFiles": [], "evidenceFiles": [] }, { "title": "인식하지 못한 SQLSTATE 는 추측하지 않는다", "slug": "an-unrecognised-sqlstate-is-not-guessed", "readiness": "READY", "source": [ "final/document.md#10-2", "final/document.md#5-1", "final/document.md#a05 §7.1" ], "decision-status": "ADOPTED", "decision-evidence": "미지의 SQLState 는 `Optional.empty()` 이고 translator 가 `DB_*` 코드를 만들지 않는다. 원본 예외가 web catch-all 까지 전파돼 detail 없는 generic INTERNAL 로 답한다 (final/document.md#5-1)", "grounds": "미지의 상태를 직렬화 실패로 분류하면 재시도 코디네이터가 이미 성공한 쓰기를 기꺼이 다시 돌린다. 감수한 비용은 새 SQLSTATE 마다 매핑을 손으로 더해야 한다는 것이다", "classification": "프로젝트가 추측하지 않는 쪽을 골랐고 그 대가를 매핑 등록으로 지불한다", "relations": [ "concept:three-failure-vocabularies", "decision:retry-safety-is-decided-by-evidence", "reference:unknown-is-a-third-result" ], "kind": "decision", "publication": "미작성" } ] } }, "fileserver-state-and-fencing": { "topic": "fileserver-state-and-fencing", "title": "파일 상태 기계와 물리 정리의 seam", "readerQuestion": "파일이 공개해도 되는 상태라는 것을 무엇이 보장하고, 물리 정리는 그 상태와 어떻게 맞물리는가?", "kinds": { "case": [ { "title": "scriptable 콘텐츠 탐지가 BOM·NUL·주석으로 우회된다", "kind": "case", "slug": "scriptable-detection-bypassed-by-a-bom", "readiness": "READY", "source": [ "`final/document.md#4-4`", "`final/document.md#a08` §40" ], "code": [ "`.../fileserver/.../ScriptableContentPolicy.java`" ], "evidence": [ "`evidence/raw/146-fileserver-verification-security-audit-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 주석 후 `