{ "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-07", "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부의 분석 재료는 근거이지 후보 자리가 아니다 — 지금 트리의 글감 대부분이 거기서 나왔고, 그것이 재판정이 필요한 이유다" }, "note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸(readiness·source·classification·relations…)은 사람이 적고, file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py <프로젝트>", "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": 44, "nodes": 1001, "written": 837, "unwritten": 164, "unlisted": 103, "candidates": 965 }, "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": [], "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" ], "evidenceFiles": [] } ], "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": [], "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": [], "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": [], "evidenceFiles": [] } ] } }, "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" ], "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" ], "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" ], "evidenceFiles": [ "../../../final/evidence/raw/observation-downgraded-by-the-composition.txt", "../../../final/evidence/raw/tl-messaging-observation-noop.txt" ] }, { "title": "이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다", "kind": "case", "slug": "autoconfiguration-in-name-only", "readiness": "READY", "source": [ "`final/document.md#a05` §14.4" ], "code": [ "`.../app-bootstrap/.../autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java`의 javadoc" ], "evidence": [ "없음 — javadoc의 사후 기록" ], "classification": "세 클래스가 `...AutoConfiguration`으로 이름 붙었고 plain factory였다 — `@AutoConfiguration`도, `@Bean`도, `.imports` 엔트리도 없었고 합성 루트는 그 패키지를 스캔에서 제외한다. 그래서 capability 리포트는 transaction retry·completion evidence·observability를 Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.** 개발자가 재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.", "missing-verification": "없음 — 수정 후 형태를 코드로 확인했다", "relations": [ "`reference:a-bean-is-not-composition-evidence`", "`case:a-retry-implementation-nobody-calls`", "`decision:capability-grade-is-declared-not-inferred`" ], "publication": "초안", "file": "assembly-ownership/case/case-autoconfiguration-in-name-only.md", "status": "게시 전", "studioId": "", "assets": [ "autoconfiguration-in-name-only" ], "evidenceFiles": [ "../../../final/evidence/raw/autoconfiguration-in-name-only.txt" ] }, { "title": "`@ConditionalOnBean(DataSource.class)`가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다", "kind": "case", "slug": "conditionalonbean-evaluated-at-parse-time", "readiness": "READY", "source": [ "`final/document.md#a05` §14.4" ], "code": [ "`.../autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java`", "`.../PersistenceJpaRootAutoConfiguration.java`" ], "evidence": [ "없음 — javadoc의 사후 기록" ], "classification": "이 클래스는 루트가 **import**하지 auto-configure하지 않으므로, 그 조건이 클래스 파싱 중 — datasource 빈 정의가 존재하기 전에 — 평가됐고 따라서 **모든 실제 배포에서 false**였다. 아래 여덟 빈이 조용히 사라졌고 아무것도 그중 어느 것에도 의존하지 않아 아무것도 보고하지 않았다. datasource validator가 caller에 배선되고 Compose 레인이 \"No qualifying bean\"이라고 답했을 때에야 드러났다. 같은 함정을 피하려고 루트의 검사가 validator를 주입받지 않고 직접 생성한다.", "missing-verification": "현재 리비전에서 재발하지 않는지 `ConditionEvaluationReport`로 확인하지 않았다", "relations": [ "`concept:when-conditions-are-evaluated`", "`open-question:conditional-evaluation-order-unverified`" ], "publication": "초안", "file": "assembly-ownership/case/case-conditionalonbean-evaluated-at-parse-time.md", "status": "게시 전", "studioId": "", "assets": [ "conditionalonbean-evaluated-at-parse-time" ], "evidenceFiles": [ "../../../final/evidence/raw/conditionalonbean-evaluated-at-parse-time.txt" ] }, { "title": "넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다", "kind": "case", "slug": "narrowing-the-scan-orphaned-eight-components", "readiness": "READY", "source": [ "`final/document.md#a05` §14.2" ], "code": [ "`.../persistence-jpa/.../config/JpaAdapterComponentsConfig.java`의 javadoc" ], "evidence": [ "없음 — javadoc의 사후 기록" ], "classification": "합성 루트의 스캔이 persistence 트리를 정규식으로 제외했고 **그 제외는 옳다** — 그것이 optional capability를 optional하게 만든다. 빠진 것은 나머지 절반이다. 이 leaf의 여덟 클래스가 scanned component로 쓰여 있는데(`SpringTransactionPort`·`PersistenceExceptionTranslator`·`StandardSqlStateErrorMapping`·`DomainContextAuditContextPort`·idempotency store와 reaper·outbox store와 reaper) 넓은 스캔이 멈추자 **아무것도 도달하지 않았다.** 특히 `TransactionPort`는 구현이 전혀 없어서 트랜잭션을 여는 모든 유스케이스가 열 포트를 갖지 못했고, 단위 테스트는 각 클래스를 직접 생성하므로 볼 수 있는 것이 없었다.", "missing-verification": "없음 — 수정된 `@ComponentScan` 대상 6개를 코드로 확인했다", "relations": [ "`concept:three-assembly-paths`", "`reference:read-the-assembling-side-first`", "`reference:off-must-be-structural`" ], "publication": "초안", "file": "assembly-ownership/case/case-narrowing-the-scan-orphaned-eight-components.md", "status": "게시 전", "studioId": "", "assets": [ "narrowing-the-scan-orphaned-eight-components" ], "evidenceFiles": [ "../../../final/evidence/raw/narrowing-the-scan-orphaned-eight-components.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" ], "evidenceFiles": [ "../../../final/evidence/raw/thirteen-startup-rules-never-run.txt" ] }, { "title": "subsystem 전체가 미배선인데 그것을 켜는 flag는 startup 검사를 수행한다", "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" ], "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" ], "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" ] } ], "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`" ], "publication": "초안", "file": "assembly-ownership/concept/concept-three-assembly-paths.md", "status": "게시 전", "studioId": "", "assets": [ "three-assembly-paths", "three-assembly-paths-diagram" ], "evidenceFiles": [ "../../../final/evidence/raw/three-assembly-paths.txt", "../../../final/evidence/raw/tl-web-six-unowned-components.txt" ] }, { "title": "조건부 빈의 평가 시점 — 파싱 시점과 등록 시점", "kind": "concept", "slug": "when-conditions-are-evaluated", "readiness": "READY", "source": [ "`final/document.md#a05` §14.4" ], "code": [ "`.../app-bootstrap/.../autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java`의 근거 javadoc", "`.../PersistenceJpaRootAutoConfiguration.java`의 `jpaResolvedDataSourceCheck`" ], "classification": "`@ConditionalOnBean`은 그 클래스가 **언제 평가되는가**에 따라 답이 달라진다. `@AutoConfiguration`으로 등록되면 다른 자동설정 이후에 평가되지만, plain `@Configuration`이 `@Import`로 들어오면 **클래스가 파싱되는 동안 — 대상 빈 정의가 존재하기 전에** 평가된다. 이 저장소가 그 함정을 실제로 밟았고(여덟 빈이 조용히 사라짐), 회피 방법 두 가지를 남겼다 — 검증기를 주입받지 않고 직접 생성하기, 그리고 조건을 루트로 올리기.", "missing-verification": "현재 리비전의 각 조건부 빈이 어느 시점에 평가되는지는 `ConditionEvaluationReport`로 확인하지 않았다", "relations": [ "`case:conditionalonbean-evaluated-at-parse-time`", "`reference:conditionalonbean-must-be-satisfiable`", "`open-question:conditional-evaluation-order-unverified`" ], "publication": "초안", "file": "assembly-ownership/concept/concept-when-conditions-are-evaluated.md", "status": "게시 전", "studioId": "", "assets": [ "when-conditions-are-evaluated" ], "evidenceFiles": [ "../../../final/evidence/raw/when-conditions-are-evaluated.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": [], "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": [], "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": [], "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": [], "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": [], "evidenceFiles": [] }, { "title": "프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다", "kind": "reference", "slug": "count-the-frameworks-own-autoconfigurations", "readiness": "READY", "source": [ "`final/document.md#a05` §14.1" ], "classification": "프로젝트 자신의 설정만 조건화하는 것으로는 부족하다 — starter가 프레임워크의 import metadata를 통해 자기 것을 기여하므로, 평범한 `@EnableAutoConfiguration` 애플리케이션은 프로젝트 조건이 무엇이라 하든 풀을 열고 마이그레이션을 돌린다. 기준은 \"이 능력이 꺼졌을 때 프레임워크가 여전히 무엇을 만드는가\"다.", "scope": [ "starter를 클래스패스에 두는 모든 optional capability. 이 저장소의 형태는 `AutoConfigurationImportFilter`가 10종을 막는 것이다." ], "exceptions": [ "필터 목록의 오타는 **조용히 fail-open**한다(매치하지 않을 뿐). 그래서 테스트가 필터의 반환값이 아니라 **빈 부재**로 assert해야 한다." ], "relations": [ "`case:narrowing-the-scan-orphaned-eight-components`", "`reference:off-must-be-structural`", "`decision:pool-need-is-a-capability-question`" ], "publication": "초안", "file": "assembly-ownership/reference/reference-count-the-frameworks-own-autoconfigurations.md", "status": "게시 전", "studioId": "", "assets": [], "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": [], "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": [], "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": [], "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" ], "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": [], "evidenceFiles": [] } ] } }, "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" ], "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" ], "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" ], "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-catalog-nine-entries-short", "readiness": "READY", "source": [ "`final/document.md#a05` §1" ], "code": [ "`.../persistence-jpa/src/test/.../JpaModuleBoundaryTest.java`" ], "evidence": [ "없음 — 테스트 javadoc의 사후 기록" ], "classification": "경계 테스트가 production root의 직계 자식 패키지 닫힌 카탈로그를 들고 실제 트리와 비교하는데, 카탈로그에 13개가 적혀 있고 트리에는 22개가 있었다. 그래서 아홉 패키지가 **아무 규칙의 지배도 받지 않았고** `transaction → postgresql` / `postgresql → transaction` 사이클이 통과했다. 수정은 \"카탈로그와 트리의 **정확한 동등성**\" 검사를 추가하는 것이었다.", "missing-verification": "없음", "relations": [ "`reference:omission-that-passes-is-not-a-gate`", "`concept:strict-test-lane`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-a-catalog-nine-entries-short.md", "status": "게시 전", "studioId": "", "assets": [ "a-catalog-nine-entries-short" ], "evidenceFiles": [ "../../../final/evidence/raw/a-catalog-nine-entries-short.txt" ] }, { "title": "릴리스 레인이 매트릭스 세 버전 중 첫 번째만 돌리고 세 개를 커버로 기록했다", "kind": "case", "slug": "three-versions-declared-one-executed", "readiness": "READY", "source": [ "`final/document.md#a05` §15.4" ], "code": [ "`config/jpa/release-registry.json`", "`.../JpaPlatformContractSupport.start()`", "`.github/workflows/jpa-release.yml`" ], "evidence": [ "없음 — 레지스트리 `_comment`와 javadoc의 사후 기록" ], "classification": "릴리스 레인이 `-Pjpa.matrix.versions=16,17,18`을 `selectedVersions().get(0)`을 쓰는 지원 클래스에 넘겼고 **통합 suite 전체가 PostgreSQL 16에 대해 돌았으며**, 지원 표는 3개 assertion짜리 smoke test의 힘으로 17과 18을 완전 커버로 기록했다. 수정은 `start()`가 다중 선택을 아예 거부하고, workflow가 major당 job으로 fan-out하며, promotion job이 세 major의 증거가 **같은 commit SHA**를 담기를 요구하는 것이다.", "missing-verification": "현재 릴리스 워크플로를 실행하지 않았다", "relations": [ "`reference:agreement-between-documents-proves-nothing`", "`concept:evidence-grades-and-provenance`", "`decision:capability-grade-is-declared-not-inferred`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-three-versions-declared-one-executed.md", "status": "게시 전", "studioId": "", "assets": [ "three-versions-declared-one-executed" ], "evidenceFiles": [ "../../../final/evidence/raw/three-versions-declared-one-executed.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" ], "evidenceFiles": [ "../../../final/evidence/raw/a-test-that-passed-on-the-wrong-guard.txt" ] }, { "title": "두 파일이 같은 검증기를 \"빌드를 실패시키는 것\"이라 적고, 어떤 빌드도 그것을 부르지 않는다", "kind": "case", "slug": "two-files-name-a-build-gate-that-no-build-runs", "readiness": "READY", "source": [ "`final/document.md#a20-grpc-proto-contract` §17.4", "`final/document.md#a20-grpc-codegen` §17.1" ], "code": [ "`.../grpc-proto-contract/.../GrpcProtoContractValidator.java`", "`.../grpc-proto-contract/src/main/resources/proto/buf.yaml:3-5`", "`.../grpc-codegen/.../GrpcBufPolicy.java:8-10`" ], "evidence": [ "없음 — `*.gradle`·`*.kts`·`*.yml` 과 자바 타입 이름 전수 grep 으로 판정했다" ], "classification": "두 파일이 같은 논증을 편다 — Buf CLI 가 이 툴체인에 없으므로 자바로 구현한 규칙 엔진이 그 자리를 대신하고, \"그것이 실제로 이 저장소의 빌드를 실패시킨다\". `buf.yaml` 주석과 `GrpcBufPolicy` javadoc 이 각각 그 문장을 갖는다. 그런데 `GrpcProtoContractValidator` 를 부르는 Gradle 태스크도, 검증 훅도, 다른 모듈의 호출도 없다. 실제로 아홉 규칙을 실행하는 것은 그 리프의 단위 테스트 하나이고, 그 테스트가 판정하는 대상은 **하드코딩된 두 파일**이다. 거꾸로 `GrpcBufPolicy` 가 계약이라고 든 네 태스크 이름(`bufFormatCheck`·`bufLint`·`bufBuild`·`bufBreaking`)도 어떤 빌드 파일에도 없고, 그 javadoc 은 \"a missing stage is a test failure rather than a stage nobody noticed was gone\" 라고 적는데 테스트는 그 목록을 리터럴 및 자기 자신과 비교한다. 두 쪽이 서로를 게이트라고 가리키고 어느 쪽도 실행되지 않는다.", "missing-verification": "리플렉션이나 서비스 로더로 부르는 형태는 배제하지 못했다 — 이름 기반 grep 으로만 확인했다", "relations": [ "`reference:a-gate-declared-in-prose-is-not-in-the-build`", "`reference:a-gate-nobody-runs-reports-the-last-run`", "`case:a-release-gate-with-no-evidence-producer`", "`case:a-build-gate-that-is-not-in-the-build`" ], "listedInTree": false, "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-two-files-name-a-build-gate-that-no-build-runs.md", "status": "게시 전", "studioId": "", "assets": [ "two-files-name-a-build-gate-that-no-build-runs" ], "evidenceFiles": [ "../../../final/evidence/raw/two-files-name-a-build-gate-that-no-build-runs.txt" ] }, { "title": "이름이 검사한다고 말하는 것을 본문이 검사하지 않는 테스트 다섯", "kind": "case", "slug": "test-names-that-assert-what-their-bodies-do-not", "readiness": "READY", "source": [ "`final/document.md#a19-messaging-pulsar-experimental` §17.3", "`final/document.md#a19-messaging-nats-experimental` §17.4", "`final/document.md#a20-grpc-advanced-bootstrap` §17.4" ], "code": [ "`.../PulsarSubscriptionGuardTest.theValidatorAcceptsAKeyedProfileOnKeyShared`", "`.../PulsarAdapterContractTest.aTransportWithoutAConsumerFactoryRefusesToRegister…`", "`.../NatsAdapterContractTest.theReportedElapsedTimeIsMeasuredRatherThanZero`", "`.../GrpcAdvancedPromotionGateTest.theStableDefaultThresholdIsHigher`" ], "evidence": [ "없음 — 테스트 본문과 이름·`as()` 메시지 대조로 판정했다" ], "classification": "네 형태가 같은 결과를 낳는다. (1) 이름이 `theValidatorAccepts…` 인데 본문에 검증기가 없다 — `PulsarProfile` 생성자만 부른다. 그 리프에서 검증기를 언급하는 유일한 테스트 이름이 이것이라, 이름만 읽으면 커버리지가 있다고 읽힌다. (2) 이름이 \"소비자 팩토리 없이 만든 전송이 등록을 거절한다\" 인데 본문은 `register(null)` 을 불러 첫 줄의 널 검사에 걸린다 — 겨냥한 `PULSAR_CONSUMER_NOT_CONFIGURED` 는 한 번도 실행되지 않는다. (3) `as()` 가 \"모든 결과가 `Duration.ZERO` 였다\" 는 회귀를 막는다고 적는데 단언이 `isGreaterThanOrEqualTo(Duration.ZERO)` 라 `Duration.ZERO` 도 통과한다 — 구현이 무엇을 하든 참이다. (4) 이름이 \"Stable default 가 되는 데 더 긴 담금이 필요하다\" 인데 실제로 평가하는 전이는 `ADVANCED_STABLE → DISABLED`(철회)다. 어느 것도 잘못된 동작을 통과시키지는 않는다 — 틀리는 것은 커버리지 지도이고, 그래서 그 아래의 진짜 공백이 오래 눈에 띄지 않았다.", "missing-verification": "테스트를 실행하지 않았다 — 단언 의미론과 호출 경로로 판정했다", "relations": [ "`reference:omission-that-passes-is-not-a-gate`", "`case:a-test-that-passed-on-the-wrong-guard`", "`reference:a-gate-declared-in-prose-is-not-in-the-build`" ], "listedInTree": false, "publication": "초안", "file": "what-a-gate-does-not-prove/case/case-test-names-that-assert-what-their-bodies-do-not.md", "status": "게시 전", "studioId": "", "assets": [ "test-names-that-assert-what-their-bodies-do-not" ], "evidenceFiles": [ "../../../final/evidence/raw/test-names-that-assert-what-their-bodies-do-not.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`" ], "publication": "초안", "file": "what-a-gate-does-not-prove/concept/concept-strict-test-lane.md", "status": "게시 전", "studioId": "", "assets": [ "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`" ], "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" ], "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": [], "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": [], "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": [], "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": [], "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": [], "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": [], "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": [], "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": [], "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": [], "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": [], "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" ], "evidenceFiles": [ "../../../final/evidence/raw/pii-through-an-exception-message.txt" ] }, { "title": "진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다", "kind": "case", "slug": "a-report-that-cannot-carry-a-datasource", "readiness": "READY", "source": [ "`final/document.md#a05` §2.5, §12.1" ], "code": [ "`.../api/capability/CapabilitySupport.java`", "`.../security/DatabasePrivilegeReport.java`" ], "evidence": [ "없음 — 값 타입 정의와 그 javadoc" ], "classification": "`CapabilitySupport`가 provider 객체(`DataSource`·`EntityManagerFactory`·`SessionFactory`)를 절대 담지 않고, `DatabasePrivilegeReport`가 JDBC URL·패스워드·호스트를 담지 않는다. 이유가 같다 — 이 값들은 리포트로 직렬화되고 actuator로 publish될 수 있어야 하는데, 살아 있는 리소스를 값 타입에 끌고 들어가면 리포트가 자격증명을 흘린다. \"publish 전에 마스킹해야 할 것은 애초에 들어가지 않는다\"가 설계 문장이다.", "missing-verification": "없음", "relations": [ "`concept:cardinality-bounds-as-types`", "`reference:telemetry-can-be-more-dangerous-than-its-subject`" ], "publication": "초안", "file": "bounding-by-type/case/case-a-report-that-cannot-carry-a-datasource.md", "status": "게시 전", "studioId": "", "assets": [ "a-report-that-cannot-carry-a-datasource" ], "evidenceFiles": [ "../../../final/evidence/raw/a-report-that-cannot-carry-a-datasource.txt" ] }, { "title": "커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유", "kind": "case", "slug": "cursor-verification-order", "readiness": "READY", "source": [ "`final/document.md#a05` §2.4" ], "code": [ "`.../api/query/SignedJsonCursorCodec.java`" ], "evidence": [ "없음 — 구현과 그 javadoc" ], "classification": "다섯 방어가 각각 다른 공격을 막고 순서가 계약이다. `MAX_ENCODED_LENGTH(4096)` 검사가 첫 줄에 없으면 decode가 caller가 보낸 크기만큼 할당하고, MAC 길이 확인이 없으면 `MessageDigest.isEqual`의 상수 시간 보장이 깨지며, 서명 검증 전에 파싱하면 서명 없는 토큰이 애플리케이션 JSON 파서에 도달한다. 그리고 MAC이 **버전과 payload를 함께** 덮어 prefix 재작성으로 옛 포맷으로 다운그레이드하는 것을 막는다.", "missing-verification": "없음", "relations": [ "`concept:signed-cursor-structure`", "`decision:cursors-are-signed-for-integrity`" ], "publication": "초안", "file": "bounding-by-type/case/case-cursor-verification-order.md", "status": "게시 전", "studioId": "", "assets": [ "cursor-verification-order" ], "evidenceFiles": [ "../../../final/evidence/raw/cursor-verification-order.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`" ], "publication": "초안", "file": "bounding-by-type/concept/concept-signed-cursor-structure.md", "status": "게시 전", "studioId": "", "assets": [ "signed-cursor-structure", "signed-cursor-structure-diagram" ], "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`" ], "publication": "초안", "file": "bounding-by-type/concept/concept-cardinality-bounds-as-types.md", "status": "게시 전", "studioId": "", "assets": [ "cardinality-bounds-as-types", "cardinality-bounds-as-types-diagram" ], "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": [], "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": [], "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": [], "evidenceFiles": [] }, { "title": "sanitize가 아니라 reject가 기본이다", "kind": "reference", "slug": "reject-rather-than-sanitize", "readiness": "READY", "source": [ "`final/document.md#a05` §12.2" ], "classification": "경계를 넘는 값을 조용히 자르거나 치환하면 caller가 계속 그 값을 넘기고 절대 눈치채지 못한다. 거부하면 도입된 자리에서 실패한다. 기준은 \"이 값의 생산자를 우리가 고칠 수 있는가\"이고, 답이 예면 reject다.", "scope": [ "metric tag·이름 값 타입·설정 키·헤더 이름. `LowCardinality.REGISTERED`가 정규식을 통과하지 못하면 던지는 것이 그 형태다." ], "exceptions": [ "생산자를 고칠 수 없는 경우(외부 드라이버의 예외 메시지, 서드파티 응답 본문)는 redaction이 맞고, 그때는 그것이 보증이 아님을 문서에 적어야 한다." ], "relations": [ "`case:pii-through-an-exception-message`", "`reference:telemetry-can-be-more-dangerous-than-its-subject`", "`concept:cardinality-bounds-as-types`" ], "publication": "초안", "file": "bounding-by-type/reference/reference-reject-rather-than-sanitize.md", "status": "게시 전", "studioId": "", "assets": [], "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": [], "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": [], "evidenceFiles": [] } ] } }, "duplicate-mechanisms": { "topic": "duplicate-mechanisms", "title": "중복 장치 — 조립된 쪽이 약한 쪽일 때", "readerQuestion": "", "kinds": { "case": [ { "title": "클라이언트가 준 엔드포인트가 SSRF 가드가 아니라 약한 private 사본을 지났다", "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" ], "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" ], "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" ], "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" ], "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" ], "evidenceFiles": [ "../../../final/evidence/raw/a-retry-implementation-nobody-calls.txt" ] }, { "title": "같은 저장소가 \"결정을 그 결정이 판정한 대상에 묶는 것\"을 한 번은 맞게, 한 번은 틀리게 썼다", "kind": "case", "slug": "the-same-repository-bound-a-decision-once-and-not-the-other-time", "readiness": "READY", "source": [ "`final/document.md#a20-grpc-codegen` §17.4", "`final/document.md#a20-grpc-advanced-bootstrap`(확인된 설계)" ], "code": [ "`.../grpc-codegen/.../GrpcSchemaArtifactPublisher.java`(`evaluate`/`publish`)", "`.../grpc-advanced-bootstrap/.../release/GrpcAdvancedSupportMatrix.java`(`apply`)" ], "evidence": [ "없음 — 두 메서드 본문 대조로 판정했다" ], "classification": "두 리프가 같은 문제를 푼다 — 판정과 기록이 두 호출로 나뉠 때 그 사이를 무엇이 묶는가. `GrpcSchemaArtifactPublisher.publish(candidate, decision)` 는 `decision.allowed()` 만 보고 기록한다. `PublishDecision` 은 `(boolean, List)` 뿐이라 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로 B 를 발행할 수 있고 그러면 소비자 컴파일 게이트와 릴리스 버전 불변성을 둘 다 우회한다. 이 클래스가 존재하는 이유인 두 규칙이 인자 짝 하나로 무력해진다. 반대편에서 `GrpcAdvancedSupportMatrix.apply(decision)` 는 결정의 `from` 이 현재 등급과 다르면 던지고, 그 이유를 \"두 승격이 경합했거나 하나가 재생된 경우\" 라고 적는다. 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 대조하는 형태다. 두 테스트의 차이도 같다 — 전자의 테스트는 `publish(artifact, evaluate(artifact, …))` 로 한 줄에서 짝을 맞춰 규율을 지키지만 코드가 그것을 강제하지 않고, 후자는 어긋난 짝을 넣는 테스트가 따로 있다.", "missing-verification": "어긋난 짝을 실제로 실행해 보지 않았다 — `publish` 본문에 대조 코드가 없다는 것으로 판정했다", "relations": [ "`reference:check-which-duplicate-is-wired`", "`case:the-same-rotation-defect-closed-once-and-reproduced`", "`reference:a-validator-is-enforced-by-injection`" ], "listedInTree": false, "publication": "초안", "file": "duplicate-mechanisms/case/case-the-same-repository-bound-a-decision-once-and-not-the-other-time.md", "status": "게시 전", "studioId": "", "assets": [ "the-same-repository-bound-a-decision-once-and-not-the-other-time" ], "evidenceFiles": [ "../../../final/evidence/raw/the-same-repository-bound-a-decision-once-and-not-the-other-time.txt" ] } ], "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": [], "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": [], "evidenceFiles": [] }, { "title": "같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다", "kind": "reference", "slug": "two-vocabularies-for-one-concept", "readiness": "READY", "source": [ "`final/document.md#a19` §5.4", "`final/document.md#a06` §58, §69", "`final/document.md#a05` §12.5" ], "classification": "같은 개념을 두 타입이 표현하고 하나만 조립돼 있으면, 남은 쪽은 다음 사람이 어느 것을 써야 할지 알 수 없게 만든다. 기준은 \"이 둘이 같은 질문에 답하는가\"이고, 그렇다면 조립된 쪽을 정본으로 표시하고 나머지를 제거 대상으로 명시한다.", "scope": [ "감사 메타데이터·자격 증명 회전·TTL 선언·recovery 어휘. 표시 방법은 support matrix의 상태 컬럼(\"Candidate, not composed\")과 ArchUnit 규칙(엔티티가 둘 다 쓰는 것을 금지)이다." ], "exceptions": [ "두 어휘가 다른 계층에 속하고 각각 소비자가 있으면 중복이 아니다 — `api.error`의 안정 예외 계층과 `failure`의 웹 표면 매핑이 그 경우다." ], "relations": [ "`reference:check-which-duplicate-is-wired`", "`decision:one-audit-mechanism-per-entity`" ], "publication": "초안", "file": "duplicate-mechanisms/reference/reference-two-vocabularies-for-one-concept.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ], "question": [], "decision": [ { "title": "감사 메커니즘은 엔티티당 정확히 하나여야 한다", "kind": "decision", "slug": "one-audit-mechanism-per-entity", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#a05` §12.5" ], "decision-evidence": [ "`.../testkit/arch/JpaArchitectureRules.java`의 `entitiesUseExactlyOneAuditMechanism`", "`docs/jpa/support-matrix.md`의 두 메커니즘 상태 표기" ], "grounds": [ "`reference:two-vocabularies-for-one-concept`", "`reference:check-which-duplicate-is-wired`" ], "classification": "`audit/AuditableEntity`(canonical, `@MappedSuperclass`, `created_at/by`·`updated_at/by`, actor 256)와 `auditing/AuditMetadata`(candidate, `@Embeddable`, `modified_*`, actor 64)가 공존하고 ArchUnit이 한 엔티티가 둘 다 쓰는 것을 막는다. 근거: \"둘 다 고른 엔티티는 하나의 의미에 두 writer, 하나의 사실에 두 컬럼 계열, 그리고 어느 엔티티가 뭘 골랐는지 알아야 하는 마이그레이션을 얻는다.\" 어느 메커니즘도 bulk/native update에 도달하지 않으므로 `bulkUpdatesOfAuditedEntitiesStampAudit`가 별도로 그것을 강제한다.", "relations": [ "`reference:two-vocabularies-for-one-concept`" ], "publication": "초안", "file": "duplicate-mechanisms/decision/decision-one-audit-mechanism-per-entity.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ] } }, "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" ], "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" ], "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" ], "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" ], "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" ], "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" ], "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": [], "evidenceFiles": [] }, { "title": "문서의 수치는 세지 말고 파생하거나 게이트로 붙든다", "kind": "reference", "slug": "numbers-in-docs-should-be-derived", "readiness": "READY", "source": [ "`final/document.md#a19` §6.4", "`final/document.md#a05` §17 P3" ], "classification": "산문에 적은 수치는 다음 변경에서 드리프트한다. 기준은 \"이 수치의 정본이 어디인가\"이고, 정본이 있으면 문서는 세지 말고 가리켜야 한다. messaging 가족 문서가 자기 오류를 고치며 남긴 문장이 그 규칙이다 — \"정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다.\"", "scope": [ "leaf 수·버전 목록·커버리지 수치·capability 개수. 대안은 (1) 세지 않고 SSOT를 가리키기, (2) 세야 한다면 그 수치를 검사하는 게이트를 두고 그 게이트의 walk 범위를 문서까지 넓히기." ], "exceptions": [ "스냅샷임을 명시한 문서는 갱신하지 않는 것이 오히려 정확하다 — `00-project-overview.md`가 초기 sizing을 그대로 두고 헤더에 그 사실을 적는 형태다." ], "relations": [ "`case:five-documents-say-nineteen-leaves`", "`reference:omission-that-passes-is-not-a-gate`" ], "publication": "초안", "file": "drift-direction/reference/reference-numbers-in-docs-should-be-derived.md", "status": "게시 전", "studioId": "", "assets": [], "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": [], "evidenceFiles": [] } ], "decision": [ { "title": "문서 계약 테스트의 단언 범위를 capability 표까지 넓힐 것인가", "kind": "decision", "slug": "", "readiness": "", "publication": "미작성" }, { "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": [], "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" ], "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" ], "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": [], "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": [], "evidenceFiles": [] } ] } }, "owner-safe-state-machines": { "topic": "owner-safe-state-machines", "title": "owner-safe 상태 기계 — 소유권을 SQL에 적기", "readerQuestion": "", "kinds": { "case": [ { "title": "lease가 만료 시각만 기록하고 소유자를 기록하지 않아 terminal state가 되돌려졌다", "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" ], "evidenceFiles": [ "../../../final/evidence/raw/a-lease-without-an-owner.txt" ] }, { "title": "transition digest가 \"누가·언제\"만 덮고 \"무엇\"을 덮지 않아 다른 전이를 같다고 보고했다", "kind": "case", "slug": "a-digest-that-covered-who-but-not-what", "readiness": "READY", "source": [ "`final/document.md#a05` §10.1" ], "code": [ "`.../postgresql/idempotency/IdempotencyDigestPolicy.java`" ], "evidence": [ "없음 — javadoc의 사후 기록" ], "classification": "digest가 `transition|operationId|ownerToken|attempt|stateRevision`이었고 **무엇을 했는지**를 전혀 덮지 않았다. retryable로 기록된 `FAIL`과 abandoned로 기록된 `FAIL`이 같은 digest를 냈고, 서로 다른 응답이나 서로 다른 retention을 가진 두 completion도 그랬다. digest를 비교하는 replay는 \"차이 전체가 중요한 부분인 두 전이\"를 같다고 결론지었다. 수정은 `semanticArguments`(disposition·retention·response digest·codec identity)를 포함하고 **길이 프레이밍**으로 구성하는 것 — 모든 구성요소가 가변 폭 텍스트이고 최소 하나(owner token)는 플랫폼이 제약할 것이 아니므로 delimiter join은 서로 다른 목록을 같은 문자열로 렌더링할 수 있다. `VERSION`을 붙여 구성이 바뀌면 저장된 digest가 가로질러 비교되지 않게 한다.", "missing-verification": "없음", "relations": [ "`reference:digest-must-be-length-framed-and-versioned`", "`concept:cas-tuple-and-update-count`" ], "publication": "초안", "file": "owner-safe-state-machines/case/case-a-digest-that-covered-who-but-not-what.md", "status": "게시 전", "studioId": "", "assets": [ "a-digest-that-covered-who-but-not-what" ], "evidenceFiles": [ "../../../final/evidence/raw/a-digest-that-covered-who-but-not-what.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" ], "evidenceFiles": [ "../../../final/evidence/raw/an-active-transaction-check-that-asked-the-wrong-question.txt" ] }, { "title": "만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다", "kind": "case", "slug": "expired-claim-versus-expired-execution", "readiness": "READY", "source": [ "`final/document.md#a05` §10.1, §10.4" ], "code": [ "`.../postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java`의 claim 결정 트리", "`.../inbox/PostgreSqlSameStoreInboxAdapter.java`" ], "evidence": [ "없음 — 결정 트리와 그 javadoc" ], "classification": "만료된 lease를 일률적으로 takeover하면 이미 실행이 시작된 작업을 blind retry하게 된다. 이 저장소는 상태로 나눈다 — 만료된 `CLAIMED`는 `resetClaim`으로 takeover하고, 만료된 `EXECUTING`은 `abandonExpiredExecution`으로 `ABANDONED`에 넣고 `RecoveryRequired`를 반환한다. inbox도 같은 축을 쓰되 `RECEIVED`(takeover 가능)와 `PROCESSING`(→ DEAD, recovery-required)로 나눈다. 즉 \"claim만 했다\"와 \"실행에 들어갔다\"가 만료 시 다른 결론을 낳는다.", "missing-verification": "컨테이너 레인 미실행", "relations": [ "`reference:expired-claim-and-expired-execution-differ`", "`concept:fenced-lease`", "`open-question:v2-state-machine-lanes-not-executed`" ], "publication": "초안", "file": "owner-safe-state-machines/case/case-expired-claim-versus-expired-execution.md", "status": "게시 전", "studioId": "", "assets": [ "expired-claim-versus-expired-execution" ], "evidenceFiles": [ "../../../final/evidence/raw/expired-claim-versus-expired-execution.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" ], "evidenceFiles": [ "../../../final/evidence/raw/native-claim-did-not-bump-the-version.txt" ] } ], "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`" ], "publication": "초안", "file": "owner-safe-state-machines/concept/concept-fenced-lease.md", "status": "게시 전", "studioId": "", "assets": [ "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`" ], "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" ], "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`" ], "publication": "초안", "file": "owner-safe-state-machines/concept/concept-capability-schema-registry.md", "status": "게시 전", "studioId": "", "assets": [ "capability-schema-registry", "capability-schema-registry-diagram" ], "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": [], "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": [], "evidenceFiles": [] }, { "title": "만료된 claim과 만료된 실행은 다르게 다뤄야 한다", "kind": "reference", "slug": "expired-claim-and-expired-execution-differ", "readiness": "READY", "source": [ "`final/document.md#a05` §10.1, §10.4" ], "classification": "lease 만료는 \"누가 들고 있었는가\"만 말하고 \"무엇까지 했는가\"를 말하지 않는다. claim만 한 상태의 만료는 안전하게 takeover할 수 있지만, 실행에 들어간 상태의 만료는 외부 부수효과가 이미 발생했을 수 있으므로 blind retry가 아니라 조정으로 보내야 한다.", "scope": [ "실행 전 예약과 실행 자체를 구별하는 모든 작업 큐·멱등성 저장소. 구현은 상태를 둘로 나누는 것이다(`CLAIMED`/`EXECUTING`, `RECEIVED`/`PROCESSING`)." ], "exceptions": [ "외부 부수효과가 없는 순수 계산 작업은 구별이 필요 없다. 다만 \"부수효과 없음\"이 유지되는지는 시간이 지나며 바뀌므로 그 전제를 적어야 한다." ], "relations": [ "`case:expired-claim-versus-expired-execution`", "`concept:fenced-lease`", "`reference:unknown-is-a-third-result`" ], "publication": "초안", "file": "owner-safe-state-machines/reference/reference-expired-claim-and-expired-execution-differ.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "digest는 길이 프레이밍하고 버전을 붙인다", "kind": "reference", "slug": "digest-must-be-length-framed-and-versioned", "readiness": "READY", "source": [ "`final/document.md#a05` §10.1" ], "classification": "가변 폭 문자열을 delimiter로 이어 digest를 만들면 서로 다른 구성요소 목록이 같은 문자열로 렌더링될 수 있다. 길이 프레이밍(`len:value`)이 그것을 막고, 버전 번호가 구성 변경 전후의 digest를 가로질러 비교하지 못하게 한다. 그리고 digest는 \"누가·언제\"뿐 아니라 **\"무엇을\"**까지 덮어야 한다.", "scope": [ "replay 판정·중복 탐지·전이 동일성 비교에 쓰는 모든 digest. 구성요소 중 하나라도 플랫폼이 제약하지 않는 값(외부 토큰)이면 프레이밍이 필수다." ], "exceptions": [ "모든 구성요소가 고정 길이이거나 플랫폼이 문법을 강제하는 값이면 delimiter로 충분하다. 다만 그 강제가 어디 있는지 적어야 한다." ], "relations": [ "`case:a-digest-that-covered-who-but-not-what`", "`reference:names-are-registry-keys-not-values`" ], "publication": "초안", "file": "owner-safe-state-machines/reference/reference-digest-must-be-length-framed-and-versioned.md", "status": "게시 전", "studioId": "", "assets": [], "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": [], "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": [], "evidenceFiles": [] }, { "title": "상태 기계 구현은 Spring stereotype을 갖지 않는다", "kind": "decision", "slug": "state-machines-carry-no-stereotype", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#a05` §10" ], "decision-evidence": [ "네 V2 구현의 클래스 선언과 그 javadoc(\"두 composition root가 `dev.caskeleton.adapter`를 component-scan하므로 `@Repository`를 붙이면 선택하지 않은 배포에서도 빈이 된다\")" ], "grounds": [ "`reference:off-must-be-structural`", "`reference:a-bean-is-not-composition-evidence`" ], "classification": "스테레오타입을 붙이면 두 합성 루트의 스캔이 그것을 잡아, capability를 선택하지 않은 배포에서도 빈이 된다. 그래서 네 구현이 전부 plain class이고 협력자를 주입이 아니라 생성자에서 조립한다 — \"이건 이 store의 부품이지 애플리케이션이 조립하거나 교체하는 서비스가 아니고, 주입하면 public bean 표면이 1개에서 6개로 넓어진다.\"", "relations": [ "`reference:off-must-be-structural`", "`decision:one-root-owns-the-master-switch`" ], "publication": "초안", "file": "owner-safe-state-machines/decision/decision-state-machines-carry-no-stereotype.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ] } }, "transaction-deadline-and-pool": { "topic": "transaction-deadline-and-pool", "title": "트랜잭션 데드라인과 커넥션 예산", "readerQuestion": "", "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" ], "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": "validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다", "kind": "case", "slug": "a-validator-checking-the-wrong-datasource", "readiness": "READY", "source": [ "`final/document.md#a05` §14.5" ], "code": [ "`.../app-bootstrap/.../persistencejpa/JpaDataSourceProfileValidator.java`의 javadoc" ], "evidence": [ "없음 — javadoc의 사후 기록" ], "classification": "validator가 `app.jpa-platform.datasource.*`에 바인딩된 settings를 읽었는데 **요청을 서비스하는 풀은 `spring.datasource.hikari.*`에서** 만들어진다 — 하나의 풀에 두 개의 기술, 그리고 validator는 사용되지 않는 기술에 대해 통과할 수 있다. 더 나쁜 건 그 평행 네임스페이스가 어떤 shipped YAML에도 env-key 레지스트리의 어떤 행에도 없어서 두 필드가 항상 null이었고 `requirePoolBounds`가 모든 배포에서 던졌을 것이라는 점이다 — 아무것도 그것을 호출하지 않아서 아무것도 실패하지 않았다. **서로를 상쇄한 두 결함이고, 애플리케이션이 시작한 이유는 두 번째가 첫 번째를 숨겼기 때문이다.**", "missing-verification": "없음", "relations": [ "`reference:a-bean-is-not-composition-evidence`", "`case:a-five-second-string-that-broke-every-prod-deploy`" ], "publication": "초안", "file": "transaction-deadline-and-pool/case/case-a-validator-checking-the-wrong-datasource.md", "status": "게시 전", "studioId": "", "assets": [ "a-validator-checking-the-wrong-datasource", "a-validator-checking-the-wrong-datasource-run" ], "evidenceFiles": [ "../../../final/evidence/raw/a-validator-checking-the-wrong-datasource.txt", "../../../final/evidence/raw/a-validator-checking-the-wrong-datasource-run.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" ], "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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/concept/concept-deadline-propagation.md", "status": "게시 전", "studioId": "", "assets": [ "deadline-propagation", "deadline-propagation-diagram" ], "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`" ], "publication": "초안", "file": "transaction-deadline-and-pool/concept/concept-requires-new-connection-cost.md", "status": "게시 전", "studioId": "", "assets": [ "requires-new-connection-cost" ], "evidenceFiles": [ "../../../final/evidence/raw/requires-new-connection-cost.txt" ] } ], "reference": [ { "title": "데드라인은 호출 예산에서 시작해 세 단계로 좁힌다", "kind": "reference", "slug": "deadline-narrows-in-three-stages", "readiness": "READY", "source": [ "`final/document.md#a05` §3.3" ], "classification": "상류 호출자의 남은 시간이 트랜잭션·statement·lock 순으로 좁혀져야 하고, 각 단계에서 남은 시간이 다음 단계의 최소 요구를 못 채우면 **시작하지 않는 것**이 옳다. 시작해서 중간에 잘리면 completion-unknown을 만들지만 시작하지 않으면 확정 거부다.", "scope": [ "호출 예산을 전파하는 모든 계층. 각 단계에 여유(margin)를 두어 마지막에 결과를 기록할 시간을 남긴다." ], "exceptions": [ "예산을 모르는 진입점(스케줄러·부팅 작업)은 자기 상한을 갖되 그것이 무한이 아니어야 한다." ], "relations": [ "`concept:deadline-propagation`", "`reference:write-transactions-need-a-finite-timeout`" ], "publication": "초안", "file": "transaction-deadline-and-pool/reference/reference-deadline-narrows-in-three-stages.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "쓰기 트랜잭션에는 유한 타임아웃이 필수다", "kind": "reference", "slug": "write-transactions-need-a-finite-timeout", "readiness": "READY", "source": [ "`final/document.md#a05` §2.3" ], "classification": "무제한 write 트랜잭션은 statement 하나가 막히면 커넥션·락·row version을 무한정 잡는다. 기준은 \"이 프로파일이 read-only가 아닌데 타임아웃이 없는가\"이고, 있으면 생성자가 거부해야 한다.", "scope": [ "트랜잭션 프로파일·정책 값 타입. `TransactionProfile`의 생성자가 `!readOnly && timeout이 null/0/음수`를 거부하는 형태다." ], "exceptions": [ "읽기 전용 트랜잭션은 상한이 있으면 좋지만 필수는 아니다 — 락을 잡지 않기 때문이다. 다만 락을 잡는 read(`FOR UPDATE`)는 쓰기와 같이 취급한다." ], "relations": [ "`reference:deadline-narrows-in-three-stages`", "`reference:make-the-unsafe-state-unrepresentable`" ], "publication": "초안", "file": "transaction-deadline-and-pool/reference/reference-write-transactions-need-a-finite-timeout.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "세션 스코프 설정은 풀로 돌아간 커넥션에 남는다", "kind": "reference", "slug": "session-scoped-settings-outlive-the-transaction", "readiness": "READY", "source": [ "`final/document.md#a05` §3.3, §13.2" ], "classification": "`SET`으로 건 설정은 커넥션이 풀로 돌아가도 살아남아 다음 borrower가 상속한다. 다음 borrower가 다른 tenant이거나 tenant 없는 백그라운드 job이면 그 설정이 격리 경계를 무너뜨린다. 기준은 \"이 설정이 트랜잭션과 함께 되돌아가는가\"이고, PostgreSQL에서는 `set_config(..., true)`가 그것을 보장한다.", "scope": [ "`statement_timeout`·`lock_timeout`·`search_path`·`app.tenant_id` 등 세션 상태 전부. RLS의 tenant 바인딩이 transaction-local이어야 하는 이유가 같다." ], "exceptions": [ "대응물이 없는 벤더(H2의 idle 가드)에서는 세션 스코프를 인정하되 **그 사실을 적고** caller-side deadline에 맡긴다 — \"적용했다\"고 거짓 보고하지 않는다." ], "relations": [ "`concept:deadline-propagation`", "`concept:rls-three-preconditions`", "`case:search-path-survived-the-return-to-the-pool`" ], "publication": "초안", "file": "transaction-deadline-and-pool/reference/reference-session-scoped-settings-outlive-the-transaction.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ], "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": [], "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": [], "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": [], "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" ], "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" ], "evidenceFiles": [ "../../../final/evidence/raw/h2-hid-a-column-type-mismatch.txt" ] }, { "title": "레지스트리 컬럼이 38자 경로에서 짧아 \"더 짧은 경로를 적는\" 우회를 유혹했다", "kind": "case", "slug": "registry-column-too-short-for-its-own-path", "readiness": "READY", "source": [ "`final/document.md#a05` §8.4" ], "code": [ "`db/migration/postgresql/V9__widen_capability_schema_stream.sql`", "`db/migration/jpa/idempotency/V2`" ], "evidence": [ "없음 — 마이그레이션 헤더의 사후 기록" ], "classification": "`schema_stream`이 `varchar(32)`였고 작성 당시 모든 스트림에 맞았으며 `'db/migration/jpa/notification-platform'`(38자)에서 안 맞기 시작했다. 실패 모드가 나쁜 종류다 — 모든 면에서 올바른 등록이 `value too long`으로 마이그레이션 타임에 실패하고, **뻔한 우회책은 스트림의 실제 경로가 아닌 더 짧은 경로를 기록하는 것**이며, 스키마가 어디서 왔는지에 대해 거짓말하는 레지스트리는 없는 것보다 나쁘다. 128로 넓힌 이유도 적혀 있다 — `capability_id`가 이미 `varchar(128)`이고 **하나의 bound가 두 개보다 추론하기 쉽다.**", "missing-verification": "없음", "relations": [ "`concept:capability-schema-registry`", "`reference:an-applied-checksum-is-a-promise`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/case/case-registry-column-too-short-for-its-own-path.md", "status": "게시 전", "studioId": "", "assets": [ "registry-column-too-short-for-its-own-path" ], "evidenceFiles": [ "../../../final/evidence/raw/registry-column-too-short-for-its-own-path.txt" ] }, { "title": "Flyway location customizer가 운영자가 바인딩한 값을 덮어썼다", "kind": "case", "slug": "a-customizer-that-discarded-the-bound-property", "readiness": "READY", "source": [ "`final/document.md#a05` §7.7" ], "code": [ "`.../postgresql/PostgreSqlPersistenceConfig.java:94-115`의 javadoc" ], "evidence": [ "없음 — javadoc의 사후 기록" ], "classification": "customizer가 무조건 `locations(...)`를 호출했는데 그것은 Spring이 `spring.flyway.locations`에서 바인딩한 것을 **대체**한다. 그래서 운영자가 `SPRING_FLYWAY_LOCATIONS`로 capability 스트림을 추가하고 Flyway가 성공적 마이그레이션을 보고하는 것을 보고도 **벤더 스트림만** 얻을 수 있었다 — 프로퍼티는 읽히고 바인딩되고 그 뒤에 도는 customizer가 버렸다. `local-notification-ingest` 레인은 7개 location을 세팅하고 1개를 적용했다. 지금은 \"아무도 고르지 않았을 때만 기여하고, 누군가 골랐으면 비켜선다\".", "missing-verification": "없음", "relations": [ "`concept:independent-flyway-streams`", "`reference:a-bean-is-not-composition-evidence`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/case/case-a-customizer-that-discarded-the-bound-property.md", "status": "게시 전", "studioId": "", "assets": [ "a-customizer-that-discarded-the-bound-property" ], "evidenceFiles": [ "../../../final/evidence/raw/a-customizer-that-discarded-the-bound-property.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" ], "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`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/concept/concept-independent-flyway-streams.md", "status": "게시 전", "studioId": "", "assets": [ "independent-flyway-streams" ], "evidenceFiles": [ "../../../final/evidence/raw/independent-flyway-streams.txt" ] } ], "reference": [ { "title": "마이그레이션 스트림은 자기 history 테이블을 갖는다", "kind": "reference", "slug": "each-stream-owns-its-history-table", "readiness": "READY", "source": [ "`final/document.md#a05` §8.3" ], "classification": "두 마이그레이션 트리가 하나의 history를 공유하면 버전 공간을 공유하게 되고, 각 트리가 독립적으로 번호를 매기는 한 충돌은 시간 문제다. 기준은 \"이 트리의 버전 번호를 누가 정하는가\"이고, 답이 둘 이상이면 스트림을 나누고 history를 분리한다.", "scope": [ "capability별·모듈별로 나뉜 모든 마이그레이션. 분리 시 `baselineVersion(\"0\")` + `baselineOnMigrate(true)`가 필요하고, 0이어야 그 스트림의 마이그레이션이 전부 돈다." ], "exceptions": [ "하나의 팀이 하나의 트리를 소유하고 그 안에서 번호를 조정할 수 있으면 분리가 불필요하다. sample composition이 두 location을 하나의 목록으로 합쳐 버전 공간을 공유하는 경우가 그 예이고, 그때는 번호 충돌을 사람이 피해야 한다." ], "relations": [ "`concept:independent-flyway-streams`", "`case:two-trees-both-numbered-from-v1`", "`case:messaging-migrations-collide-at-v2`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/reference/reference-each-stream-owns-its-history-table.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "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": [], "evidenceFiles": [] }, { "title": "적용된 마이그레이션의 checksum은 그것을 돌린 모든 배포에 대한 약속이다", "kind": "reference", "slug": "an-applied-checksum-is-a-promise", "readiness": "READY", "source": [ "`final/document.md#a05` §8.1, §8.5" ], "classification": "이미 적용된 마이그레이션을 in-place로 고치면 그것을 돌린 배포들의 history와 어긋난다. 기준은 \"이 파일이 어딘가에 적용된 적이 있는가\"이고, 있으면 새 버전을 추가하고 가드된 변환(`DO $$ ... IF EXISTS ... THEN ALTER`)을 쓴다. 같은 이유로 Flyway `repair`는 모드가 아니다 — history를 지금 디스크에 맞게 다시 써서 **증거를 지워 증상을 해결**한다.", "scope": [ "모든 forward-only 마이그레이션. 스트림이 둘 이상이고 상대 순서가 고정되지 않았으면 같은 가드된 변환을 양쪽에 둔다." ], "exceptions": [ "어디에도 적용된 적 없는 마이그레이션(방금 작성한 것)은 고쳐도 된다. 판정 근거는 개발자의 기억이 아니라 history 테이블이다." ], "relations": [ "`case:h2-hid-a-column-type-mismatch`", "`case:registry-column-too-short-for-its-own-path`", "`decision:repair-is-not-a-mode`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/reference/reference-an-applied-checksum-is-a-promise.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ], "question": [], "decision": [ { "title": "Flyway가 스키마를 소유하고 런타임 롤은 DDL 권한을 갖지 않는다", "kind": "decision", "slug": "flyway-owns-the-schema", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#4-1`, `#10-3`", "`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": [], "evidenceFiles": [] }, { "title": "Repair는 모드가 아니라 운영자가 호출하는 작업이다", "kind": "decision", "slug": "repair-is-not-a-mode", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#a05` §8.1" ], "decision-evidence": [ "`.../migration/FlywaySchemaPolicy.java`(모드 enum에 repair 없음)", "`.../FlywayValidationGate.java`의 javadoc" ], "grounds": [ "`reference:an-applied-checksum-is-a-promise`", "`decision:flyway-owns-the-schema`" ], "classification": "Flyway의 `repair`는 schema history를 지금 디스크에 있는 스크립트에 맞게 다시 쓴다 — **증거를 지워서 증상을 해결**한다. checksum mismatch는 배포된 스크립트가 적용된 것과 다르다는 뜻이고 흥미로운 질문은 \"어떤 변경이 이 DB에 빠졌는가\"인데, repair는 그 질문을 물을 수 없게 만들어 답한다. 그래서 startup 동작이 아니라 운영자가 의도적으로 호출하는 operation descriptor로만 존재한다.", "relations": [ "`decision:flyway-owns-the-schema`", "`reference:an-applied-checksum-is-a-promise`" ], "publication": "초안", "file": "schema-ownership-and-capability-streams/decision/decision-repair-is-not-a-mode.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ] } }, "redis-command-admission": { "topic": "redis-command-admission", "title": "Redis 명령 admission과 카탈로그", "readerQuestion": "", "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" ], "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" ], "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" ], "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" ], "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`" ], "publication": "초안", "file": "redis-command-admission/concept/concept-redis-admission-stages.md", "status": "게시 전", "studioId": "", "assets": [ "redis-admission-stages", "redis-admission-stages-diagram" ], "evidenceFiles": [ "../../../final/evidence/raw/redis-admission-stages.txt" ] } ], "reference": [ { "title": "서버 메타데이터가 명령의 정의이고 정책 파일은 허용 범위다", "kind": "reference", "slug": "server-metadata-defines-the-command", "readiness": "READY", "source": [ "`final/document.md#a10` §47" ], "classification": "명령이 **무엇인가**는 서버의 `COMMAND DOCS`/`COMMAND INFO`/`COMMAND GETKEYSANDFLAGS`가 정하고, 이 SDK가 그것으로 **무엇을 할 용의가 있는가**는 정책 파일이 정한다. 둘을 대조하는 게이트가 없으면 서버가 명령을 늘리거나 key spec을 옮겨도 알 수 없다.", "scope": [ "벤더 프로토콜을 감싸는 모든 SDK. 대조해야 할 다섯 버킷 — 미분류 신규 명령", "사라진 명령", "key spec 이동", "ACL 카테고리 변경", "deprecation." ], "exceptions": [ "미분류 명령은 fail-closed 카탈로그가 이미 막으므로 그 버킷만은 게이트 없이도 안전하다. 나머지 넷은 카탈로그가 잡지 못한다." ], "relations": [ "`case:a-build-gate-that-is-not-in-the-build`", "`decision:unclassified-commands-are-refused`" ], "publication": "초안", "file": "redis-command-admission/reference/reference-server-metadata-defines-the-command.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "단일 admission point는 우회 경로를 세어야 성립한다", "kind": "reference", "slug": "a-single-admission-point-must-count-its-bypasses", "readiness": "READY", "source": [ "`final/document.md#a10` §64" ], "classification": "\"모든 것이 여기를 지난다\"는 주장은 그 지점의 코드가 아니라 **그것을 지나지 않는 경로의 수**로 검증된다. 기준은 하류 실행기를 직접 부르는 호출자를 세는 것이고, 0이 아니면 그 주장은 성립하지 않는다.", "scope": [ "guard·interceptor·gateway처럼 \"유일한 통로\"를 주장하는 모든 컴포넌트. 판정 방법은 하류 타입(gateway·executor)의 참조를 전수로 세고 guard를 지나는 것과 아닌 것을 나누는 것이다." ], "exceptions": [ "우회 경로가 있어도 그 경로가 **같은 보장을 다른 방식으로** 유지하면 부분적으로 정당하다 — 이 저장소에서 네임스페이스가 그런 경우다. 다만 그 사실을 주장 옆에 적어야 하고, 나머지 여덟 단계는 그렇지 않다." ], "relations": [ "`case:five-adapters-bypass-the-single-admission-point`", "`concept:redis-admission-stages`", "`reference:check-which-duplicate-is-wired`" ], "publication": "초안", "file": "redis-command-admission/reference/reference-a-single-admission-point-must-count-its-bypasses.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ], "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": [], "evidenceFiles": [] } ], "decision": [ { "title": "분류되지 않은 명령은 fail-closed로 거부한다", "kind": "decision", "slug": "unclassified-commands-are-refused", "readiness": "READY", "decision-status": "`ADOPTED`", "source": [ "`final/document.md#a10` §47" ], "decision-evidence": [ "`.../RedisCommandCatalog.java`의 `require` 구현과 테스트 `theCatalogFailsClosedForAnUnclassifiedCommand`" ], "grounds": [ "`reference:server-metadata-defines-the-command`", "`concept:redis-admission-stages`" ], "classification": "카탈로그에 분류가 없는 명령은 허용이 아니라 거부다. 그래서 서버가 새 명령을 추가해도 이 SDK를 통해 조용히 나가지 못한다. 이것이 catalog drift 게이트가 없는 상태에서도 한 버킷(미분류 신규 명령)만은 안전한 이유이고, 동시에 나머지 네 버킷은 이 fail-closed가 잡지 못한다는 사실의 근거이기도 하다.", "relations": [ "`reference:server-metadata-defines-the-command`", "`case:a-build-gate-that-is-not-in-the-build`" ], "publication": "초안", "file": "redis-command-admission/decision/decision-unclassified-commands-are-refused.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ] } }, "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" ], "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" ] }, { "title": "로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다", "kind": "case", "slug": "a-circuit-breaker-permit-that-leaks-on-local-rejection", "readiness": "READY", "source": [ "`final/document.md#a11` §22" ], "code": [ "`.../httpclient/...`의 회로 브레이커 permit 획득/반환 경로" ], "evidence": [ "없음 — 경로 확인" ], "classification": "회로 브레이커 permit을 얻은 뒤 로컬 검증에서 요청이 거부되면 그 permit이 반환되지 않는다. 실패 한 번에 하나씩 줄어드는 형태이고, 같은 저장소의 messaging publish 경로가 명시적으로 막은 것(\"실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다\")과 같은 결함이다.", "missing-verification": "반복 호출로 permit 고갈을 재현하지 않았다", "relations": [ "`concept:transport-failure-stage-and-category`", "`reference:check-which-duplicate-is-wired`" ], "publication": "초안", "file": "http-failure-classification/case/case-a-circuit-breaker-permit-that-leaks-on-local-rejection.md", "status": "게시 전", "studioId": "", "assets": [ "a-circuit-breaker-permit-that-leaks-on-local-rejection" ], "evidenceFiles": [ "../../../final/evidence/raw/a-circuit-breaker-permit-that-leaks-on-local-rejection.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`" ], "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" ], "evidenceFiles": [ "../../../final/evidence/raw/transport-failure-stage-and-category.txt" ] } ], "reference": [ { "title": "원인 사슬은 가장 구체적인 분류가 이기도록 순회한다", "kind": "reference", "slug": "walk-the-cause-chain-most-specific-wins", "readiness": "READY", "source": [ "일반 규칙 — 이 규칙을 처음 끌어낸 사례(`final/document.md#a11` §51)는 사이클 2에서 철회되었다(`EVD-332`). 규칙 자체는 유효하지만, **이 저장소의 `ApacheFailureClassifier`는 그 실패의 사례가 아니다.**" ], "classification": "예외 사슬을 바깥에서 안쪽으로 훑으며 처음 인식되는 것을 돌려주면, 라이브러리가 구체적 원인을 일반적 예외로 감쌌을 때 잘못된 분류가 나온다. 기준은 \"이 사슬에서 가장 구체적인 분류가 이기는가\"이고, 구현은 (a) 구체적 분기를 앞으로 옮기거나 (b) 사슬 전체를 훑어 최선의 매치를 고르는 것이다.", "scope": [ "드라이버·클라이언트 예외를 자기 범주로 번역하는 모든 분류기. 특히 전송 계층은 감싸기가 흔하다. `IdentityHashMap` + 최대 깊이로 사이클 안전을 확보하고, `SQLException.getNextException()` 같은 벤더별 곁가지도 따라간다." ], "exceptions": [ "바깥 예외가 실제로 더 구체적인 경우가 있다 — 그때는 순서가 아니라 우선순위 표가 필요하고, 그 표를 테스트로 고정해야 한다." ], "relations": [ "`case:a-red-test-misread-as-a-product-defect`", "`reference:translation-chain-order-is-a-contract`" ], "publication": "초안", "file": "http-failure-classification/reference/reference-walk-the-cause-chain-most-specific-wins.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "재시도 가능성은 멱등성과 실패 범주를 함께 봐야 정해진다", "kind": "reference", "slug": "retryability-needs-both-idempotency-and-category", "readiness": "READY", "source": [ "`final/document.md#a11` §51", "`final/document.md#a05` §3.6" ], "classification": "실패 범주만으로 재시도를 정하면 비멱등 요청을 재시도하고, 멱등성만으로 정하면 영구 실패를 반복한다. 기준은 두 입력의 곱이다 — 범주가 재시도 가능하고 **동시에** 요청이 재시도 안전할 때만 재시도한다. 그리고 그 판정에 \"아무것도 전송되지 않았다\"는 증거가 있으면 멱등성 요구가 완화된다.", "scope": [ "HTTP·gRPC·메시징 클라이언트의 재시도 결정. 이 저장소는 `notSent` 증거를 별도 축으로 두어 그 완화를 표현한다." ], "exceptions": [ "분류기가 terminal로 표시한 실패는 정책의 화이트리스트로 되살릴 수 없다 — 화이트리스트는 어떤 **범주**가 재시도될 수 있는지를 넓히지 **이 실패**에 대한 판정을 뒤집지 않는다." ], "relations": [ "`concept:transport-failure-stage-and-category`", "`reference:translation-chain-order-is-a-contract`", "`decision:retry-safety-is-decided-by-evidence`" ], "publication": "초안", "file": "http-failure-classification/reference/reference-retryability-needs-both-idempotency-and-category.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "분류기는 엔진이 남긴 것만 볼 수 있다", "kind": "reference", "slug": "a-classifier-sees-only-what-the-engine-kept", "readiness": "READY", "source": [ "`final/document.md#a11` §51.3-51.4", "`EVD-332`" ], "classification": "예외를 자기 범주로 번역하는 계층은 그 아래 엔진이 **버리지 않고 남긴 것**만 볼 수 있다. Apache HttpClient 5의 다중 주소 연결 루프는 마지막이 아닌 주소의 실패를 삼키므로, 호스트명이 여러 주소로 풀리면 호출자에게 도달하는 것은 마지막 주소의 오류뿐이다. 분기 순서를 아무리 잘 짜도 사슬에 없는 원인은 분류할 수 없다.", "scope": [ "이름 하나가 여러 엔드포인트로 풀리는 모든 클라이언트 — DNS A/AAAA, 서비스 디스커버리, 다중 브로커 부트스트랩. 실패 분류가 재시도 안전성이나 보안 판정으로 이어지는 곳에서 특히 중요하다." ], "exceptions": [ "모든 주소가 같은 이유로 실패하면 마지막 오류가 대표성을 가지므로 문제가 되지 않는다. 강등은 **패밀리별·엔드포인트별 실패 양상이 다를 때만** 일어난다." ], "relations": [ "`case:a-red-test-misread-as-a-product-defect`", "`reference:walk-the-cause-chain-most-specific-wins`" ], "publication": "초안", "file": "http-failure-classification/reference/reference-a-classifier-sees-only-what-the-engine-kept.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] }, { "title": "런타임의 모양에 대한 주장은 런타임에서 확인한다", "kind": "reference", "slug": "verify-runtime-shape-at-runtime", "readiness": "READY", "source": [ "`final/document.md#a99` §5", "`EVD-332`", "`EVD-326`" ], "classification": "\"이 라이브러리는 예외를 이렇게 감쌀 것이다\", \"이 게이트가 이 값을 읽을 것이다\", \"이 경로가 프로덕션 기본값이다\" — 이런 주장은 코드를 읽어서 얻은 **추론**이고, 런타임에서 확인하기 전까지는 가설이다. 사이클 2가 만든 판정 번복 한 건과 자기 교정 세 건은 모두 이 형태였고, 넷 다 측정 하나로 갈렸다. 사슬을 출력하고, 리플렉션으로 private 메서드를 부르고, 조건만 바꿔 대조하는 데 드는 비용은 분 단위다.", "scope": [ "프레임워크·드라이버·클라이언트 라이브러리의 런타임 동작에 의존하는 모든 판정. 특히 **실패하는 테스트를 결함의 증거로 읽을 때** — 붉은 테스트는 조사의 시작점이지 결론이 아니다." ], "exceptions": [ "소스가 저장소 안에 있고 그 경로가 테스트로 고정돼 있으면 읽기로 충분하다. 벤더 코드의 동작에는 해당하지 않는다." ], "relations": [ "`case:a-red-test-misread-as-a-product-defect`", "`reference:a-classifier-sees-only-what-the-engine-kept`" ], "publication": "초안", "file": "http-failure-classification/reference/reference-verify-runtime-shape-at-runtime.md", "status": "게시 전", "studioId": "", "assets": [], "evidenceFiles": [] } ], "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` §2.4" ], "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": [], "evidenceFiles": [] } ] } }, "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 주석 후 `