# clean-architecture-backend-template — 상세 분석 (통합 정본) > **성격**: 정책 문서가 아니라 **읽기 기록의 통합본**이다. 62개 등록 leaf를 20편의 bounded analysis로 > 나눠 읽고, 그 결과를 한 문서로 합쳤다. 새로운 사실을 여기서 처음 만들지 않는다 — 모든 판정에 > 모듈 문서의 절 번호를 붙였고, 그쪽이 원본이다. > > SSOT는 `src/config/architecture/modules.json`, 각 모듈의 `CLAUDE.md`, `docs/**/support-matrix.md`다. > 이 문서와 그것들이 어긋나면 그쪽이 맞다. | | | |---|---| | 리비전 | `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` | | 최초 기준선 | `a24ece9cf797f7ea647e33bf846b115208ed1ba5` (모듈 01~19) | | 등록 leaf | 62 — COMPLETE 61 · EXCLUDED 1 | | 추적 파일 | 6,747 | | main Java | 4,614 파일 / 320,318 LOC | | 모듈 분석 | 제2부 65편 (최상위 22 · messaging 25 · grpc 18) — 합계 43,700줄 | | 증거 | `evidence/raw` 370 · `evidence/meta` 14 · 끊긴 참조 **0** | | finding 총계 | **462** — P1 30 · P2 147 · P3 285 (델타 조정값, §14.2) | | 사이클 2 통독 추가 | **92** — P1 2 · P2 25 · P3 65 (23개 리프, §14.2) | | 분석 사이클 | **2** — 18개 리프 재검증(번복 1건) + 23개 리프 통독(1회 재작업, §14.2) | | 교차 스코프 | §A99 COMPLETE | | 앵커 | SRC 173행 · EVD 81행 | | 소스 트리 변경 | 없음 | --- ## 0. 이 문서를 읽는 법 이 저장소는 **Clean Architecture 템플릿**인 동시에 **두 개의 벤더드 플랫폼**(messaging 25 leaf, gRPC 18 leaf)을 품고 있다. 그래서 leaf 수(62)가 계층 수보다 훨씬 많고, 읽는 순서가 중요하다. 읽는 순서를 하나만 고른다면 이렇다. 1. **§1 숫자 지도** — 무엇이 얼마나 있고 무엇이 출하되는가. 2. **§2 경계** — 무엇이 그 구조를 강제하는가. 여기가 이 저장소의 실제 설계다. 3. **§7 반복된 네 가지 형태** — 62개 leaf를 읽고 나서 남는 것. 개별 결함보다 이쪽이 값이 크다. 4. **§8 확인된 문제** — 고칠 것. 깊이가 필요하면 제2부로 간다. 제1부의 각 절이 가리키는 자리가 거기 있다. 예를 들어 §A05(persistence-jpa)는 4,674줄이고 제1부 §4.1은 그 요약이다. **표기.** `(05 §23)`은 제2부 §A05 안의 §23을 뜻한다. 제2부는 분석하는 동안 `analysis/` 아래에 파일로 나뉘어 있던 것을 그대로 옮겨 온 것이고, 파일 이름의 번호가 절 번호가 됐다. --- ## 1. Project map — 숫자로 먼저 ### 1.1 빌드와 레지스트리 Java 21 · Spring Boot 4.0.8 · Gradle 9.0.0 멀티모듈. `src/settings.gradle`은 16줄이고, 실제 검증은 included build `build-logic`의 `ca.architecture-registry` settings plugin이 한다. **`src/config/architecture/modules.json`이 SSOT다.** leaf의 존재, 경로, 허용 project dependency, runtime membership을 전부 여기서 읽는다. 등록되지 않은 디렉터리는 빌드에 포함되지 않는다 — **fail-closed**. 이 성질이 이 저장소의 형태를 결정했다. `docs/jpa/repository-adaptation.md`가 그 결정을 기록한다: > 원래 설계는 JPA 플랫폼을 **18개 Stable Gradle 모듈 + 7개 Experimental 모듈**로 모델링한다. > 그런데 이 저장소는 fail-closed 레지스트리를 쓴다. Gradle 프로젝트를 25개 더 만드는 건 > `AGENTS.md`의 HARD-STOP #5 위반이다. 그래서 **설계상의 모듈이 이 leaf 안의 패키지 경계가 됐다.** 같은 압력이 messaging과 gRPC에서는 반대로 풀렸다 — 그쪽은 실제로 leaf 25개와 18개를 등록했다. 결과적으로 이 저장소에는 **두 가지 크기의 leaf**가 공존한다: 패키지가 모듈 역할을 하는 거대 leaf(`persistence-jpa` 350 main / 27,744 LOC)와, 파일 몇 개짜리 leaf(`messaging-schema-json` 1 main). ### 1.2 가족별 분모와 출하 여부 | 가족 | leaf | 출하 | 추적 파일 | main Java | 담당 문서 | |---|---|---|---|---|---| | core/composition | 5 | 5 | 1,859 | 1,295 | `01`·`02`·`03`·`18` | | adapter/outbound | 10 | 10 | 2,515 | 1,713 | `04`–`13` | | adapter/inbound | 4 | 2 | 1,443 | 982 | `14`–`17` | | messaging 플랫폼 | 25 | 18 | 549 | 364 | `19` | | gRPC 플랫폼 | 18 | **0** | 381 | 260 | `20` | | **합계** | **62** | **35** | **6,747** | **4,614** | | 런타임 조합은 둘 — `app-bootstrap`(33 leaf), `sample-portfolio`(8 leaf, 사용자 지시로 분석 제외). ### 1.3 leaf별 규모 (main Java 기준 상위) | leaf | main | LOC | 스테레오타입 | 비율 | |---|---|---|---|---| | `application-core` | 885 | 35,772 | **0** | 0.0% | | `adapter/inbound/graphql` | 408 | 26,303 | 3 | 0.7% | | `adapter/inbound/web` | 397 | 27,473 | 42 | 10.6% | | `adapter/outbound/persistence-mongo` | 351 | 22,924 | 4 | 1.1% | | `adapter/outbound/persistence-jpa` | 350 | 27,744 | 28 | 8.0% | | `adapter/outbound/cache-redis` | 314 | 32,082 | 1 | 0.3% | | `adapter/outbound/httpclient` | 260 | 15,004 | **0** | 0.0% | | `sample-portfolio` | 201 | 9,387 | 50 | 24.9% | | `grpc` (12 leaf 합) | 194 | 14,778 | 1 | 0.5% | | `adapter/outbound/notification` | 171 | 14,695 | 5 | 2.9% | | `adapter/inbound/websocket` | 169 | 12,784 | 5 | 3.0% | | `app-bootstrap` | 149 | 12,380 | 48 | 32.2% | | `adapter/outbound/objectstorage` | 147 | 14,336 | 5 | 3.4% | | `messaging-core-api` | 85 | 3,948 | **0** | 0.0% | | `adapter/outbound/fileserver` | 78 | 12,707 | 2 | 2.6% | | `grpc-advanced` (6 leaf 합) | 66 | 3,948 | **0** | 0.0% | | `shared-contract` | 53 | 2,628 | **0** | 0.0% | | `grpc-core-api` | 32 | 1,897 | **0** | 0.0% | | `domain-core` | 7 | 104 | **0** | 0.0% | | **전체** | **4,614** | **320,318** | **206** | **4.5%** | ### 1.4 이 표에서 읽어야 할 것 **(1) 프레임워크 표면이 4.5%다.** main Java 4,614개 중 Spring 스테레오타입(`@Component`·`@Service`· `@Repository`·`@Configuration`·`@AutoConfiguration`·`@Controller`·`@RestController`· `@(Rest)ControllerAdvice`)을 가진 것은 **206개**이고, 그중 98개가 두 합성 루트에 있다. 나머지 4,408개(95.5%)는 프레임워크 애노테이션이 없다. 선언된 아키텍처 그대로다. `application-core` 885개, `httpclient` 260개, `messaging-core-api` 85개, `grpc-core-api` 32개가 전부 0이다. **그리고 이 사실이 §7의 지배적 결함 형태를 설명한다** — 코드의 절대다수가 프레임워크와 무관하게 옳게 작성돼 있고, 그것을 런타임에 연결하는 일은 4.5%의 좁은 표면에서만 일어나며, **결함은 거의 전부 그 표면에서 발생한다.** **(2) 자동설정 루트가 저장소 전체에 13개뿐이다.** ``` adapter/inbound/graphql GraphQlRootAutoConfiguration adapter/inbound/web WebMvcPlatformAutoConfiguration WebFluxPlatformAutoConfiguration adapter/outbound/cache-redis RedisSdkAutoConfiguration adapter/outbound/messaging MessagingBridgeRootAutoConfiguration adapter/outbound/persistence-mongo MongoRootAutoConfiguration app-bootstrap FileserverPlatformAutoConfiguration HttpClientPlatformAutoConfiguration PersistenceJpaRootAutoConfiguration DisabledMessagingSentinelAutoConfiguration NotificationRootAutoConfiguration AdapterActivationAutoConfiguration messaging-spring-boot-starter MessagingPlatformRootAutoConfiguration grpc-spring-boot-starter GrpcPlatformAutoConfiguration ``` 8개 `.imports` 파일 / 13개 클래스. 이 목록이 "무엇이 조립될 수 있는가"의 전체 집합이다. **(3) build-only가 27 leaf / 729 파일 / main 479개다.** ``` adapter-inbound-websocket 253 파일 / main 169 ← 단일 최대 build-only leaf grpc + grpc-advanced 18개 381 파일 / main 260 ← 가족 전체가 build-only messaging 7개 77 파일 / main 42 adapter-inbound-grpc 18 파일 / main 8 ``` 이 구분이 이 문서 전체의 심각도 축이다 — **런타임에 오르지 않는 leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채다**(`17` §26.6 · `19` §1.1 · `20` §1.1). --- ## 2. Architectural boundaries — 무엇이 경계를 강제하는가 이 저장소의 핵심은 "규칙을 문서에 적었다"가 아니라 **"규칙을 어기면 빌드나 부팅이 실패한다"**로 바꾼 장치들이다. 실제로 도는 것만 정리한다. ### 2.1 강제 장치 목록 | 장치 | 시점 | 무엇을 | |---|---|---| | `modules.json` `allowed_dependencies` | build | leaf 간 project edge | | `verifyCleanArchitectureDependencies` | build | 실제 `api/implementation/compileOnly/runtimeOnly` 집합 ↔ 레지스트리 allowlist | | `verifyRuntimeModuleMembership` | build | 어떤 leaf가 어떤 런타임 조합에 들어가는가 | | `verifyPublicPathSnapshot` · `verifyDependencyLocks` | build | 공개 경로·의존 잠금 | | `CleanArchitectureTest` (ArchUnit) | test | production classes 전체에 규칙 14종 + **위반/허용 픽스처 76개** | | `JpaModuleBoundaryTest` | test | JPA leaf의 패키지 카탈로그 **정확한 동등성** + 사이클 | | `ConditionalTransportCompositionContractTest` | 별도 레인 | build-only 전송이 어떤 런타임에도 오르지 않음 | | `MessagingPublicSurfaceContractTest` | app-bootstrap 레인 | vendor 타입 노출 ↔ `api` 선언 일치 | | `verifyDocumentedLeafCount` | build | 문서의 leaf 수 진술 ↔ 레지스트리 | | `verifyJpaSqlConstructionSafety` | build | 벤더 소스의 SQL 문자열 연결 | | 각 leaf의 startup validator | 부팅 | §5.3 | ### 2.2 `CleanArchitectureTest`의 규칙 14종 `app-bootstrap`이 소유하고 production classes 전체를 대상으로 돈다. 위반/허용 **합성 픽스처 76개**가 각 규칙의 양방향을 고정한다 — 규칙이 통과만 하는 것으로는 그 모양을 알 수 없기 때문이다 (`18` SRC-159). `testkit/arch`의 `JpaArchitectureRules`가 그중 6종을 담당한다. | 규칙 | 막는 것 | |---|---| | `noEntityFromWeb()` | 컨트롤러가 엔티티 반환 → 트랜잭션 밖 lazy association 직렬화 | | `entitiesFollowPortableMappingRules()` | `final` 엔티티(프록시 불가 → 모든 lazy 참조가 eager), no-arg 생성자 | | `entitiesStayOutOfWebPackages()` | web 패키지 안의 엔티티는 노출될 엔티티 | | `domainDoesNotDependOnHibernate()` | 도메인이 ORM에 의존 | | `tenantScopedRepositoriesDoNotInheritBroadCrud()` | tenant-scoped 엔티티 리포지토리가 CRUD 상속 | | `noGenericRepository()` | 플랫폼이 `CrudRepository` 재구현 | `tenantScopedRepositoriesDoNotInheritBroadCrud`의 설명이 이 팩의 성격을 보여준다: > **이름은 중요한 속성이 아니다.** `tenantId`를 가진 엔티티에 대해 `JpaRepository`를 확장하는 > `OrderRepository`는 `findById(UUID)`, `findAll()`, `deleteById(UUID)`를 노출한다 — 전부 > tenant-blind, 전부 상속, 그리고 **리뷰어가 볼 만한 어디에도 적혀 있지 않다.** ### 2.3 검증된 경계 — 실제로 성립하는 것 **`domain-core`** (`01`). 허용 의존 0개. main 7파일 / 104 LOC. 이 모듈이 소유하는 것은 재사용 가능한 도메인 "내용"이 아니라 **도메인 모델링 계약**이다 — `ResourceId`, `IdFactory`, 그리고 `@ValueObject` / `@AggregateRoot` / `@DomainEvent` 세 개의 stereotype marker. 두 런타임 조합 모두의 membership에 들어가지만, 그건 런타임 closure 포함 계약이지 Spring bean을 갖는다는 뜻이 아니다. **`shared-contract`** (`02`). production dependency block이 비어 있고 Java stdlib만 쓴다. 이 모듈의 아키텍처적 의미는 "공통 유틸리티"가 아니라 **서로 다른 outer module이 한쪽 adapter의 타입에 의존하지 않고 합의할 수 있는 중립 계약 지점**이다. 그 방향성의 직접 증거: GraphQL persisted-operation registry가 자기 저장소 인터페이스를 선언하지 않고 `OperationalRecordStorePort`(중립 CAS port)에 의존한다. inbound adapter가 선언한 인터페이스를 outbound가 구현하는 역방향 dependency를 피한다. **`grpc-core-api`** (`20` §2.1). 이 저장소에서 본 가장 강한 형태의 자기 제약이다. ```groovy apply plugin: 'java-library' dependencies { } ``` `dependencies {}`가 **비어 있다.** `io.grpc`가 컴파일 클래스패스에 아예 없으므로 "framework-free"가 문서가 아니라 빌드로 강제된다. main 32파일 / 1,897 LOC가 Java stdlib만으로 선다. 소스에서 `io.grpc`가 3번 등장하는데 전부 **javadoc 산문**이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다: > The canonical gRPC status codes, **mirrored so that `grpc-core-api` stays free of io.grpc**. ... > a failure context that names `io.grpc.Status` would put the transport inside the contract that > exists to describe what the transport did. **`messaging-core-api`** (`19` §2.1). 25개 leaf main 전체에서 예외 타입을 문자열로 판별하는 코드 (`getClass().getName().contains` / `getSimpleName().contains` / `getMessage().contains`)가 **0건**. 이 규칙은 앞선 모듈들에서 반복해서 깨진 것이다(`11` httpclient, `13` notification). **Stable → advanced 금지** (`20` §2.2). 레지스트리 0위반 · 소스 0참조. `grpc-spring-boot-starter`의 `allowed_dependencies`는 Stable 10개 leaf뿐이고, `src/grpc` 전체에서 `dev.caskeleton.grpc.advanced` 참조가 0건이다. `grpc-advanced`의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 `allowed_dependencies`만으로 **기계 검증할 수 있게** 하기 위해서." *(단, 이 규칙의 runtime 절반은 실행되지 않는다 — §8.2 참조.)* **`adapter-inbound-grpc` ↛ gRPC 가족** (`20` §2.3). `allowed_dependencies`가 `[domain-core, application-core, shared-contract]`다. **이 가족을 볼 수 없다.** 이것이 messaging과의 결정적 차이다. messaging은 `messaging-spring-boot-starter`가 `app-bootstrap` 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 아직 그 선을 넘지 않았고, **넘지 않았다는 사실을 문서가 정확히 말한다.** ### 2.4 경계가 열려 있는 지점 **MSG-015 — 서로 모르는 Kafka 스택 둘이 한 아티팩트에** (`19` §6.5, 가족 문서가 P0 미해결로 표시). | | app-bootstrap seam | messaging platform | |---|---|---| | 설정 클래스 | `bootstrap/messaging/KafkaSenderConfig` | `autoconfigure/KafkaMessagingAutoConfiguration` | | producer bean | `kafkaSeamProducer` : `Producer` | `messagingKafkaProducer` : `Producer` | | 조건 | `@ConditionalOnProperty("app.messaging.broker", havingValue="kafka")` | root의 `app.messaging.enabled=true` → 선택 → `broker=kafka` | | 의존 방향 | `adapter-outbound-messaging`은 platform에 의존하지 않음 | platform은 adapter를 모름 | bean 이름 충돌은 해소됐고, `KafkaSenderConfig`의 javadoc이 그 과정을 적는다: > Sharing the method name `messagingKafkaProducer` made the context refuse to start with a > `BeanDefinitionOverrideException`, and a type-scoped `@ConditionalOnMissingBean` would have been > worse: whichever configuration lost the race would leave its own stack without a producer while > the other stack's, with incompatible serializers, sat in its place. 판단은 옳다. 그러나 **결과적으로 이 결함의 유일한 가시적 증상이 제거됐다.** 지금은 부팅이 성공하고 두 스택이 조용히 공존한다. 그리고 **정정할 부분이 하나 있다.** 가족 문서는 안전의 근거를 "지금 안전한 이유는 설계가 아니라 기본값이다 — `app.messaging.enabled=false`"에 둔다. 그런데 seam 스택은 `enabled`를 보지 않고 `broker`만 본다. 따라서 `enabled=false`는 **두 스택 중 하나만 막는다.** **리액티브 web 절반** (`14` §40.1). `adapter-inbound-web`의 리액티브 29개 파일은 어떤 출하 배포에서도 활성화될 수 없다. **`messaging-rabbit`** (`19` §6.2). 20 main / 2,443 LOC가 출하되지만 선택할 수 없다 — `MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT`에 등록돼 있고 `RabbitChannelPublisher` 구현이 저장소에 없다. 코드는 fail-closed로 정직하고, 운영 문서만 그 사실을 말하지 않는다. --- ## 3. Representative execution paths ### 3.1 HTTP 요청 — 출하 경로 `adapter-inbound-web` → `application-core` port → outbound adapter. **오류 응답은 `Envelope` 계약이 낸다.** `GlobalExceptionHandler`(22 handler + 6 override) + `EnvelopeBodyAdvice` + `ErrorResponseFactory` + `ClientSafeErrorMessages`가 컴포넌트 스캔으로 등록된다. **RFC 9457 `problem+json` 계약 23개 파일은 등록되지 않는다** (`14` §8.1). 합성 루트가 다섯 web 패키지를 컴포넌트 스캔에서 제외하고, 그 이유를 `CaSkeletonApplication`의 javadoc이 적는다: > The web platform's error, budget and operation packages are here for a third reason. Their > advices and controllers need beans that only exist when the corresponding platform > auto-configuration is active, and a component scan finds them regardless — so an all-off or > partially configured deployment failed to start on an unsatisfied dependency rather than simply > not installing the control. **Ownership by auto-configuration is what ties a control's presence > to its dependency's.** 진단도 조치의 방향도 옳다. 자동설정도 존재하고 `.imports`에 등록돼 있으며 출하 컨텍스트에 실제로 import된다(`app-bootstrap/build.gradle:98`이 이 leaf를 물고, 저장소의 유일한 `AutoConfigurationImportFilter`는 JPA 전용이다). 그런데 그 자동설정이 등록하는 `@Bean` **13개 (MVC)·10개(WebFlux)가 전부 협력자**다: ``` MVC : InMemoryWebOperationCatalog · WebBudgetCatalog · ProblemCatalog · WebProblemFactory · WebValidationExceptionMapper · WebValidationIssueMapper · WebWireTypeManifest · WebUriPolicy · WebMethodPolicy · webStrictObjectMapper · WebMvcRequestIdFilter · WebMvcEvidenceFilter · webMvcRequestContextConfigurer ``` 스캔에서 제외된 다섯 패키지의 **컴포넌트 여섯 개**는 어느 쪽도 소유하지 않는다: | 컴포넌트 | 패키지 | 애노테이션 | main 참조 | |---|---|---|---| | `WebMvcProblemExceptionHandler` | `mvc.error` | `@RestControllerAdvice @Order` | **0** | | `WebFluxProblemExceptionHandler` | `webflux.error` | `@RestControllerAdvice @Order` | **0** | | `WebMvcBudgetExceptionHandler` | `mvc.budget` | `@RestControllerAdvice @Order` | **0** | | `WebMvcBudgetFilter` | `mvc.budget` | (없음) | **0** | | `OperationHttpController` | `mvc.operation` | `@RestController` | **0** | | `ReactiveOperationHttpController` | `webflux.operation` | `@RestController` | **0** | 즉 `ProblemCatalog`와 `WebProblemFactory`는 빈으로 존재하고, **그것을 사용하는 `@RestControllerAdvice`가 존재하지 않는다.** javadoc이 말한 "Ownership by auto-configuration"에서 **dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.** **실패 시나리오.** 팀이 `ProblemCode.VALIDATION_FAILED`로 분기하는 클라이언트 SDK를 작성한다. `WebProblemFactoryTest`(122줄)·`ProblemCatalogTest`(99줄)·`WebValidationExceptionMapperTest`(125줄)가 전부 통과하고, `/v3/api-docs`에도 problem 스키마가 기여되지 않아 계약 불일치를 볼 방법이 없다. 배포된 API는 422 대신 `Envelope`의 400을 내고 `code` 필드는 `ProblemCode`가 아니라 `OperationalError` 어휘다. **SDK의 모든 분기가 빗나간다.** **같은 경로의 다른 미조립:** - **용량 보호 계층 41 main 파일**이 자기 테스트 픽스처 안에서만 실행된다 (`14` §16.1). - **멱등 실행 계층·durable-operation 표면**이 픽스처에서만 조립된다 (`14` §20.1). - 요청 식별자를 클라이언트가 고를 수 없다는 정책이, **뒤에 도는 다른 배선 필터에 의해 뒤집힌다** (`14` §32.1). - forwarded 헤더 신뢰 판정이 **Nginx 설정에만** 있고 그것을 위해 쓴 Java 정책 **421 LOC**은 배선되지 않는다 (`14` §32.2). - 배선된 캐시 필터의 `no-store`가 배선된 조건부 읽기 경로를 무력화하고, 둘을 조정하려고 만든 패키지는 참조 0이다 (`14` §24.1). ### 3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지 이 저장소에서 가장 밀도 높은 경로다 (`03` §4, `05` §3). **포트가 둘이고 같은 빈이 둘 다 구현한다.** ``` application-core: TransactionPort inWrite / inRootWrite / inRead / inNew PolicyTransactionPort → extends TransactionPort + inTransaction(TransactionRequest, Supplier) : TransactionResult adapter: SpringTransactionPort implements PolicyTransactionPort ├─ 레거시 4모드 → 미리 만들어 둔 TransactionTemplate 3개 └─ inTransaction → SpringPolicyTransactionPort (package-private 위임체) ``` 템플릿을 **생성 시점에 미리 만들어 두는** 이유: `TransactionTemplate`은 thread-safe지만 mutable이라, 호출마다 propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다. `inRootWrite`는 `REQUIRES_NEW`로 suspend해서 "root인 척"하지 **않는다.** 그러면 호출자 트랜잭션과 독립 커밋되는 silent 의미 변경이 생기므로, 대신 fail-fast한다: ```java public T inRootWrite(Supplier action) { if (TransactionSynchronizationManager.isActualTransactionActive()) { throw new NestedRootTransactionRejectedException(); } return executeLegacy(writeTemplate, action); } ``` **정책 기반 실행기의 반환 타입이 결과 대수를 만든다.** | 변형 | 의미 | |---|---| | `Committed(value, operationId)` | 물리 커밋 확인됨 | | `Participating(value)` | 바깥 트랜잭션에 참여 — **커밋을 주장하지 않는다** | | `DeterminateRollback(failure)` | 롤백 확정 | | `Indeterminate(operationId, lastObservedPhase, reconciliationReference)` | 결과 불명 — **replay 권한을 주지 않는다** | | `CommittedWithPostCommitFailure(value, operationId, operationalFailure)` | 커밋됐지만 afterCommit 훅이 실패 | 핵심 실행 루틴: ```java tracker.observe(TransactionPhase.COMMIT_REQUESTED); try { transactionManager.commit(status); } catch (RuntimeException commitFailure) { if (sentinel.commitAcknowledged()) { return CommittedWithPostCommitFailure(...); // afterCommit이 이미 왔다 } if (sentinel.rolledBack() || commitFailure instanceof UnexpectedRollbackException || TransactionRetryClassifier.isReplayCandidate(commitFailure)) { return DeterminateRollback(translate(commitFailure)); // 롤백 확정 } return Indeterminate(operationId, tracker.lastObserved(), empty()); // 모른다 } ``` **데드라인이 3단으로 좁혀진다.** ``` 획득 전: required = connectionTimeout + beginBudget + minimumActionWindow + completionMargin remaining < required → TransactionAdmissionException springTimeoutSeconds < 1 → TransactionAdmissionException (Spring은 초 단위) begin 이후: statementWindow = min(callRemaining - completionMargin, springRemaining) - transactionMargin statement = min(settings.statementTimeout, statementWindow) lock = min(settings.lockTimeout, statement - lockMargin) idle = min(settings.idleGuardTimeout, callRemaining - completionMargin) 셋 중 하나라도 1ms 미만 → TransactionAdmissionException ``` 그리고 DB에 밀어 넣는다: ```java "select set_config('statement_timeout', ?, true)" "select set_config('lock_timeout', ?, true)" "select set_config('idle_in_transaction_session_timeout', ?, true)" ``` 세 번째 인자 `true`가 **transaction-local**이다. `SET statement_timeout = ?`가 아닌 이유는 두 가지 — `SET`은 파라미터 바인딩 전에 파싱되어 syntax error가 나고, `set_config` 함수 호출은 **값이 statement text에서 빠진다.** ### 3.3 메시지 발행 — messaging 플랫폼 `DefaultMessagePublisher`가 유일한 publish 경로이고, 단계 순서가 고정돼 있다 (`19` §3.1): ``` resolve → authorize → encode → admit → lease → send → normalize ``` 각 단계 위치의 근거가 javadoc에 있다. - **destination·access가 encode보다 먼저** — 인가되지 않은 publish가 payload를 직렬화하면 claim-check나 로그가 그 바이트를 들고 있게 된다. - **encode가 admit보다 먼저** — admission 한도가 바이트 기준이고, 바이트 수는 인코딩 전에 알 수 없다. - **runtime lease가 send 직전 마지막** — 이미 in-flight 카운트에 잡힌 메시지 밑에서 rotation이 transport를 갈아끼우지 못하게. 그리고 획득한 permit·lease는 **성공·실패·예외·취소 모든 경로에서 정확히 한 번** 반환된다 — "실패 경로에서 새는 permit은 실패 한 번에 하나씩 줄어들다 아무것도 받지 않게 되는 limiter다." **증거와 결론이 분리돼 있다** (`19` §3.2). `PublishEvidence`가 compact constructor에서 표현 불가능한 조합을 거부한다: ```java if (!brokerAccepted && confirmationLevel != ConfirmationLevel.NONE) throw new IllegalArgumentException("confirmation level requires broker acceptance: " + ...); if (transmission == TransmissionEvidence.NOT_TRANSMITTED && brokerAccepted) throw new IllegalArgumentException("untransmitted message cannot be broker accepted"); ``` | 지점 | completion | 근거 | |---|---|---| | access 거부 / encode 실패 | `REJECTED` + `notTransmitted()` | "아무것도 이 프로세스를 떠나지 않았으므로 결과는 확정적이다. ambiguous로 보고하면 어떤 브로커도 보지 못한 메시지에 대해 caller를 reconciliation으로 보낸다." | | 준비 중 데드라인 소진 | `REJECTED` | 아직 전송 전 | | transport 단계 실패/데드라인 | `AMBIGUOUS` + `retryable=true` | "요청이 wire 위에 있었으므로 브로커가 들고 있을 수 있다." | `sanitized(Throwable)`는 **타입만** 남기고 메시지를 버린다 — "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 `FailureDescriptor`는 로깅·export되도록 설계됐다." **오늘 실제로 배포 가능한 브로커는 Kafka 하나다** (§2.4). ### 3.4 gRPC — 채택 시점 경로 `ca-skeleton.grpc.platform.enabled=true`(fail-closed, `matchIfMissing=false`)가 `GrpcPlatformAutoConfiguration`을 켜고 `@Bean` 9개를 만든다 — 전부 프로파일·정책이다: ``` GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry · GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper ``` **서버도, 인터셉터 체인도, 서비스 어댑터 등록도 만들지 않는다** (`20` §3.4). 그 담당 타입들이 전부 main 참조 0이다 — `GrpcServerInterceptorChain`, `ProtovalidateGrpcInterceptor`, `GrpcIdempotencyInterceptor`, `GrpcServiceAdapter`, 스트리밍 기계 4종, `GrpcTypedStubFactory`. `GrpcServerInterceptorChain`의 javadoc이 자기 존재 이유를 적는다: > That reversal is the reason this class exists rather than a list literal at the call site. > `ServerInterceptors.intercept` wraps each interceptor around the previous one, so the last one > passed is the outermost at runtime — the opposite of how the order reads. **Every codebase that > builds this list by hand gets it backwards at least once, and the symptom is an exception > boundary that catches nothing.** 18 leaf 전부 `runtime_memberships: []`이므로 이 경로는 아직 어떤 배포에도 없다. ### 3.5 알림 발송 — 논리적 수락과 provider 불확실성 `application-core`가 **논리적 수락**(요청을 받아 저장)과 **provider 결과**(실제 발송)를 분리한다 (`03` §12). 어댑터(`13`)가 8개 provider를 갖고, 그중 공유 계약(`ProviderAdapterContract`)을 상속하는 것은 3개뿐이며 강제 장치가 없다 (`13` §29.2). 이 경로에서 확인된 P2 중 무거운 것: - **`SINGLE` 전용 가드가 먼저 던져** 다중 타깃 검증 전체(순환 탐지 포함)가 도달 불가이고, 그것을 검증한다는 테스트가 `hasMessageContaining("strategy")`로 **다른 가드에 걸려** 통과한다 (`13` §9). - "Every reveal is auditable"을 선언한 `AccessContext`의 세 필드를 읽는 코드가 저장소에 **0개**. 감사 싱크는 존재하고 다른 경로는 사용 중이다 (`13` §17.1). - **클라이언트 제공 Web Push 엔드포인트가 SSRF 가드를 지나지 않는다.** 약한 검사의 private 사본을 쓴다 (`13` §25.1). - "상한을 두고 읽는다"는 본문 핸들러가 `ofByteArray()`로 **전부 읽은 뒤** 자른다 (`13` §25.2). - FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖이고, 배치라 한 번의 손실이 배치 크기만큼 영향을 준다 (`13` §29.1). --- ## 4. Data and state ### 4.1 관계형 — `persistence-jpa` (605 파일 / main 350 / 27,744 LOC) 이 leaf는 어댑터가 아니라 **플랫폼**이다. 원래 설계의 25개 모듈이 fail-closed 레지스트리 때문에 이 leaf 안의 패키지 경계가 됐다(§1.1). top-level child 패키지 22개가 각각 원래는 별도 라이브러리다. **계약층 `api`가 프레임워크를 모른다.** 여기 있는 결정이 아래층 전부를 규정한다. 이름을 타입으로 만든다 — `PersistenceOperationName`, `QueryName`, `ConstraintCode`, `FetchPlanName`, `BulkOperationName`, `WorkQueueName`, `JsonPathName`, `TenantId`가 전부 같은 패턴이다: ```java public record PersistenceOperationName(String value) { private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}"); } ``` 목적은 하나다 — 이 값은 metric tag / trace / retry policy의 키가 되므로 **엔티티 id, tenant id, SQL 조각, 요청 스코프 값을 담을 수 없다.** 카디널리티 상한이 관례가 아니라 타입 차원에서 보장된다. "메트릭 태그에 id 넣지 마세요"를 코드 리뷰 규칙이 아니라 **정규식을 통과 못 하면 생성자가 던진다**로 만들었다. **핵심 불변식이 하나 있다.** ```java // JpaFailureContext if (completionUnknown && retryable) { throw new IllegalArgumentException("completion unknown failures are never retryable"); } ``` 이게 이 플랫폼 전체가 존재하는 이유다. *커밋됐을지도 모르는 작업을 자동 재실행하는 것*이 할 수 있는 최악의 일이고, 그래서 **타입 시스템이 그 상태를 표현하는 것 자체를 거부한다.** 이중 방어도 있다 — `TransactionCompletionUnknownException` 생성자가 `forceCompletionUnknown(context)`로 컨텍스트를 다시 만들어, 정책 버그가 retryable한 completion-unknown을 만들 방법이 없다. `RetryProfile`도 `COMPLETION_UNKNOWN`을 화이트리스트에 넣는 것을 생성자에서 거부한다. **`57P01` 발견.** completion-unknown 판정 규칙은 좁다 — 커밋 단계에서 발생했고 **동시에** 드라이버가 어느 쪽인지 말해주지 못했을 때만이다. 그 규칙에 `57P0x`가 들어온 경위가 기록돼 있다: > **"커밋 모호성은 SQLSTATE class 08뿐"은 틀렸다.** 커밋이 in-flight인 백엔드에 > `pg_terminate_backend`를 하면 connection-class가 아니라 `57P01`(admin_shutdown)이 보고되고 — > 그리고 그게 도착할 때 **커밋 레코드는 이미 WAL에 있을 수 있다.** 컨테이너 레인이 그걸 > 보여줬고, `CommitAmbiguityContractTest`가 SQLSTATE를 직접 assert해서 규칙이 다시 조용히 > 좁아지지 못하게 한다. 넓히지 않는 이유도 적혀 있다 — 커밋 단계의 모든 커넥션 에러를 completion-unknown으로 표시하면 평범한 풀 고갈과 서버 재시작이 reconciliation 큐로 밀려들고, **운영자는 그 큐를 읽지 않고 비우는 습관을 배운다.** **Flyway가 스키마를 소유하고, 그것이 강제 가능하다.** 런타임 롤에서 DDL 권한을 뺐기 때문이다: ```sql select has_schema_privilege(current_user, current_schema(), 'CREATE') as create_on_schema, has_database_privilege(current_user, current_database(), 'CREATE') as create_on_database ``` > 질문은 **설정이 아니라 서버가** 답한다. 롤의 유효 권한은 직접 grant, 상속된 롤 멤버십, > `PUBLIC` grant, 스키마 소유권에서 나오고, 어떤 배포 매니페스트를 읽어도 그 조합을 신뢰성 있게 > 재구성할 수 없다. `has_schema_privilege`는 할 수 있다. **마이그레이션 스트림이 8개로 갈라져 있다.** ``` db/migration/postgresql/ flyway_schema_history (legacy/adoption) db/migration/jpa/core/ flyway_jpa_core_history db/migration/jpa/idempotency/ flyway_jpa_idempotency_history db/migration/jpa/outbox-storage/ flyway_jpa_outbox_storage_history db/migration/jpa/outbox-polling/ flyway_jpa_outbox_polling_history db/migration/jpa/inbox/ flyway_jpa_inbox_history db/migration/jpa/fileserver/ (capability 스트림) db/migration/jpa/notification-platform/ flyway_jpa_notification_history db/experimental-rls/ Stable location이 절대 적용하지 않음 ``` 이유가 `NotificationSchemaStream` javadoc에 명확하다: > 뻔한 해법 — 디렉터리를 primary location 목록에 추가 — 이 틀린 이유: **두 트리가 다 V1부터 > 번호를 매기고**, 공유 history 테이블은 `V1__notification_platform_core`와 `V1__initial_schema`를 > 같은 버전으로 만든다. Flyway는 두 번째를 거부하거나, 더 나쁘게는 resolution 순서에 따라 하나를 > 기록하고 하나를 건너뛴다. **`capability_schema_registry`가 그 접착제다.** 각 capability 스트림의 V1이 (1) 선행조건 검사 (`DO $$ ... RAISE EXCEPTION`), (2) 테이블 생성, (3) **자기를 `INSTALLED_INACTIVE`로 등록**을 한다. 스키마가 적용된 것과 capability가 사용 승인된 것이 분리되고, 어댑터가 런타임에 `ACTIVE`인지 확인한다. **owner-safe 상태 기계 네 개**(idempotency / outbox-storage / outbox-polling / inbox)가 공통 패턴을 공유한다: 1. capability guard — 레지스트리에서 `ACTIVE` + 정확한 epoch/revision 2. same-resource primary write transaction guard — `hasResource(dataSource)` 3. **row lock 먼저, `clock_timestamp()` 그 다음** — 행이 바뀔 수 없게 된 뒤에 DB 시계를 읽는다 4. **owner CAS 튜플을 SQL where 절에 전부 반복** — scope, token, attempt, claim operation, state revision. update count가 곧 답이다 5. transition digest — 재시도가 같은 전이인지 구별 6. Spring stereotype 없음 — 두 합성 루트가 `dev.caskeleton.adapter`를 스캔하므로 (2)번이 생긴 경위가 좋다: > store가 스레드에 활성 read-write 트랜잭션이 있는지 확인했는데, 그건 **어떤 data source에서든** > 참이다. 차이는 data source가 둘인 애플리케이션에서 드러난다: **다른** 쪽의 트랜잭션 안에서 > 발행된 mutation이 옛 검사를 통과하고, 이 store의 커넥션에서 트랜잭션 밖으로 돌고, > **원자적이어야 했던 작업과 독립적으로 커밋됐다.** **확정된 P1급 상태 결함** (`05`): `inspect()`와 `claim()`이 만료된 COMPLETED row를 동시에 다르게 해석(§59.1) · baseline outbox가 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있음(§70) · durable operation의 lease 만료 후 stale owner 완료 가능(§71) · persistent byte quota가 admission에서 집행되지 않음(§78) · schema activation이 V2/V4를 current로 오인(§79·§88) · provider 호출 뒤 recipient projection write가 lease fencing 우회(§89) · RLS verifier가 "반드시 보호돼야 하는 table"의 부재를 성공으로 인정(§97) · database-per-tenant 전역 커넥션 예산이 새 pool 크기를 계산하지 않아 ceiling 초과(§98). ### 4.2 문서형 — `persistence-mongo` (497 파일 / main 351 / 22,924 LOC) **subsystem 전체가 배선돼 있지 않은데 그것을 켜는 flag는 startup 검사를 수행한다** (`06` §49). **출하 default 조합이 첫 write에서 예외를 던진다** (`06` §23). 세 사실이 겹친다: 1. `mongoTypeMetadataRegistry()`가 **비어 있는** registry를 기본 bean으로 등록한다 — "An empty registry so a deployment with no long-lived collection still starts." 2. `MongoTypeMetadataConfigurer`가 `PolicyAwareMongoTypeMapper`를 **모든** `MappingMongoConverter`에 무조건 설치한다. 3. `PolicyAwareMongoTypeMapper.writeType(...)`이 등록되지 않은 타입에 `IllegalStateException`을 던진다. 즉 module을 켜기만 하고 type metadata를 등록하지 않은 배포는 **시작은 하고 첫 write에서 실패한다.** 그리고 같은 컴포넌트가 같은 질문에 세 가지로 답한다: | 물음 | 답 | |---|---| | 미등록 타입의 정책은? | `CLASS_METADATA_ALLOWED` (registry javadoc: "unregistered types keep Spring Data's default") | | 미등록 타입으로 type-restricted **query**를 만들면? | Java class name을 `_class` predicate에 씀 | | 미등록 타입을 **write**하면? | 예외 | 읽기 경로와 쓰기 경로가 같은 정책 질문에 정반대로 답하고, 그중 어느 쪽도 registry가 스스로 문서화한 기본값과 일치하지 않는다. **high-water mark가 재전달된 이벤트를 삼킨다** (`06` §67). `MongoChangeStreamPipeline`의 mark는 "투영이 완료된 위치"가 아니라 "**본 적 있는 위치**"다(이벤트를 받자마자 전진). resume 시 같은 pipeline 인스턴스를 다시 쓰므로 mark가 남는다. 실행 probe C: worker 하나, 평범한 failover. E(clusterTime 5.1)의 투영이 시작됐다가 취소되고, resume 후 재전달된 E는 **pipeline이 삼킨다**(mark가 이미 5.1). 그 다음 F가 투영되고 checkpoint가 token-6으로 저장되면서 저장 위치가 E를 지나친다. ``` PROBE-C projector started=2 completed=1 PROBE-C checkpoints saved=[token-6] PROBE-C highWaterMark=6.1 PROBE-C state=RUNNING runbook= ``` **E는 영구히 사라졌고, 구독은 `RUNNING`에 runbook은 비어 있고, caller의 `Flux`는 정상 완료한다.** 왜 테스트가 못 잡았나가 특히 중요하다 — 세 테스트가 각각 절반씩 본다. 하나는 이벤트를 하나만 돌리고, 하나는 checkpoint store가 `NoOpCheckpoints`라 상호작용이 안 보이고, 하나는 첫 stream을 이벤트 전달 없이 실패시켜 mark가 설정되지 않는다. **"본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 어느 테스트에도 없다.** 그 밖에: 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음(§75) · sharding admin gateway의 네 작업 중 셋은 어떤 입력으로도 완료 불가(§85) · index diff가 실제로 비교하는 것은 두 필드뿐(§57) · Flamingock lease로는 어떤 migration도 실행할 수 없는데 javadoc은 다르게 적음(§59). ### 4.3 messaging 신뢰성 저장소 (`19` §7) **outbox/inbox 체인 전체가 만족되지 않는 `@ConditionalOnBean` 뒤에 있다.** ```java @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class}) public OutboxRelay outboxRelay(...) @ConditionalOnBean(OutboxRelay.class) public OutboxRelayWorker outboxRelayWorker(...) @ConditionalOnBean(OutboxRelayWorker.class) public MessagingOutboxRelayLifecycle outboxRelayLifecycle(...) @ConditionalOnBean(InboxRepository.class) public InboxCleanupJob inboxCleanupJob(...) ``` 사슬의 뿌리인 `OutboxRepository`/`InboxRepository`의 유일한 구현(`JdbcOutboxRepository`, `JdbcInboxRepository`)을 **어떤 자동설정도 만들지 않는다.** 19 main 파일 / 2,818 LOC가 전부 조용히 비어 있다. **실패가 특히 조용하다.** Spring은 조건부 bean이 조건을 만족하지 못하는 것을 오류로 보고하지 않는다. 즉 **"outbox가 꺼져 있음"과 "outbox가 조립될 수 없음"이 런타임에서 구별되지 않는다.** 같은 starter가 `MessageCodecRegistry`에는 `@ConditionalOnMissingBean` 기본 구현을 제공했다는 점이 이것을 결함으로 만든다. **마이그레이션 스트림을 적용하는 곳이 없고, 적용하려는 순간 버전이 충돌한다** (`19` §7.2). 합성 루트의 Flyway 기본 위치는 `PostgreSqlPersistenceConfig:115`의 `classpath:db/migration/postgresql`이고, `db/migration/messaging`을 이름으로 부르는 main 코드가 저장소 전체에 **0건**이다. 그리고 두 leaf가 같은 리소스 디렉터리에 각자 번호를 매긴다: ``` messaging-inbox-jdbc-postgresql V2__messaging_inbox.sql (CREATE TABLE) messaging-outbox-jdbc-postgresql V1__messaging_outbox.sql V2__messaging_outbox_lease_fencing.sql (ALTER ×4) V3__messaging_admin_operation_journal.sql V4__messaging_outbox_canonical_metadata.sql ``` **`V2`가 둘이다.** 그 위치를 Flyway에 주는 순간 duplicate version으로 부팅이 실패한다. 각 leaf의 IT는 자기 jar 리소스만 보므로 재현하지 못한다. 원 구현 계획서는 분리된 위치 (`db/migration/messaging-outbox`, `messaging-inbox`)를 지정했었다. **설계 자체는 정확하다.** `V2__messaging_outbox_lease_fencing.sql`의 헤더: > V1 recorded only `lease_expires_at`, so a claim said *when* it would end and nothing about *who* > held it. ... relay A claims the row and calls the broker / the lease expires; relay B reclaims it, > publishes, and records PUBLISHED / relay A finally times out and records AMBIGUOUS over the top. > **Making the lease longer than the publish timeout lowers the odds; it does not turn a GC pause, > a scheduler stall** ... inbox는 `(message_id, consumer_id)` 복합 키로 소비자별 dedup을 트랜잭션 안에서 보장한다. ### 4.4 fileserver / objectstorage / cache-redis **`fileserver`** (`08`, 119 파일). 6개 테이블. 가장 좋은 제약이 이것이다: ```sql CONSTRAINT ck_fs_file_ready_is_complete CHECK ( state <> 'READY' OR (content_key IS NOT NULL AND actual_size IS NOT NULL AND sha256 IS NOT NULL AND strong_etag IS NOT NULL AND published_at IS NOT NULL)) ``` **READY가 유일하게 공개 읽기 가능한 상태이므로, 완전하고 검증된 identity를 반드시 들고 있어야 한다** — 를 DB CHECK으로 강제한다. V2/V3/V4가 각각 실제 사고의 수정이다(staging 객체 주소지정 누락 / fenced cleanup lease 부재 / upload terminal state 부재). 발견: README가 "노출된 setting도 bean도 없다"고 적은 능력들에 **production bean 8개**가 있음 (§4·§39) · scriptable 콘텐츠 탐지가 접두사 **시작**에만 고정돼 BOM·NUL·주석으로 우회됨(§40). **`objectstorage`** (`09`, 206 파일, `sample-portfolio`에만 출하). 발견: 직접 multipart의 마지막 part는 grant를 받을 수 없음(§38) · 서명된 grant의 endpoint 검증이 upload 경로에만 있음(§39) · APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없음(§49) · production 판정이 두 개의 리터럴 프로파일 이름에 걸림(§5). **`cache-redis`** (`10`, 390 파일 / main 314 / 32,082 LOC). 발견: startup probe가 production에서 한 번도 실행되지 않음(§6) · SDK가 선언한 두 진입점에 구현이 없음(§15) · "build gate"라 불리는 catalog drift 검사가 **어디에서도 실행되지 않음**(§47) · 의미 어댑터 다섯이 `CommandPolicyGuard`를 지나지 않음(§64) · NOSCRIPT 복구가 다섯 벌로 구현돼 있고 넷은 스크립트 레지스트리를 지나지 않음(§56). --- ## 5. Failure and operational behavior ### 5.1 실패 분류 — 세 개의 계층 이 저장소에는 실패 어휘가 세 층으로 있고, 각각 다른 질문에 답한다. **(1) `shared-contract`의 `Category` 10값** — 웹 표면이 응답 코드를 정하는 어휘. VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL. `PERSISTENCE` 카테고리는 **없다**. `OperationalErrorTest`가 category × retryable 의미를 강하게 검증한다 — deterministic VALIDATION/AUTHZ/NOT_FOUND는 `retryable=false`, transient INTERNAL은 기본 `true`이되 deploy-time/ configuration/terminal 상태는 명시적 예외로 `false`. `AUTH_KID_UNKNOWN`은 key rotation 중 JWKS refresh 가능성 때문에 **AUTH 중 유일한 retryable case**로 pin돼 있다. **(2) `persistence-jpa`의 `FailureCategory` 16값** — 플랫폼 내부의 안정 예외 계층. `api.error` 23개 파일이고, **메시지를 서브클래스가 만들지 않는다** — 루트가 bounded 조각과 고정 라벨로 조립한다. ```java private static String describe(FailureCategory category, JpaFailureContext context) { // 등록된 operation name, 검증된 SQLSTATE, bounded identifier, enum, int, boolean 뿐. // row 데이터에서 유래할 수 있는 것이 하나도 없다. } ``` **(3) messaging의 3축** — `FailureCategory` + `PublishCompletion`(CONFIRMED/REJECTED/AMBIGUOUS) + `TransmissionEvidence`. §3.3. **웹 표면 SQLState 매트릭스**(`failure` 패키지)가 (2)와 (1)을 잇는다. merge 규칙이 강경하다 — 서로 다른 contributor가 같은 exact SQLState를 등록하면 **코드가 같아도 startup을 실패시킨다.** last-writer-wins merge는 매핑 소유권 drift를 숨기기 때문이다. 미지의 SQLState는 `Optional.empty()`이고 **translator는 `DB_*` 코드를 절대 지어내지 않는다** — 원본 예외를 web catch-all까지 전파시켜 detail 누설 없는 generic `INTERNAL`로 답한다. ### 5.2 관측 — 태그를 유한하게, 그리고 그 대가 `JpaMetricTags`는 **다섯 개** 태그뿐이다: `persistence.unit`, `persistence.operation`, `persistence.query`, `outcome`, `failure.category`. 없는 것이 요점이다 — 엔티티 id 없음, tenant id 없음, SQL 파라미터 없음, 예외 메시지 없음. 검증이 registry가 아니라 **생성자**에서 일어나고, sanitize가 아니라 **reject**다 — sanitize하면 caller가 unbounded 값을 계속 넘기고 절대 눈치채지 못한다. `shared-contract`의 `ForbiddenMetricTags`가 같은 규칙을 프로젝트 전체에 건다. `request_id`가 baggage에는 허용되지만 metric label에는 금지되는 비대칭이 테스트에서 의도적으로 pin돼 있다. `jpa.transaction.completion.unknown`이 **자기 카운터를 갖는** 이유: > 사람이 봐야 하는 유일한 결과다: 다른 모든 실패는 **확실히 일어나지 않은** 트랜잭션이고, 이건 > **일어났을 수도 있는** 트랜잭션이다. 일반 실패율에 묻는 게 눈치채지 못하게 되는 방식이다. **그런데 messaging의 출하 publish 경로는 관측을 하나도 기록하지 않는다** (`19` §5.1). 자동설정이 6인자 생성자를 골라 `NO_OBSERVATION`을 주입하고, `MessagingMetrics`·`MessagingTracer`· `MessagingAuditSink`·`DefaultMessagingObservationConvention`이 전부 main 참조 0이며, 등록되는 것은 협력자 `MessagingRedactor`·`CardinalityGuard` 둘뿐이다. `DefaultMessagePublisher`의 해당 필드 javadoc이 그 상태를 정확히 예언한다: > `MessagingObservation` existed as a bean and no publish path called it, so the platform's own > metrics described nothing. It is a constructor argument rather than an optional decorator because > **an unobserved publish path is how "the dashboards were empty during the incident" happens.** 수정은 runtime-core에서 이뤄졌고, **조립이 그 수정을 되돌렸다.** 그리고 §3.3의 "예외 메시지를 버린다"와 합쳐지면 **진단 가능한 흔적이 남지 않는다.** ### 5.3 시작 검증기 — 법칙과 그 예외 모듈 18에서 확립한 법칙: **시작 검증기가 도는지 여부는 그 능력에 `.imports` 자동설정 루트가 있는지와 정확히 일치한다.** `app-bootstrap`의 runtime 검증기 12종은 전부 배선돼 있다(`RuntimeSafetyConfig` `@Bean` 7 · `SecretSourceConfig` · `MigrationStartupConfig`). `StartupFailures`(75줄)가 구조화 실패 로그의 단일 발생원이며 종료 코드까지 규정한다(STARTUP_VALIDATION_FAILED=78 · MigrationFailed=70). main 157 파일에 **고아 0** — 참조 0인 파일은 전부 `@Configuration` 루트 · logback 컴포넌트 · `spring.factories` 항목으로 설명된다. JPA 쪽 startup 가드: | 가드 | 하는 일 | |---|---| | `JpaDangerousConfigurationGuard` | `open-in-view=true`(local 편의 프로필 외), 스키마 변경 `ddl-auto` 거부 | | `JpaDataSourceProfileValidator` | resolved `DataSource`를 열어서 product/version 확인 | | `PostgreSqlVersionPolicy` | PostgreSQL 16/17/18만 | | `PersistenceVendorProdSafetyValidator` | prod에서 H2 거부 | | `HikariPoolConstraintValidator` | 풀 제약 | | `NotificationSchemaActivation` / `FileserverSchemaActivation` | capability 스트림 승격 확인 | | `PostgreSqlRuntimeRoleVerifier` | 런타임 롤이 DDL 못 함 (§4.1) | messaging 쪽은 **6개 시작 검증기**를 실제로 돌린다 (`19` §9.1) — `MessagingPrefixMigrationValidator` (죽은 세 네임스페이스) · `MessagingConfigurationKeyValidator`(적법 키를 settings record에서 **파생**) · `MessagingCredentialRequirementValidator`(production 한정) · `MessagingAdminDurabilityValidator` · Kafka/Rabbit `StartupProfileValidation` 2종. `MessagingConfigurationKeyValidator`의 근거가 특히 좋다: > A misspelt prefix is loud — the whole section is missing and someone notices. A misspelt key > inside a section that does bind is the opposite: the entry appears, the platform starts, and > **the one setting the operator came to change is the only one that did not take.** > `consumer.prefech: 64` is a throughput change that never happened. **법칙의 예외가 둘이다** — 루트가 있는데 검증기를 부르지 않는 경우: - `adapter-inbound-web`의 `WebPlatformStartupValidator` (`14` §44.2) - `grpc-spring-boot-starter`의 `GrpcPlatformStartupValidator` (`20` §3.1) 후자는 실행되지 않는 규칙이 **13개**다(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1). 클래스 javadoc이 13개를 고른 기준을 적는다: > Every rule here is a mistake **whose runtime symptom is either silence or a misattributed > failure**: a unary method with no deadline hangs until the client's, an unbounded executor turns > overload into unbounded latency, **trust-all in production reports TLS while providing none**, > reflection in production publishes the schema, and a keyed method without a ledger accepts > idempotency keys it cannot honour. **None of them fails a smoke test.** ### 5.4 admin plane — 가장 잘 조립된 게이트 messaging의 admin plane이 이 저장소에서 가장 잘 만들어진 조립이다 (`19` §8.1). ```java @ConditionalOnProperty(prefix = "app.messaging.admin", name = "enabled", havingValue = "true") ``` `matchIfMissing` 없음 — 기본 꺼짐. 그리고 세 가지가 정확하다: 1. **`DestructiveOperationGuard(false)`** — "an application runtime never holds an admin credential, so the guard refuses the operations that would need one." 2. **`DestructiveMessagingAdmin`은 의도적으로 bean이 아니다** — javadoc이 명시한다. **부재를 문서화한 것**이 이 저장소에서 드물다. 3. **비내구 journal + 시작 검증기 쌍**: ```java throw new MessagingConfigurationException("ADMIN_JOURNAL_NOT_DURABLE", "the destructive-operation journal in use (" + journal.getClass().getSimpleName() + ") is not durable, and profiles " + active + " include a production profile; supply an " + "AdminOperationJournal bean backed by the shared database (JdbcAdminOperationJournal) so " + "one approval cannot be executed twice across replicas or across a restart"); ``` 메시지가 **무엇을 공급해야 하는지 클래스 이름으로** 말한다. **그런데 가드만 켜고 서비스는 켜지 않는다** (`19` §8.2). `DefaultMessagingAdminService`· `HmacApprovalVerifier`·`TopologyValidationRuntime`이 main 참조 0이다. 부재 4건 중 하나만 문서화됐다. **실패 시나리오.** 사고 대응 중 redrive를 실행하려고 `app.messaging.admin.enabled=true`로 켠다. 부팅은 성공하고 가드·journal·durability 검증기가 올라온다. 그런데 `MessagingAdminService` bean이 없으므로 호출할 대상이 없다. `MessagingAdminDurabilityValidator`의 javadoc이 경계한 시점 — "the gap only shows up during the incident the operation was run to resolve, which is **the worst possible moment to discover it**" — 과 정확히 같다. ### 5.5 gRPC 구현 층의 원자성 (`20` §7) `grpc-policy`·`grpc-server`의 동시성·경계를 읽고 6건을 확인했다. 여섯 중 **넷이 같은 형태**다 — `Atomic*` 타입을 쓰면서 원자적 연산을 하지 않는다. **`GrpcAdmissionController.tryAdmit()`** — 조립되는 9개 bean 중 하나다. ```java int running = inFlight.get(); if (running < maxConcurrentCalls) { inFlight.incrementAndGet(); // 검사와 증가 사이가 열려 있다 ``` 경계에 있는 N개 스레드가 모두 같은 `running`을 읽고 모두 통과한다. 클래스 javadoc이 "under load"를 두 번 강조하는데, **부하 아래에서 지키라고 만든 경계가 부하 아래에서만 샌다.** `release()`도 `get() > 0` 후 감소라 카운터가 −1이 될 수 있고, 그러면 경계가 영구적으로 느슨해진다. `promoteFromQueue()`는 `inFlight`를 경계와 대조하지도 않는다. **`GrpcCredentialRotationManager`** — `AtomicReference`를 `get()`→`set()` 홀더로만 쓴다(CAS 0건, `synchronized` 0건). 동시 회전 시 한 세대가 `draining`에 오르지 못한 채 사라진다. javadoc이 그 경합을 이미 알고 있다 — "the usual reason for one is two rotators racing" — 면서 닫는 연산을 쓰지 않았다. **그리고 이것은 messaging이 이미 고친 결함이다.** `CredentialRotationContractTest`: > `resolve` was get → fetch → put → clear **with no synchronization**. Two callers rotating the > same credential both read the same old runtime and both fetched a replacement: **one replacement > was dropped from the map without ever being cleared — a secret left in memory that nothing owns**. **`GrpcSerializedStreamWriter`의 `DROP_OLDEST`** — 동기화는 정확한데(9곳) 바이트 회계가 틀렸다. ```java queuedBytes = Math.max(0L, queuedBytes - nextBytes); // nextBytes = 들어오는 메시지 크기 ``` 버려지는 것은 `dropped`인데 빼는 값은 새로 들어오는 메시지의 크기다. `GrpcStreamEnvelope`가 크기를 담지 않으므로 **알 방법이 애초에 없다.** 그리고 테스트는 sizer가 상수 `8L`이라 모든 메시지 크기가 같아 결함이 보이지 않는 구성이다. 나머지 둘: `GrpcStreamAdmission`(같은 TOCTOU + `perCaller` 맵 무제한) · `GrpcOutcomeReplay`(제거· TTL·개수 상한 없는 인메모리 저장소) · `GrpcCompletionReconciler`(요청 경로에서 동기화 없는 `ArrayList`). **정확한 참조 구현이 전부 같은 가족 안에 있다** — `GrpcRetryBudget`은 CAS 루프, `GrpcCancellationCoordinator`는 5개 메서드 전부 `synchronized`, `GrpcClientMessageDeduplicator`는 `endSession`으로 두 맵을 정리한다. **지식의 부재가 아니라 적용의 불균일이다.** **사이클 2 전수 통독이 더한 것 — 같은 형태가 둘 더 있고, 그중 하나는 더 무겁다.** `GrpcChannelRuntimeRegistry.rotate` 는 같은 파일의 `install` 이 `compareAndSet` 을 쓰는데도 조건 없는 `set` 을 쓴다. 두 회전이 겹치면 덮인 대체본이 `draining` 목록에 오르지 못해 배수도 회수도 되지 않는다 — 자격증명 쪽과 같은 형태다. `GrpcChannelRuntime.finishUnaryCall`·`closeStream` 은 `get() > 0` 후 별도 감소인데, 이 리프에서는 결과가 구체적이다. `quiescent()` 가 **정확히 0** 을 요구하므로 카운터가 음수가 되면 그 세대는 영원히 조용해지지 않고 `retireQuiescent` 가 결코 제거하지 않는다. 회전이 반복될수록 배수 목록이 자란다. 그리고 자격증명 회전의 더 무거운 절반이 통독에서 나왔다. ```java public void completeDrain() { State observed = state.get(); state.set(new State(observed.current(), null, null)); } ``` 읽기와 쓰기 사이에 회전이 일어나면 그 회전이 활성화한 세대가 지워지고 **이전 세대가 다시 현재가 된다.** 방금 교체된 자격 자재가 되살아난다는 뜻이고, 이 클래스의 존재 이유가 정확히 그 교체다. 수정은 `updateAndGet` 한 줄이다. (`grpc/grpc-policy` §17.2, `grpc/grpc-client` §17.1·§17.2) --- ## 6. Tests and verification coverage ### 6.1 실행한 것 | 대상 | 결과 | |---|---| | messaging 25 leaf `:test` 전량 | BUILD SUCCESSFUL 2m27s · classes=110 tests=851 failures=0 **skipped=0** | | gRPC 18 leaf `:test` 전량 | BUILD SUCCESSFUL 1m12s · classes=71 tests=579 failures=0 **skipped=0** | | gRPC 증거 레인 3종 (inProcess·netty·fault) | BUILD SUCCESSFUL · 7+9+9 · Netty는 실제 소켓 | | `:adapter:inbound:graphql:test` | classes=186 tests=1603 failures=0 skipped=0 | | `:adapter:inbound:websocket:test` | classes=91 tests=720 failures=0 skipped=0 | | `:adapter:inbound:grpc:test` + qualification | classes=8 tests=48 failures=0 skipped=0 | | `:app-bootstrap:test` | tests=1016 **failures=1** — 유일 실패는 환경 원인(`jq` 부재), 1,015 통과 | ### 6.2 실행하지 않은 것과 그 이유 컨테이너·브로커·DB·별도 서버가 필요한 레인: - `messagingCertificationTest` — Docker 필수. **의도적으로 skip 가드가 없다**(§6.4). - `grpcPerformanceTest` — 공유 러너 측정은 baseline이 될 수 없다. - JPA의 `jpaPlatformContractTest`·`MigrationTest`·`FailureTest`·`QueryPlanTest`·`SecurityTest`· `PoolContractTest` + readiness task 15종 — 실 PostgreSQL 필요. - mongo/redis/fileserver/objectstorage/httpclient의 Testcontainers 계열. - websocket 커스텀 레인 4종(nginx·brokerRelay·advanced·jetty). **이들이 통과한다는 것은 문서와 커밋된 manifest의 주장이고, 그중 messaging 인증만 CI가 강제한다**(§6.4). **사이클 2 정정 — JPA 태그 레인 다섯은 실제로 돌았다.** 위 목록은 사이클 1 시점이다. 사이클 2는 Docker가 있는 환경에서 `jpaPlatformContractTest`·`MigrationTest`·`FailureTest`·`QueryPlanTest`· `SecurityTest`를 `--rerun-tasks`로 실행했고 51 클래스 244 tests · 0 skipped · 0 failures · BUILD SUCCESSFUL in 3m 10s였다 (`EVD-115`, §A05 §135·§139). 리비전은 `a24ece9c`이고 HEAD가 아니다. 아직 돌지 않은 것은 compose 스택, readiness task 중 실패한 2건 (`postgresqlMigrationIntegrationTest` 단독 실행이 BUILD FAILED — `EVD-117`·`EVD-118`), 그리고 messaging 인증 레인이다. §13.2와 §14.1이 이 정정의 정본이다. ### 6.3 fail-closed 레인 규약 JPA 레인이 이 저장소의 표준을 세웠다. ```groovy failOnNoDiscoveredTests = true outputs.upToDateWhen { false } ``` > `failOnNoDiscoveredTests`가 여기서는 평소보다 중요하다: 아무것도 발견하지 못한 선택된 레인은 > **성공을 보고**하고, 조용히 돌기를 멈춘 계약 suite는 **통과하는 것과 구별되지 않는다.** Docker 부재도 skip이 아니라 **에러**다: ```java throw new IllegalStateException( "Docker is required for the PostgreSQL contract suite and is not available; this lane" + " fails closed rather than skipping, because a skipped contract reports success" + " for a database nobody tested"); ``` `PostgreSqlVersion.parseSelection("")`도 **에러**다 — "빈 PostgreSQL 매트릭스 선택은 빈 실행이 아니라 에러다." ### 6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인 이 저장소에서 유일하게 네 층을 모두 갖춘 게이트다 (`19` §2.3). ``` 레인이 파일을 쓴다 messagingCertificationTest → build/.../broker-certification-evidence.jsonl Gradle이 대조한다 verifyMessagingCertificationEvidence: ran ≠ shipped → GradleException (gitCommit·observedAt은 제거 후 비교, upToDateWhen{false}) CI가 게이트를 돌린다 messaging-certification.yml, src/messaging/** PR마다 가드를 일부러 안 단다 "a lane that skipped would report success for a broker nobody started" ``` 대조가 **양방향**이다 — 손으로 추가한 줄(`shipped - ran`)도, 기록되지 않은 실행 결과 (`ran - shipped`)도 실패시킨다. 그리고 커버리지를 손으로 유지하지 않는다. `CertifiedEvidence.knownGaps(adapter)`가 `all() − covered`로 **파생**하고, 빠진 시나리오를 이름과 이유까지 붙여 단언한다: ```java void aScenarioWithNoLineInTheManifestIsAGapRatherThanAnAbsence() { assertThat(CertifiedEvidence.knownGaps("messaging-kafka")) .as("a Kafka producer buffers before it learns a connection exists, so this stays unproven") .contains(NetworkFaultScenario.CONNECTION_REFUSED); assertThat(CertifiedEvidence.knownGaps("messaging-rabbit")) .as("no lane runs a fault scenario against RabbitMQ, so every scenario is a gap") .containsExactlyElementsOf(NetworkFaultScenario.all()); } ``` 등급도 boolean이 아니라 증거에서 파생된다: ```java public boolean hasLiveBrokerCertification() { return BrokerFailureMatrix.from(CertifiedEvidence.recorded()).hasLiveBrokerCoverage(adapter); } ``` > Read from the evidence rather than declared. **As a field it was a boolean an author set next to > the tier**, and RabbitMQ carried `true` while no fault scenario had ever been executed against it. ### 6.5 evidence manifest — JPA의 R1/R2 분리 `gradle/jpa-evidence.gradle`(917줄)이 active card 11개의 producer를 실행하고 **JUnit XML에서 exact selector와 executed/skipped/failure/error 수를 읽는다.** 각 manifest는 source revision/dirty digest, prerequisite manifest ID, **PostgreSQL image digest**, 드라이버/Hibernate/Flyway 버전을 담고 canonical JSON SHA-256 이름으로 생성된다. 후보 검증이 통과해도 **`attainedReadiness=R1`을 유지한다.** 각 manifest가 candidate profile, dirty source, 아직 R2가 아닌 prerequisite를 `readinessBlockers`에 보존해서 **후보 통과를 R2로 오인할 수 없다.** 진짜 게이트는 별도이고 clean revision + CI provenance + immutable image digest를 요구한다. 로컬 dirty worktree에서 `worktree-is-dirty` blocker로 **실패하는 것이 정식 동작**이다. ### 6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건 | 모듈 | § | 게이트가 실제로 하는 일 | |---|---|---| | 05 jpa | 52 | blocking release gate `collection-fetch-pagination`이 실제 위험을 증명하지 않음 | | 05 jpa | 132 | 선택된 base card `jpa-flyway-migration`의 producer가 현재 리비전에서 **실패** | | 05 jpa | 133 | base card 3개의 evidence tag가 **production code 없는 fixture**로 충족됨 | | 05 jpa | 125 | nightly workflow가 광고하는 세 가지 중 하나를 lane이 관측하지 않음 | | 06 mongo | 94 | 커버리지 gate 둘이 나란히 있고 하나는 **발화할 수 없음** | | 06 mongo | 95 | release gate가 실제로 차단하는 것은 hermetic test 3개, mongo용 CI workflow는 없음 | | 10 redis | 47 | "build gate"라 불리는 catalog drift 검사가 **어디에서도 실행되지 않음** | | 13 notification | 9 | 다중 타깃 검증을 확인한다는 테스트가 `hasMessageContaining("strategy")`로 **다른 가드**에 걸려 통과 | | 14 web | 48.1 | 크로스 스택 게이트가 검증하는 조립은 **픽스처의 조립** | | 18 bootstrap | 4.1c | 조건부 전송 게이트가 별도 레인에서만 돌아 **빨간 채로 방치된 이력**이 javadoc에 기록됨 | | 19 messaging | 3.7 | `everyRecordedScenarioIsALineTheCertificationLaneWrote`가 **같은 파일을 두 경로로 비교** | | 19 messaging | 9.3 | 문서 계약 테스트 단언 8개의 커버리지 밖에 **발견된 문서 드리프트 3건이 전부** | | 20 grpc | 3.2 | 릴리스 게이트가 읽는 증거를 아무도 생산하지 않음 (Gradle 태스크 0 · CI 0) | | 20 grpc | 3.3 | 증거 등급 모델(25개 테스트)이 `check` 밖 · CI 밖 | **JPA `jpaPlatformPoolContractTest`의 이름 변경 기록**이 이 유형의 정석적 수정이다: > `jpaPlatformPerformanceTest`였고, pool과 REQUIRES_NEW 압력을 **certify**한다고 기술됐고, > **나타나는 모든 곳에서 off가 기본인 boolean 뒤에** 게이트되어 있었다. 그래서 릴리스 게이트가 > **유일한 threshold assertion이 "threshold를 assert하지 않고 있다"인 레인**에 의존했고, > "certified"는 **어떤 latency나 throughput bound도 무언가와 비교된 적 없는 실행**을 기술했다. > > 프로퍼티는 사라졌다; 그 이름은 여기 일부러 반복하지 않는데, **주석 속의 이름이 다음 사람이 > 설정해 보려는 바로 그것**이기 때문이다. --- ## 7. 이 저장소에서 반복된 네 가지 형태 62개 leaf를 읽고 나서 남는 것이다. 개별 결함보다 이쪽이 값이 크다. 원본은 `99` §2–§5. ### 7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다 **20개 모듈 중 13개에 있다.** 저장소 자신이 이 형태에 이름을 세 번 붙였다: > `StartupProfileValidation`: "The Kafka, RabbitMQ and security validators **were all beans and > none of them was injected anywhere**: the context published a validator per broker and > **validated nothing.**" > > `DefaultMessagePublisher`: "the admission controller, access policy, runtime registry and > observation **existed as beans that no publish ever called.**" > > `RegisteredMessageCodecs`: "`MessageCodecRegistry` was **an interface with no implementation > anywhere** — declared, consumed by `DefaultMessagePublisher`, and satisfiable by nothing." | 모듈 | § | 무엇이 조립되지 않았나 | 규모 | |---|---|---|---| | 05 jpa | 23·69·78·91·116 | completion-evidence capability · runtime-role verification · byte quota · V8 atomic admin claim · vendor selector fail-fast | — | | 06 mongo | 49 | **subsystem 전체 미배선인데 켜는 flag는 startup 검사를 수행** | 497 파일 leaf | | 06 mongo | 32 | 문서가 지목한 deadline 메커니즘의 production 호출자 0 | — | | 07 identifier | 3 | 모듈의 존재 논거인 `UuidCodec`에 production 소비자 0 | 10 파일 leaf | | 09 objectstorage | 49 | APPLY를 켜는 설정은 있고 승인을 검증하는 bean은 없음 | — | | 10 redis | 6·15 | startup probe가 production에서 미실행 · SDK 진입점 둘에 구현 없음 | — | | 13 notification | 17.1 | "Every reveal is auditable"의 세 필드를 읽는 코드 0 | — | | 14 web | 8.1 | 스캔 제외 패키지의 **여섯 컴포넌트**를 두 자동설정 어느 것도 소유 안 함 | RFC 9457 23파일 | | 14 web | 16.1·20.1 | 용량 보호 41파일 · 멱등/durable 표면이 픽스처에서만 조립 | 41파일 | | 14 web | 32.2 | forwarded 헤더 Java 정책 **421 LOC** 미배선 | 421 LOC | | 14 web | 44.2 | `WebPlatformStartupValidator` 미실행 | — | | 16 graphql | 8.1·16.1·24.1 | 스키마 조립·해시 사슬 · 파서 한계 미설치 · **커서 서명 키를 요구하는데 서명하는 코드가 없음** | — | | 17 websocket | 4.1 | 세 안전 장치 호출자 0 | 약 90파일 | | 19 messaging | 3.4·3.5·4.3·4.5·5.1·5.2·7.1·8.2 | capability 거부 1/12 · 트랜잭션 validator · 스키마 호환성 · cloudevents · 관측 · ACL 자기점검 · outbox 체인 · admin 서비스 | 2,818 LOC 등 | | 20 grpc | 3.1·3.4 | 시작 검증기 13규칙 · 인터셉터 체인/어댑터 | 15종 | **원인이 세 갈래로 갈린다.** 세 경우의 조치가 다르다. **(a) 자동설정 루트 자체가 없다.** §5.3의 법칙이 여기서 나온다. **(b) 루트는 있는데 통제를 소유하지 않는다.** 모듈 14 §8.1(§3.1)과 모듈 20 §3.1이 이 경우다. `ProblemCatalog`는 빈이고 그것을 쓰는 `@RestControllerAdvice`는 빈이 아니다. **dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.** **(c) 조건이 영원히 만족되지 않는다.** 모듈 19 §7.1(§4.3)이 유일한 순수 사례다. Spring은 이것을 오류로 보고하지 않으므로 **"꺼져 있음"과 "조립될 수 없음"이 런타임에서 구별되지 않는다.** ### 7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다 §6.6에 14건의 표가 있다. 판정 기준 셋은 전부 저장소 자신의 문장에서 나왔다: 1. **"class existence is not composition evidence."** — `ConditionalTransportCompositionContractTest` 2. **"A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw."** — 같은 테스트 3. **"the matrix and the support document agreed with each other and with nothing that had executed."** — `CompatibilityMatrix`. **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다.** ### 7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다 | 모듈 | § | 조립된 장치 | 조립되지 않은 / 우회된 장치 | |---|---|---|---| | 06 mongo | 58 | 규칙 없는 TTL 선언 | 규칙을 가진 TTL 선언 (아무도 안 씀) | | 10 redis | 56 | NOSCRIPT 복구 5벌 중 4벌이 레지스트리를 안 지남 | 레지스트리를 지나는 1벌 | | 10 redis | 64 | 의미 어댑터 5종이 `CommandPolicyGuard`를 안 지남 | `CommandPolicyGuard` | | 13 notification | 25.1 | 클라이언트 제공 Web Push 엔드포인트가 쓰는 **약한 검사의 private 사본** | SSRF 가드 | | 13 notification | 17.2 | 프로덕션이 타는 오버로드 | Thymeleaf 예외 메시지 삭제 가드가 있는 오버로드 | | 14 web | 24.1 | 배선된 캐시 필터의 `no-store` | 조건부 읽기 경로(무력화) · 조정하려던 패키지(참조 0) | | 14 web | 32.1 | 클라이언트가 요청 식별자를 고르게 하는 다른 배선 필터 | "고를 수 없다"는 정책 | | 14 web | 32.2 | Nginx 설정의 forwarded 헤더 신뢰 판정 | Java 정책 421 LOC | | 19 messaging | 5.3 | 인가 거부를 `CONFIGURATION`으로 분류 | `DestinationAccessValidator`(`MessageAuthorizationException`) | | 19 messaging | 5.4 | `CredentialRuntimeRegistry`(동시성 계약 테스트 있음) | `CredentialRotationPlan`(참조 0) | | 19 messaging | 6.5 | **둘 다 조립됨** — `kafkaSeamProducer` · `messagingKafkaProducer` | — (MSG-015) | | 05 jpa | §17 P1 | `SpringPolicyTransactionPort` + `TransactionRetryBackoff` | `FullTransactionRetryCoordinator`(주입처 0) | | 05 jpa | §12.5 | `audit/AuditableEntity` | `auditing/AuditMetadata`(완성됐고 조립 안 됨) | **방향에 규칙이 있다.** 13건 중 11건에서 조립된 쪽이 더 약하거나 덜 정확하다. 이유는 §1.4가 설명한다 — 잘 만들어진 정책 객체는 프레임워크 표면 밖(95.5%)에 있고, 실제로 요청 경로에 놓이는 것은 프레임워크 표면 안(4.5%)에서 손으로 배선한 것이다. **변종: 원자적 타입을 쓰면서 원자적으로 하지 않는다** (`20` §7, §5.5). 두 장치가 아니라 **같은 문제의 옳은 해법과 틀린 해법이 한 가족 안에 공존**한다. ### 7.4 형태 D — 문서 드리프트, 그리고 그 방향 | 모듈 | § | 드리프트 | 방향 | |---|---|---|---| | 06 mongo | 4 | README의 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않음 | **과대** | | 07 identifier | 5 | 문서는 UUIDv7이라 말하고 생성되는 것은 v4 | **과대** | | 07 identifier | 6·7·8 | CLAUDE.md 의존성 서술 **세 항목 모두** 틀림 · README 사실 오류 3건 · 대는 두 가드 중 하나가 저장소에 없음 | **과대** | | 08 fileserver | 4 | README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean 8개 | **과소** | | 10 redis | 5 | README readiness 표와 build.gradle 주석이 실제 소스와 어긋남 | — | | 12 messaging-adapter | 4 | README의 `jackson-databind` 부재 주장이 현재 상태와 어긋남 | — | | 14 web | 40.2 | 리액티브 활성화 조건에 대한 `build.gradle` 서술이 코드와 다름 | — | | 16 graphql | 20.1 | 등급표 13행 중 한 행만 관측과 어긋나고 **방향이 표가 금지한 "높게 표현" 쪽** | **과대** | | 19 messaging | **6.3** | 지원 매트릭스가 Kafka `deduplicatedPublish`를 `O`로 적고 코드는 `false` | **과대** | | 19 messaging | 6.4 | 지원 매트릭스가 "모든 leaf build-only" — 실제 18/25 출하 | **과대** | | 19 messaging | 6.2 | Rabbit이 선택 불가라는 사실이 운영 문서에 없음 | **과소** | | 20 grpc | 3.6 | CLAUDE.md의 `grpc-discovery` 행이 UDS resolver를 빠뜨림 | **과소** | | 20 grpc | 2.6 | 지원 매트릭스가 "Not released / build-only"를 정확히 공시 | **일치** | | 05 jpa | §17 P3 | `docs/**` 5개 문서가 "exactly 19 leaf"라고 적음 (실제 62) | — | **과대 진술이 과소보다 위험하다.** 20개 문서에서 나온 P1급 문서 드리프트는 단 하나이고(`19` §6.3), 그것이 과대 방향이다. 이유는 그 문서를 읽은 팀이 **자기 코드를 생략하기 때문**이다: > `KafkaMessagingTransport` javadoc: "Declaring it true means `PublishDeduplication` is accepted > and silently does nothing — **the caller believes the broker is deduplicating and skips the > idempotency it would otherwise build.**" **문서 계약 테스트의 경계가 드리프트 위치를 예측한다** (`19` §9.3). messaging에는 doc rot를 막기 위한 `MessagingDocumentationContractTest`가 있고 단언 8개를 갖는다. 그 단언이 붙드는 것(등급 이름 · Kafka 버전 문자열 · 존재하지 않는 두 enum 상수)은 **전부 정확하고**, 붙들지 않는 것(capability 표 60칸 · runtime membership 문장 · 브로커 등급표의 "제한" 칸)에 **발견된 드리프트 3건이 전부** 있다. 우연이 아니다. 그 테스트의 javadoc이 좁은 단언을 고른 이유까지 옳게 적는다 — "Asserting on wording would make every edit a test failure and **the check would be deleted**." 결함은 좁게 고른 것이 아니라 **그 경계가 어디인지가 문서에도 테스트에도 적혀 있지 않다**는 점이다. ### 7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가 같은 저장소 안에서 모듈마다 공시 정도가 다르고, **그 차이가 P1 개수와 거의 정확히 반비례한다.** | 등급 | 모듈 | 공시 방식 | P1 | |---|---|---|---| | **완전 공시** | 20 grpc | 지원 매트릭스가 "Not released … build-only"와 미해결 게이트 입력 2건을 스스로 나열 | **0** | | **완전 공시** | 16 graphql | `CLAUDE.md`가 4등급을 정의하고 13행 중 **일곱을 스스로 강등** | **0** | | **완전 공시** | 18 bootstrap | 조건부 전송 게이트가 자기 이력(빨간 채 방치)을 javadoc에 기록 | **0** | | 부분 공시 | 17 websocket | `CLAUDE.md`가 `stomp` 8파일만 서술 — 90개 플랫폼 파일은 덮지 않음 | 0(하향) | | 부분 공시 | 19 messaging | 가족 `CLAUDE.md`가 MSG-015를 P0 미해결로 명시하고 자기 문장의 오류까지 정정 — **그러나 운영 문서는 미수정** | 1 | | **역방향 공시** | 14 web | README가 반대를 서술 | **6** | **그러나 공시가 조립을 대체하지 않는다.** 모듈 20은 P1 0이면서 P2 10건이다. ### 7.6 학습 전이 — messaging → grpc 같은 문제를 두 번 푼 흔적이 있고, 두 번째가 첫 번째에서 무엇을 가져왔는지 추적할 수 있다. | messaging이 배운 것 | grpc로 옮겨졌나 | 근거 | |---|---|---| | 애플리케이션이 플랫폼에 도달하는 bridge를 먼저 정하라 (MSG-015) | **✔** | `adapter-inbound-grpc`가 레지스트리상 이 가족을 볼 수 없음 (`20` §2.3) | | framework-free 계약 leaf | **✔ 더 강해짐** | `grpc-core-api/build.gradle`의 `dependencies {}`가 비어 있음 | | 전송 선택은 classpath 사고가 아니라 속성 | **✔** | 두 가족 모두 단일 master switch + fail-closed | | 등급은 boolean이 아니라 증거에서 파생 | **✘** | `GrpcReleaseEvidence` 5성분 전부 호출자 제공. 유일 생성 지점이 자기 테스트 | | 게이트를 CI가 돌려야 한다 | **✘** | Gradle 태스크 0 · 28개 워크플로 중 grpc 언급 0 | | 시작 검증기를 조립에 연결하라 | **✘** | `GrpcPlatformStartupValidator` main 참조 0 | **설계는 옮겨졌고 강제 배선은 옮겨지지 않았다.** 옮겨진 셋은 전부 **레지스트리와 build.gradle로 표현되는 것**이고, 안 옮겨진 셋은 전부 **Gradle 태스크와 CI YAML로 표현되는 것**이다. 전자는 문서에 적으면 다음 사람이 따라 하고, 후자는 적어도 따라 하지 않는다. --- ## 8. Confirmed problems ### 8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작 | # | 발견 | 위치 | |---|---|---| | 1 | **지원 매트릭스가 Kafka `deduplicatedPublish`를 `O`로 적고 코드는 `false`.** capability 표 60칸 중 유일한 불일치이고, 하필 12개 중 유일하게 실제 거부를 발생시키는 플래그다 | `19` §6.3 | | 2 | RFC 9457 계약 23파일이 출하 애플리케이션에 등록되지 않음 (§3.1) | `14` §8.1 | | 3 | 플랫폼 요청 컨텍스트가 서블릿에 생산자가 없고 리액티브에 익명 액터로 고정 | `14` §12.1 | | 4 | 용량 보호 계층 41파일이 자기 테스트 픽스처 안에서만 실행 | `14` §16.1 | | 5 | 멱등 실행 계층·durable-operation 표면이 픽스처에서만 조립 | `14` §20.1 | | 6 | 리액티브 절반 29파일이 어떤 출하 배포에서도 활성화 불가 | `14` §40.1 | | 7 | 크로스 스택 게이트가 픽스처의 조립을 검증 | `14` §48.1 | | 8 | mongo 출하 default 조합이 첫 write에서 예외 (§4.2) | `06` §23 | | 9 | mongo high-water mark가 재전달 이벤트를 삼켜 변경이 영구 소실 (§4.2) | `06` §67 | | 10 | mongo 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음 | `06` §75 | | 11 | `cause.getMessage()` 때문에 PII-safe logging 계약이 성립하지 않음 | `04` §4 | | 12 | notification consumer가 diagnostic failure를 authoritative failure로 바꿀 수 있음 | `04` §5 | | 13 | notification admin atomic claim contract가 service에서 사용되지 않음 | `03` §12.4 | | 14 | UUIDv7 계약과 실제 validation 불일치 | `01` §11 | | 15 | jpa: 상태·펜싱·쿼터 계열 다수 (§59.1·69·70·71·78·79·88·89·97·98·107·108·132) | `05` | > **사이클 2에서 P1 하나가 철회되었다.** 사이클 1의 11번 — "영구 TLS 실패가 재시도 가능한 > CONNECT로 분류" — 은 제품 결함이 아니라 테스트 픽스처의 듀얼스택 호스트명이 원인이었다. > 위 표는 그 철회를 반영해 15건이며, 근거는 §14와 `EVD-332`다. **#11의 재현이 특히 구체적이다** (`04` §4.3). support source와 README가 "logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다"고 주장하는데, `logFailure`가 `cause.getClass().getSimpleName() + ": " + cause.getMessage()`를 그대로 넣는다. 실제 probe: ``` 입력: RuntimeException("provider rejected recipient secret@gmail.com body=secret-body-content") 출력 WARN: error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content" ``` 기존 테스트가 통과한 이유는 fixture의 exception이 단순히 `"connection refused"`라서다 — **payload object가 직접 argument로 전달되지 않는다는 것만** 확인할 뿐 exception message를 통한 leakage를 검사하지 않는다. app-bootstrap의 `LogMaskingPatterns`도 arbitrary email/free-form body PII를 제거하는 규칙이 없고, README 스스로 regex masking을 **보증이 아니라 defence-in-depth**라고 적는다. ### 8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백 형태별로 §7에 정리했다. 가장 무거운 여덟: 1. **messaging 관측 경로 전체가 no-op** — 대시보드·감사·트레이스 모두 없음 (`19` §5.1, §5.2) 2. **messaging outbox/inbox 2,818 LOC이 만족 불가 조건 뒤** (`19` §7.1, §4.3) 3. **messaging 마이그레이션 스트림 미적용 + 적용 시 `V2` 중복 실패** (`19` §7.2, §4.3) 4. **capability 12개 중 거부하는 것 1개** — javadoc의 "fails loudly" 미성립 (`19` §3.4) 5. **gRPC 릴리스 게이트가 자기 증거를 읽지 않음** — messaging이 이미 닫은 모양의 재발 (`20` §3.2) 6. **gRPC 구현 층의 원자성 결함 4건** — `GrpcAdmissionController`(조립되는 bean) 포함 (`20` §7) 7. **JPA 재시도 구현이 둘이고 하나는 아무도 호출하지 않음** (`05` §17 P1) — 실제로 도는 재시도는 `COMMAND_SERIALIZABLE_REPLAY_SAFE` 정책에만 적용되고, `DefaultJpaRetryPolicy`의 6단계 순서· `IrreversibleSideEffectContext` 확인·`RetryBudget` elapsed 상한이 전부 **아무도 호출하지 않는 경로**에 있다. `support-matrix.md`는 "Full-transaction retry | Stable"이라고 선언한다. 8. **JPA 플랫폼 capability 대부분에 production 소비자가 없다** (`05` §17 P8) — `springdata` / `hibernate.*` / `postgresql.{write,lock,json,array,range,copy}` / `cache` / `envers` / `querydsl` 전부. 결정적 증거는 **같은 leaf 안의 두 스토어**(fileserver 25파일, notification 53파일, 합 350KB)가 JPA 플랫폼 타입을 **하나도 import하지 않고** 같은 문제(큐 클레임·정렬·충돌 판정)를 각자 다시 만들었다는 것이다. **사이클 2 전수 통독이 더한 P2 — 다섯.** 전부 미배선 블록 안쪽이므로 §8.3 의 등급 완화가 함께 적용되고, "조립되는 즉시 성립" 이라는 조건이 붙는다. 9. **gRPC 연산 원장의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다** — 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중이 INSERT 를 흉내 내 그 차이를 가린다 (`grpc/grpc-operation-ledger-jpa` §17.1) 10. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다** — 그 검증기가 유일한 소비자인 설정 키 넷(`transport`·`tls-enabled`·`trust-all-certificates`·`operation-ledger-enabled`)이 아무것도 게이트하지 않는다 (`grpc/grpc-spring-boot-starter` §17.1) 11. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다** — 방금 교체된 자격 자재가 다시 현재가 된다 (`grpc/grpc-policy` §17.2, §5.5) 12. **NATS 가 `deduplicatedPublish` 를 무조건 참으로 선언한다** — 프로파일에 중복 제거 창이 없으면 `Nats-Msg-Id` 를 보내지 않으므로 서버가 중복을 제거하지 않는다. 이 플래그는 부재가 예외를 만드는 유일한 능력이라 창 없는 목적지가 그 가드를 통과한다 (`messaging/messaging-nats-experimental` §17.1) 13. **Kafka 트랜잭션 검증기가 감싸이지 않았고, 능력은 무조건 참을 답한다** — 검증기는 `messaging-kafka` 가 소유하고 그것을 시작 시 부르는 배선은 스타터가 소유한다. 어느 쪽 문서도 혼자서는 이 사실을 말할 수 없다 (`messaging/messaging-kafka` §17.2, `messaging/messaging-spring-boot-starter` §17.2) 그리고 **진단 마스킹의 IPv4 전용 가정** 은 형태가 달라 따로 둔다 — `GrpcDiagnosticsRedactor.maskAddress` 가 IPv4 가 아닌 입력을 그대로 돌려주고, 스냅숏의 "마스킹되지 않은 주소" 검사가 결과==입력을 통과로 읽으므로 IPv6·호스트 이름·유닉스 소켓 경로가 전부 통과한다 (`grpc/grpc-advanced-diagnostics` §17.1). 사이클 2 가 철회한 P1 의 원인과 같은 계열의 가정이다. ### 8.3 심각도가 등급 때문에 낮아진 것 `runtime_memberships: []`인 27 leaf(729 파일 / main 479)의 미조립은 채택 시점 부채로 기록했다. - `adapter-inbound-websocket` — 약 90개 플랫폼 파일에 조립 지점 없음, 세 안전 장치 호출자 0 (`17` §4.1, P1→P2 하향) - gRPC 가족 18 leaf 전체 — P1 0건인 이유가 이것이다 (`20` §5). 다만 §7.1의 `GrpcAdmissionController`는 조립되는 9개 bean 중 하나이므로 채택 시 가장 먼저 청구된다. --- ## 9. Reusable criteria and rules 이 20편을 쓰는 데 실제로 쓴 규칙이다. 전부 저장소 자신의 문장에서 나왔거나 반복 관찰에서 굳었다. 원본은 `99` §9. 1. **`runtime_memberships`를 먼저 읽는다.** build-only leaf의 미조립은 오늘의 사고가 아니라 채택 시점의 부채다. (`17` §26.6 · `19` §1.1 · `20` §1.1) 2. **클래스가 로드된다는 것은 조립 증거가 아니다.** (`18` §4.1c) 3. **`@Bean`이 있다는 것도 조립 증거가 아니다.** 주입처를 확인한다. (`19` §3.5 · `20` §3.1 · `14` §8.1) 4. **`@ConditionalOnBean`은 만족 *가능성*을 확인해야 한다.** 뿌리 타입의 구현이 저장소 안에 있고 그것을 만드는 자동설정이 있는지. (`19` §7.1) 5. **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다.** 실행된 것과 대조한다. (`19` §6.6) 6. **아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다.** `check` 연결과 CI 워크플로를 확인한다. (`18` §4.1c · `20` §3.3) 7. **문서 계약 테스트가 있으면 그 단언 목록을 먼저 읽는다.** 드리프트는 그 경계 밖에 있다. (`19` §9.3) 8. **중복 장치를 찾으면 조립된 쪽이 어느 쪽인지 확인한다.** 13건 중 11건이 약한 쪽이었다. (§7.3) 9. **과대 진술 문서를 과소보다 먼저 고친다.** 과대는 팀이 자기 안전장치를 생략하게 만든다. (`19` §6.3) 10. **조립하는 쪽을 먼저 읽는다.** 정정 네 건 전부가 이 순서를 어겨서 생겼다. (§13) 11. **`Atomic*` 타입의 존재는 원자성의 증거가 아니다.** `get()`으로 비교하고 별도로 `increment`/`set` 하는 것은 `AtomicInteger`를 쓴 check-then-act다. (`20` §7) 12. **빠뜨림이 통과가 되는 게이트는 게이트가 아니다.** `JpaModuleBoundaryTest`의 카탈로그 **정확한 동등성**, `PersistenceEntityScanCoverageTest`, `failOnNoDiscoveredTests = true`, `verifyDocumentedLeafCount`의 트리 walk가 전부 같은 교훈이다. (`05` §1·§16) 13. **모르는 것은 일급 결과여야 한다.** `RetryDisposition.RECONCILE`, `TransactionResult.Indeterminate`, `WriteDisposition.UNDETERMINED`, `PublishCompletion.AMBIGUOUS`, `ReplicaLagMonitor.replayedThrough(): Optional`. "모른다"를 성공이나 실패로 접으면 그 정보가 영원히 사라진다. (`05` §16 · `19` §3.2) 14. **위험한 상태는 타입이 표현할 수 없게 만든다.** `completionUnknown && retryable` 거부, `PublishEvidence`의 compact constructor. 리뷰 규칙이 아니라 생성자다. (`05` §2.2 · `19` §3.2) 15. **이름은 값이 아니라 registry key다.** 쿼리·큐·업서트·JSON path·COPY 이름 전부. **이름이 statement를 선택하지, statement의 일부가 되지 않는다.** (`05` §16) 16. **path/identifier는 등록, value는 바인딩.** 파라미터로 바인딩할 수 **없는** 것만 registry로 고정한다. (`05` §7.5) 17. **시간은 DB에서, 그리고 락 이후에 읽는다.** (`05` §10) 18. **로컬 환경이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다.** `char(64)`와 `fs_cleanup_item` 두 사건이 `application-local.yml`을 PostgreSQL 기본으로 바꿨다. (`05` §8.5) 19. **계약 테스트는 SQL을 재타이핑하지 말고 어댑터가 실제로 돌리는 statement를 실행해야 한다.** "테스트 작성자와 어댑터 작성자가 쿼리에 대해 합의했다"는 아무도 필요로 하지 않는 속성이다. (`05` §11.2) 20. **버그를 고칠 때 왜 그 버그가 가능했는지를 코드 옆에 남긴다.** 이 저장소 javadoc의 상당량이 사후 기록이고, 다 읽고 나면 **같은 실수가 다시 들어오는 걸 막는 유일하게 작동하는 장치**로 보인다. (`05` §16) --- ## 10. Explicit project decisions 문서·javadoc에 근거가 명시된 설계 결정 중, 읽고 나서 **성립을 확인한** 것들이다. "왜 이렇게 했나"의 답이 코드 옆에 있는 것만 골랐다. ### 10.1 계약과 경계 | 결정 | 근거 문장 | 출처 | |---|---|---| | 계약 leaf는 framework-free | `grpc-core-api`의 빈 `dependencies {}` — 클래스패스로 강제 | `20` §2.1 | | 상태 코드는 미러링하고 번역은 한 곳이 소유 | "a failure context that names `io.grpc.Status` would put the transport inside the contract that exists to describe what the transport did" | `20` §2.1 | | 도메인은 리포지토리를 소유하고 플랫폼은 base repository를 만들지 않는다 | "모든 애그리거트가 강제로 통과해야 하는 generic API, 그리고 한 애그리거트의 요구가 전부의 동작을 조용히 바꾸는 단일 지점" | `05` §1·§5.1 | | 어댑터가 `@Transactional` 경계를 소유하지 않는다 | 유스케이스가 `TransactionPort`로 소유 | `03` §4 | | DB 드라이버는 벤더 패키지 밖에 나올 수 없다 | ArchUnit `PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` | `05` §1 | | 중립 port가 adapter 타입 의존을 대체한다 | GraphQL persisted-operation registry가 `OperationalRecordStorePort`에 의존 | `02` | ### 10.2 실패와 불확실성 | 결정 | 근거 문장 | 출처 | |---|---|---| | completion-unknown은 절대 retryable이 아니다 | 생성자가 그 조합을 거부. 이중 방어로 `forceCompletionUnknown` | `05` §2.2 | | 증거를 먼저 기록하고 결론을 나중에 고른다 | "lets an operator answer 'could the broker be holding this message?' from a stored result" | `19` §3.2 | | 커밋 모호성 규칙을 넓히지 않는다 | "운영자는 그 큐를 **읽지 않고 비우는 습관**을 배운다 — 그러면 중요했던 한 건이 나머지와 같이 지워진다" | `05` §3.5 | | `PropagationMode`는 셋뿐 | NESTED/SUPPORTS/NOT_SUPPORTED/NEVER는 "호출자의 작업이 트랜잭션 안에 있는지 자체를 조용히 바꾸기 때문에" 없다 | `05` §2.3 | | `READ_UNCOMMITTED`가 없다 | PostgreSQL이 READ COMMITTED로 취급하니 "프로파일이 DB가 제공하지 않는 격리를 주장하게 된다" | `05` §2.3 | | 미인식 SQLSTATE는 추측하지 않는다 | "미지의 상태를 직렬화 실패로 분류하면 재시도 코디네이터가 **이미 성공한 쓰기를 기꺼이 다시 돌린다**" | `05` §7.1 | | 예외 메시지를 버리고 타입만 남긴다 | "드라이버 메시지는 routing key, payload 조각, connection string을 담을 수 있고 `FailureDescriptor`는 로깅·export되도록 설계됐다" | `19` §3.2 | ### 10.3 조립과 활성화 | 결정 | 근거 문장 | 출처 | |---|---|---| | 마스터 스위치는 루트 하나가 소유하고 자식은 조건을 갖지 않는다 | "a bean added to any child next month is gated without anyone remembering to repeat a condition" | `19` §6.1 · `05` §14.1 | | 전송 선택은 classpath 사고가 아니라 속성 | "Nothing failed; the message simply went somewhere nobody chose." | `19` §6.1 | | "꺼짐"은 구조적이어야 한다 | 빈 없음, 소켓/풀/스레드 없음, 설정 미바인딩, 스키마 기대치 없음 | `05` §16 · `19` §6.1 | | 프레임워크 자동설정까지 막는다 | 프로젝트 조건만으로는 부족 — JPA/Flyway starter가 Boot의 import metadata로 자기 것을 기여한다 | `05` §14.1 | | 질문은 "JPA가 켜졌나"가 아니라 "관계형 커넥션이 필요한 capability가 있나" | 풀은 JPA의 사유물이 아니다 — outbox, JDBC idempotency, multi-instance lock, notification, fileserver가 전부 필요로 한다 | `05` §14.1 | | 클래스패스에 있는 것은 동의가 아니다 | experimental 게이트를 static이 아니라 **생성자 파라미터**로 받아 "컴파일러가 요구하는 인자를 잊을 수 없게" | `05` §13 | | admin plane의 파괴적 작업은 자동설정하지 않는다 | "an operator tool that needs purge or delete registers one itself, with an admin credential this runtime does not hold" | `19` §8.1 | ### 10.4 데이터와 경계값 | 결정 | 근거 문장 | 출처 | |---|---|---| | 인코딩 한도는 보고 기준이 아니라 할당 경계 | "a process-wide outage caused by one message" — `BoundedByteSink`가 한도를 넘기는 write에서 실패 | `19` §4.1 | | 기본 코덱을 "먼저 등록된 것"으로 고르지 않는다 | "a wire-format decision made by accident" | `19` §4.2 | | raw-bytes 코덱은 기본이 될 수 없다 | "인코딩을 선언하지 않은 모든 destination이 스키마 검증을 조용히 건너뛴다" | `19` §4.2 | | keyset 페이지네이션에 offset 필드를 두지 않는다 | "필드의 부재가 나중에 하나 추가되는 걸 막는다" | `05` §2.4 | | keyset에 total count도 page number도 없다 | 같은 predicate에 두 번째 집계 쿼리가 필요하고 "움직이는 데이터셋에서 그 숫자는 클라이언트에 닿기 전에 이미 낡았다" | `05` §2.4 | | 커서를 서명하는 이유는 기밀성이 아니라 무결성 | "서명 없는 커서는 클라이언트가 제어하는 정렬 상태이고 ... **접근 제어 우회**다" | `05` §2.4 | | JSONB 문서에 타입 메타데이터를 넣지 않는다 | "문서 안의 타입 메타데이터는 JSONB 컬럼을 **역직렬화 가젯**으로 만든다" | `05` §7.5 | | 범위는 두 컬럼이 아니라 range 타입 | "`[09:00, 10:00)`과 `[10:00, 11:00)`이 겹치는지는 **값이 아니라 bracket**에 달렸다" | `05` §7.5 | | tenant id는 절대 메트릭 태그가 되지 않는다 | 카디널리티가 정의상 unbounded이고 "텔레메트리 안의 tenant id는 그렇게 취급되지 않는 시스템 안의 고객 데이터" | `05` §13.2 | ### 10.5 증거와 게이트 | 결정 | 근거 문장 | 출처 | |---|---|---| | 등급은 boolean이 아니라 증거에서 파생 | "As a field it was a boolean an author set next to the tier" | `19` §6.6 | | in-process 결과로 전송 능력을 주장할 수 없다 | `GrpcEvidenceGrade.requireCertifies` — `CONTRACT` 등급의 `tls` 주장은 예외 | `20` §2.4 | | 성능 레인은 릴리스 게이트에 넣지 않는다 | "a measurement in the release gate is a flaky test on a shared CI runner" | `20` §2.5 · `05` §15.3 | | 인증 레인만 Docker 가드를 달지 않는다 | "a lane that skipped would report success for a broker nobody started" | `19` §2.3 | | 문서 부재는 후속 과제가 아니라 릴리스 차단 사유 | "the first person to meet it is the one who has to work it out at three in the morning" | `20` §3.2 | | 지원 수준은 추론이 아니라 선언 | "evidence suite가 돌지 않은 capability는 컴파일이 된다는 이유로 STABLE이 되지 않는다" | `05` §2.5 | | Repair는 모드가 아니다 | "Repair는 그 질문을 물을 수 없게 만들어서 답한다" — schema history를 다시 써서 증거를 지운다 | `05` §8.1 | | 예약 헤더 위조 방어 | "A forged `msg.id` corrupts **another** message's inbox deduplication" | `19` §6.8 | | actuator 엔드포인트는 읽기 전용이고 재식별 표면을 만들지 않는다 | "a diagnostic that is worse than absent, because it looks like an answer" | `19` §8.4 | | 설정 오타를 바인딩 섹션 **안에서** 거부하고 적법 키를 settings record에서 파생 | "the one setting the operator came to change is the only one that did not take" | `19` §9.1 | --- ## 11. Unresolved questions 각각 왜 답하지 못했는지 적는다. 원본은 `99` §10. **1. 컨테이너·브로커·DB가 필요한 레인의 실제 결과.** §6.2 목록. 이들이 통과한다는 것은 문서와 커밋된 manifest의 주장이고, 그중 messaging 인증만 CI가 강제한다. **가장 값싼 해소**: Docker가 있는 환경에서 `./gradlew jpaPlatformReleaseGate`와 `:messaging:messaging-kafka:verifyMessagingCertificationEvidence` 두 개를 돌리는 것. **사이클 2에서 절반 해소했다.** JPA 태그 레인 다섯은 `a24ece9c`에서 244 tests · 0 failures로 통과했다(`EVD-115`, §6.2 정정). 남은 것은 HEAD에서의 재실행, readiness task 2건의 실패 (`EVD-117`·`EVD-118`), messaging 인증 레인이다. **2. `sample-portfolio`의 내부** (279파일 · main 201 · 스테레오타입 50). 사용자 지시로 제외했다. 두 번째 런타임 조합이고 8개 leaf를 갖는다 — `adapter-inbound-web`, `persistence-jpa`, `objectstorage`(이 조합에만 출하), `identifier`, `domain-core`, `application-core`, `shared-contract`. 따라서 `14`·`05`·`09`의 판정 일부는 이 조합에서 다르게 나올 수 있다. **3. 런타임 관측.** 이 분석은 전부 정적이다. 부팅하지 않았고, 로그·액추에이터 응답·메트릭 시리즈를 보지 않았다. `evidence/browser`·`evidence/terminal`·`evidence/svg`가 비어 있는 이유다. **가장 값싼 해소가 여기 있다**: `19` §5.1(관측 경로 no-op)은 부팅 후 `/actuator/metrics`에 `messaging.*` 시리즈가 없다는 것으로 **1분 만에 확증**된다. 같은 부팅에서 `ConditionEvaluationReport`를 켜면 `19` §7.1(outbox 조건 사슬)도 함께 확정된다. **4. `@ConditionalOnBean` 사슬의 실제 평가 순서.** Spring의 조건 평가는 등록 순서에 민감하고, 정적 읽기로는 "이 조건이 만족될 수 있는가"까지만 판정했다. `05` §14.4가 기록한 사고 — `@ConditionalOnBean(DataSource.class)`가 **클래스 파싱 시점에** 평가되어 모든 실제 배포에서 false였고 여덟 빈이 조용히 사라진 것 — 가 이 질문이 사소하지 않다는 증거다. **5. 성능·용량 주장.** 어떤 모듈에서도 측정하지 않았다. `20` §2.5가 확인한 것은 성능 레인이 존재하고 기본 `test`에서 제외됐다는 사실까지다. `05` §15.3의 `jpaPlatformPoolContractTest`도 지금은 **행동 계약**만 검증하고 threshold를 갖지 않는다. **6. gRPC 도메인 로직 정확성.** `20` §7의 읽기는 초점이 동시성과 경계였다. `grpc-proto-contract`의 스키마 규칙 판정(3 main / 605 LOC), `grpc-codegen`의 매니페스트 해시 규약, `grpc-advanced-resilience`의 hedging 적격성·xDS 실패 정책, `grpc-advanced-compat`의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다. --- ## 12. Evidence index | | | |---|---| | `evidence/raw` | **297 파일** (번호 최대 268). 각 파일 헤더에 리비전·cwd·명령·원본 출력 | | `evidence/meta` | 6 파일 | | `evidence/browser` · `terminal` · `svg` | **비어 있음** — 정적 분석만 수행 (§11.3) | | `source-index.md` | **SRC 173행 · EVD 81행**. 각 행이 sub-scope ↔ 증거 파일 ↔ 판정을 연결 | | 모듈 문서 | §A01–`20`, 각 문서 말미에 커버리지 원장과 §검증 | | 교차 종합 | §A99 | | 상태 정본 | `state.json` — leaf 62개의 status · analysisFile · sourceRevision · coverage · notes 5건 | 증거 파일은 **원본 출력만** 담는다. 가공한 표는 전부 모듈 문서에 있고, 그 표의 각 숫자가 어느 증거 파일의 어느 줄에서 왔는지는 `source-index.md`의 해당 SRC 행이 기술한다. --- ## 13. Limits of this analysis **1. 정적 분석이다.** §11.3. **2. 컨테이너 의존 레인 — 사이클 2에서 일부 해소.** 사이클 1 시점의 제약이었다. 사이클 2는 이 컨테이너에 Docker가 있음을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 기반 lane을 실제로 돌렸다 — persistence-jpa·mongo·cache-redis 포함. 여전히 기동하지 않은 것은 compose 스택 자체다(§14, `EVD-334`). **3. `sample-portfolio` 제외.** 사용자 지시(2026-08-30). §11.2. **4. 리비전 이동을 겪었다.** 분석 도중 코드베이스가 `a24ece9c` → `21234e38`으로 이동했다. ``` git diff --stat a24ece9c..HEAD → 400 files changed, 40217 insertions(+), 4 deletions(-) 변경 경로: src/grpc* · modules.json(18항목 추가) · src/build.gradle · docs 15개 ``` 차이가 gRPC 가족 추가에 국한됨을 diff로 확인한 뒤 모듈 20으로 닫았다. 모듈 01~19가 다룬 경로는 변경되지 않았다. (`20` §0, `99` §8) **5. 정정 다섯 건이 있었다.** 이미 닫은 모듈로 되돌아가 판정을 바꾼 것 넷 + 분모 정정 하나. | # | 대상 | 언제 발견 | 정정 | |---|---|---|---| | 1 | `14` §8.1 | `15` grpc 분석 중 | "한 API가 두 에러 와이어 형식을 낸다" → "RFC 9457 23파일이 출하 애플리케이션에 등록되지 않는다" | | 2 | `17` §4.1 | `18` app-bootstrap 분석 중 | P1 → **P2**. `runtime_memberships=[]`가 기계 강제되는 build-only 등급임을 확인 | | 3 | `18` §4.1 | 자기 초안 검토 중 | "출하 스위치가 다섯보다 많다" → **결함 아님** + 더 좁은 §4.1b/§4.1c | | 4 | `14` §8.1 | 교차 분석 중 | "넘겨받을 자동설정이 작성되지 않았다" → 자동설정 둘은 **존재하고 import되며**, 협력자만 소유하고 여섯 컴포넌트를 소유하지 않는다 | | 5 | 분모 | 교차 분석 중 | `state.json` 44 스코프 vs 레지스트리 62 — 리비전 이동이 원인, 모듈 20으로 해소 | **전부 원인이 같다 — 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다.** 네 번째 정정이 특히 중요하다. 결함을 없애지 않고 **더 좁고 정확하게** 만들었고, 그 결과 권고가 "새 자동설정을 만든다"에서 "기존 자동설정에 `@Bean` 여섯 개를 추가한다"로 작아졌다. **6. 모듈별 읽기 깊이가 균일하지 않다.** 커버리지 원장의 `FULL_READ`/`STRUCTURAL_ONLY` 비율이 모듈마다 크게 다르다. `20`(gRPC)은 2026-08-31 보강으로 383 파일 중 전문 판독 **45개**가 됐고 (`20` §7, 동시성·경계 초점) 그 결과 P2가 4건에서 10건이 됐다. 남은 미독은 **도메인 로직 정확성**이다(§11.6). 각 모듈의 실제 비율은 그 문서의 커버리지 원장이 정본이다. **7. P1/P2/P3 총계 — 사이클 2에서 제시한다.** 사이클 1은 표기 형식이 갈려 기계 집계가 중복된다는 이유로 총계를 내지 않았다. 사이클 2는 두 형식(모듈 findings 표 · §17 `### P` 헤딩)을 모두 파싱하고 문서 내 중복을 제거해 **422건**(P1 29 · P2 135 · P3 258)을 얻었다. 다만 이 총계는 정본 목록만 센 것이고, 본문 산문 안의 언급은 포함하지 않는다. 모듈별 확정 수치는 여전히 각 문서의 §발견 종합이 정본이다. 그 뒤 23개 리프를 다시 읽으면서 그 몫이 60→100 으로 늘었고, 총계를 **462건**(P1 30 · P2 147 · P3 285)으로 조정했다. **조정은 델타이지 재측정이 아니다** — 23개 리프의 100건은 직접 셌고, 나머지 38개 문서는 이번 재작업의 대상이 아니어서 두 형식 파서를 다시 돌리지 않았다. 462는 422가 정확했다는 가정 위에 있다. --- ## 14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독 ### 14.1 18개 리프 재검증 사이클 1이 남긴 18개 리프 문서(`01`–`18`)를 HEAD에서 다시 검증했다. 전량은 §A99에 있고, 여기서는 이 정본 문서의 판정에 영향을 준 것만 옮긴다. **소스는 움직이지 않았다.** 18개 문서가 기준으로 삼은 `a24ece9c`와 HEAD `21234e38` 사이는 커밋 하나이며, 그 커밋은 grpc 블록과 공통 파일 둘만 건드렸다. 18개 리프 경로의 변경 파일 수는 전부 0이고, 공통 파일 둘도 이 18개에 영향이 없다 (`EVD-333`). 따라서 §1~§13의 서술은 HEAD에서도 유효하다. **lane을 다시 돌렸다.** 실패 5건 — httpclient 3 · fileserver 1 · app-bootstrap 1. 셋 다 실행 환경 결손이며 프로덕션 결함이 아니다 (`EVD-332`, `EVD-334`). | 리프 | 사이클 1 판정 | 사이클 2 재측정 | |---|---|---| | `08` fileserver | 환경(로케일) | 확인 — `LANG=C.utf8`에서 통과 | | `18` app-bootstrap | 환경(`jq` 부재) + 가드 비대칭 P3 | 확인 + 공백 보강 — 15개 레인 계약을 독립 경로로 검증 | | `11` httpclient | **P1 제품 결함** | **철회** — 픽스처의 듀얼스택 호스트명이 원인 | **철회된 P1.** 사이클 1은 `ApacheFailureClassifier`의 분기 순서를 읽고 "Apache가 TLS 실패를 `HttpHostConnectException`으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고 결론했다. 예외 사슬을 실제로 출력하면 그 사슬에 `SSLHandshakeException`이 없다. 원인은 `MockHttpServer.uri()`가 호스트명 `localhost`를 돌려주는데 이 컨테이너의 `localhost`가 `127.0.0.1`과 `::1` 양쪽으로 풀리고 `MockWebServer`는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가 첫 주소의 진짜 TLS 실패를 삼키고 마지막 주소의 연결 거부만 승격시킨다. 접속 호스트를 `127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다 (`EVD-332`). 남는 것은 두 가지다. 픽스처가 호스트명을 쓰는 것(P3), 그리고 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 `CONNECT`로 강등될 수 있다는 성질(P2/기록, 이 모듈에서 고칠 수 없다). **17/18은 확인, 1/18은 번복.** 이 비율이 사이클 1 문서에 대한 이 사이클의 측정치다. **finding 총계를 처음으로 제시한다.** 61개 COMPLETE 문서의 정본 목록만 파싱해 **422건** (P1 29 · P2 135 · P3 258)을 얻었다. 두 가지 표기 형식 — 모듈 findings 표, 그리고 §17 「손볼 것」의 `### P` 헤딩 — 을 모두 읽고 문서 내 중복을 제거한 수치다. ### 14.2 23개 리프 전수 통독 재검증과 별개로, 사이클 2 는 `FULL_READ_REQUIRED` 로 열려 있던 23개 리프(messaging 5 · grpc 18)를 닫았다. 그 리프들의 사이클 1 문서는 production 구현을 `STRUCTURAL_ONLY` 로 판정하고 파일 이름·LOC·`build.gradle` 주석으로 서술한 상태였다. **이 통독은 두 번 했고, 첫 번째는 통독이 아니었다.** 첫 판에서 23개 리프를 `FULL_READ_DONE` 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 리프마다 실제로 읽은 것은 일부였다. Coverage ledger 가 사실이 아닌 상태였으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한 분모는 이렇다. | 대상 | 리프 | main 파일 | main 줄 | test 파일 | test 줄 | |---|---:|---:|---:|---:|---:| | grpc 계열 | 18 | 260 | 18,726 | 73 | 10,719 | | messaging 계열 | 5 | 97 | 10,816 | 48 | 9,037 | | **합계** | **23** | **357** | **29,542** | **121** | **19,756** | 통독 후 `STRUCTURAL_ONLY` 잔여는 0 이고, 23개 SSOT 의 Coverage ledger 를 이 숫자로 다시 썼다. 23개 문서의 finding 은 **100건**(P1 2 · P2 31 · P3 67)이고, 그중 8건은 가족 문서(`19`·`20`)가 이미 기록한 것을 canonical 리프로 옮긴 것이다. **통독에서 처음 나온 것은 92건 — P1 2 · P2 25 · P3 65.** **앞선 판이 "P1 0" 이라고 적은 것은 부분 통독의 결과였다.** 다시 읽으니 P1 이 둘 나왔고, 둘 다 messaging 계열이며 둘 다 배선되는 경로 위에 있다. 1. **운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다.** `KafkaProfileValidator` 는 전송 보안 없는 운영 프로파일의 기동을 거부하고 테스트가 그 거부를 지킨다. 그런데 실제 조립되는 `KafkaProducer` 설정에는 `security.protocol` 이 없다 — Kafka 기본값 `PLAINTEXT` 다. 그 값을 만드는 `KafkaSecurityConfigurer` 의 production 호출자는 저장소 전역에서 0 이다. (`messaging/messaging-spring-boot-starter` §17.1) 2. **지원 문서가 `deduplicatedPublish` 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다.** (`messaging/messaging-kafka` §17.1) 그 아래 층위에서는 앞선 판의 성격 서술이 그대로 유효하다 — 새 사고가 아니라 이미 알려진 패턴이 **블록 안 어디에서 성립하는지** 를 찾은 것이다. 가족 문서는 "블록 전체가 미배선" 층위에서 멈췄고, 통독은 그 블록이 배선되더라도 성립하지 않을 것들을 찾았다. **§1~§13 에 대한 영향.** 앞선 판은 "없다" 고 적었다. **정정한다 — 위 두 P1 은 출하 경로 위에 있다.** `messaging-spring-boot-starter` 와 `messaging-kafka` 는 `app-bootstrap` 소속이므로(§2 의 미소속 27개 목록에 없다) grpc 블록과 달리 "켜면 성립하는" 층위가 아니다. 나머지 90건은 여전히 미배선 블록 안쪽이고, 그 부분에 대해서는 §8.2 와 §5.5 의 반영이 그대로 유효하다. **가장 무거운 셋(미배선 블록 안쪽).** 1. **`JpaGrpcOperationLedger.claim` 의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다.** 엔티티 식별자가 배정값이라 `save` 가 `merge` 로 가고, 파생 기본 키(`caller|method|keyHash`)가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 위반을 일으키지 않고 **커밋된 결과를 덮어쓴다.** 테스트 이중의 `save` 가 INSERT 를 흉내 내 그 차이를 가린다. (`grpc/grpc-operation-ledger-jpa` §17.1) 2. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다.** 읽기와 쓰기 사이에 회전이 일어나면 방금 교체된 자격증명이 다시 현재가 된다. (`grpc/grpc-policy` §17.2) 3. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다.** 그 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다. (`grpc/grpc-spring-boot-starter` §17.1) **통독이 만든 새 형태 셋** — 상세는 §A99 §3.5 · §3.2 · §3.7. | 형태 | 곳 | 요지 | |---|---:|---| | 원자 타입을 쓰면서 비교 후 교체를 하지 않음 | 5 | 정본이 같은 저장소에 둘 있다(`GrpcRetryBudget`·`GrpcHedgingBudget`) | | 선언되고 주입되지 않는 검증기 | **9** | 재통독이 다섯을 더 찾았다 — 아래 | | 능력 선언이 프로파일에서 파생되지 않음 | 3 | NATS 의 `deduplicatedPublish` 가 가장 무겁다 — 부재가 예외를 만드는 유일한 플래그다 | | **게이트라고 적힌 채 조립되지 않음** | 5 | 검증기 9 중 다섯. 문서가 "이것이 빌드를 실패시킨다" 고 단언한다 | | **선언만 있고 코드가 닿지 않는 project 의존** | 6 | 의존 그래프가 코드보다 넓다 | | **테스트 이름이 검사하지 않는 것을 검사한다고 말함** | 5 | 커버리지 지도가 틀린다 | **넷째 형태가 이번 재통독의 가장 큰 수확이다.** 앞의 "선언되고 주입되지 않는 검증기" 는 공백이지만, 이 다섯은 **오해**다 — 문서가 그 게이트가 돈다고 단언하기 때문이다. | 게이트 | 상태 | 그렇게 적은 곳 | |---|---|---| | `GrpcProtoContractValidator` | 코드 호출자 0 · 빌드 파일 어디에도 없음 | `buf.yaml` 과 `GrpcBufPolicy` 가 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다 | | `GrpcBufPolicy` 의 네 Buf 태스크 | 어떤 빌드 파일에도 없음 | javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다 | | `GrpcAdvancedModuleGuard.requireStableStarterIsClean` | 호출자 0 | javadoc 이 "a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다 | | `NatsJetStreamProfileValidator` | 코드 0 · 테스트 0 · 흔적은 javadoc `{@link}` 한 줄 | 전송 javadoc 이 "refuses the combination at startup" 이라고 적는다 | | `PulsarProfileValidator` | 자기 선언 한 줄 말고 저장소 전체에 없음 | 그 리프 SSOT 가 검증을 서술했다(이번에 정정) | 앞의 둘이 서로를 가리킨다 — `buf.yaml` 은 CLI 가 없으니 자바 검증기가 게이트라고 하고, `GrpcBufPolicy` 는 자바 검증기가 실제 게이트라고 한다. 두 쪽 다 상대가 게이트라고 말하고 어느 쪽도 실행되지 않는다. --- ## 부록 A. 모듈 문서 지도 | 문서 | 줄 | 대상 | leaf | 이 문서에서 | |---|---|---|---|---| | §A00 | 141 | 초기 sizing 스냅샷 | — | §1 | | §A01 | 215 | `domain-core` | 1 | §2.3 | | §A02 | 161 | `shared-contract` | 1 | §2.3 · §5.1 | | §A03 | 379 | `application-core` | 1 | §3.2 · §3.5 | | §A04 | 645 | `support` | 1 | §8.1 #12·#13 | | §A05 | **4,674** | `persistence-jpa` | 1 | §4.1 · §5.1–5.3 · §6.3·6.5 · §10 | | §A06 | 1,496 | `persistence-mongo` | 1 | §4.2 | | §A07 | 188 | `identifier` | 1 | §7.1 · §7.4 | | §A08 | 656 | `fileserver` | 1 | §4.4 | | §A09 | 793 | `objectstorage` | 1 | §4.4 | | §A10 | 933 | `cache-redis` | 1 | §4.4 · §7.3 | | §A11 | 716 | `httpclient` | 1 | §8.1 #11 | | §A12 | 379 | `adapter-outbound-messaging` | 1 | §2.4 | | §A13 | 1,062 | `notification` | 1 | §3.5 | | §A14 | 1,702 | `web` | 1 | §3.1 · §7.1 · §8.1 | | §A15 | 244 | `adapter-inbound-grpc` | 1 | §2.3 | | §A16 | 1,122 | `graphql` | 1 | §7.1 · §7.5 | | §A17 | 546 | `websocket` | 1 | §8.3 | | §A18 | 571 | `app-bootstrap` | 1 | §5.3 · §7.2 | | §A19 | 1,284 | messaging 가족 | **25** | §3.3 · §4.3 · §5.2·5.4 · §6.4 | | §A20 | 716 | gRPC 가족 | **18** | §3.4 · §5.5 · §7.6 | | §A99 | 364 | 교차 | — | §7 · §9 | **분석 단위와 문서 단위가 1:1이 아니다.** messaging 25 leaf와 gRPC 18 leaf는 leaf 경계를 넘는 계약(capability 선언 → validator → 인증 증거 → 지원 문서)이 실제 설계 단위라 각각 한 문서로 통합했다. `state.json`의 `scopes`가 leaf 단위 정본이고, 각 항목의 `analysisFile`이 담당 문서를 가리킨다. ## 부록 B. 자주 쓸 명령 ```bash cd src # 아키텍처 경계 ./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain ./gradlew verifyRuntimeModuleMembership verifyDocumentedLeafCount --console=plain # 플랫폼 가족 전량 ./gradlew $(python3 - <<'PY' import json,io d=json.load(io.open("config/architecture/modules.json")) print(" ".join(m["gradle_path"]+":test" for m in d["modules"] if m["id"].startswith("messaging-"))) PY ) --console=plain ./gradlew $(python3 - <<'PY' import json,io d=json.load(io.open("config/architecture/modules.json")) print(" ".join(m["gradle_path"]+":test" for m in d["modules"] if m["id"].startswith("grpc-"))) PY ) --console=plain # gRPC 증거 레인 (Docker 불필요, 실제 소켓) ./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \ :grpc:grpc-testkit:grpcNettyContractTest \ :grpc:grpc-testkit:grpcFaultTest --console=plain # 컨테이너 필요 — 이 분석에서 실행하지 않은 것들 ./gradlew jpaPlatformReleaseGate # 실 PostgreSQL 6레인 ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence # 실 Kafka + 게이트 ./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence ./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2 # SQL 안전 게이트 ./gradlew :adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety ./gradlew :adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures # §11.3의 값싼 확증 (부팅 1회로 P2 두 건 확정) # 1) app-bootstrap 부팅 후 /actuator/metrics 에 messaging.* 시리즈 부재 확인 → 19 §5.1 # 2) 같은 부팅에 debug=true 로 ConditionEvaluationReport 확인 → 19 §7.1 ``` ## 부록 C. 다시 읽는다면 이 순서 1. `src/config/architecture/modules.json` — leaf 정체와 runtime membership 2. 각 가족의 `CLAUDE.md` — 규칙과 계약 표 (`src/messaging/`, `src/grpc/`, `src/grpc-advanced/`) 3. `src/app-bootstrap/.../CaSkeletonApplication.java` — 스캔 경계와 `AUTO_CONFIGURED_PACKAGES` 4. 8개 `AutoConfiguration.imports` — 조립될 수 있는 것의 전체 집합 5. `shared-contract/api` → `domain-core` → `application-core` port — 계약층 6. `persistence-jpa/api` + `transaction` — 가장 밀도 높음 7. `messaging-core-api` + `messaging-runtime-core/DefaultMessagePublisher` — 단일 publish 경로 8. 각 leaf의 `build.gradle` + `config/**` + `gradle/*-evidence.gradle` — 검증 체계 9. `docs/**/support-matrix.md` — 무엇을 약속했는가 (그리고 §7.4로 대조) --- # 제2부 — 모듈 분석 전문 제1부는 이 부의 종합이다. 여기 실린 것이 근거이고, 분석하는 동안에는 `analysis/` 아래에 파일로 나뉘어 있었다. 파일이 아니라 이 문서가 정본이므로 그대로 옮겨 왔다 — 제목 수준만 내렸고 본문은 손대지 않았다. --- ## A00. project-overview > 분석 중에는 `00-project-overview.md` 파일이었다. 141줄. ### Project Overview > **이 문서는 분석 시작 시점의 sizing 스냅샷이다.** 최종 결과는 `final/document.md`, > 교차 스코프 종합은 §A99, 모듈별 확정 수치는 제2부 각 절의 > 커버리지 원장이 정본이다. 아래 파일/LOC 표와 bounded scope 목록은 갱신하지 않는다 — > 스냅샷으로서의 값이 그 정확성이기 때문이다. #### 분석 기준 revision - repository: `/shared/codebase/clean-architecture-backend-template` - 최초 기준선: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` (모듈 01~19) - **최종 기준**: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` — 분석 도중 커밋 `21234e38`("feat: grpc 기능 deep 구현", 2026-08-31)이 gRPC 가족 18 leaf를 추가했다. `git diff a24ece9c..HEAD` = 400 files / +40,217 / −4이고 변경 경로가 `src/grpc*` · `modules.json` · `src/build.gradle` · docs 15개뿐이어서 모듈 01~19는 영향받지 않는다 (§A20 §0, §A99 §8). - working tree: clean #### 최종 커버리지 | | | |---|---| | 등록 leaf (최종) | **62** (최초 스냅샷 시점 44) | | COMPLETE | **61** | | EXCLUDED | 1 — `sample-portfolio` (사용자 지시 2026-08-30) | | PENDING | 0 | | 모듈 문서 | §A01–`20` (20개). messaging 25 leaf는 `19`, gRPC 18 leaf는 `20`으로 통합 | | 교차 스코프 | §A99 | | 최종 문서 | `final/document.md` | **분석 단위와 문서 단위가 1:1이 아니다.** 아래 "분석할 bounded scopes" 표는 leaf마다 문서 하나를 계획했으나, `messaging:*` 25개와 `grpc:*`·`grpc-advanced:*` 18개는 leaf 경계를 넘는 계약 (capability 선언 → profile validator → 인증 증거 → 지원 문서)이 실제 설계 단위여서 각각 한 문서로 통합했다. `state.json`의 `scopes`가 leaf 단위 정본이고, 각 항목의 `analysisFile`이 담당 문서를 가리킨다. #### Build and module map - Gradle 9.0.0 wrapper 기반 멀티모듈 build이며 `src/settings.gradle`이 included build `build-logic`의 `ca.architecture-registry` settings plugin을 적용한다. - `src/config/architecture/modules.json`이 leaf 존재/경로/허용 project dependency/runtime membership의 SSOT다. - registry leaf 수: 이 스냅샷 시점 **44**, 최종 **62**. 숫자는 측정치이며 문서 규칙의 별도 SSOT로 취급하지 않는다 — `modules.json`이 SSOT다. | module | Gradle path | production files/LOC* | test files/LOC* | runtime membership | status | |---|---|---:|---:|---|---| | `domain-core` | `:domain-core` | 8 / 107 | 0 / 0 | app-bootstrap, sample-portfolio | COMPLETE | | `shared-contract` | `:shared-contract` | 56 / 2839 | 23 / 2706 | app-bootstrap, sample-portfolio | PENDING | | `application-core` | `:application-core` | 886 / 35798 | 136 / 19751 | app-bootstrap, sample-portfolio | PENDING | | `adapter-outbound-support` | `:adapter:outbound:support` | 5 / 107 | 1 / 89 | app-bootstrap | PENDING | | `adapter-outbound-persistence-jpa` | `:adapter:outbound:persistence-jpa` | 460 / 44594 | 141 / 12581 | app-bootstrap, sample-portfolio | PENDING | | `adapter-outbound-persistence-mongo` | `:adapter:outbound:persistence-mongo` | 353 / 23401 | 139 / 15416 | app-bootstrap | PENDING | | `adapter-outbound-identifier` | `:adapter:outbound:identifier` | 5 / 134 | 2 / 151 | app-bootstrap, sample-portfolio | PENDING | | `adapter-outbound-fileserver` | `:adapter:outbound:fileserver` | 79 / 12723 | 37 / 12043 | app-bootstrap | PENDING | | `adapter-outbound-objectstorage` | `:adapter:outbound:objectstorage` | 154 / 14966 | 49 / 6778 | sample-portfolio | PENDING | | `adapter-outbound-cache-redis` | `:adapter:outbound:cache-redis` | 316 / 33690 | 70 / 15245 | app-bootstrap | PENDING | | `adapter-outbound-httpclient` | `:adapter:outbound:httpclient` | 270 / 15887 | 97 / 8803 | app-bootstrap | PENDING | | `adapter-outbound-messaging` | `:adapter:outbound:messaging` | 35 / 4350 | 19 / 3670 | app-bootstrap | PENDING | | `adapter-outbound-notification` | `:adapter:outbound:notification` | 172 / 14749 | 61 / 10037 | app-bootstrap | PENDING | | `adapter-inbound-web` | `:adapter:inbound:web` | 428 / 29488 | 202 / 23500 | app-bootstrap, sample-portfolio | PENDING | | `adapter-inbound-grpc` | `:adapter:inbound:grpc` | 9 / 650 | 6 / 782 | none | PENDING | | `adapter-inbound-graphql` | `:adapter:inbound:graphql` | 409 / 26477 | 117 / 14745 | app-bootstrap | PENDING | | `adapter-inbound-websocket` | `:adapter:inbound:websocket` | 174 / 13426 | 74 / 10336 | none | PENDING | | `app-bootstrap` | `:app-bootstrap` | 160 / 15096 | 282 / 32724 | app-bootstrap | PENDING | | `sample-portfolio` | `:sample-portfolio` | 210 / 10429 | 63 / 7020 | sample-portfolio | PENDING | | `messaging-core-api` | `:messaging:messaging-core-api` | 86 / 3952 | 8 / 934 | app-bootstrap | PENDING | | `messaging-schema-api` | `:messaging:messaging-schema-api` | 11 / 635 | 3 / 263 | app-bootstrap | PENDING | | `messaging-schema-json` | `:messaging:messaging-schema-json` | 2 / 234 | 3 / 373 | app-bootstrap | PENDING | | `messaging-schema-avro` | `:messaging:messaging-schema-avro` | 3 / 356 | 4 / 463 | none | PENDING | | `messaging-schema-protobuf` | `:messaging:messaging-schema-protobuf` | 3 / 209 | 2 / 278 | none | PENDING | | `messaging-cloudevents` | `:messaging:messaging-cloudevents` | 4 / 243 | 1 / 162 | app-bootstrap | PENDING | | `messaging-policy` | `:messaging:messaging-policy` | 27 / 1744 | 4 / 1236 | app-bootstrap | PENDING | | `messaging-transport-spi` | `:messaging:messaging-transport-spi` | 14 / 783 | 4 / 586 | app-bootstrap | PENDING | | `messaging-runtime-core` | `:messaging:messaging-runtime-core` | 7 / 804 | 4 / 866 | app-bootstrap | PENDING | | `messaging-observability` | `:messaging:messaging-observability` | 10 / 847 | 6 / 827 | app-bootstrap | PENDING | | `messaging-security` | `:messaging:messaging-security` | 13 / 959 | 3 / 475 | app-bootstrap | PENDING | | `messaging-kafka` | `:messaging:messaging-kafka` | 36 / 3711 | 24 / 4087 | app-bootstrap | PENDING | | `messaging-kafka-share-experimental` | `:messaging:messaging-kafka-share-experimental` | 5 / 200 | 1 / 112 | none | PENDING | | `messaging-rabbit` | `:messaging:messaging-rabbit` | 22 / 2624 | 10 / 1727 | app-bootstrap | PENDING | | `messaging-reliability-api` | `:messaging:messaging-reliability-api` | 14 / 822 | 0 / 0 | app-bootstrap | PENDING | | `messaging-outbox-jdbc-postgresql` | `:messaging:messaging-outbox-jdbc-postgresql` | 19 / 2525 | 8 / 2140 | app-bootstrap | PENDING | | `messaging-inbox-jdbc-postgresql` | `:messaging:messaging-inbox-jdbc-postgresql` | 8 / 576 | 4 / 607 | app-bootstrap | PENDING | | `messaging-claim-check` | `:messaging:messaging-claim-check` | 7 / 424 | 3 / 333 | app-bootstrap | PENDING | | `messaging-admin-api` | `:messaging:messaging-admin-api` | 26 / 1619 | 1 / 147 | app-bootstrap | PENDING | | `messaging-admin-runtime` | `:messaging:messaging-admin-runtime` | 13 / 1263 | 6 / 1051 | app-bootstrap | PENDING | | `messaging-pulsar-experimental` | `:messaging:messaging-pulsar-experimental` | 9 / 676 | 2 / 414 | none | PENDING | | `messaging-nats-experimental` | `:messaging:messaging-nats-experimental` | 8 / 768 | 2 / 460 | none | PENDING | | `messaging-spring-cloud-stream-bridge` | `:messaging:messaging-spring-cloud-stream-bridge` | 7 / 516 | 2 / 295 | none | PENDING | | `messaging-spring-boot-starter` | `:messaging:messaging-spring-boot-starter` | 29 / 3590 | 12 / 2384 | app-bootstrap | PENDING | | `messaging-testkit` | `:messaging:messaging-testkit` | 15 / 1246 | 6 / 828 | none | PENDING | *파일/LOC 측정은 build output을 제외하고 Java/Kotlin/Groovy/proto/avsc/sql/yaml/properties/json 및 module build.gradle 계열을 대상으로 한 초기 scope sizing 값이다. 이후 각 bounded scope의 coverage denominator는 해당 분석에서 다시 확정한다. #### Dependency direction - registry상 `domain-core`의 허용 project dependency는 0개다. - `application-core`는 `domain-core`, `shared-contract`를 허용하며, adapter/runtime leaf들은 registry에 명시된 방향으로만 project edge를 가질 수 있다. - root `verifyCleanArchitectureDependencies`는 실제 `api/implementation/compileOnly/runtimeOnly` ProjectDependency 집합과 registry allowlist를 비교한다. #### Runtime entry points - registry가 인식하는 runtime composition은 `app-bootstrap`, `sample-portfolio` 두 개다. - `domain-core`는 두 composition 모두의 membership에 포함된다. 이는 런타임 closure 포함 계약이며, `domain-core` 자체가 framework entry point나 Spring bean을 가진다는 뜻은 아니다. - 각 composition의 실제 wiring/conditional activation은 해당 bounded scope에서 추가 추적한다. #### Persistence / messaging / external systems - 이 overview에서는 registry와 top-level structure만 확정했다. persistence, messaging, cache, object storage, HTTP client 등은 독립 leaf가 존재하며 세부 동작은 아직 분석하지 않았다. #### Test topology - 각 leaf의 테스트 소스 수를 초기 계수했다. `domain-core` 자체에는 Java test가 없고 `.gitkeep`만 존재한다. - 도메인 모델링 규칙은 `app-bootstrap`의 `CleanArchitectureTest`가 production classes 전체를 대상으로 cross-module ArchUnit 검증한다. #### Configuration and operational surfaces - top-level Docker Compose variants, `infra/`, runtime configuration이 존재하지만 아직 bounded analysis 전이다. #### 분석할 bounded scopes (계획 — 실제 문서 배치는 위 "최종 커버리지" 참조) 아래는 분석 시작 시점의 계획이며, 실제 산출물은 다음과 같이 통합됐다: | 계획 | 실제 | |---|---| | leaf 1개 = 문서 1개 (44개 문서) | 20개 문서 | | `analysis/19-sample-portfolio.md` | **EXCLUDED** (사용자 지시) | | §A20–`44`: messaging leaf 25개 각각 | **§A19** 하나로 통합 | | (계획에 없음 — 분석 도중 추가된 가족) | **§A20** — grpc 18 leaf | 계획 표 원본은 git 이력에 남아 있다. #### 아직 단정하지 않는 것 (분석 시작 시점의 목록) 아래는 이 개요를 쓴 시점의 미결 목록이다. 각 항목의 현재 상태를 병기한다. - 각 adapter의 실제 runtime activation 조건과 external system behavior → **해소**. 각 모듈 문서 §조립/활성화 절 및 `99-cross-scope.md` §2. - 각 messaging leaf의 production reachability와 experimental/stable 경계 → **해소**. `19-messaging-platform.md` §1.1(출하 18 / build-only 7)·§10.1. - persistence migration ownership과 provider-specific guarantees → **부분 해소**. `05`·`06`·`19` §7.2가 소유권과 미적용 스트림을 확정했으나, 컨테이너가 필요한 마이그레이션 IT는 실행하지 않았다(`99-cross-scope.md` §10.1). - project 전체가 모든 문서상의 architectural claim을 만족한다는 결론 → **부정**. 만족하지 않는 지점이 `99-cross-scope.md` §2~§5에 형태별로 정리돼 있다. --- ## A01. domain-core > 분석 중에는 `01-domain-core.md` 파일이었다. 260줄. ### domain-core 상세 분석 #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `domain-core` - canonical state `analysisFile`: §A01 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/domain-core` · Gradle `:domain-core` - registry `allowed_dependencies`: **`[]`** - registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]` - coverage ledger: `FULL_READ` **10** / `STRUCTURAL_ONLY` **3** / `EXCLUDED` **1** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- #### 분석 범위와 결론 상태 - **revision:** `a24ece9cf797f7ea647e33bf846b115208ed1ba5` - **Gradle path:** `:domain-core` - **registry source path:** `src/domain-core` - **allowed project dependencies:** 없음 - **runtime memberships:** `app-bootstrap`, `sample-portfolio` - **scope status:** COMPLETE 이 문서는 `domain-core` 자체의 모든 production Java와 module-local build/docs를 읽고, 이 모듈이 제공하는 식별자 계약과 도메인 stereotype marker가 실제로 어디서 소비되고 어떤 build-time rule로 강제되는지까지 추적한 bounded analysis다. `sample-portfolio`의 구체 도메인 모델 전체나 `app-bootstrap` 전체는 이 scope의 소유 대상이 아니며, reachability/enforcement를 증명하는 관련 부분만 읽었다. #### 1. Quantified scope map ##### Owned source - production Java: **7 files / 107 LOC** (`package-info.java` 2개 포함) - module build file: **1** (`build.gradle`) - module docs/instructions: **2** (`CLAUDE.md`, `README.md`) - dependency lock: **1** (`gradle.lockfile`) - Java tests: **0** - test placeholder: `.gitkeep` 2개 - production packages: `dev.caskeleton.domain`, `.identifier`, `.stereotype` - public domain contracts: `IdFactory`, `ResourceId` - runtime-retained markers: `AggregateRoot`, `DomainEvent`, `ValueObject` `build.gradle`의 `dependencies {}`는 비어 있다. production Java import도 `java.lang.annotation.*` 외 제3자/framework import가 없다. 따라서 현재 source shape 자체는 module instruction의 “pure domain layer”와 일치한다. #### 2. Coverage ledger | item/group | disposition | reason | |---|---|---| | `CLAUDE.md` | FULL_READ | module policy SSOT | | `README.md` | FULL_READ | explicit design rationale | | `build.gradle` | FULL_READ | declared dependency surface | | `gradle.lockfile` | STRUCTURAL_ONLY | inherited build/check/test tooling dependency lock; production dependency edge를 추가하지 않음 | | `identifier/IdFactory.java` | FULL_READ | public identity generation port | | `identifier/ResourceId.java` | FULL_READ | public resource-id contract | | root `package-info.java` | FULL_READ | domain package anchor | | `stereotype/AggregateRoot.java` | FULL_READ | modeling marker | | `stereotype/DomainEvent.java` | FULL_READ | modeling marker | | `stereotype/ValueObject.java` | FULL_READ | modeling marker | | stereotype `package-info.java` | FULL_READ | marker semantics | | two test `.gitkeep` files | STRUCTURAL_ONLY | no executable test content | | generated `build/` tree | EXCLUDED | source/build-output ownership evidence상 generated artifact; current source contract denominator에서 제외 | Owned source 기준 unclassified relevant item은 **0**이다. #### 3. 이 모듈이 실제로 소유하는 것 ##### 관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다 현재 `domain-core`에는 WorkLog 같은 실제 aggregate가 없다. 실제 샘플 aggregate/value object/event는 `sample-portfolio`에 있다. 이 모듈에 남은 production surface는 다음 두 종류다. 1. **식별자 추상화** — `ResourceId`, `IdFactory` 2. **모델링 표식** — `@ValueObject`, `@AggregateRoot`, `@DomainEvent` 따라서 “business concepts, entities, value objects…”를 둘 수 있는 계층이라는 정책과 달리, 현재 snapshot의 실제 contents는 skeleton 전반에서 사용할 **domain-layer contract/marker**에 가깝다. 이는 현재 source에 대한 관찰이며, 향후 실제 production domain type이 이 module에 추가되지 않는다는 뜻은 아니다. #### 4. Identifier contract ##### `ResourceId` `ResourceId`는 F-bounded generic marker이며 public surface는 `String value()` 하나다. README에 기록된 명시적 rationale은 실제 ID 구현이 `sample-portfolio`에 있으므로 `sealed permits`로 닫으면 `domain-core -> sample-portfolio` 역방향 의존이 생긴다는 것이다. 현재 registry에서도 `domain-core.allowed_dependencies=[]`이고, `ModuleRegistry`는 production module이 `sample-portfolio`를 allowlist에 넣는 것 자체를 거부한다. root `verifyCleanArchitectureDependencies`도 실제 project edge가 registry allowlist를 넘으면 실패한다. 따라서 **unsealed 선택의 이유는 문서와 build policy가 서로 일치한다**. 다만 `ResourceId.value()`의 Javadoc/README는 “36-character canonical UUID / RFC 9562 UUIDv7”를 계약처럼 서술하지만 interface 자체는 이를 검증하지 않는다. 실제 샘플 `WorkLogId`도 현재 regex로 `8-4-4-4-12` hex shape만 검사하며 UUID version nibble이 7인지, RFC variant인지 검사하지 않는다. property test `acceptsEveryCanonicalUuid`는 오히려 임의의 32 hex를 hyphenate한 모든 값을 허용한다고 명시적으로 검증한다. **관찰 결과:** “UUID-shaped canonical string”은 현재 검증되지만 “반드시 UUIDv7”이라는 더 강한 서술은 생성 adapter 경로에서는 성립해도 모든 `WorkLogId.of(...)` 입력 경로의 불변식으로는 강제되지 않는다. ##### `IdFactory>` `IdFactory`는 `newId()` 하나를 가진다. sample에서는 `WorkLogIdFactory extends IdFactory`로 specialization하고, `UuidWorkLogIdFactory`가 Spring `@Component` adapter로 구현한다. adapter는 `UuidCreator.getTimeOrderedEpochPlus1()`을 호출하고 application use case가 factory를 주입받는 구조가 확인된다. 즉 source dependency는 안쪽의 domain port를 바깥 adapter가 구현하는 방향이며, domain-core는 concrete UUID library/Spring을 모른다. `newId()` Javadoc의 “never-before-used”는 타입/저장소 확인으로 강제되는 보장은 아니다. 현재 adapter test는 연속 두 값의 distinctness와 1,000회 monotonic ordering을 확인한다. 이 표현은 생성 전략의 기대 계약이지 전역 uniqueness를 저장소와 대조해 증명하는 메커니즘으로 읽어서는 안 된다. #### 5. Stereotype markers와 invariants 세 annotation은 모두 `@Target(TYPE)`, `@Retention(RUNTIME)`, `@Documented`인 framework-neutral marker다. ##### `@ValueObject` marker 자체는 불변성을 구현하지 않는다. `CleanArchitectureTest`가 annotation 대상 또는 `..domain.vo..` package type에 public no-arg constructor가 없어야 한다고 강제한다. sample의 `WorkLogId`, `Period`, `WorkLogOwner`, `PosterId` 등이 실제 production consumer다. 따라서 marker의 의미는 **“이 annotation을 붙이면 ArchUnit guardrail의 subject가 된다”**는 build-time qualification이다. 실제 field 불변성/defensive copy 등 모든 value-object 속성을 자동 검증하는 것은 아니다. ##### `@AggregateRoot` sample의 `WorkLog`, `Poster`가 실제 production consumer다. `CleanArchitectureTest`는 `set.*` 이름의 method가 public이면 실패시킨다. README와 test description 모두 이 rule이 이름 패턴 밖의 mutator(`applyXxx` 등)는 포착하지 못한다고 명시한다. 따라서 이 marker는 aggregate consistency를 자동으로 보장하는 annotation이 아니라 **특정 위험 surface(public raw setter)를 정적으로 제한하는 qualification marker**다. ##### `@DomainEvent` sample의 `WorkLogReserved`, `PosterCreated/Archived/...` 등이 사용한다. ArchUnit은 annotation type이 record인지 검사하고 Kafka/Spring HTTP/JAX-RS package dependency를 금지한다. production `LiveEventStompBroadcaster`는 runtime reflection으로 event class가 `@DomainEvent`인지 검사하므로 `RUNTIME` retention은 ArchUnit 외 실제 runtime consumer에도 필요하다. transport-free rule의 forbidden package list는 exhaustive transport taxonomy가 아니다. test 설명 자체가 “새 broker/transport가 도입되면 list를 확장”해야 하는 구현상 한계를 명시한다. #### 6. Purity / dependency enforcement ##### source-level observation 현재 domain-core production code는 Java standard annotation API 외 외부 import가 없다. module `dependencies {}`도 비어 있다. ##### project-edge enforcement `src/settings.gradle`은 `ca.architecture-registry` settings plugin을 통해 `modules.json`을 읽고 각 registered leaf를 include/mapping한다. `ModuleRegistry`는 다음을 settings time에 fail-closed 검증한다. - root/module field set 정확성 - nonblank id/path - duplicate id/Gradle path/canonical source directory - source path가 repository root 밖으로 escape하지 않음 - runtime composition 값 - self dependency - unknown allowed dependency - production module의 `sample-portfolio` 허용 금지 root `verifyCleanArchitectureDependencies`는 이후 실제 Gradle project dependencies를 registry allowlist와 비교한다. 즉 domain-core에 project dependency가 추가되면 `allowed=[]`과 불일치해 verification failure가 된다. ##### class dependency enforcement `CleanArchitectureTest.DOMAIN_IS_PURE`는 `..domain..` classes가 Spring/JPA/Hibernate/Lombok/application/adapter/bootstrap/service/infra/presentation/cmd 계열에 의존하지 못하게 한다. 별도 `DOMAIN_HAS_NO_LOGGER`도 logging framework dependency를 금지한다. 중요한 구분은 이 ArchUnit rule이 **domain-core module만이 아니라 package name에 `domain`이 들어가는 production classes 전체**를 subject로 한다는 점이다. 따라서 sample-portfolio domain model도 같은 purity/modeling guardrail의 대상이다. #### 7. Runtime reachability / wiring `domain-core` 자체에는 Spring bean/configuration/entry point가 없다. Registry상 `app-bootstrap`, `sample-portfolio` 두 runtime composition에 membership이 있고, concrete consumers가 compile-time type/annotation으로 이 module을 참조한다. - `ResourceId`: application-core messaging contract 및 sample IDs에서 참조 - `IdFactory`: sample factory/use-case/identifier adapter에서 참조 - `AggregateRoot`: sample aggregate에서 사용 - `DomainEvent`: sample events와 websocket broadcaster qualification에서 사용 - `ValueObject`: sample IDs/value objects에서 사용 따라서 major public abstraction이 완전히 dead/unwired인 상태는 아니다. 반대로 `domain-core`가 runtime service를 직접 수행한다는 근거도 없다. #### 8. Success / failure mechanics 이 module의 runtime executable behavior는 매우 작다. annotation 자체에는 success/failure path가 없고, interface도 implementation을 가지지 않는다. 주요 failure mechanics는 **build-time architecture violation**이다. - forbidden framework/domain dependency → `DOMAIN_IS_PURE` - domain logger dependency → `DOMAIN_HAS_NO_LOGGER` - public no-arg value object → `VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR` - public `set*` aggregate mutator → `AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC` - non-record domain event → `DOMAIN_EVENTS_ARE_RECORDS` - enumerated transport dependency → `DOMAIN_EVENTS_ARE_TRANSPORT_FREE` - `id` field raw type not assignable to `ResourceId` → `NO_LONG_ID_PK` - project dependency not in registry → `verifyCleanArchitectureDependencies` - production -> `sample-portfolio` edge → settings registry validation and root dependency verification, plus cross-module ArchUnit rule #### 9. Tests as evidence ##### `:domain-core:test` 현재 module에는 executable Java test가 없으므로 이 task의 green result는 domain semantic behavior를 검증한 것이 아니라 **module compile/test task가 현재 build에서 정상 구성되고 완료됨**을 보여준다. 이번 실행 raw evidence는 `evidence/raw/003-domain-core-test.txt`에 보존했다. ##### `CleanArchitectureTest` 도메인 purity와 marker-specific rules의 실제 enforcement owner다. 별도 `app-bootstrap` test task로 실행했고 **BUILD SUCCESSFUL / exit code 0**을 확인했다. raw output은 `evidence/raw/004-clean-architecture-test.txt`에 저장했다. 이 테스트는 production class import option을 사용해 `dev.caskeleton` production class graph를 분석한다. ##### Sample ID tests `WorkLogIdPropertyTest`는 UUID **shape** invariant를 property-based로 검증하지만 UUIDv7 version/variant invariant는 검증하지 않는다. `UuidWorkLogIdFactoryTest`는 factory output canonical shape, pairwise distinctness, 1,000회 strict lexical monotonicity를 검증한다. 따라서 “factory가 time-ordered UUIDv7 generator를 사용한다”와 “어떤 ResourceId 입력도 v7만 허용한다”는 서로 다른 claim이다. #### 10. Explicit rationale vs inference ##### 문서로 명시된 rationale - `ResourceId`를 sealed로 만들지 않은 이유: sample module을 production core가 역참조하지 않기 위해서. - ID generation 책임(contract)과 concrete generation을 분리한 이유: domain purity 유지. - stereotype annotation을 둔 이유: brittle naming convention 대신 explicit declaration을 ArchUnit 기준으로 사용. - runtime retention 이유: ArchUnit/reflection reader가 annotation을 볼 수 있게 하기 위해서. - aggregate public setter rule 한계는 의도적으로 문서화되어 있음. ##### 분석 inference - 현재 domain-core는 구체 business model repository라기보다 skeleton-level domain modeling contract module의 성격이 강하다. 이는 현재 7개 production Java의 실제 내용에서 도출한 해석이다. #### 11. Improvement backlog ##### P1 — UUIDv7 계약과 실제 validation의 불일치 확인/정렬 **Fact:** `ResourceId.value()`와 README는 RFC 9562 UUIDv7을 서술하지만 `WorkLogId` regex와 property test는 version/variant를 가리지 않는 모든 canonical UUID-shaped hex 문자열을 허용한다. **Why it matters:** 외부/rehydration 경로에서 `WorkLogId.of()`로 non-v7 UUID가 들어가도 domain invariant가 거부하지 않는다. 생성 adapter가 v7을 만들기 때문에 정상 create path에서 가려질 수 있다. **Verification:** `WorkLogId.of("00000000-0000-4000-8000-000000000000")`가 현재 성공하는지 focused test로 고정하고, 계약 의도가 “shape only”인지 “v7 only”인지 결정한다. **Candidate options:** 1. 계약 문서를 “canonical UUID shape”로 낮춘다. 2. value object가 UUID version 7 + RFC variant를 실제 검증하고 property test를 수정한다. **Later record candidate:** OPEN QUESTION 또는 DECISION. 의도 확인 전 자동 refactor candidate로 단정하지 않는다. ##### P3 — `IdFactory.newId()`의 “never-before-used” 문구 정밀화 **Fact:** interface는 저장소 collision check를 요구하지 않고 sample test도 전역 uniqueness를 증명하지 않는다. **Why it matters:** API doc을 강한 guarantee로 읽을 가능성이 있다. **Verification:** identifier 설계 문서/역사에서 uniqueness 의미가 probabilistic UUID uniqueness인지 persistence-level uniqueness인지 확인한다. **Later record candidate:** REFERENCE 또는 OPEN QUESTION. #### 12. Limitations / exclusions - sample-portfolio business invariants 전체는 이 scope에서 분석하지 않았다. 위 consumer들은 domain-core contract의 reachability/guardrail 의미를 검증하는 데 필요한 부분만 읽었다. - `CleanArchitectureTest` 2,792라인 전체의 다른 architecture rules는 해당 future scope에서 분석한다. 여기서는 domain-core contract와 직접 연관된 rule bodies를 읽었다. - runtime composition closure 전체와 conditional startup wiring은 아직 분석하지 않았다. - 이 scope의 COMPLETE는 프로젝트 전체 COMPLETE를 의미하지 않는다. #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **9개** (main 5 · test 0 · 기타 4). ``` src/domain-core/build.gradle src/config/architecture/modules.json (domain-core 항목) main: src/main/java/dev/caskeleton/domain/identifier/IdFactory.java src/main/java/dev/caskeleton/domain/identifier/ResourceId.java src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java 기타: CLAUDE.md README.md src/build.gradle src/settings.gradle 해석되지 않은 인용 (4종) — 외부 타입·문서상 약칭 등: package-info.java modules.json evidence/raw/003-domain-core-test.txt evidence/raw/004-clean-architecture-test.txt ``` --- ## A02. shared-contract > 분석 중에는 `02-shared-contract.md` 파일이었다. 233줄. ### shared-contract 상세 분석 #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `shared-contract` - canonical state `analysisFile`: §A02 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/shared-contract` · Gradle `:shared-contract` - registry `allowed_dependencies`: **`[]`** - registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]` - coverage ledger: `FULL_READ` **82** / `STRUCTURAL_ONLY` **4** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- #### 분석 상태 - scope: `shared-contract` - source path: `src/shared-contract` - Gradle path: `:shared-contract` - source revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` - analysis cycle: 1 / normal - result: COMPLETE - registry dependencies: production project dependency 0 - runtime memberships: `app-bootstrap`, `sample-portfolio` #### 역할과 경계 `shared-contract`는 특정 도메인이나 Spring/Jackson/JPA 구현을 소유하지 않고 여러 adapter와 composition root가 공유하는 운영 계약을 보관하는 leaf module이다. `build.gradle`의 production dependency block은 비어 있으며, `CLAUDE.md`도 Java standard library only를 명시한다. 실제 production source에서도 Spring/Jackson/JPA type은 관찰되지 않았다. 이 모듈이 제공하는 계약은 단일 관심사라기보다 다음의 skeleton-wide boundary 묶음이다. - error taxonomy와 framework-neutral exception carrier - API response/bulk/pagination/long-running-operation shape - partial-update의 3-state `Patch` - `resource:action` permission value - provider-neutral edge rate-limit contract - metric naming/cardinality guardrail - traceparent/baggage/span-error seam - domain/business context propagation seam - compare-and-set operational record storage port - adapter master-switch parser - Redis semantic health snapshot projection - messaging envelope JSON Schema v1와 checked-in SHA-256 digest 따라서 이 module의 핵심 아키텍처적 의미는 "공통 유틸리티"가 아니라, 서로 다른 outer module이 한쪽 adapter의 type에 의존하지 않고 합의할 수 있는 중립 계약 지점이다. `OperationalRecordStorePort`의 실제 consumer인 GraphQL persisted-operation registry가 inbound adapter 자체의 저장소 interface를 선언하지 않고 이 중립 port에 의존하는 것이 그 방향성을 직접 보여준다. #### 주요 계약과 불변식 ##### Error contract `ApiErrorCode`는 code/category/httpStatus/retryable의 최소 표면을 제공하고 `OperationalError`가 registry mirror 역할을 한다. `Category`는 VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL의 10개 값으로 고정되어 있으며 테스트가 정확한 vocabulary를 pin 한다. `OperationalErrorTest`는 단순 enum 존재보다 category × retryable 의미를 강하게 검증한다. deterministic VALIDATION/AUTHZ/NOT_FOUND는 retryable=false이고, transient INTERNAL은 기본적으로 retryable=true이되 deploy-time/configuration/terminal 상태인 일부 code는 명시적 예외로 false다. `AUTH_KID_UNKNOWN`은 key rotation 중 JWKS refresh 가능성을 이유로 AUTH 중 유일한 retryable case로 pin 되어 있다. upstream 4xx 전체를 permanent/non-retryable로 분류하면서 408/429의 의미 차이가 남는다는 점은 source comment와 README가 이미 known edge로 기록한다. `DependencyFailureException`과 `PersistenceFailureException`은 `ApiErrorCarrier`를 통해 transport adapter에 stable error code를 전달하면서 raw cause/diagnostic message를 server-side 정보로 남긴다. `AdapterDisabledException`은 carrier를 구현하지 않고 별도 mapping 대상이다. ##### Response / operation contract `Envelope`, `BulkEnvelope`, `ResponseMeta`, `PageMeta`, `Operation`은 framework-neutral record/factory로 API shape를 전달한다. 여기서는 중요한 enforcement boundary 차이가 관찰된다. `Envelope.ok/failure`, `BulkEnvelope.allOk/partial`, `Operation.pending/succeeded/failed` factory는 문서의 정상 shape를 생성하고 테스트도 이 factory path를 검증한다. 그러나 canonical record constructor 자체는 success/data/error의 배타성, operation status와 result/error의 조합, pagination 범위 등을 검증하지 않는다. 따라서 이 규칙은 rate-limit value object처럼 intrinsic constructor invariant가 아니라 factory/adapter usage contract다. 현재 source와 test가 일치하므로 즉시 결함으로 분류하지 않지만, raw constructor가 외부 module에 public인 만큼 invalid shape 생성 가능성은 P1 hardening 후보로 남는다. `Patch`는 ABSENT / PRESENT_NULL / PRESENT_VALUE의 3-state를 명확하게 보존하며 absent에서 `value()`를 호출하면 실패한다. 이는 JSON Merge Patch 계열에서 "필드 미전송"과 "명시적 null"을 구분해야 하는 boundary를 framework type 없이 표현한다. ##### Permission `Permission`은 정확히 한 개의 colon으로 `resource:action`을 분리하고 trim/lowercase normalization을 수행한다. 테스트는 mixed case, surrounding whitespace, blank component, 0/2+ colon을 검증한다. 다만 source는 component 내부 character set을 제한하지 않는다. 즉 "lowercase colon-delimited"는 normalization 결과이지 `[a-z0-9-]+` 같은 strict grammar는 아니다. 현재 test 역시 이를 요구하지 않으므로 observed contract로만 기록한다. ##### Edge rate-limit contract 이 영역은 shared-contract 안에서도 가장 강하게 self-validating 된다. `RateLimitPolicy`, `RateParameters`, `RateLimitRequest`, `RateLimitDecision`, `RateLimitOutcome`, `RateLimitEvaluationDedupPolicy`가 생성 시점에 bounded representation과 arithmetic safety를 검증한다. - Lua exact integer range를 `9_007_199_254_740_991`로 제한한다. - sliding counter/token bucket fixed-point 계산에 scale `1_000_000`을 사용하며 중간 합/곱도 exact-range를 넘지 않게 검증한다. - window/cleanup/retry duration은 whole milliseconds만 허용하고 상한을 둔다. - policy id/revision, subject digest, evaluation id는 bounded regex로 제한한다. - raw edge identity는 `EdgeRateLimitSubject`에서만 잠시 존재하고 provider request에는 pseudonymous digest만 전달하도록 type/regex로 가드한다. - v1 failure policy는 `FAIL_CLOSED`만 허용한다. - unavailable / indeterminate / incompatible를 evaluated denial과 분리하여 transport/provider ambiguity를 숨기지 않는다. - response-loss replay dedup은 TTL, entry count, logical stored bytes를 동시에 제한한다. 별도 `edgeRateLimitContractTest` source set이 provider-neutrality와 bounded request semantics를 qualification lane으로 다시 pin 한다. ##### Metrics and tracing `MetricNaming`은 Micrometer-facing dot.case naming과 seconds/bytes/total suffix vocabulary를 framework dependency 없이 보존한다. `CardinalityBounds`는 bounded tag의 상한을 Java mirror로 제공하고, `ForbiddenMetricTags`는 request_id/user_id/raw URL/query/header/IP 같은 unbounded source를 metric label에서 금지한다. `request_id`가 baggage에는 허용되지만 metric label에는 금지되는 비대칭은 test에서 의도적으로 pin 되어 있다. `TraceParent`는 이 skeleton이 지원하는 strict v00 subset을 parse/render한다. lowercase hex, non-zero trace/span id, 2-byte flags를 검사하고 wrong version을 거부한다. `BaggageAllowlist`는 `tenant_id`, `request_id`만 보존하는 단순 parse/filter/render utility다. 이는 full W3C baggage grammar validator라기보다 propagation boundary allowlist다. `SpanErrorRecorder.NOOP`은 tracer library가 없는 기본 template에서도 outer adapter가 동일 seam을 호출할 수 있게 한다. ##### Domain context propagation `DomainContextPropagator`는 diagnostic MDC와 분리된 domain/business context channel이다. default `ThreadLocalDomainContextPropagator`는 plain `ThreadLocal`을 쓰고 implicit inheritance를 금지하며 `capture()/restore()`와 `wrap()`으로 명시적 hand-off를 수행한다. virtual-thread test는 wrap을 썼을 때 전달되고 쓰지 않았을 때 상속되지 않으며 scope close 뒤 worker context가 복원되는 것을 검증한다. 이 seam은 문서상 계획에 그치지 않는다. production reachability 검색에서 `app-bootstrap`의 `DomainContextConfig`, `AsyncContextTaskDecorator`, `AsyncExecutorConfig`, persistence-jpa audit adapter, sample composition config가 실제로 소비하는 것이 확인됐다. `DomainContextKey` equality/hash는 **name only**이고 read 시 요청 key의 `Class`로 cast한다. 동일 이름의 서로 다른 type key를 만들면 같은 slot을 공유할 수 있고 잘못된 type으로 읽을 때 `ClassCastException` 가능성이 있다. source javadoc이 name-only identity를 명시하므로 hidden implementation bug로 단정하지 않지만, 현재 test는 same-name/different-type collision을 pin 하지 않는다. P1 contract-hardening 후보로 남긴다. ##### Operational record store `OperationalRecordStorePort`는 durable operational state를 특정 inbound/outbound adapter에 종속시키지 않는 neutral CAS port다. record version 0은 absent를 뜻하며 compareAndSet/compareAndRemove의 expectedVersion이 lost update 방지 evidence 역할을 한다. GraphQL persisted-operation adapter가 이 port를 실제 production dependency로 사용하며, durable provider implementation 자체는 해당 inbound adapter에 들어있지 않다. `OperationalRecord`는 namespace/key non-blank와 version >= 0은 강제하지만 javadoc의 "bounded"라는 표현에 대응하는 길이/character limit은 source에 없다. 이는 문서와 constructor enforcement 강도의 차이이며 P2 확인 후보로 남긴다. ##### Activation and health snapshot `MasterSwitchParser`는 unset=false, true/false case-insensitive만 허용하며 `yes`, `1`, `on`, whitespace-padded value를 invalid로 처리한다. canonical+legacy가 동시에 있으면 값이 같아도 ambiguous로 실패하고 legacy-only는 replacement property를 반환한다. 이는 operator configuration을 permissive coercion하지 않는 fail-closed contract다. `RedisHealthSnapshotProvider`는 Redis client/connection/credential을 shared boundary로 새지 않게 role/capability/state/reason/semantic freshness만 projection한다. eviction policy는 runtime CONFIG 조회 증명이 아니라 configured expectation임을 enum 이름과 javadoc으로 명시한다. ##### Messaging envelope schema `contracts/messaging/envelope/v1.schema.json`은 Draft 2020-12 schema resource이며 envelopeVersion/eventId/contractId/payloadVersion/logicalDestination/aggregate/occurredAt/correlationId/contentType/payload를 required로 고정하고 top-level/aggregate에 `unevaluatedProperties:false`를 둔다. checked-in SHA-256은 `bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1`이다. `MessagingEnvelopeSchemaResourceTest`는 schema text 자체, identifier regex parity, Java int/long 경계 vector, strict UTF-8, exact digest를 JDK API로 검증한다. 이 테스트는 resource drift와 digest mismatch를 강하게 막지만 README가 명시하듯 실제 Draft 2020-12 validator interoperability나 broker runtime discovery를 증명하지는 않는다. #### Reachability / wiring evidence - `DomainContextPropagator`: app-bootstrap composition + async decorator, persistence-jpa audit adapter, sample composition에서 production use 확인. - `OperationalRecordStorePort`: inbound GraphQL persisted-operation registry에서 production use 확인. 이 방향성은 adapter-specific storage interface를 outbound가 구현하는 역방향 dependency를 피한다. - registry상 shared-contract는 다른 production project를 의존하지 않는 leaf이며 app-bootstrap/sample-portfolio runtime membership을 가진다. - rate-limit, response, error 등의 세부 consumer 전체는 각 adapter/application bounded scope에서 추가 분석할 대상이며 이번 scope에서는 representative reachability와 contract 자체를 완전 읽기 대상으로 삼았다. #### Verification 실제 실행 결과: - `./gradlew :shared-contract:test --console=plain` → BUILD SUCCESSFUL, exit 0 - `./gradlew :shared-contract:edgeRateLimitContractTest --console=plain` → BUILD SUCCESSFUL, exit 0 - source revision 확인: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` - `git status --short` → output 없음, working tree clean #### Coverage ledger 분모는 `src/main` 전체 파일, `src/test` 전체 파일, custom `edgeRateLimitContractTest` source, 그리고 module-level `CLAUDE.md`, `README.md`, `build.gradle`이다. - FULL_READ: 82 - main production/resource 55 - unit/contract test 23 - edgeRateLimitContractTest 1 - module policy/rationale/build 3 - STRUCTURAL_ONLY: 4 - production placeholder `.gitkeep` 3 - test placeholder `.gitkeep` 1 - EXCLUDED: 0 - UNCLASSIFIED: 0 따라서 selected bounded scope는 completion standard를 충족한다. #### Open questions / improvement backlog ##### P1 — response/LRO invariant enforcement boundary `Envelope`, `BulkEnvelope`, `Operation`, `PageMeta`의 문서상 valid shape가 factory tests에는 고정되어 있지만 public canonical constructor에서 강제되지 않는다. raw constructor 사용이 실제로 허용된 extension surface인지, 아니면 constructor-level validation으로 invalid state를 막아야 하는지 결정이 필요하다. ##### P1 — DomainContextKey same-name different-type collision key identity가 name only인 반면 retrieval은 requested type cast를 수행한다. 동일 name의 다른 `Class` key를 선언하는 것이 forbidden contract라면 creation-time collision 방지 또는 registry rule/test가 필요하고, 의도적으로 허용한다면 failure semantics를 문서화할 필요가 있다. ##### P2 — bounded operational record identifiers `OperationalRecord` javadoc은 namespace/key를 bounded라고 설명하지만 constructor는 blank 여부만 확인한다. provider key size/character-set 제한을 shared contract가 소유해야 하는지 확인이 필요하다. ##### P2 — permission component grammar permission은 colon segment 수, blank, normalization은 강제하지만 segment character grammar는 제한하지 않는다. registry SSOT가 더 좁은 grammar를 요구한다면 shared value object와 parity test가 필요하다. ##### P2 — messaging schema qualification boundary 현재 JDK-only test는 exact resource/digest/selected semantic vectors를 검증한다. Draft 2020-12 validator 호환성은 별도 qualification evidence가 필요하며 현재 module test 성공만으로 이를 주장해서는 안 된다. #### 다음 scope queue의 동일 active project를 유지하고 다음 PENDING scope인 `application-core`를 다음 실행에서 분석한다. #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **40개** (main 35 · test 2 · 기타 3). ``` src/shared-contract/build.gradle src/config/architecture/modules.json (shared-contract 항목) main: src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java src/main/java/dev/caskeleton/shared/error/Category.java src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java src/main/java/dev/caskeleton/shared/error/OperationalError.java src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java src/main/java/dev/caskeleton/shared/operation/Operation.java src/main/java/dev/caskeleton/shared/opstore/OperationalRecord.java src/main/java/dev/caskeleton/shared/opstore/OperationalRecordStorePort.java src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java src/main/java/dev/caskeleton/shared/request/Patch.java src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java src/main/java/dev/caskeleton/shared/response/Envelope.java src/main/java/dev/caskeleton/shared/response/PageMeta.java src/main/java/dev/caskeleton/shared/response/ResponseMeta.java src/main/java/dev/caskeleton/shared/security/Permission.java src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java src/main/java/dev/caskeleton/shared/tracing/TraceParent.java src/main/resources/contracts/messaging/envelope/v1.schema.json test: src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java 기타: CLAUDE.md README.md src/build.gradle ``` --- ## A03. application-core > 분석 중에는 `03-application-core.md` 파일이었다. 454줄. ### application-core 상세 분석 #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `application-core` - canonical state `analysisFile`: §A03 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/application-core` · Gradle `:application-core` - registry `allowed_dependencies`: `["domain-core", "shared-contract"]` - registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]` - coverage ledger: `FULL_READ` **1021** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- Status: COMPLETE Source revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` Analysis cycle: 1 (normal) #### 1. 분석 범위와 완료 기준 `application-core`는 `src/application-core` 하나의 Gradle leaf이지만 작은 use-case 모듈이 아니다. framework-free inbound use-case 계약, transaction/idempotency/outbox/inbox, cache/lease/lock, durable operation, realtime, object/file publication, fileserver, notification까지 애플리케이션 정책과 outbound port를 폭넓게 소유한다. 이번 분석의 source denominator는 `src/application-core/src` 아래 Java source 전부다. `src/main` 885개, `src/test` 136개로 총 1,021개이며 resource/non-Java source는 없다. `build/`의 generated output은 source coverage에서 제외했다. `CLAUDE.md`, `README.md`, `build.gradle`, downstream adapter/bootstrap/architecture-test source는 별도 source anchor로 읽었으며 1,021개 denominator에는 포함하지 않는다. | top-level package | production | test | disposition | |---|---:|---:|---| | cache | 29 | 5 | FULL_READ | | capability | 3 | 1 | FULL_READ | | command | 1 | 0 | FULL_READ | | fileexport | 2 | 0 | FULL_READ | | filepublication | 16 | 1 | FULL_READ | | fileserver | 166 | 38 | FULL_READ | | idempotency | 34 | 6 | FULL_READ | | inbox | 9 | 1 | FULL_READ | | lease | 14 | 2 | FULL_READ | | lock | 3 | 2 | FULL_READ | | messaging | 13 | 3 | FULL_READ | | notification | 415 | 51 | FULL_READ | | objectstorage | 88 | 5 | FULL_READ | | observability | 2 | 1 | FULL_READ | | operation | 11 | 2 | FULL_READ | | outbound | 1 | 1 | FULL_READ | | outbox | 28 | 9 | FULL_READ | | query | 1 | 0 | FULL_READ | | realtime | 14 | 0 | FULL_READ | | security | 7 | 2 | FULL_READ | | storage | 8 | 0 | FULL_READ | | transaction | 17 | 5 | FULL_READ | | usecase | 3 | 1 | FULL_READ | | **합계** | **885** | **136** | **FULL_READ 1,021 / UNCLASSIFIED 0** | 따라서 이 문서에서 `COMPLETE`는 “대표 파일을 샘플링했다”는 뜻이 아니라 source denominator 전체를 읽고 package별 contract/invariant/test/wiring을 분류했다는 뜻이다. #### 2. 모듈 경계와 빌드 의존성 **Observed.** `build.gradle`의 production project dependency는 `:shared-contract` 하나뿐이다. application-core가 Spring, JPA, Redis, Kafka, filesystem provider 같은 구현 모듈을 직접 참조하지 않고, 외부 구현은 composition root와 adapter가 역으로 이 모듈의 port를 구현한다. **Observed.** `CommandUseCase`와 `QueryUseCase`는 `UseCase.handle(I)`를 write/read intent에 맞게 타입으로 좁힌다. 자체적으로 transaction을 열거나 security interceptor를 실행하지 않는다. 실행 정책은 `@UseCaseCapability`에 별도로 선언된다. **Observed.** `@UseCaseCapability`는 runtime TYPE annotation이며 `transactionMode`, `idempotency`, `repositoryAccess`를 필수로 받고 `externalOutboundAllowed`, `sensitiveRead`, `bulkWrite`, `crossTenantAdmin`을 추가 선언한다. annotation 자체는 metadata에 불과하지만 `CleanArchitectureTest`가 concrete Command/Query use case에 annotation 존재를 강제한다. **Observed.** architecture fitness function은 다음 coherence를 직접 검사한다. - `READ_ONLY + READ_REPOSITORY`는 `TransactionPort.inRead`를 직접 호출해야 한다. - `WRITE + WRITE_REPOSITORY`는 `inWrite` 또는 `inRootWrite`를 직접 호출해야 한다. - `REQUIRES_NEW`는 `inNew`를 직접 호출해야 한다. - `repositoryAccess != WRITE_REPOSITORY`인 use case가 repository write verb를 직접 호출하면 실패한다. - `bulkWrite=true`는 `WRITE_REPOSITORY`를 요구한다. - mutating use case는 type-level `@RequiresPermission`을 선언해야 한다. - application/domain은 Spring Security에 의존할 수 없다. 이 enforcement에는 의도적으로 한계가 있다. ArchUnit의 direct-call 분석이므로 helper 뒤에 숨은 repository mutation/transaction call은 잡지 못하고, AOP self-invocation/non-bean path도 static rule만으로 보장하지 않는다. 이 제한은 테스트 설명 자체에 명시돼 있어 최종 계약의 일부로 봐야 한다. #### 3. authorization: permission과 object access를 분리한다 `AuthorizationPort`는 principal의 raw role/permission을 기준으로 “이 종류의 작업을 수행할 수 있는가”를 판정하는 framework-free PEP다. `AuthorizationPrincipal`은 role set을 defensive copy + unmodifiable로 만들고 null roles는 empty set으로 정규화한다. `AuthorizationDeniedException`은 Spring `AccessDeniedException` 대신 application-owned failure를 사용한다. object-level access는 별도 `ObjectAccessPolicy`가 담당한다. 같은 permission을 가진 사용자라도 ownership, membership, workflow state에 따라 특정 object 접근 결과가 달라질 수 있기 때문이다. `ObjectAccessDecision`은 denial에 stable code를 요구하고 `hideExistence`를 별도 boolean으로 보존해 transport가 403/404 disclosure 정책을 추측하지 않게 한다. **Historical evidence.** `ObjectAccessPolicyTest`에는 이 계약이 과거 inbound GraphQL adapter에 있었고 GraphQL request context를 signature에 포함해 application-core가 구현하려면 transport에 역의존해야 했던 문제가 기록돼 있다. 현재 regression test는 policy/request/decision signature에 `dev.caskeleton.adapter.*` 타입이 다시 등장하면 실패한다. 이 프로젝트에서 “여러 호출자가 공유해야 하는 계약을 inbound adapter가 소유하면 Core가 Adapter에 의존하게 된다”는 문제가 실제로 있었던 근거다. `decideAll()`의 default는 요청 순서를 보존하지만 object마다 `decide()`를 호출한다. set-based authorization을 제공하는 구현체가 override하지 않으면 batched loading 안에서 authorization N+1을 다시 만들 수 있다는 제한도 계약에 명시돼 있다. #### 4. transaction: framework vocabulary 대신 application semantic policy `TransactionPort`는 `inWrite`, `inRootWrite`, `inRead`, `inNew` 네 개의 framework-neutral boundary를 노출한다. `PolicyTransactionPort`는 기존 surface를 깨지 않고 `TransactionRequest -> TransactionResult` 정책 기반 API를 추가한다. `TransactionPolicyId`는 Spring propagation 숫자가 아니라 `COMMAND_DEFAULT`, `COMMAND_SERIALIZABLE_REPLAY_SAFE`, `QUERY_PRIMARY`, `QUERY_REPLICA_ELIGIBLE`, `OUTBOX_APPEND`, `INBOX_AND_HANDLER`, `MAINTENANCE_NEW`처럼 application semantic ID를 노출한다. `TransactionRequest` constructor는 read policy의 consistency allowlist, non-read의 readConsistency 금지, operationId-required policy의 stable id 존재를 fail-fast한다. `TransactionResult`는 commit 결과를 다섯 상태로 분리한다. - `Committed`: physical commit을 확인한 결과. - `Participating`: outer transaction에 참여했지만 아직 commit을 주장할 수 없는 결과. - `DeterminateRollback`: rollback이 확정된 실패. - `Indeterminate`: commit 여부를 확정할 수 없는 결과. - `CommittedWithPostCommitFailure`: commit은 됐지만 이후 operational cleanup이 실패한 결과. 이 algebra의 핵심은 “exception이 발생했다 = rollback”으로 단순화하지 않는 것이다. 특히 `Indeterminate`는 last observed transaction phase와 optional reconciliation reference를 보존하며, `CompletionResolution`은 `STILL_UNKNOWN`을 정식 상태로 둔다. 불확실한 commit을 임의로 NOT_COMMITTED로 가정해 use case를 재실행하는 것을 피한다. `OperationId`는 caller-owned opaque identity이며 1~128 printable non-whitespace ASCII로 제한된다. `ReconciliationReference`도 1~256으로 bounded/sanitized된다. **Historical evidence.** `TransactionCompletionResolver`는 과거 JPA transaction engine 옆에 있었지만 실제 commit 증거(고유 제약, business row, idempotency row, outbox row)를 해석하는 주체는 application/domain이어서 dependency direction이 뒤집히는 문제가 있었다. 현재 SPI는 application-core에 있고 adapter가 이 계약에 의존한다. resolver는 evidence read만 해야 하며 original use case 재실행을 금지한다. `IrreversibleSideEffectContext` 역시 persistence 쪽에서 application-core로 이동했다. use case가 email/payment/broker/object-storage 같은 rollback 불가능한 effect 직전에 `mark()`해야 retry coordinator가 해당 attempt를 재실행하지 않을 수 있기 때문이다. ThreadLocal marker는 in-transaction external I/O를 권장하는 장치가 아니라 아직 제거되지 못한 side effect에서 unsafe retry를 차단하는 fence다. ##### 4.1 Spring/JPA 구현까지 추적한 결과 `SpringTransactionPort`는 `PolicyTransactionPort`를 구현한다. write/read/root-write는 REQUIRED, independent write는 REQUIRES_NEW이며 모든 legacy template은 provider default가 아니라 READ_COMMITTED를 명시적으로 pin한다. read template만 read-only다. `inRootWrite`는 `TransactionSynchronizationManager.isActualTransactionActive()`를 transaction manager나 action 호출 전에 검사한다. focused adapter test는 ambient transaction이 있으면 action도 transaction manager도 호출되지 않음을 증명한다. 또 action 반환값은 physical commit 이후에만 caller에게 반환되고 commit failure 시 caller-visible result가 publish되지 않음을 검증한다. `Isolation` enum에는 READ_COMMITTED/REPEATABLE_READ/SERIALIZABLE이 있지만 `IsolationTest`는 stricter level routing이 아직 planned라고 명시한다. 따라서 enum vocabulary가 존재한다는 사실을 “현재 legacy TransactionPort에서 세 isolation을 선택할 수 있다”로 확대 해석하면 안 된다. #### 5. idempotency, inbox, outbox: uncertainty를 상태로 보존한다 ##### 5.1 idempotency 초기 contract는 scope + request fingerprint로 claim/replay를 제공하고, same key/different fingerprint를 conflict로 분리한다. completed result는 replay하고 in-flight는 bounded poll한다. 이 버전은 “DB operation의 효과가 이미 발생했지만 응답만 잃은 상태”를 충분히 표현하지 못한다. V2는 owner-safe CAS handle에 scope/token/attempt/revision/claimOperationId를 넣고 stale owner mutation을 거부한다. processing-start를 durable하게 확인하기 전에는 body를 실행하지 않으며 claim/start/completion의 unknown result는 inspect/reconcile 대상으로 남긴다. processing start 이후 ordinary RuntimeException은 효과가 없다고 증명할 수 없으므로 `EFFECT_UNKNOWN_ABANDONED` 쪽으로 분류되고 자동 replay 권한을 주지 않는다. 명시적인 `RetryableNoEffect`만 안전 재시도 근거로 취급한다. scope digest는 versioned keyed digest + operation code로 정규화되고 raw identity는 외부 surface에서 제거된다. lease/replay TTL과 owner token grammar도 bounded다. **Historical evidence.** V2 contract가 인접한 package에 중복 복제돼 구현체들이 서로 다른 nominal type을 참조한 문제가 있었고, singular contract를 유지하는 regression test가 존재한다. ##### 5.2 inbox Inbox contract는 same-store 처리와 owner-safe receive/process state를 모델링한다. `RECEIVED -> PROCESSING -> COMPLETED/RETRYABLE/DEAD` 상태를 가지고 ACK는 handler transaction commit 이후에만 가능하다. expired owner가 늦게 결과를 기록하는 것을 owner token/attempt/revision/operation identity로 막는다. acquire/processing uncertainty 역시 provider-neutral typed outcome으로 보존한다. ##### 5.3 outbox Outbox append는 caller write transaction에 참여하고 broker publish는 transaction 밖에서 수행한다. claim/status transition만 짧은 transaction으로 분리한다. publish outcome은 accepted/ambiguous/rejected/exception을 구분한다. payload validator는 append boundary에서 poison event를 미리 차단한다. legacy JSON payload는 256 KiB, depth 64 등 bounded parser contract를 갖고 control/trailing content를 거부한다. 과거 relay에서야 invalid payload를 발견해 batch를 독성화하던 문제가 이 boundary 이동의 근거다. accepted 후 `markPublished`가 실패하면 row는 IN_FLIGHT에 남고 lease expiry 뒤 reclaim되어 **중복 publish가 실제로 가능하다**. 테스트는 이 duplicate window를 숨기지 않고 증명한다. 따라서 이 outbox는 exactly-once가 아니라 at-least-once + downstream dedupe 모델이다. V2는 immutable event version/ordinal, DB-authoritative receipt retention/publication epoch/dispatch authority를 추가한다. claim batch와 lease가 bounded되고 owner-safe CAS가 사용된다. #### 6. durable operation: process-local future 대신 durable state machine `DurableOperation`은 PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/EXPIRED를 저장 가능한 state로 모델링한다. RUNNING은 lease가 필수이고 terminal state는 completion timestamp가 필수이며 terminal state가 lease를 유지할 수 없다. SUCCEEDED는 result reference, FAILED는 failure가 필수다. `DurableOperationStorePort`의 running-state mutation은 worker identity를 받아 stale worker가 takeover 이후 result/progress를 기록하지 못하게 한다. expired lease는 reclaim되어 PENDING으로 돌아가고 TTL이 지난 nonterminal record는 EXPIRED 처리된다. `SubmitDurableOperationUseCase`는 operation row와 outbox publish intent를 **같은 `inWrite` transaction** 안에 기록한다. broker를 직접 호출하지 않는다. 테스트는 outbox write failure 시 operation row도 rollback되어 둘 중 하나만 남는 상태를 막는 것을 검증하고, identical resubmission은 기존 operation을 반환하며 두 번째 outbox row를 만들지 않는 것을 검증한다. #### 7. cache, lease, lock: 동시성 완화와 correctness authority를 구분한다 ##### 7.1 cache `CacheAsideExecutor`는 fresh/negative hit, hard miss, stale, incompatible schema, provider unavailable을 명시적으로 구분한다. source load에는 key-local single-flight와 global source bulkhead를 함께 적용한다. in-flight key 수, waiter 수, source concurrency, admission wait, load deadline이 모두 bounded다. stale value는 hard expiry 이전이며 **classified transient failure**일 때만 fallback될 수 있다. permanent failure에는 stale을 반환하지 않는다. source load 중 invalidation이 발생하면 lookup 때 캡처한 `CacheWriteCondition`이 더 이상 일치하지 않아 이전 source result의 refill을 거부한다. 이는 invalidate 직후 늦게 끝난 source load가 stale value를 resurrect하는 race를 막는다. optional distributed refresh coordination은 soft lease로 한 pod만 refresh하도록 하지만 correctness lock은 아니다. owner는 lease 획득 후 cache를 재확인해 다른 pod가 이미 fill했다면 source를 호출하지 않는다. claim 결과가 indeterminate이면 **동일 attempt token으로 한 번만 재시도**한다. contender는 stale이 아직 valid하면 즉시 stale을 반환할 수 있다. `CacheSingleFlight`는 waiter timeout/interruption을 보존하고 완료된 flight를 제거한다. leader가 영원히 남아 key bound를 점유하지 않도록 monotonic deadline 이후 abandoned flight를 opportunistic reap한다. ##### 7.2 distributed lease V2 `DistributedLeasePort`는 caller가 provider send 전에 owner/operation token을 생성하고 acquire retry/inspection에서 동일 attempt를 유지하게 한다. response-loss uncertainty를 `Indeterminate`로 별도 표현한다. resource는 raw key가 아니라 versioned lowercase SHA-256 digest를 사용하고 toString은 token/digest를 redaction한다. `LeaseGuarantee`는 명시적으로 `EFFICIENCY_ONLY` 하나다. 즉 generic lease는 duplicate work를 줄일 뿐 correctness-sensitive write를 authorize할 수 없다. `LeaseWatchdog`도 renewal failure/unknown이면 work cancellation과 loss signal을 한 번만 발생시키는 bounded scheduler일 뿐 process pause/Redis failover를 correctness guarantee로 감추지 않는다. ##### 7.3 distributed lock `DistributedLockPort`도 문서상 efficiency lock이다. finite wait와 crash-safety TTL을 갖지만 DB constraint 같은 correctness authority를 대체하지 않는다. lock release는 protected transaction commit 이후에 수행해야 한다. timeout은 shared `OperationalError.LOCK_ACQUISITION_TIMEOUT`으로 매핑 가능한 application exception으로 표현된다. #### 8. messaging과 realtime은 provider/transport vocabulary를 밖으로 밀어낸다 messaging application contract catalog는 contract id, logical destination, schema resource, ordering, payload/envelope bounds, sensitivity, retry/requeue horizon 등 semantic 정보만 가진다. Kafka topic/provider runtime type은 public contract에 없다. validated integration event는 partition key, schema/content hash, catalog/binding revision 같은 immutable evidence를 보존한다. strict `messagingApplicationContractQualificationTest`는 normal test source set의 세 required class를 no-skip 조건으로 실행한다. 처음 digest property 없이 실행했을 때 `prepareMessagingContractEvidence`가 fail-closed로 거부했다. current source/archive, current application-core JAR, exact profile file의 SHA-256을 공급한 재실행에서는 **15 tests, 0 skipped, BUILD SUCCESSFUL**이었다. 즉 qualification은 단순 테스트 이름이 아니라 evidence provenance property까지 요구한다. realtime contract는 durable fanout과 ephemeral fanout을 분리한다. durable은 accepted와 delivered를 동일시하지 않고 stream+position dedupe/replay를 모델링한다. stale cursor는 resnapshot 요구로 분리된다. presence는 non-authoritative이며 TTL/heartbeat failure 시 empty로 degrade할 뿐 security 판단에 사용하지 않는다. logical channel은 WebSocket/STOMP 같은 transport 명칭을 소유하지 않는다. #### 9. storage/file publication: legacy 경로와 semantic 경로가 공존한다 `application.storage.ObjectStoragePort`는 raw object key/whole-byte 방식의 legacy contract이며 `forRemoval` 표시가 있지만 실제 production consumer가 남아 있다. sample poster upload, adapter/config, characterization test에서 사용되므로 dead code로 분류할 수 없다. 제거 시점은 날짜가 아니라 실제 migration/zero usage로 판단하도록 문서화돼 있다. `fileexport` 역시 raw filesystem path를 반환하는 opt-in legacy capability이며 `FilesystemCsvExportAdapter`/configuration을 통해 조건부 활성화된다. 반대로 `filepublication`은 logical destination, operation/reference/version, schema, row streaming/checkpoint, durability semantic을 provider-neutral 계약으로 만든다. CSV formula injection(`=`, `+`, `-`, `@`, tab, CR)을 reject하는 정책이 테스트로 고정돼 있고, raw Path/SFTP/fileserver 타입이 receipt surface에 나오지 않는다. #### 10. objectstorage: staged lifecycle, opaque identity, privilege separation semantic objectstorage API는 provider/filesystem type을 노출하지 않는다. object identity/reference는 prefix + check digit를 포함한 opaque routed representation이고 redacted rendering을 제공한다. tampered/cross-prefix reference를 거부한다. content I/O는 bounded pull/push callback context와 budget/cancellation/chunk contract를 사용하며 callback lifetime 밖에서 context를 재사용할 수 없다. zero-progress가 무한 loop로 이어지지 않도록 bounded 후 실패한다. lifecycle은 staged -> verified -> published를 분리한다. scanner verdict는 exact stage/version/operation/policy revision에 결합되고 publish/cleanup mutation은 exact-version/fencing을 요구한다. scanner 권한과 purge 권한은 분리돼 검증 주체가 임의 삭제까지 할 수 없게 한다. transient bearer grant는 URI/header를 redaction하고 TTL은 최대 24시간으로 제한한다. multipart part count는 1..10000이고 completion은 expected content identity를 요구한다. `FullContentIdentity`는 SHA-256 기반으로 ETag를 content identity로 오인하지 않는다. #### 11. fileserver: DB metadata와 physical content 사이의 실패 seam을 명시한다 fileserver는 application-core 안의 가장 큰 독립 orchestration 중 하나다. 핵심 contract는 “metadata transaction과 filesystem/object I/O가 원자적이지 않다”는 사실을 숨기지 않고 recovery model을 두는 것이다. ##### 11.1 upload/write fencing upload admission은 authorization을 quota/storage admission보다 먼저 수행해 denial이 side-effect-free이도록 한다. reservation metadata/session은 DB transaction에서 만들지만 staging physical object는 외부 작업이므로 실패 시 compensation/reconciliation 대상이 된다. writer는 one-writer lease + fencing token을 사용한다. stale token은 append/finalize를 진행할 수 없고 takeover는 새 token을 만든다. append 시 metadata offset과 physical length가 다르면 자동 repair하지 않고 conflict로 중단한다. finalize는 declared length, server-computed digest, optional client digest를 순서대로 검사한다. client digest는 server digest를 대체하지 않는다. 이후 VERIFYING으로 이동하고 verifier가 publish 승인해야 READY가 된다. READY가 유일한 public/downloadable state다. publish physical success 뒤 READY metadata transaction이 실패하면 결과는 단순 retryable failure가 아니라 `AmbiguousCompletionException`과 recovery queue로 간다. physical publish가 이미 발생했을 수 있기 때문이다. ##### 11.2 cleanup/recovery cancel/cleanup race를 막기 위해 cleanup claim은 writer/cleaner barrier를 형성하며 stale cleanup claim을 reclaim하는 경로가 실제로 호출된다. physical delete 전에는 terminal state, lease, exact size/digest/metadata key를 재확인한다. orphan reconciliation은 reference race를 다시 검사하고 retire/quarantine 후 physical purge를 분리한다. recovery 결과는 `CONFIRMED_SUCCESS`, `NOT_APPLIED`, `RECOVERABLE_PARTIAL`, `QUARANTINE_REQUIRED`, `UNRESOLVED` 등으로 unknown을 추측하지 않는다. READY인데 physical content가 없거나 digest가 불일치하면 정상으로 가장하지 않고 quarantine한다. ##### 11.3 download/security/HTTP semantics download authorization은 physical open보다 먼저 수행된다. HTTP precondition ordering을 명시하고 range 수를 최대 8개로 제한하며 overlap을 merge한다. malformed/unsatisfiable range는 typed 416 path로 분리되고 HEAD는 body를 열지 않는다. stored-XSS 위험이 있는 HTML/SVG/XHTML/JavaScript 계열은 attachment로 강제하며 filename sanitizer는 path/control/bidi/Windows reserved-name을 처리한다. observability는 raw file/path/user id 대신 HMAC fingerprint와 low-cardinality dimensions를 사용한다. #### 12. notification: logical acceptance, provider uncertainty, callback reconciliation notification은 application-core production 415개로 가장 큰 package다. public API, contact protection, routing/template, dispatch, callback, admin/operator plane까지 application-level semantics를 소유한다. ##### 12.1 public API와 secret boundary public contract는 arbitrary `Object`/`Map`를 허용하지 않고 sealed `NotificationVariable` algebra를 사용한다. 과거 mutable/arbitrary variable 때문에 serialization/fingerprint drift와 `toString` collision이 가능했던 것이 변경 근거다. structural test는 public API에 arbitrary Object가 다시 들어오지 않는지 검사하며, 과거 잘못된 test root로 vacuous pass했던 문제도 regression guard로 남아 있다. `NotificationPlan`은 exact template version을 pin한다. recipient/metadata/variable count/depth가 bounded되어 있고 receipt는 “durable logical acceptance”이지 provider delivery를 의미하지 않는다. contact point는 encrypted value + keyed fingerprint로 분리되고 protected contact rendering은 원문을 노출하지 않는다. template variable 자체에 reset token 같은 secret이 들어갈 수 있어 payload protection이 존재하며 decrypt 실패를 빈 값으로 degrade하지 않는다. contact lookup/provider request/callback fingerprint는 HMAC purpose를 분리해 동일 secret-purpose reuse를 피한다. ##### 12.2 routing과 dispatch routing은 explicit 또는 ordered fallback이며 parallel-first-success가 없다. 두 provider를 동시에 호출하면 irreversible duplicate를 만들 수 있기 때문이다. fallback도 앞 attempt가 ambiguous이면 차단된다. `NotificationDispatchService`는 pre-call attempt state를 짧은 transaction에서 commit한 뒤 provider를 transaction 밖에서 호출하고, 결과를 두 번째 transaction에서 finalize한다. irreversible call 직전 lease ownership을 다시 검사하고 post-call transition은 lease generation으로 fencing한다. provider response는 accepted/rejected/ambiguous를 구분한다. classified “provider call not started” failure는 NOT_SUBMITTED로 판단할 수 있지만 unclassified runtime exception은 effect가 발생했을 가능성을 버리지 않고 ambiguous로 간다. cancellation도 future attempt를 막을 뿐 provider 쪽 이미 발생한 effect를 undo했다고 주장하지 않는다. canonical plan encoding은 versioned/length-framed 형식이고 map을 sort하며 fallback order까지 fingerprint semantics에 포함한다. 과거 canonical string을 JSON codec으로 다시 decode해 모든 dispatch가 실패하던 문제 때문에 writer와 dispatcher가 동일 canonical codec을 공유하도록 고정됐다. ##### 12.3 callback/receipt provider callback은 signature 검증을 위해 raw bytes를 보존하고 normalized headers를 제공하며 rendering은 body를 redaction한다. callback append와 projection 사이의 atomicity bug 때문에 durable callback write 후 projector 예외가 broker redelivery를 만들던 과거 경로가 제거되고 batch append contract가 명확해졌다. provider request id가 callback보다 늦게 알려지는 경우를 위해 unmatched callback을 이후 attempt에 연결하는 late-match 경로가 있다. synthetic provider event fingerprint도 과거 사실상 attempt UUID만 반영해 event type이 달라도 dedupe될 수 있던 문제를 length-framed SHA-256 semantics로 교정했다. ##### 12.4 확인된 P1 contract/implementation drift: admin atomic claim 미사용 **Observed defect.** `AdminOperationStorePort.claim()`의 javadoc은 과거 admin 경로가 `find -> destructive side effect -> save`여서 같은 operation id를 동시에 제시한 두 요청이 모두 “not found”를 보고 redrive를 두 번 실행할 수 있었음을 명시한다. 이를 막기 위해 command fingerprint를 포함한 **atomic claim-before-effect** 계약이 추가됐고 `JpaAdminOperationStore`도 DB-level `claimOperation(...)`을 구현한다. 그러나 현재 `NotificationAdminApplicationService`는 redrive/reconcile/suppress/provider-state 작업에서 여전히 `operations.findByOperationId(...)`를 먼저 읽고 side effect 이후 `operations.save(...)`한다. application-core notification production/test에서 `operations.claim(...)` 호출은 발견되지 않았다. 즉 저장소와 port에는 race fix가 구현돼 있지만 application service가 그 경로를 사용하지 않는다. 이것은 단순 미사용 API가 아니라 **계약이 설명하는 동일 race가 service path에서 다시 열려 있는 drift**다. 특히 concurrent same-operation-id redrive나 provider state/suppression에서 destructive/operator action이 중복 실행될 수 있다. 현재 application-core에는 이 race를 재현하는 admin concurrency test도 없다. 검증/수정 후보는 명확하다. service가 side effect 전에 command semantic fingerprint로 `claim()`하고, CLAIMED만 실행하며 replay/conflict/in-progress를 typed result로 반환하도록 바꾼 뒤 두 concurrent caller가 같은 operation id로 들어와도 side effect count가 정확히 1인지 regression test로 고정해야 한다. 이 분석에서는 source를 수정하지 않았다. ##### 12.5 P2 hardening: derived idempotency key의 32-bit hash `AcceptNotificationApplicationUseCase.derivedKey()`는 caller key가 없을 때 `Integer.toHexString(Objects.hash(...))`로 recipient/channel/template/version/variables를 축약한다. 이는 32-bit Java hash이므로 javadoc의 “서로 다른 요청은 collapse하지 않는다”는 표현을 수학적으로 보장하지 못한다. 다만 downstream submission logic은 동일 idempotency key의 canonical request fingerprint가 다르면 conflict로 분리하므로, 관찰된 구조상 collision의 주된 영향은 다른 요청이 조용히 같은 delivery로 합쳐지는 것보다 **false idempotency conflict/availability failure**에 가깝다. 따라서 P1 data corruption으로 확대하지 않고 P2 hardening으로 기록한다. canonical plan에 대한 cryptographic/keyed digest 또는 caller-supplied key를 우선하는 방향이 더 강한 계약이다. #### 13. 실제 production reachability와 legacy/dead-path 판정 static production reference scan에서 주요 application package는 모두 외부 production consumer를 확인했다. | package | application-core 밖 production reference file 수 | |---|---:| | notification | 156 | | objectstorage | 96 | | fileserver | 91 | | transaction | 35 | | idempotency | 30 | | usecase | 23 | | security | 22 | | filepublication | 16 | | storage | 16 | | outbox | 15 | | messaging | 14 | | realtime | 12 | | outbound | 11 | | observability | 10 | | lock | 4 | | operation | 4 | | cache | 2 | | fileexport | 2 | | lease | 2 | | inbox | 1 | 이 count는 “모든 type이 각각 호출된다”는 의미가 아니라 package-level runtime/repository reachability의 evidence다. 세부 파일은 `evidence/raw/013-application-core-reachability.txt`에 보존했다. legacy surface도 무조건 dead로 분류하지 않았다. `application.storage.ObjectStoragePort`, root notification `NotificationPort`, `NotificationVariablesCodecPort`, old idempotency-related exception 등은 adapter/config/characterization path에서 실제 reference가 남아 있다. 현재 상태는 dead code가 아니라 migration/compatibility surface다. 반대로 notification admin atomic `claim()`은 adapter 구현까지 존재하지만 application service consumer가 없는 **unwired corrective path**로 판정했다. 이것이 이번 scope의 가장 중요한 reachability finding이다. #### 14. 테스트 및 build-time verification 현재 snapshot에서 다음을 fresh 실행했다. 1. `./gradlew :application-core:test --rerun-tasks` 결과: BUILD SUCCESSFUL, 14 tasks executed. application-core의 136 test source가 포함된 normal lane을 fresh 실행했다. 2. `:application-core:messagingApplicationContractQualificationTest --rerun-tasks` 첫 실행: digest properties 미제공으로 `prepareMessagingContractEvidence`가 fail-closed. 재실행: source=`git archive HEAD` SHA-256, current application-core JAR SHA-256, exact profile bytes SHA-256을 명시. 결과: **15 tests, 0 skipped, BUILD SUCCESSFUL**. 3. `./gradlew :app-bootstrap:test --tests dev.caskeleton.bootstrap.architecture.CleanArchitectureTest --rerun-tasks` 결과: BUILD SUCCESSFUL, 98 actionable tasks executed. capability/transaction/repository/security dependency fitness rules를 fresh 실행했다. 4. `./gradlew verifyCleanArchitectureDependencies` 결과: BUILD SUCCESSFUL. module-registry allowlist와 실제 project dependency edge 검증이 통과했다. qualification에 사용한 source digest는 `e52b60c97a9496a0b18dc03f9232c295da37de7c0f56e692d000660eef0e370a`, artifact digest는 `6fd061854ad9a8631b65f5940f2a0792241d688aa4c11efcf392662109965942`, profile hash는 `7ac987233951a4d0427ac9e731a20df3a1c4aa1c15f30a5ed2b1e6c280b72afd`였다. 이 값은 release-wide provenance를 주장하기 위한 것이 아니라 이 분석에서 실행한 application-core qualification 입력을 재현하기 위한 evidence다. #### 15. 주요 역사적 회귀 근거 현재 코드 형태의 이유를 source가 직접 설명하는 사례가 여러 개 확인됐다. | 현재 형태 | source가 기록한 과거 문제 | |---|---| | `ObjectAccessPolicy`가 application-core 소유 | GraphQL adapter-owned contract가 transport 역의존을 만들었음 | | `TransactionCompletionResolver`가 application-core 소유 | JPA 옆 SPI를 domain이 구현하려면 adapter에 역의존해야 했음 | | `IrreversibleSideEffectContext`가 application-core 소유 | use case가 persistence adapter를 import해야 marker를 호출할 수 있었음 | | outbox payload append-boundary validation | relay에서 poison payload를 늦게 발견해 batch를 망가뜨릴 수 있었음 | | V2 idempotency singular contract | 인접 duplicate nominal contract가 서로 다른 구현 타입을 만들었음 | | fileserver fenced writer/cleanup/recovery | cancel/cleanup/writer race와 ambiguous physical/metadata seam | | notification canonical codec | canonical payload를 JSON codec으로 읽어 dispatch가 실패하던 path | | notification owner+generation fencing | expired worker가 renewal/write를 계속할 수 있던 race | | callback append/project separation | durable append 뒤 projector failure가 redelivery를 유발하던 atomicity 문제 | | admin `claim()` 계약 | find-before-side-effect race로 동일 destructive operation이 동시에 두 번 실행될 수 있었음 | 따라서 application-core의 복잡성 상당 부분은 단순 추상화 선호가 아니라 **failure/uncertainty/concurrency를 provider 구현보다 안쪽의 semantic contract로 끌어올린 결과**로 관찰된다. #### 16. Findings / improvement backlog ##### P1 — notification admin atomic claim contract가 service에서 사용되지 않음 - **Fact:** `AdminOperationStorePort.claim()`과 `JpaAdminOperationStore.claim()`은 존재하지만 `NotificationAdminApplicationService`는 redrive/reconcile/suppress/provider-state에서 `findByOperationId -> side effect -> save`를 사용한다. - **Why it matters:** 동일 operation id의 concurrent 요청이 둘 다 side effect를 실행할 수 있으며, 이는 claim javadoc이 명시한 과거 race와 동일하다. - **Verification:** 동일 operation id/command를 barrier로 동시에 호출하고 destructive action invocation count가 1인지 검증하는 concurrency regression test. - **Candidate direction:** service가 command fingerprint를 계산해 atomic claim을 먼저 수행하고 claimed/replay/conflict/in-progress를 분기. - **Tech-Log:** CASE + OPEN QUESTION/DECISION 후보. ##### P2 — notification derived idempotency key가 32-bit hash - **Fact:** fallback key는 `Integer.toHexString(Objects.hash(...))`다. - **Why it matters:** 서로 다른 request의 collision을 배제할 수 없어 javadoc의 강한 uniqueness 표현과 실제 guarantee가 맞지 않는다. canonical fingerprint 비교가 있으므로 silent convergence보다는 false conflict 위험이 중심이다. - **Verification:** known Java hash collision fixture 또는 property search로 distinct canonical request가 같은 derived key를 만들 수 있음을 확인하고 downstream conflict behavior를 고정. - **Candidate direction:** canonical plan에 대한 SHA-256/HMAC 계열 digest. - **Tech-Log:** OPEN QUESTION/REFERENCE 후보. ##### P2 — legacy storage/notification compatibility surface의 제거 조건 추적 - **Fact:** deprecated/legacy 계약이 production wiring에 여전히 사용된다. - **Why it matters:** 이름만 보고 dead로 삭제할 수 없고 adapter/runtime migration이 먼저 끝나야 한다. - **Verification:** external production reference 0 + characterization replacement + config path removal을 migration gate로 사용. - **Tech-Log:** DECISION/REFERENCE 후보. ##### P3 — isolation vocabulary와 legacy routing capability의 시차 - **Fact:** `Isolation`에는 stricter levels가 있지만 legacy `TransactionPort` template은 READ_COMMITTED로 고정되고 test도 stricter routing을 planned라고 명시한다. - **Why it matters:** public vocabulary만 보고 이미 지원되는 capability로 오해할 수 있다. - **Verification:** future routing이 추가될 때 use-case policy -> adapter transaction definition test를 함께 추가. - **Tech-Log:** OPEN QUESTION 후보. #### 17. 분석 한계 이 scope의 1,021 source/test 파일은 전부 읽었지만 모든 downstream adapter 내부 구현을 exhaustive 분석한 것은 아니다. application-core 계약의 실제 reachability/중요 semantic을 검증하는 데 필요한 adapter/bootstrap/architecture source만 cross-scope evidence로 추적했다. JPA, Redis, messaging provider, objectstorage/fileserver adapters 자체의 전체 implementation detail은 각 후속 bounded scope에서 다시 exhaustive 분석해야 한다. `CleanArchitectureTest`가 통과해도 helper indirect call, reflection/string lookup, AOP self-invocation 같은 static-analysis blind spot은 남는다. 또한 in-memory/fake contract test가 실제 provider failure model 전체를 증명하지는 않는다. 이 한계는 관련 adapter scope에서 real engine/runtime evidence로 보강해야 한다. #### 18. 완료 판정 application-core는 다음 gate를 충족했다. - quantified denominator: production 885 + test 136 = 1,021. - coverage: FULL_READ 1,021 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0. - 23 top-level package를 모두 account했다. - build dependency와 shared-contract-only production edge를 확인했다. - use-case/capability/transaction/security architecture fitness function을 추적했다. - 주요 transaction/idempotency/cache/lease/outbox/fileserver/notification uncertainty와 failure mechanics를 문서화했다. - external production reachability와 legacy/unwired path를 검사했다. - fresh normal tests, strict messaging qualification, architecture tests, dependency verification이 모두 통과했다. - historical regression 근거와 P1/P2/P3 backlog를 분리했다. - source revision은 분석 종료 시점까지 `a24ece9cf797f7ea647e33bf846b115208ed1ba5`이며 source working tree는 변경하지 않았다. 따라서 `application-core` bounded scope를 COMPLETE로 판정한다. 프로젝트 전체는 아직 후속 adapter/messaging/bootstrap scope가 남아 있으므로 `clean-architecture-backend-template` 자체는 계속 IN_PROGRESS다. #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **40개** (main 35 · test 2 · 기타 3). ``` src/application-core/build.gradle src/config/architecture/modules.json (application-core 항목) main: src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java src/main/java/dev/caskeleton/application/fileserver/api/error/AmbiguousCompletionException.java src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java src/main/java/dev/caskeleton/application/notification/NotificationPort.java src/main/java/dev/caskeleton/application/notification/platform/admin/AdminOperationStorePort.java src/main/java/dev/caskeleton/application/notification/platform/admin/NotificationAdminApplicationService.java src/main/java/dev/caskeleton/application/notification/platform/api/NotificationPlan.java src/main/java/dev/caskeleton/application/notification/platform/api/NotificationVariable.java src/main/java/dev/caskeleton/application/notification/platform/dispatch/AcceptNotificationApplicationUseCase.java src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationDispatchService.java src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationVariablesCodecPort.java src/main/java/dev/caskeleton/application/operation/DurableOperation.java src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java src/main/java/dev/caskeleton/application/security/AuthorizationPort.java src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java src/main/java/dev/caskeleton/application/security/ObjectAccessDecision.java src/main/java/dev/caskeleton/application/security/ObjectAccessPolicy.java src/main/java/dev/caskeleton/application/transaction/CompletionResolution.java src/main/java/dev/caskeleton/application/transaction/IrreversibleSideEffectContext.java src/main/java/dev/caskeleton/application/transaction/Isolation.java src/main/java/dev/caskeleton/application/transaction/OperationId.java src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java src/main/java/dev/caskeleton/application/transaction/TransactionCompletionResolver.java src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java src/main/java/dev/caskeleton/application/transaction/TransactionPort.java src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java src/main/java/dev/caskeleton/application/transaction/TransactionResult.java test: src/test/java/dev/caskeleton/application/security/ObjectAccessPolicyTest.java src/test/java/dev/caskeleton/application/transaction/IsolationTest.java 기타: CLAUDE.md README.md src/build.gradle 해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등: evidence/raw/013-application-core-reachability.txt ``` --- ## A04. adapter-outbound-support > 분석 중에는 `04-adapter-outbound-support.md` 파일이었다. 703줄. ### adapter-outbound-support 상세 분석 #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-support` - canonical state `analysisFile`: §A04 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/support` · Gradle `:adapter:outbound:support` - registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]` - registry `runtime_memberships`: `["app-bootstrap"]` - coverage ledger: `FULL_READ` **8** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: COMPLETE > 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 분석 범위: `src/adapter/outbound/support` > Gradle path: `:adapter:outbound:support` #### 0. 커버리지와 숫자 지도 이 leaf는 크기가 작다. 그래서 대표 파일을 샘플링하지 않고 leaf-owned source/build/document를 전부 읽고, 실제 consumer와 composition-root wiring을 별도 cross-scope evidence로 추적했다. | file group | count | disposition | reason | |---|---:|---|---| | production Java | 4 | FULL_READ | leaf의 전체 production source | | test Java | 1 | FULL_READ | leaf의 전체 dedicated test source | | `build.gradle` | 1 | FULL_READ | 실제 compile dependency와 leaf build policy | | `README.md` | 1 | FULL_READ | 현재 코드와 대조해야 하는 설계 결정 문서 | | `CLAUDE.md` | 1 | FULL_READ | 현재 leaf-local 분석/경계 지침 | | resources | 0 | FULL_READ | main/test resource 없음 | | **합계** | **8** | **FULL_READ 8 / UNCLASSIFIED 0** | bounded scope complete denominator | 추가 측정: - production Java: 4 files / 약 97 LOC - test Java: 1 file / 약 89 LOC - production package directories: 2 - `dev.caskeleton.adapter.outbound` - `dev.caskeleton.adapter.outbound.support` - entities/tables/migrations: 없음 - configuration properties: 없음 - runtime membership registry: `app-bootstrap` - leaf-owned Spring configuration: `OutboundSupportConfig` Raw inventory는 `evidence/raw/015-adapter-outbound-support-inventory.txt`에 보존했다. #### 1. 모듈의 정체와 경계 `adapter-outbound-support`는 application port를 구현하는 하나의 기술 adapter라기보다 **여러 outbound adapter가 공유할 수 있는 기술적 보조 seam**이다. 현재 production surface는 사실상 세 가지다. 1. `OutboundCorrelation` - SLF4J MDC에서 `correlation_id`를 조회한다. - 값이 없거나 blank면 `"unknown"`을 반환한다. 2. `FailOpenDependencyLogger` - optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다. - success는 DEBUG, failure는 WARN이다. 3. `OutboundSupportConfig` - `FailOpenDependencyLogger` default bean을 제공한다. - `@ConditionalOnMissingBean`으로 fork/application이 같은 타입을 override할 수 있게 한다. `package-info.java`는 outbound adapter package의 개괄만 가진다. ##### 1.1 허용 dependency와 실제 dependency는 다르다 `src/config/architecture/modules.json`은 support leaf가 다음 project dependency를 **허용**한다. - `domain-core` - `application-core` - `shared-contract` 그러나 현재 `build.gradle`과 fresh `compileClasspath` 결과를 보면 실제 project dependency는 **0개**다. 실제 compile dependency는 다음 외부 라이브러리뿐이다. - `spring-boot-autoconfigure` 4.0.8 - `slf4j-api` 2.0.18 즉 registry의 `allowed_dependencies`는 가능한 최대 경계를 나타내고, 현재 source graph가 그 edge를 모두 사용한다는 뜻이 아니다. support는 현 snapshot에서 domain/application/shared 타입과도 결합하지 않는다. 이 구분은 Clean Architecture 설명에서도 중요하다. “Core 쪽 dependency가 허용된다”와 “현재 adapter가 Core에 실제로 의존한다”는 별개의 사실이다. #### 2. `OutboundCorrelation`: MDC lookup을 한 곳으로 모은 작은 seam `OutboundCorrelation.current()`의 규칙은 단순하다. ```text MDC[correlation_id] != null && !blank -> 해당 값 otherwise -> "unknown" ``` `docs/registries/mdc-keys.yaml`의 `correlation_id` 계약은 다음을 선언한다. - source: inbound filter - type: ULID - required in: request/dependency/application - propagation: HTTP/async/message - metric tag 사용 금지(high cardinality) `OutboundCorrelation`은 이 중 **조회와 missing sentinel만 소유**한다. ULID 형식 검증이나 생성/전파를 여기서 하지 않는다. 따라서 이 utility가 임의 문자열을 그대로 반환한다고 해서 곧바로 contract 위반이라고 볼 근거는 없다. canonicalization/validation은 upstream context owner의 책임으로 보인다. ##### Reachability scope 밖 production code에서 `OutboundCorrelation`을 직접 참조하는 파일은 현재 0개다. 하지만 dead code는 아니다. - `FailOpenDependencyLogger`가 같은 leaf 내부에서 production consumer다. - messaging/notification tests는 MDC key를 맞추기 위해 이 상수를 직접 사용한다. 따라서 “external production reference 0”만으로 dead 판정을 하면 오탐이다. 이번에 추가된 negative-space rule이 요구하는 바로 그 사례다. #### 3. `FailOpenDependencyLogger`: 진단을 business outcome과 분리하려는 계약 ##### 3.1 성공과 실패 포맷 `logSuccess(...)`는 DEBUG로 다음 정보를 기록한다. - dependency_name - dependency_type - operation - outcome=`SUCCESS` - correlation_id `logFailure(...)`는 WARN으로 다음을 추가한다. - outcome=`FAILURE` - error=`: ` README와 javadoc은 WARN을 선택한 이유를 “optional fail-open dependency가 실패해도 core use case 자체는 성공했기 때문”이라고 설명한다. 이 logger 자체는 retry, recovery, fallback을 수행하지 않는다. **실패 정책을 결정하는 주체가 아니라 이미 결정된 fail-open outcome을 관측하는 기술 seam**이다. ##### 3.2 실제 production consumer repository-wide production reference scan에서 support package를 직접 import하는 current production files는 네 개뿐이었다. Messaging: - `MessagingConfig` - `OutboundMessagePublisher` Notification: - `NotificationConfig` - `FailOpenNotificationProvider` 반대로 support README가 “공유 consumer”로 설명하는 `cache-redis`, `httpclient`는 Gradle dependency는 유지하지만 support production type을 직접 참조하지 않는다. 이 차이는 §8에서 별도로 다룬다. #### 4. Confirmed P1 — `cause.getMessage()` 때문에 PII-safe logging 계약이 성립하지 않는다 ##### 4.1 문서와 테스트가 주장하는 계약 support source와 README는 다음 취지의 강한 주장을 한다. > logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다. `FailOpenDependencyLoggerTest`도 실패 로그에 email/body marker가 없음을 검사한다. 하지만 테스트 fixture의 exception은 단순히 `"connection refused"`다. 즉 PII marker는 logger에 들어가는 어떤 argument에도 존재하지 않는다. 이 테스트는 **payload object가 직접 argument로 전달되지 않는다는 것**만 확인할 뿐, exception message를 통한 leakage를 검사하지 않는다. ##### 4.2 실제 logger input은 payload-free가 아니다 `logFailure`는 다음 값을 그대로 formatted message에 넣는다. ```java cause.getClass().getSimpleName() + ": " + cause.getMessage() ``` 그리고 consumer SPI들은 exception message의 내용을 제한하지 않는다. - `NotificationProvider.send(Notification)` → arbitrary `Exception` - `GoogleEmailClient.send(Notification)` → arbitrary `Exception` - `SlackClient.send(Notification)` → arbitrary `Exception` - `MessageBroker.send(OutboundMessage)` → arbitrary `Exception` 특히 `Notification` contract는 recipient/body가 PII이며 logger에 전달하면 안 된다고 명시한다. 하지만 provider SDK/fork implementation이 recipient나 response/body 일부를 exception message에 넣는 것을 이 interface가 통제할 수 없다. ##### 4.3 실행 재현 현재 compiled `FailOpenDependencyLogger`에 다음 exception을 전달하는 focused probe를 실행했다. ```text RuntimeException( "provider rejected recipient secret@gmail.com body=secret-body-content") ``` 실제 formatted WARN에는 다음 문자열이 그대로 남았다. ```text error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content" ``` probe source와 output은 각각: - `evidence/raw/021a-support-logger-pii-probe.java` - `evidence/raw/021-support-logger-pii-probe.txt` 에 보존했다. ##### 4.4 global masking도 이 보장을 복구하지 않는다 `app-bootstrap`의 `LogMaskingPatterns`는 방어 심층화로 다음과 같은 secret 형태를 mask한다. - password/secret/token/api-key 계열 key=value - Authorization credentials - standalone Bearer token 그러나 arbitrary email address나 free-form body PII를 일반적으로 제거하는 규칙은 없다. app-bootstrap README 자체도 regex masking을 **보증이 아니라 defence-in-depth**라고 설명한다. 따라서 현재 “logger signature 때문에 PII가 들어올 수 없다”는 1차 방어선 설명은 사실과 맞지 않는다. ##### 4.5 영향과 수정 후보 우선순위: **P1 (security/privacy contract)** 가능한 방향은 두 가지다. 1. 공통 logger가 raw `cause.getMessage()`를 기록하지 않고 exception type + bounded/stable error classification만 기록한다. 2. raw cause message가 정말 필요한 일부 dependency만 별도의 sanitizer/classifier를 거쳐 명시적으로 허용한다. 어느 쪽이든 현재의 “arbitrary exception message를 공통 logger가 그대로 출력”하는 방식은 PII-safe라는 강한 계약과 양립하지 않는다. Regression test는 exception message 자체에 email/body/token marker를 넣어 formatted log에 남지 않는지 검증해야 한다. 현재 테스트처럼 payload object만 logger argument에서 제외하는 것으로는 부족하다. #### 5. Confirmed P1 — notification consumer는 diagnostic failure를 authoritative failure로 바꿀 수 있다 이 finding은 support logger의 consumer semantics를 추적하면서 발견했다. ##### 5.1 messaging은 이미 이 문제를 구분한다 `OutboundMessagePublisher`는 broker call과 observation을 분리한다. ```text broker.send -> success/failure fact 결정 -> observeQuietly(logger...) ``` `observeQuietly`는 logger가 RuntimeException을 던져도 caller-visible broker outcome을 바꾸지 않는다. source comment에는 과거 버그도 직접 기록돼 있다. - send와 success log가 같은 try block에 있었음 - broker는 이미 메시지를 받았음 - success logger가 실패함 - 같은 catch가 이를 publish failure로 오인했음 현재 `OutboundMessagePublisherTest.aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure`는 logger가 DEBUG에서 실제로 예외를 던지도록 만들고도 publish가 예외 없이 끝나며 broker send가 1회 완료됐음을 검증한다. fresh focused test도 통과했다. ##### 5.2 notification은 같은 shared logger를 다른 방식으로 사용한다 현재 `FailOpenNotificationProvider`는 다음 구조다. ```text try { delegate.send() logSuccess() } catch (Exception ex) { logFailure(ex) } ``` 여기서는 provider outcome과 diagnostic outcome이 분리되지 않는다. ###### Case A — provider 성공 후 success logger 실패 `delegate.send()`가 성공한 뒤 `logSuccess()`가 RuntimeException을 던지면 같은 catch가 잡는다. 그 결과 이미 성공한 provider send에 대해 `logFailure()`까지 호출된다. focused probe 결과: ```text SUCCESS_PATH sends=1 debugCalls=1 warnCalls=1 ``` 실제 send는 1회 성공했지만 success observation failure 때문에 WARN failure observation이 추가 호출됐다. ###### Case B — provider 실패 후 failure logger도 실패 provider failure가 catch된 뒤 `logFailure()`가 RuntimeException을 던지면 이를 흡수하는 바깥 경계가 없다. probe 결과: ```text FAILURE_PATH propagated=IllegalStateException:logger-warn-failed warnCalls=1 ``` 즉 클래스가 “provider failure를 swallow해 core use case를 실패시키지 않는다”고 선언해도 diagnostics failure가 caller까지 전파될 수 있다. probe source/output: - `evidence/raw/022a-notification-logger-failure-probe.java` - `evidence/raw/022-notification-logger-failure-probe.txt` ##### 5.3 현재 notification test가 green인 이유 `NotificationAdapterTest`는 ordinary `ListAppender`를 사용한다. provider failure와 PII object가 log line에 직접 들어가지 않는 것은 검증하지만 logger/appender 자체가 실패하는 fixture는 없다. fresh `NotificationAdapterTest`는 정상 통과했다. 따라서 이 finding은 “기존 테스트 실패”가 아니라 **green test가 다루지 않는 failure seam**이다. 우선순위: **P1 (reliability / outcome correctness)** 수정 후보: - messaging과 동일하게 provider call과 observation을 분리하고 observation failure를 non-authoritative로 흡수한다. - 또는 `FailOpenDependencyLogger` 자체를 no-throw contract로 바꿔 모든 consumer를 보호한다. 후자는 shared behavior를 바꾸므로 messaging/notification뿐 아니라 future consumer까지 contract review가 필요하다. 어느 owner가 isolation을 가져갈지는 후속 Decision 후보로 남긴다. #### 6. `OutboundSupportConfig`: unconditional shared bean seam과 실제 runtime wiring `OutboundSupportConfig`는 `@Configuration`이며 `FailOpenDependencyLogger` bean 하나만 제공한다. ```text @ConditionalOnMissingBean FailOpenDependencyLogger failOpenDependencyLogger() ``` 별도 master property condition은 없다. 이는 support 자체를 optional capability로 취급하지 않고, 실제 provider/client capability의 on/off를 sibling adapter가 소유하게 하려는 구조다. ##### 6.1 direct production reference 0이지만 unwired가 아니다 `OutboundSupportConfig`를 support 밖 production Java에서 명시적으로 참조하는 파일은 0개다. 그러나 실제 composition root `CaSkeletonApplication`은 다음 broad package를 component scan한다. ```text dev.caskeleton.adapter ``` `AUTO_CONFIGURED_PACKAGES` exclusion에는 messaging/notification/persistence 등은 들어가지만 support package는 포함되지 않는다. 따라서 support config는 broad component scan으로 도달한다. registry도 support runtime membership을 `app-bootstrap`으로 선언하고 `app-bootstrap/build.gradle`이 support project를 직접 `implementation`한다. 따라서 이 configuration은 현재 **active scanned path**다. ##### 6.2 conditional sibling comparison support config 자체에는 `@ConditionalOnProperty`가 없고 `@ConditionalOnMissingBean`만 있다. 이것은 같은 optional adapter들의 master switch 누락으로 판정하지 않았다. 이유: - support는 provider/client를 생성하지 않는다. - logger bean 하나만 default로 제공한다. - actual messaging/notification/httpclient 등은 자기 capability root에서 activation을 소유한다. - support README와 config javadoc 모두 이 비대칭을 의도적으로 설명한다. `OptionalAdapterBeanGatingTest`도 support config를 함께 넣은 상태에서 optional adapters가 기본 disabled여도 context가 성공함을 검증한다. 다만 이 test는 `OutboundSupportConfig`를 `.withUserConfiguration(...)`으로 직접 공급하므로 full-app component-scan evidence 자체는 아니다. full app wiring은 `CaSkeletonApplication` source와 registry/build edge가 별도 근거다. #### 7. Build / ArchUnit enforcement ##### 7.1 registry `modules.json`에서 support는 독립 leaf이며 runtime membership은 app-bootstrap이다. ##### 7.2 Gradle dependency validation fresh `verifyCleanArchitectureDependencies`가 통과했다. 이 task는 registry의 allowed dependency와 실제 Gradle project edge를 비교한다. 중요한 한계는 이 검증이 **edge가 허용되는지**를 판단한다는 점이다. 사용되지 않는 allowed edge까지 제거해야 한다고 판단하지는 않는다. ##### 7.3 outbound peer isolation `CleanArchitectureTest.OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT`는 outbound adapter family를 slice로 나누고 서로 직접 의존하지 못하게 한다. 유일한 shared-code 예외는 target package가: ```text ..adapter.outbound.support.. ``` 인 dependency다. 따라서 messaging → notification 같은 peer coupling은 금지하지만 messaging → support는 허용한다. fresh `CleanArchitectureTest --rerun-tasks`도 통과했다. 이 구조는 support 모듈이 단순 편의 library가 아니라 **outbound family에서 sanctioned shared dependency point**라는 점을 build-time fitness function으로 고정한다. #### 8. Negative-space probes 강화된 분석 규칙에 따라 네 가지 부재/중복/drift probe를 별도로 수행했다. ##### 8.1 Public surface reachability Raw evidence: `016-adapter-outbound-support-public-reachability.txt` | public type | support 밖 current reference | 판정 | |---|---|---| | `FailOpenDependencyLogger` | messaging/notification production + tests | active shared surface | | `OutboundCorrelation` | external production 0, downstream tests 존재 | leaf-internal production utility, dead 아님 | | `OutboundSupportConfig` | external production direct ref 0, app-bootstrap test ref 존재 | component-scan active path | 결론적으로 현재 세 타입 중 confirmed dead public type은 없다. ##### 8.2 Conditional sibling comparison Raw evidence: `017-adapter-outbound-support-conditional-wiring.txt` - support config: unconditional configuration + missing-bean override seam - app composition root: support package는 broad component scan에 포함 - optional provider adapter package들은 별도 conditional/auto-config ownership support의 unconditional nature는 현재 역할과 일치하며 conditional mismatch defect로 판정하지 않았다. ##### 8.3 Duplicate / competing mechanism sweep Raw evidence: `018-adapter-outbound-support-duplicate-mechanisms.txt` 확인한 주요 후보: - `FailOpenDependencyLogger`: current fail-open shared logger - `Slf4jOutboxRelayFailureReportAdapter`: durable/outbox failure reporter - cache Redis 내부의 자체 logger들 - historical `OutboundHttpDependencyLogger` 현재 evidence로는 이들을 같은 runtime responsibility의 confirmed duplicate라고 볼 수 없다. - outbox reporter는 fail-closed durable relay의 typed report를 기록한다. - Redis logger는 lifecycle/config/SDK observability 역할이다. - HTTP dependency logger는 현재 source에서 제거됐다. 따라서 **현재 중복 fail-open dependency logger 구현 defect는 확인되지 않았다.** 다만 messaging과 notification이 동일 logger를 사용하면서 diagnostics-failure semantics가 다르다는 consumer-level inconsistency는 §5의 confirmed finding이다. ##### 8.4 Documentation / measured-claim drift Raw evidence: `019-adapter-outbound-support-document-drift.txt` 여기서는 명확한 drift가 확인됐다. ###### Drift 1 — dependency SSOT 위치 README: ```text src/build.gradle 의 allowedProjectDependencies[...]가 SSOT ``` 현재: - root `AGENTS.md`: `src/config/architecture/modules.json`가 SSOT - support `CLAUDE.md`: 동일 - `src/build.gradle`은 registry를 읽고 `allowedProjectDependencies` map을 **파생 생성**함 즉 variable 자체는 아직 존재하지만 source-of-truth 위치 설명은 outdated다. ###### Drift 2 — CLAUDE.md 부재 주장 README: ```text 이 모듈은 아직 별도 CLAUDE.md를 두지 않았다 ``` 현재: ```text src/adapter/outbound/support/CLAUDE.md ``` 가 실제 존재한다. ###### Drift 3 — 존재하지 않는 현재 비교 대상 README는 fail-open WARN logger와 `OutboundHttpDependencyLogger`를 현재 대비되는 구현처럼 설명한다. current repository exact search에서는 이 symbol이 support README 한 줄 외에 존재하지 않는다. Git history를 보면 해당 class는 초기 repository에 존재했으나 commit `5f10b791...`에서 httpclient 관련 old classes/tests와 함께 삭제됐다. support README는 initial commit 이후 이 변화에 맞춰 갱신되지 않았다. 우선순위: **P3 documentation maintenance** #### 9. Candidate unnecessary Gradle edges — cache/httpclient → support Raw evidence: `020-adapter-outbound-support-project-edge-usage.txt` 다음 네 leaf는 모두 support를 `implementation project(':adapter:outbound:support')`로 선언한다. - cache-redis - httpclient - messaging - notification 하지만 current production Java reference는: - messaging: 있음 - notification: 있음 - cache-redis: 0 - httpclient: 0 support leaf에는 resource도 없다. 따라서 cache/httpclient의 edge는 **현재 source에서 직접 필요성을 찾지 못한 candidate stale dependency**다. 다만 static textual reference만으로 Gradle edge가 100% 불필요하다고 단정하지 않는다. compile/runtime classpath presence 자체를 의도적으로 이용하는 plugin/reflection mechanism이 있는지 downstream leaf 전체 분석에서 다시 확인해야 한다. 우선순위: **P3 cleanup candidate** 검증 기준: 1. 해당 leaf에서 support dependency 제거 2. compile/test/runtime classpath 및 focused tests 실행 3. app-bootstrap shipped composition/architecture tests 실행 4. runtime bean graph 차이가 없는지 확인 현재 분석에서는 source를 수정하지 않았다. #### 10. 테스트 레인과 실제 증명 범위 ##### 10.1 support dedicated test Fresh command: ```text ./gradlew :adapter:outbound:support:test --rerun-tasks --console=plain ``` 결과: BUILD SUCCESSFUL. 이 test가 실제로 증명하는 것: - normal Logback path에서 failure correlation id가 기록됨 - MDC 없을 때 unknown sentinel - ordinary exception fixture에서 payload marker가 log에 없음 - success DEBUG logging 증명하지 않는 것: - exception message에 PII가 있을 때의 안전성 - logger/appender 자체 실패 시 consumer behavior - full app component scan - downstream provider semantics ##### 10.2 messaging consumer test Fresh `OutboundMessagePublisherTest` 통과. 이 class에는 logger가 success observation에서 실제 RuntimeException을 던지는 regression test가 있고, send outcome이 logger failure와 분리됨을 증명한다. ##### 10.3 notification consumer test Fresh `NotificationAdapterTest` 통과. normal logger에서 provider failure를 swallow하고 direct Notification PII가 log line에 없음을 증명한다. throwing-logger case는 없다. ##### 10.4 optional adapter gating Fresh `OptionalAdapterBeanGatingTest` 통과. support config와 여러 optional adapter configs를 ApplicationContextRunner에 함께 넣었을 때 disabled defaults가 실제 provider bean을 만들지 않는다는 것을 검증한다. full `CaSkeletonApplication` scan과 동일한 boot path는 아니다. ##### 10.5 architecture suite / dependency registry - `CleanArchitectureTest --rerun-tasks`: BUILD SUCCESSFUL - `verifyCleanArchitectureDependencies`: BUILD SUCCESSFUL 이 둘은 source/package/project dependency constraint를 증명하며 diagnostics runtime failure나 PII behavior를 증명하지 않는다. #### 11. 역사적 형태 support README와 source는 initial repository부터 존재한다. 이후 architecture가 크게 성장하는 동안 support의 역할 설명 일부가 current implementation과 어긋났다. 특히 `OutboundHttpDependencyLogger`는 실제 historical class였다. 따라서 README의 해당 문장이 처음부터 허구였던 것은 아니다. 문제는 **class가 삭제된 이후 문서가 함께 이동하지 않았다는 것**이다. 또 messaging current source에는 logger failure를 business/publish failure와 분리하기 위해 `observeQuietly`가 도입된 과거 regression 설명이 남아 있다. 이 history는 notification consumer의 현재 shape와 비교할 때 중요한 evidence가 된다. 두 consumer가 같은 shared logger를 사용하지만 하나만 diagnostics를 non-authoritative로 격리한다. #### 12. Findings / improvement backlog ##### P1 — arbitrary exception message가 PII-safe logging boundary를 우회한다 - **Observed fact:** `FailOpenDependencyLogger.logFailure`는 raw `cause.getMessage()`를 formatted WARN에 포함한다. - **Runtime evidence:** explicit email/body marker가 포함된 exception message가 실제 formatted log에 그대로 출력됐다. - **Contract conflict:** README/source/test는 payload/PII가 logger에 닿지 않는다고 주장한다. - **Why it matters:** fork/provider SDK exception message는 application이 통제하지 못하며 recipient/body/remote response를 포함할 수 있다. - **Verification:** `021a` probe와 raw output, 향후 dedicated regression test. - **Candidate:** raw cause message 제거 또는 explicit sanitizer/classifier. - **Tech-Log:** CASE + DECISION 후보. ##### P1 — notification fail-open consumer가 logger failure를 격리하지 않는다 - **Observed fact:** `FailOpenNotificationProvider`는 send와 logSuccess를 동일 try에 두고 catch 안의 logFailure를 보호하지 않는다. - **Runtime evidence:** successful send 뒤 debug logger failure가 warn failure observation을 만들었고, provider failure 뒤 warn logger failure는 caller까지 전파됐다. - **Comparison:** messaging은 같은 shared logger를 `observeQuietly`로 이미 격리하고 regression test를 갖는다. - **Why it matters:** diagnostics가 business/provider outcome을 바꿔서는 안 된다는 non-authoritative observation 원칙이 consumer마다 달라진다. - **Verification:** `022a` focused probe; notification에 throwing-logger regression 추가. - **Candidate:** notification에서 observation isolation 또는 shared logger no-throw contract. - **Tech-Log:** CASE + DECISION 후보. ##### P3 — support README가 current architecture registry/history와 drift - **Observed fact:** SSOT 위치, CLAUDE.md 존재 여부, HTTP logger 존재 여부가 current source와 불일치. - **Why it matters:** support module의 dependency policy와 비교 설계를 읽는 사람이 현재 architecture를 잘못 이해한다. - **Verification:** `019` raw search/history. - **Candidate:** README를 `modules.json`/current consumer topology에 맞춰 갱신. - **Tech-Log:** 보통 refactor/doc maintenance; 독립 CASE 우선순위는 낮음. ##### P3 — cache-redis/httpclient의 support project dependency 필요성 재검증 - **Observed fact:** 두 leaf 모두 Gradle support dependency는 있지만 current production Java support reference는 0이고 support resource도 없다. - **Why it matters:** 불필요 edge는 classpath와 architecture narrative를 실제 필요보다 넓힌다. - **Verification:** dependency 제거 후 각 leaf focused test + app composition test. - **Status:** candidate only; downstream leaf exhaustive analysis 전에는 confirmed dead edge로 단정하지 않음. - **Tech-Log:** OPEN QUESTION / refactor candidate. #### 13. 확인한 것 / 확인하지 못한 것 ##### 확인한 것 - leaf-owned source/test/build/README/CLAUDE 8개 전부 FULL_READ - current compile dependency graph - registry membership와 allowed project edges - app-bootstrap component-scan wiring - support public surface reachability - optional sibling activation shape - duplicate logger/correlation mechanism search - README named-claim drift와 relevant Git history - messaging/notification direct consumers - PII exception-message runtime probe - diagnostics-failure runtime probe - focused support/messaging/notification/gating tests - architecture/dependency verification ##### 이 scope에서 exhaustive하지 않은 것 - messaging 전체 module - notification 전체 adapter module - cache-redis 전체 module - httpclient 전체 module - production Logback deployment/backend 장애 행동 - 실제 외부 provider SDK의 구체 exception message corpus 따라서 §4의 핵심은 “특정 SDK가 지금 반드시 PII를 exception에 넣는다”가 아니다. **공통 logger contract가 arbitrary exception message를 허용하고 실제로 그대로 출력하므로 PII-safe를 보장할 수 없다는 것**이다. §5 역시 실제 production disk-full 사고를 주장하지 않는다. 현재 compiled consumer에 throwing logger를 주었을 때 outcome semantics가 깨지는 코드 경로를 재현한 것이다. #### 14. 완료 판정 `adapter-outbound-support`는 다음 gate를 충족했다. - bounded denominator: 8 / unclassified 0 - production/test source 전부 FULL_READ - build dependency와 runtime membership 확인 - composition-root wiring 확인 - public contract와 logging semantics 추출 - success/failure consumer path 추적 - dedicated tests와 downstream focused tests 실행 - ArchUnit/project dependency enforcement 확인 - public reachability probe 수행 - conditional sibling probe 수행 - duplicate mechanism probe 수행 - documentation drift probe 수행 - raw evidence에 exact command/cwd/time/revision/exit/output 보존 - P1/P3 backlog와 limitations 분리 따라서 이 bounded scope를 `COMPLETE`로 판정할 수 있다. 프로젝트 전체는 아직 후속 outbound/persistence/messaging/inbound/bootstrap scopes와 마지막 §A99가 남아 있으므로 계속 `IN_PROGRESS`다. #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **12개** (main 4 · test 1 · 기타 7). ``` src/adapter/outbound/support/build.gradle src/config/architecture/modules.json (adapter-outbound-support 항목) main: src/main/java/dev/caskeleton/adapter/outbound/package-info.java src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java test: src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java 기타: AGENTS.md CLAUDE.md README.md docs/registries/mdc-keys.yaml src/app-bootstrap/build.gradle src/build.gradle src/config/architecture/modules.json 해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등: modules.json evidence/raw/015-adapter-outbound-support-inventory.txt evidence/raw/021a-support-logger-pii-probe.java evidence/raw/021-support-logger-pii-probe.txt evidence/raw/022a-notification-logger-failure-probe.java evidence/raw/022-notification-logger-failure-probe.txt 016-adapter-outbound-support-public-reachability.txt 017-adapter-outbound-support-conditional-wiring.txt 018-adapter-outbound-support-duplicate-mechanisms.txt 019-adapter-outbound-support-document-drift.txt 020-adapter-outbound-support-project-edge-usage.txt §A99 ``` --- ## A05. adapter-outbound-persistence-jpa > 분석 중에는 `05-adapter-outbound-persistence-jpa.md` 파일이었다. 4,950줄. ### adapter-outbound-persistence-jpa 상세 분석 #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-persistence-jpa` - canonical state `analysisFile`: §A05 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/persistence-jpa` · Gradle `:adapter:outbound:persistence-jpa` - registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]` - registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]` - coverage ledger: `FULL_READ` **605** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: COMPLETE > 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 분석 범위: `src/adapter/outbound/persistence-jpa` > Gradle path: `:adapter:outbound:persistence-jpa` #### 0. 왜 내부 sub-scope로 나누는가 이 leaf는 하나의 Gradle module이지만 실제로는 JPA platform의 여러 capability를 package boundary로 합쳐 놓은 큰 구현체다. tracked file denominator는 605개이며 production Java만 350개다. 따라서 module 전체를 한 번에 훑지 않고, 파일이 정확히 하나의 내부 bounded sub-scope에 귀속되도록 ledger를 먼저 고정하고 각 sub-scope를 exhaustive-reading unit으로 처리한다. ##### 전체 denominator - tracked module files: **605** - `src/main`: 381 files / 350 Java / 31 resources / 약 27,744 Java LOC - `src/test`: 101 files / 100 Java / 1 `.gitkeep` / 약 9,511 Java LOC - `src/testkit`: 41 Java / 약 3,070 LOC - `src/postgresqlIntegrationTest`: 75 files / 71 Java / 4 SQL / 약 13,958 Java LOC - `src/jpaPlatformPerformanceTest`: 3 Java / 약 268 LOC - leaf top-level: `CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile` ##### 내부 bounded sub-scope ledger | # | sub-scope | denominator | status | |---:|---|---:|---| | 1 | governance / build / docs / root boundary | 11 | COMPLETE | | 2 | API contracts (`api/**`) | 55 | COMPLETE | | 3 | transaction + persistence failure | 51 | COMPLETE | | 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE | | 5 | PostgreSQL vendor implementation + vendor migrations | 73 | COMPLETE | | 6 | baseline capability stores/config/audit/cache/H2/etc. | 87 | COMPLETE | | 7 | Fileserver persistence + migrations | 29 | COMPLETE | | 8 | Notification persistence + migrations | 68 | COMPLETE | | 9 | Experimental platform | 38 | COMPLETE | | 10 | testkit + testkit fixture tests | 62 | COMPLETE | | 11 | PostgreSQL integration/readiness lane | 75 | COMPLETE | | 12 | pool/performance contract lane | 3 | COMPLETE | | | **TOTAL** | **605** | | 이 ledger는 module completion 전까지 모든 tracked file의 최종 disposition(`FULL_READ` / `STRUCTURAL_ONLY` / `EXCLUDED`)을 추적하기 위한 내부 작업 단위다. module-level `state.json`은 이 12개가 모두 닫힐 때만 COMPLETE로 전환한다. 12개 sub-scope는 모두 닫혔다. 최종 disposition은 **605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED**이며, ledger 재조정과 module 완료 조건은 §142에 있다. #### 1. 모듈 구조의 1차 관찰 `CLAUDE.md`와 `docs/jpa/repository-adaptation.md`에 따르면 원래 별도의 Stable library module들로 설계된 JPA platform을 이 저장소의 fail-closed leaf registry에 맞춰 **하나의 Gradle leaf 내부 package boundary**로 적응시켰다. 그래서 이 module의 package graph 자체가 사실상 내부 모듈 그래프 역할을 한다. 실제 build는 production project dependency로 `application-core`, `shared-contract`를 선언하고 Spring Data JPA, Spring Integration JDBC, Flyway, Micrometer 등을 사용한다. PostgreSQL/H2 driver는 runtimeOnly, Querydsl/Envers는 compileOnly다. 별도 `testkit`, `postgresqlIntegrationTest`, `jpaPlatformPerformanceTest` source set과 fail-closed lane을 갖는다. 이 문서는 각 sub-scope를 닫아가며 위 설계 문서의 주장과 실제 source/build/test/runtime evidence를 계속 대조한다. --- #### 2. Sub-scope 02 — API contracts (`api/**`) > 내부 상태: COMPLETE — 49 production + 6 test, **55 / 55 FULL_READ** > 범위: `src/main/java/dev/caskeleton/adapter/outbound/persistence/api/**` + matching dedicated tests > 역할: provider/framework implementation보다 안쪽에서 persistence platform의 외부 계약, 실패 의미, query safety, transaction/retry algebra를 고정한다. ##### 2.1 숫자 지도와 package map | package | production | dedicated test | 역할 | |---|---:|---:|---| | `api` root | 1 | 1 | bounded persistence operation identity | | `api.capability` | 3 | 0 | capability/support-level report vocabulary | | `api.error` | 23 | 2 | provider-neutral persistence failure algebra | | `api.query` | 10 | 2 | keyset/cursor/query-observation contract | | `api.transaction` | 12 | 1 | transaction profile/retry/completion-evidence algebra | | **합계** | **49** | **6** | **55** | public top-level production type도 정확히 49개다. `docs/architecture/jpa-api-surface.txt`의 committed API baseline 역시 `api` namespace에서 49개를 기록하고 있어 현재 이름 목록 drift는 없다. Gradle `verifyJpaApiSurface`가 이 surface의 추가/삭제를 fail-closed로 검증한다. 이 package에는 Spring/JPA/Repository/Entity/Configuration annotation이 하나도 없다. 즉 JPA adapter 안에 위치하지만 **API vocabulary 자체는 Spring bean discovery나 JPA mapping으로 활성화되지 않는다.** 실제 composition은 `app-bootstrap` 및 implementation package가 소유한다. ##### 2.2 이 API가 “adapter 내부 DTO”와 다른 이유 `api/**`는 implementation package와 달리 의도적으로 외부 adopter surface다. committed API baseline 상단도 `api`를 intended external package로 명시한다. 따라서 다음 두 사실을 구분해야 한다. 1. repository 내부 production consumer가 있는가 2. public library contract로 존재할 이유가 있는가 예를 들어 `JpaEntityNotFoundException`은 현재 repository production에서 자신을 제외한 참조 파일이 0개다. 하지만 이 한 사실만으로 dead type이라고 판정하지 않았다. external API surface는 repository 내부에서 직접 생성되지 않더라도 adopter가 catch/translate하는 계약일 수 있기 때문이다. 반대로 public API라는 이유로 내부 invariant 결함까지 “미사용이라 안전”으로 넘기지는 않는다. `SignedJsonCursorCodec`처럼 codec 자체가 public contract이고 자기 encode/decode algebra가 불일치하면 repository 내부 consumer 유무와 무관하게 API defect다. ##### 2.3 `PersistenceOperationName`: 자유 문자열 대신 등록 가능한 identity를 타입으로 만든다 `PersistenceOperationName`은 `[a-z][a-z0-9.-]{2,95}` 형식만 허용한다. 목적은 단순 validation이 아니다. 이 값은 다음 구현 계층에서 실제로 사용된다. - retry observation - transaction observation - migration gate - PostgreSQL failure translation - JSON/lock observation - transaction executor/coordinator 즉 persistence operation의 이름이 metric/trace/policy lookup으로 퍼지기 전에 **cardinality와 데이터 유출 가능성을 가장 안쪽 public type에서 제한**한다. 전용 테스트는 다음을 확인한다. - 동적 identifier를 붙인 이름 거부 - raw/invalid 형태 거부 - uppercase/길이 초과 거부 - registered dotted name 허용 이 구조는 이후 query 쪽 `QueryName`과 동일한 방향을 가진다. “관측 이름을 호출자가 자유 문자열로 만드는 것”을 허용하지 않는 것이 공통 원칙이다. #### 3. Capability API — 실행 기능과 지원 등급을 reportable contract로 분리 ##### 3.1 `JpaCapability` 현재 enum은 16개 capability id를 갖는다. app-bootstrap의 `JpaPlatformAutoConfiguration.capabilities()` 역시 16개를 선언하므로 **enum catalog와 current composition count는 일치**한다. Stable composition은 대표적으로 다음을 기본 지원으로 보고한다. - transaction retry - completion evidence - keyset pagination - batch - schema gate - runtime-role verification - observability Advanced capability는 PostgreSQL native write/work claim/JSONB/array-range, bulk DML, stateless session, COPY, L2 cache, Envers 등을 constraints와 함께 보고한다. 이 분리는 “classpath에 코드가 있다”와 “현재 composition이 기본 지원한다고 약속한다”를 동일시하지 않는다. capability enum은 vocabulary이고, `CapabilitySupport`가 support level을 결합하며, app-bootstrap composition이 실제 현재 report를 구성한다. ##### 3.2 `CapabilitySupport` record는: ```text capability level constraints[] ``` 을 가진다. constructor가 보장하는 것은: - capability non-null - level non-null - constraints list defensive copy - 각 constraint non-null / non-blank 이다. `usableByDefault()`는 STABLE만 true다. Advanced/Experimental이 “존재하므로 기본 사용 가능”으로 오해되지 않게 support level을 코드에 남긴다. ##### 3.3 actuator까지 이어지는 실제 consumer `CapabilitySupport`는 단순 문서용 record가 아니다. 실제 production 흐름은: ```text JpaPlatformAutoConfiguration.capabilities() -> List -> JpaPlatformReport.capabilities -> JpaPlatformEndpoint @ReadOperation -> management endpoint "jpaplatform" ``` 이다. `JpaPlatformReport`는 JDBC URL/user/password/SQL/entity catalog를 필드로 갖지 않도록 설계되어 있고, privilege detail도 boolean으로 축약한다. 즉 management endpoint의 reconnaissance surface를 줄이려는 의도가 source에 명시돼 있다. ##### 3.4 API invariant gap — “bounded constraint”는 타입이 강제하지 않는다 `CapabilitySupport` javadoc은 constraints를 actuator report에 게시할 수 있는 **plain, bounded string**으로 설명한다. 그러나 constructor는 길이/형식 상한을 두지 않는다. focused constructor probe에서 100,000-character constraint가 그대로 accepted/copy되는 것을 확인했다. ```text constraintLength=100000 ``` 다만 current app-bootstrap composition이 만드는 constraints는 모두 source에 고정된 짧은 literal이다. 따라서 현재 shipped composition에서 즉시 100KB user-controlled value가 endpoint에 노출된다고 주장하지 않는다. 판정: - **Observed:** public API type의 “bounded” invariant는 constructor에서 강제되지 않는다. - **Observed:** type은 actuator report의 실제 element type이다. - **Observed:** current composition은 bounded static literal만 생성한다. - **Conclusion:** current exploit/incident가 아니라 **P2 API-contract hardening gap**이다. 향후 이 type을 외부 composition/fork가 직접 사용하거나 dynamic constraint source가 생기면 report bound가 호출자의 규율에 의존한다. public API가 “safe to publish”를 자기 계약으로 주장하려면 max length/accepted vocabulary를 타입에서 고정하거나, report projection 단계에서 별도 sanitization/bounding이 필요하다. #### 4. Error API — provider exception을 stable failure algebra로 변환 ##### 4.1 `FailureCategory`가 retry보다 먼저 존재한다 error hierarchy의 핵심은 “예외 class를 많이 만든 것”이 아니라 provider-specific signal을 bounded semantic category로 변환하는 것이다. 대표 category는: - serialization failure - deadlock - optimistic conflict - lock not available - connection unavailable - timeout 계열 - unique/FK/not-null/check constraint - entity not found - schema mismatch - data corruption - completion unknown 등이다. 이 category는 뒤의 retry policy/metric이 SQLSTATE/provider message를 직접 해석하지 않게 하는 중간 vocabulary다. ##### 4.2 `JpaFailureContext`: telemetry-safe failure metadata `JpaFailureContext`가 가지는 정보는 operation, SQLSTATE/constraint, attempt, retryability, completion-unknown, elapsed, trace 등으로 제한된다. 중요한 invariant는 다음이다. - arbitrary identifier는 그대로 담지 않고 bounded/redacted form으로 축약 - malformed SQLSTATE는 `redacted` - absent SQLSTATE는 sentinel로 표현 - completion unknown과 retryable=true를 동시에 표현할 수 없음 - completion-unknown factory는 항상 automatic retry를 차단하는 형태를 만든다 즉 “exception이 발생한 뒤 로그에서 실수하지 말자”보다 앞선 위치에서 **failure context가 위험한 shape 자체를 표현하기 어렵게** 만든다. ##### 4.3 `JpaPersistenceException`: bounded message와 raw cause의 역할을 분리 base exception message는 provider cause message를 그대로 복사하지 않고 category + bounded context로 만든다. dedicated test도 provider cause에 email marker를 넣었을 때 top-level exception message에 노출되지 않는 것을 검증한다. 동시에 raw `Throwable cause`는 보존한다. 이는 중요한 구분이다. ```text exception.getMessage() -> bounded platform message exception.getCause() -> original provider failure ``` 따라서 API 자체의 message contract는 안전하게 설계되어 있지만 **downstream logger가 stacktrace/cause message까지 출력해도 안전하다는 뜻은 아니다.** 이 여부는 `observation`/transaction failure logging consumer를 읽을 때 별도 검증해야 한다. 현재 API sub-scope에서는 이를 defect로 확대하지 않고 후속 observation/transaction trace 항목으로 넘긴다. ##### 4.4 constraint exception은 raw constraint name을 외부 meaning으로 쓰지 않는다 `ConstraintCode`/`ConstraintViolationDetails`는 DB constraint의 raw name이 application-visible meaning이 되지 않도록 stable code/details로 변환하기 위한 계약이다. 실제 PostgreSQL catalog/translator가 consumer다. Unique/FK/NotNull/Check exception은 이 details를 결합한다. 즉 application이 `uk_user_email_2026_v2` 같은 physical identifier를 분기 조건으로 쓰는 대신 stable platform vocabulary에 의존하게 한다. 구체적인 catalog mapping 및 SQLSTATE correctness는 PostgreSQL vendor sub-scope에서 exhaustive하게 검증한다. ##### 4.5 completion unknown을 exception type으로 분리 `TransactionCompletionUnknownException`은 단순 `ConnectionUnavailableException`의 한 종류로 흡수되지 않는다. 이 타입은: - failure category = completion unknown - retryable=false - transaction completion evidence - bounded optional transaction key 를 결합한다. constructor도 들어온 context가 retryable 형태라 해도 completion-unknown-safe context로 변환한다. 결과적으로 뒤의 retry policy가 실수하더라도 “commit됐을 수 있는 work를 다시 실행”하는 경로를 만들기 어렵다. 이 contract는 transaction implementation의 evidence frame/commit classifier가 실제로 언제 UNKNOWN을 선택하는지 확인해야 완성된다. 그 실행 의미는 다음 sub-scope의 핵심 대상이다. ##### 4.6 `JpaEntityNotFoundException`: current repository consumer 0 exact-ish repository production reachability probe에서 이 public type만 현재 source 외 production reference가 0이었다. 이 type은 initial JPA platform commit부터 존재하고 committed API baseline에도 명시적으로 포함된다. 따라서 현 단계 판정은: ```text confirmed dead code ✗ current repository production consumer 없음 ✓ committed intended external API ✓ ``` 이다. 향후 public API budget을 줄이는 refactor를 할 때는 “실제 external adopter가 존재하는지”를 확인할 후보지만, source tree만으로 제거 가능하다고 결론 내리지 않는다. #### 5. Query API — pagination 비용과 trust boundary를 type shape로 제한 ##### 5.1 `KeysetPageRequest`: offset 자체가 없다 record는: ```text after: Optional size: 1..500 direction ``` 만 가진다. offset/page number를 아예 표현하지 않으므로 keyset API를 사용하는 consumer가 실수로 large offset pagination으로 회귀하기 어렵다. `fetchSize()`는 요청 size + 1을 반환한다. 즉 별도 count query 없이 한 row를 더 읽어 `hasNext`를 판단하는 계약이다. ##### 5.2 `KeysetSlice`: total count를 contract에서 제거 slice는: ```text items nextCursor hasNext ``` 만 가진다. invariant: - hasNext=true -> nextCursor 필수 - terminal slice -> nextCursor 금지 - items defensive copy 이다. page number/total count가 없다는 것은 API omission이 아니라 의도된 성능 정책이다. “keyset을 쓰면서 매번 count(*)도 수행”하는 모순을 contract shape에서 제거한다. ##### 5.3 `QueryName`과 `QueryObservation` `QueryName`도 bounded registry key다. raw SQL을 metric/trace identity로 사용할 수 없다. `QueryObservation.start(QueryName)` → `QueryScope` 구조에서 scope는: - rows(count) - failure(Throwable) - close() 를 제공한다. 특히 `QueryScope.failure` 문서가 “throwable message를 log하지 말 것”을 직접 계약한다. Micrometer implementation이 이를 실제로 지키는지는 observation sub-scope에서 확인한다. `NoopQueryObservation`은 backend가 없을 때도 caller control flow가 갈라지지 않게 singleton no-op scope를 제공한다. app-bootstrap `JpaObservabilityAutoConfiguration`에서 actual fallback consumer가 존재한다. #### 6. `SignedJsonCursorCodec`: 좋은 trust-boundary 설계와 경계값 결함이 동시에 존재 ##### 6.1 의도된 security properties codec은 다음 token을 만든다. ```text v1.. ``` 확인한 방어는 다음과 같다. - signing key 최소 32 bytes - URL-safe Base64 / no padding - version까지 MAC input에 포함 - token 전체 길이 4096-character cap - payload 2048-byte cap - presented MAC 32-byte exact length 확인 - `MessageDigest.isEqual` constant-time comparison - MAC 검증 전에 application payload decoder를 호출하지 않음 - oversized public input을 substring/decode/MAC allocation 전에 거부하려는 선행 check 기존 dedicated tests도 tampering, foreign key, unknown version, short key, oversized token, wrong-length MAC, oversized payload 등을 폭넓게 검증한다. ##### 6.2 Confirmed P2 — encode가 발급한 2046~2048-byte cursor를 decode가 거부한다 문제는 decoded payload size를 decode **전에** 추정하는 helper다. ```java private static int decodedLengthOf(int encodedLength) { return encodedLength / 4 * 3 + 3; } ``` 이 함수는 “최대 decoded size”를 빠르게 계산하려는 의도로 commit `2f5d2fc`에서 hostile-input bounds와 함께 추가됐다. 그러나 codec은 **unpadded Base64URL**을 사용한다. 실제 self-round-trip probe: ```text size=2045 -> encode OK / decode OK size=2046 -> encode OK / decode rejects as oversized size=2047 -> encode OK / decode rejects as oversized size=2048 -> encode OK / decode rejects as oversized size=2049 -> encode itself rejects ``` 즉 현재 accepted encode domain과 accepted decode domain이 다르다. 형식적으로: ```text encode accepts payload bytes <= 2048 decode precheck effectively accepts only a smaller subset there exists x: encode(x) succeeds decode(encode(x)) fails ``` 이건 hostile token을 더 엄격히 거부하는 정도가 아니다. **codec의 자기 round-trip contract를 깨는 boundary defect**다. 실행 evidence: - `evidence/raw/035a-jpa-cursor-boundary-probe.java` - `evidence/raw/035-jpa-cursor-boundary-probe.txt` ##### 6.3 왜 기존 테스트가 못 잡았는가 현재 `SignedJsonCursorCodecTest`는: - ordinary round-trip - 2049-byte encode rejection - decode 쪽 arbitrary oversized payload segment rejection 을 각각 검증한다. 하지만 `MAX_PAYLOAD_BYTES` 바로 아래와 정확히 같은 크기에 대해: ```text decode(encode(payload)) == payload ``` 을 검증하지 않는다. 따라서 security-bound 테스트는 많지만 **양쪽 bound가 같은 집합을 표현하는지**에 대한 property가 빠져 있다. 수정 후보는 두 방향이다. 1. unpadded Base64URL의 decoded length를 remainder까지 반영해 정확히 계산 2. MAC 검증 전에 encoded segment의 최대 허용 길이를 exact bound로 계산하고, 실제 decode 후 byte length도 재확인 어느 구현을 택하든 regression criterion은 최소: ```text payload sizes: 2045, 2046, 2047, 2048 -> round-trip success 2049 -> encode reject forged oversized segment -> pre-decode reject ``` 이어야 한다. Tech-Log 후보: **CASE** — “DoS 방어용 Base64 사전 크기 검사가 codec의 자기 round-trip을 깨뜨린 경계값 문제”. #### 7. Transaction API — 실행체보다 먼저 retry 가능 상태를 제한한다 ##### 7.1 `TransactionProfile` profile은: - name - propagation - isolation - timeout - readOnly - retryProfile 을 결합한다. write profile은 positive timeout이 필수다. read-only는 zero timeout을 “connection default” 의미로 허용한다. 지원 propagation을 REQUIRED / MANDATORY / REQUIRES_NEW로 좁혀 SUPPORTS/NESTED/NOT_SUPPORTED/NEVER처럼 “실제로 transaction 안에 있는가”를 흐리는 mode를 surface에서 제거했다. isolation 역시 PostgreSQL에서 의미가 겹치는 READ_UNCOMMITTED를 expose하지 않는다. ##### 7.2 `RetryProfile`: completion unknown을 config로 다시 살릴 수 없다 retryable category allowlist는 다음 contender 계열로 제한된다. - serialization failure - deadlock - optimistic conflict - lock not available - connection unavailable `COMPLETION_UNKNOWN`을 넣으면 constructor가 즉시 거부한다. Unique constraint 같은 ineligible category도 거부한다. 즉 failure translator가 retryability를 판단하고, profile이 category allowlist를 가진다고 해서 “어떤 failure도 설정으로 retry 가능하게” 만들 수 없다. ##### 7.3 `RetryDecision`: retry / reconcile / fail을 별도 algebra로 둔다 decision은: - RETRY_FULL_TRANSACTION - RECONCILE - FAIL 세 가지이며 retry만 non-zero delay를 가질 수 있다. 이 분리 덕분에 completion unknown이 `delay=0 retry`처럼 표현되지 않는다. “모르겠음”을 “즉시 한 번 더”와 구분한다. ##### 7.4 `reason`의 bounded 주석과 현재 사용 `RetryDecision.reason`은 javadoc상 bounded diagnostic/low-cardinality-safe string으로 설명된다. 그러나 constructor는 non-null/nonblank만 확인하고 길이/형식 상한은 없다. runtime constructor probe에서는 100,000-character reason도 accepted됐다. ```text retryDecisionReasonLength=100000 ``` 다만 actual `JpaRetryObservation`은 decision.reason을 metric tag로 사용하지 않는다. metric tag는: - persistence unit - operation - failure category - retry disposition 으로 구성된다. current repository의 retry-decision reason accessor도 retry policy test 외 실질 telemetry consumer가 확인되지 않았다. 따라서 현재 판정은: - docs/type invariant mismatch: **observed** - current metric cardinality incident: **not observed** - 우선순위: **P3 API hardening/document precision candidate** 이다. ##### 7.5 `maxAttempts`에는 타입-level upper bound가 없다 `RetryProfile.maxAttempts`는 최소 1만 강제한다. probe에서 `Integer.MAX_VALUE`도 구성 가능했다. 이것만으로 retry storm defect라고 판정하지 않는다. 실제 coordinator에는 elapsed-time budget도 있고 backoff와 profile binding이 별도로 존재한다. 따라서 maxAttempts의 effective runtime bound는 `RetryBudget`, settings, composition을 포함해 **다음 transaction sub-scope에서 판단**한다. ##### 7.6 cross-scope candidate — fallback policy branch의 도달 가능성 API consumer trace 과정에서 `FullTransactionRetryCoordinator`는: ```java profile.retryProfile() == null ? fallbackRetryPolicy : DefaultJpaRetryPolicy.forProfile(profile.retryProfile()) ``` 로 분기한다. 그런데 `TransactionProfile` compact constructor는 `retryProfile`을 `Objects.requireNonNull`로 강제한다. public constructors/factory도 모두 non-null profile을 만든다. 따라서 current type algebra만 보면 fallback branch는 도달 불가능해 보인다. 그러나 이 파일은 transaction sub-scope 소유이고 coordinator 생성/wiring/history까지 읽지 않았으므로 이번 API scope에서는 **dead-path candidate**로만 넘긴다. #### 8. Negative-space probes — API scope ##### 8.1 Public surface reachability Raw: `032-persistence-jpa-api-public-reachability.txt` 49개 public type을 exact import/FQN + same-package reference 기준으로 production tree와 비교했다. 핵심 결과: - 대부분 implementation/app-bootstrap consumer가 존재 - `JpaEntityNotFoundException`: current repository production reference 0 - 일부 codec/SPI는 implementation 내부 또는 app-bootstrap fallback으로만 소비 단 이 probe는 external adopter, reflection/generated code를 볼 수 없고 same-package Javadoc reference를 과대계수할 수 있다. 따라서 zero reference만 meaningful negative evidence로 사용했다. ##### 8.2 Conditional-wiring sibling comparison API package 안에는 Spring configuration/conditional/entity/repository stereotype가 없다. 반면 actual runtime composition은 app-bootstrap의: - `JpaPlatformRuntimeAutoConfiguration` - `JpaObservabilityAutoConfiguration` - `JpaTransactionAutoConfiguration` 등이 소유한다. 즉 API가 자체 component scan/autoconfiguration으로 몰래 활성화되는 경로는 확인되지 않았다. 이는 “adapter leaf는 implementation을 제공하고 app-bootstrap이 composition을 소유한다”는 repository 정책과 맞는다. ##### 8.3 Duplicate-mechanism sweep repository에는 다른 bounded context에 같은 simple name이 있다. - inbound-web `CursorCodec` - httpclient `RetryDecision` - httpclient `FailureCategory` - cache-redis `SortDirection` 그러나 package와 input/output responsibility가 서로 다르다. 예를 들어 inbound web cursor codec은 transport pagination cursor 계약이고, persistence cursor codec은 ordering-key payload에 대한 HMAC integrity seam이다. httpclient retry algebra 역시 HTTP request/retry ambiguity를 다룬다. 따라서 **same-name duplication은 존재하지만 현재 evidence로 competing implementation defect는 아니다.** 오히려 bounded context별 vocabulary가 우연히 같은 이름을 갖는 경우다. ##### 8.4 Documentation / count drift - committed API baseline: 49 `api` top-level types - current source: 49 - `JpaCapability` enum: 16 - current app-bootstrap capability declarations: 16 현재 수치 drift 없음. API surface verification도 별도 Gradle task가 소유하므로 수동 문서 count만 믿는 구조가 아니다. #### 9. 테스트와 증명 범위 ##### 9.1 Dedicated API tests 전용 test class는 6개다. - `PersistenceOperationNameTest` - `JpaFailureContextTest` - `JpaPersistenceExceptionTest` - `QueryNameTest` - `SignedJsonCursorCodecTest` - `TransactionProfileTest` fresh `--rerun-tasks` 실행에서 모두 통과했다. 이들은 다음을 잘 검증한다. - low-cardinality operation/query name shape - completionUnknown/retryable contradiction 차단 - top-level exception message의 provider-message 비노출 - cursor signature/tamper/version/size basics - transaction/retry profile의 주요 unsafe shape 하지만 확인된 cursor self-round-trip boundary property는 포함하지 않는다. ##### 9.2 API surface verification `verifyJpaApiSurface --rerun-tasks`가 통과했다. 이 task가 증명하는 것은 **public type names가 committed baseline과 동일하다**는 것이다. method semantics나 constructor invariant까지 ABI/API compatibility를 검증하는 것은 아니다. ##### 9.3 app-bootstrap capability composition test current repository composition은 별도 app-bootstrap test로 확인한다. 이 test는 현재 16개 capability와 default usable support 등을 검증하지만 public `CapabilitySupport`에 arbitrary external constraints가 들어오는 경우의 bound를 검증하는 test는 아니다. #### 10. API sub-scope findings backlog ##### P2 — `SignedJsonCursorCodec` accepted encode domain과 decode domain 불일치 - **Observed:** 2046~2048-byte payload는 encode 성공 후 자기 token decode 실패. - **Cause shape:** unpadded Base64URL encoded segment의 decoded byte count를 `encodedLength / 4 * 3 + 3`으로 과대 추정. - **Why it matters:** public paging cursor codec이 자기가 발급한 token을 다음 page에서 거부할 수 있음. - **Existing tests:** green이지만 exact-bound round-trip 없음. - **Verification:** `035` runtime probe + 향후 boundary regression/property test. - **Candidate fix:** exact unpadded Base64 decoded-size arithmetic 또는 encoded-length exact cap + post-decode byte cap. - **Tech-Log:** CASE 우선 후보. ##### P2 — `CapabilitySupport.constraints`의 bounded/report-safe 계약이 타입에서 강제되지 않음 - **Observed:** 100,000-character constraint accepted. - **Observed:** capability list는 `JpaPlatformReport`를 통해 actuator endpoint model에 포함됨. - **Observed:** current shipped composition은 static short literals만 생성. - **Impact:** 현 composition incident가 아니라 public API invariant gap; fork/dynamic composition에서 bound가 caller discipline에 의존. - **Candidate fix:** type-level max length/vocabulary 또는 report projection에서 bounding. - **Tech-Log:** OPEN QUESTION/DECISION 후보; 실제 외부 dynamic source가 확인되면 CASE 승격 가능. ##### P3 — `RetryDecision.reason`의 “bounded” 설명과 constructor contract 불일치 - **Observed:** 100,000-character reason accepted. - **Observed:** current retry metrics는 reason을 tag로 사용하지 않음. - **Impact:** 현재 cardinality defect로 확인되지 않음. - **Candidate:** length bound를 추가하거나 javadoc의 low-cardinality claim을 실제 사용 범위에 맞게 좁힘. ##### Cross-scope candidate — retry fallback branch reachability - `TransactionProfile.retryProfile`은 non-null invariant. - `FullTransactionRetryCoordinator`는 null retryProfile을 fallback policy 선택 조건으로 사용. - transaction wiring/history까지 확인 후 dead branch인지 판정. ##### External-surface candidate — `JpaEntityNotFoundException` - current repository production consumer 0. - committed intended external API baseline에는 존재. - external adoption evidence 없이 dead/remove 판정 금지. #### 11. API sub-scope에서 확인한 것과 남긴 경계 ##### FULL_READ - production `api/**`: 49 / 49 - dedicated test `api/**`: 6 / 6 - unclassified: 0 ##### Cross-scope evidence로 읽은 consumer - `JpaPlatformAutoConfiguration` - `JpaPlatformReport` - `JpaPlatformEndpoint` - `JpaPlatformRuntimeAutoConfiguration` relevant wiring - `JpaRetryObservation` - `DefaultJpaRetryPolicy` - `FullTransactionRetryCoordinator` relevant control flow 이 consumer 파일들은 API 의미를 확인하기 위한 cross-scope trace이며, 해당 소유 sub-scope 전체가 FULL_READ됐다는 뜻은 아니다. ##### 다음 sub-scope로 넘긴 것 - 실제 transaction begin/commit/rollback/evidence semantics - `RetryBudget`가 maxAttempts/defaultMaxElapsed를 어떻게 결합하는지 - completion unknown record/reconciliation path - provider failure translator chain과 raw cause logging - Micrometer query scope가 `Throwable` message를 실제로 무시하는지 - PostgreSQL SQLSTATE/constraint mapping correctness --- #### 12. Sub-scope 03 — transaction + persistence failure > 내부 상태: COMPLETE — 32 production + 19 test, **51 / 51 FULL_READ** > 범위: `persistence/transaction/**`, `persistence/failure/**`와 matching dedicated tests > 핵심 질문: transaction을 여는 코드가 아니라 **commit 결과를 언제 확정하는가, 어떤 failure만 replay하는가, completion-unknown을 어떤 evidence로 남기는가**. ##### 12.1 숫자 지도 | package | production | dedicated test | 역할 | |---|---:|---:|---| | `transaction` | 29 | 18 | application transaction port, JPA executor, retry, deadline, completion evidence | | `failure` | 3 | 1 | shared operational-error translation | | **합계** | **32** | **19** | **51** | 모든 51개 source/test를 FULL_READ했다. 이 scope에서는 implementation class를 샘플링하지 않고 transaction state machine, retry budget, Spring mapping, failure translation, root wiring, consumer reachability까지 연결했다. #### 13. 같은 leaf 안에 두 개의 transaction model이 존재한다 현재 persistence-jpa에는 transaction을 표현하는 두 계열이 동시에 존재한다. ##### A. application-core canonical boundary ```text PolicyTransactionPort / TransactionPort -> SpringTransactionPort (@Component) -> SpringPolicyTransactionPort -> PlatformTransactionManager ``` input/output vocabulary: - `TransactionRequest` - `TransactionPolicyId` - `CallBudget` - `TransactionResult` - `TransactionOutcome` - `OperationId` - `TransactionPhase` - `ReconciliationReference` 이 모델은 application-core가 소유한다. use case가 outbound adapter type을 import하지 않아도 transaction policy와 uncertain outcome을 표현할 수 있다. ##### B. persistence-jpa public API boundary ```text JpaTransactionExecutor -> SpringJpaTransactionExecutor -> FullTransactionRetryCoordinator -> TransactionProfile / RetryProfile / JpaRetryPolicy ``` input/output vocabulary: - `PersistenceOperationName` - `TransactionProfile` - `RetryProfile` - `JpaPersistenceException` - `TransactionCompletionEvidence` `JpaPlatformRuntimeAutoConfiguration`은 `PlatformTransactionManager`가 있으면 `SpringJpaTransactionExecutor` bean을 만들고, 그 executor가 있으면 `FullTransactionRetryCoordinator` bean도 만든다. 따라서 source tree 수준에서는 B가 단순 historical class가 아니라 **현재 runtime bean graph에도 포함되는 구현**이다. 그러나 repository production call search에서는 `FullTransactionRetryCoordinator.execute(...)`를 실제 business/application code가 호출하는 경로가 확인되지 않았다. 반대로 application-core transaction port는 sample/use-case/composition에서 canonical contract로 사용된다. 이 공존 자체는 곧바로 defect가 아니다. `api/**`는 intended external surface이므로 fork/application이 B를 programmatically 사용할 수 있다. 문제는 문서가 두 boundary의 관계를 일관되게 설명하지 못하고, 일부 composition helper는 실제 type relationship과 다른 설명을 한다는 점이다. #### 14. `SpringTransactionPort`: application-core의 실제 Spring 구현 `SpringTransactionPort`는 `PolicyTransactionPort`를 구현하며 `JpaAdapterComponentsConfig`의 narrow component scan으로 등록된다. 이 wiring은 중요하다. root `CaSkeletonApplication`은 persistence package를 broad scan에서 의도적으로 제외한다. 그래서 adapter leaf 내부의 `@Component`를 “annotation이 있으니 알아서 등록될 것”이라고 볼 수 없다. `JpaAdapterComponentsConfig` source에는 과거 실제 회귀가 기록돼 있다. - persistence package를 broad scan에서 제외 - `SpringTransactionPort` 같은 component를 별도 scan하지 않음 - 처음 transaction port가 필요한 capability가 조립될 때 unsatisfied dependency로 드러남 - 해결: JPA master switch 아래에서만 persistence adapter package를 narrow scan 즉 이 module에서 Spring stereotype의 존재와 runtime reachability는 별개다. current root는 `PersistenceJpaRootAutoConfiguration -> JpaAdapterComponentsConfig -> component scan` 체인을 통해 이를 해결한다. ##### 14.1 기본 transaction mode `TransactionPort` primitive는 다음으로 매핑된다. | application operation | Spring propagation | isolation | read-only | |---|---|---|---| | `inWrite` | REQUIRED | READ_COMMITTED | false | | `inRootWrite` | REQUIRED | READ_COMMITTED | false | | `inRead` | REQUIRED | READ_COMMITTED | true | | `inNew` | REQUIRES_NEW | READ_COMMITTED | false | 특히 vendor default isolation에 맡기지 않고 READ_COMMITTED를 명시한다. `inRootWrite`는 REQUIRED이지만 일반 `inWrite`와 의미가 다르다. 시작 전에 `TransactionSynchronizationManager.isActualTransactionActive()`를 확인해 ambient physical transaction이 있으면 manager/action 호출 전에 거부한다. “root boundary”를 REQUIRED의 join semantics로 조용히 바꾸지 않는다. focused test는 실제로 manager call count/action call count까지 0인지 확인한다. ##### 14.2 caller-visible 성공은 physical commit 이후 `SpringTransactionPortTest`는 다음을 고정한다. - work가 value를 만들었다고 바로 caller에게 반환하지 않음 - transaction template/manager commit이 끝난 뒤에만 success가 caller-visible - commit failure면 work value를 반환하지 않음 - action failure는 rollback - REQUIRES_NEW는 별도 propagation 이것은 application-core의 `TransactionPort` javadoc이 요구한 “return after physical commit”을 adapter가 실제로 구현하는 evidence다. #### 15. `SpringPolicyTransactionPort`: transaction result를 boolean 성공/실패보다 세밀하게 표현 `PolicyTransactionPort.inTransaction(...)`은 단순 예외 기반 wrapper가 아니다. 결과는 최소 다음 상태를 구분한다. - `Committed` - `CommittedWithPostCommitFailure` - `Participating` - `DeterminateRollback` - `Indeterminate` 핵심은 **commit exception = rollback**으로 가정하지 않는 것이다. ##### 15.1 commit failure 분기 commit 호출 전에 phase를 `COMMIT_REQUESTED`로 올리고, Spring `TransactionSynchronization` sentinel로 실제 callback을 관찰한다. commit에서 exception이 발생해도: 1. `afterCommit()`이 이미 확인됐으면 `CommittedWithPostCommitFailure` 2. rollback callback/`UnexpectedRollbackException`/replay-candidate가 확인되면 `DeterminateRollback` 3. 그 외에는 `Indeterminate` 로 나눈다. 즉 연결 끊김 같은 애매한 exception을 “rollback이겠지”라고 간주하지 않는다. ##### 15.2 canonical application path는 자동 duplicate replay를 막는다 `Indeterminate`는 retry 대상이 아니다. replay 조건은 모두 만족해야 한다. - policy = `COMMAND_SERIALIZABLE_REPLAY_SAFE` - 현재 attempt가 physical transaction owner - attempt < configured max - current thread not interrupted - result가 `DeterminateRollback` - failure가 40001 serialization 또는 40P01 deadlock replay candidate 따라서 commit ack를 못 받은 상태는 replay되지 않는다. 이 점은 뒤에서 다룰 JPA public API completion-evidence wiring gap의 중요한 mitigation이다. **현재 canonical application path는 completion evidence infrastructure가 없어도 불확정 commit을 자동 재실행하지 않는다.** 다만 current `SpringPolicyTransactionPort`가 만드는 `TransactionResult.Indeterminate`의 `reconciliationReference`는 두 생성 경로 모두 `Optional.empty()`다. 즉 application-core type은 durable reconciliation reference를 표현할 수 있지만 이 adapter는 현재 그 reference를 채우지 않는다. #### 16. CallBudget를 transaction timeout보다 먼저 적용한다 application policy path는 timeout을 단순히 `TransactionDefinition.setTimeout()` 하나로 끝내지 않는다. ##### 16.1 `JpaTransactionSettings` `ca-skeleton.jpa.transaction` settings는 transaction/resource-budget defaults를 가진다. 주요 invariant: - duration positive - duration <= 1 day - retry max attempts 1..5 - statement timeout <= transaction timeout - lock timeout < statement timeout - completion/acquisition/action margin hierarchy 즉 runtime에서 무한 retry나 무한 transaction timeout을 property 하나로 열 수 없게 hard cap을 둔다. 이 점은 API `RetryProfile.maxAttempts`가 upper bound를 갖지 않는 것과 대비된다. canonical application path는 실제 deployment settings에서 최대 5회를 강제한다. ##### 16.2 `TransactionDeadlineCalculator` CallBudget admission은 connection pool을 빌리기 **전**부터 시작한다. transaction을 열 가치가 있으려면 남은 budget이 최소 다음을 감당해야 한다. ```text pool acquisition reserve + transaction begin reserve + minimum action budget + completion margin ``` begin 후에는 실제 남은 budget으로: - Spring whole-transaction timeout - statement timeout - lock timeout - idle-in-transaction timeout 을 다시 계산한다. 따라서 pool에서 오래 기다린 요청이 “원래 5초 timeout이었으니 DB에서 다시 5초”를 받지 않는다. 이미 소비한 wall-clock budget을 transaction layer가 다시 주지 않는 구조다. ##### 16.3 `TransactionRetryBackoff` canonical path의 retry backoff도 CallBudget-aware다. 다음 attempt를 시작하기 전에: - jitter delay - 다음 acquisition reserve - 다음 최소 transaction/action margin 을 모두 감당할 수 있는지 확인한다. budget이 부족하면 sleep 후 시작했다가 즉시 timeout되는 대신 retry 자체를 포기한다. #### 17. retry classification은 structured state로 제한한다 `TransactionRetryClassifier`는 cause chain에서 SQLSTATE를 찾지만 automatic replay candidate는: - `40001` - `40P01` 뿐이다. `08007` 같은 transaction-resolution-unknown은 candidate가 아니다. `SpringPolicyTransactionPort`는 ordinary command에서 40001이 나더라도 `COMMAND_SERIALIZABLE_REPLAY_SAFE`가 아니면 retry하지 않는다. failure 종류뿐 아니라 **업무 side-effect가 replay-safe하다고 application policy가 선언했는가**가 함께 필요하다. 이것은 “DB가 retryable이라고 말하니 use case를 다시 실행”하는 구조와 다르다. #### 18. public JPA path: `SpringJpaTransactionExecutor` 이 executor는 한 번의 physical attempt만 담당한다. 자체 retry는 하지 않는다. 실행 순서: ```text TransactionEvidenceContext.begin(...) -> TransactionTemplate.execute(work) -> success return or -> attempt boundary에서 failure translation -> translated runtime exception rethrow finally -> TransactionEvidenceScope close ``` attempt boundary에서 operation, attempt number, elapsed time, reconciliation key를 알고 있으므로 raw provider exception을 `JpaPersistenceException`으로 변환하는 위치로 사용된다. vendor translator가 조립되면 PostgreSQL 40001/40P01 같은 structured SQLSTATE가 stable exception으로 바뀌어 coordinator가 처리할 수 있다. #### 19. `FullTransactionRetryCoordinator`: whole-use-case retry 의도 coordinator는 `JpaPersistenceException`만 catch하고, retry decision에 따라 **새 transaction / 새 persistence context에서 전체 work를 다시 호출**한다. 설계상 중요한 guard: - completion unknown -> no retry - irreversible side effect context -> no retry - retry budget elapsed -> stop - max attempts -> stop - backoff interrupt -> stop - retry listener는 observation only 이 모델 자체의 unit tests는 강하다. serialization/deadlock retry, exhaustion, completion unknown no-retry, interrupted sleep, irreversible side effect 등을 검증한다. 하지만 current implementation에는 public composition contract와 맞지 않는 별도 defect가 있다. #### 20. Confirmed P2 — application-supplied `JpaRetryPolicy`가 valid execution에서 무시된다 `JpaTransactionAutoConfiguration`은 명시적으로 다음 overload를 제공한다. ```java retryCoordinator( SpringJpaTransactionExecutor executor, JpaRetryPolicy policy, RetryEventListener listener) ``` javadoc도 **“retry coordinator for an application-supplied policy”**라고 설명한다. constructor는 이 policy를 `fallbackRetryPolicy`로 저장한다. 그러나 `FullTransactionRetryCoordinator.execute(...)`는 실행마다: ```java profile.retryProfile() == null ? fallbackRetryPolicy : DefaultJpaRetryPolicy.forProfile(profile.retryProfile()) ``` 를 선택한다. API `TransactionProfile` compact constructor는 `retryProfile`을 non-null로 강제한다. 따라서 valid `TransactionProfile`을 사용하면 `fallbackRetryPolicy` branch는 도달할 수 없다. 더구나 바로 다음 `RetryBudget.forProfile(profile.retryProfile(), ...)`도 non-null profile을 요구하므로 null branch가 hypothetically 열려도 정상 execution model과 맞지 않는다. ##### 실행 probe custom policy를 다음처럼 넣었다. ```text custom policy decision = always FAIL custom policy invocation counter valid TransactionProfile with maxAttempts=2 work = SerializationFailureException ``` 결과: ```text customPolicyCalls=0 workCalls=2 ``` 즉 custom policy가 “retry하지 말라”고 해도 한 번도 호출되지 않고 default profile policy에 따라 work가 두 번 실행됐다. Raw: - `evidence/raw/047a-jpa-custom-retry-policy-probe.java` - `evidence/raw/047-jpa-custom-retry-policy-probe.txt` 이것은 단순 dead field가 아니라 **public composition factory가 제공하는 custom policy 기능이 실제로 작동하지 않는 functional contract bug**다. 우선순위: **P2** 수정 방향 후보: 1. coordinator가 constructor-supplied policy를 authoritative하게 사용하고 budget만 profile에서 계산 2. custom policy overload를 제거하고 RetryProfile이 단일 SSOT임을 API에 명시 3. custom policy가 profile-aware해야 한다면 factory에서 policy/profile을 하나의 object로 합성 현재처럼 두 설정원을 받되 하나를 silent ignore하는 형태가 가장 위험하다. #### 21. completion evidence state machine 자체는 잘 설계돼 있다 `EvidenceAwareJpaTransactionManager`는 `JpaTransactionManager`를 상속하고 transaction phase를 `TransactionEvidenceContext`에 기록한다. 대략: ```text NOT_STARTED -> ACTIVE (begin) -> COMMITTING (provider commit 직전) -> COMMITTED (provider commit return) ``` rollback은 `ROLLED_BACK`으로 표시한다. commit 중 RuntimeException이 발생하면 `CommitFailureClassifier`가 **commit phase라는 사실**과 cause chain을 함께 보고 completion unknown 여부를 판단한다. ##### 21.1 `CommitFailureClassifier` completion unknown candidate: - SQLSTATE 40003 - connection class 08* - admin shutdown / crash / cannot-connect-now 계열 - transport break cause 이다. 중요한 건 이 classifier를 generic SQLSTATE translator 대신 **commit call 내부에서만** 적용한다는 것이다. connection reset이 query 실행 중 발생했다면 connection unavailable일 수 있지만, provider에게 COMMIT을 보낸 후 reset됐다면 “commit됐는지 모름”이다. SQLSTATE만으로 이 둘을 구분할 수 없고 transaction phase가 필요하다. PostgreSQL classifier source도 이 이유를 직접 설명한다. #### 22. historical regression — REQUIRES_NEW evidence stack ownership `TransactionEvidenceContext`는 single ThreadLocal slot이 아니라 stack을 사용한다. 이유는 `REQUIRES_NEW` 때문이다. 과거에는: - transaction manager가 commit/rollback 후 frame pop - executor finally도 자신이 push한 frame을 pop 두 owner가 존재했다. outer와 inner가 같은 operation/attempt를 가진 경우: 1. inner manager가 inner frame pop 2. inner executor finally가 top을 보고 outer frame까지 자기 것이라고 착각해 pop 3. outer commit failure 시 operation/reconciliation key evidence가 사라짐 현재는: - manager는 phase만 mark - `TransactionEvidenceScope`만 pop owner - scope는 depth identity를 갖고 자기 frame이 top일 때만 pop 으로 고쳐졌다. `TransactionEvidenceScopeTest`와 `EvidenceAwareJpaTransactionManagerTest`가 nested/thread-local cleanup을 고정한다. 이것은 **현재 defect가 아니라 잘 복구된 historical CASE 후보**다. Clean Architecture보다 transaction infrastructure의 “소유권을 하나로 만들지 않으면 lifecycle evidence가 깨진다”는 주제로 가치가 있다. #### 23. Confirmed P1 — Stable completion-evidence capability가 shipped composition에 설치되지 않는다 여기서는 알고리즘 존재와 runtime wiring을 분리해야 한다. ##### 23.1 custom manager production construction = 0 production source 전체에서: ```text EvidenceAwareJpaTransactionManager.standard(...) new EvidenceAwareJpaTransactionManager(...) ``` 호출이 없다. `JpaTransactionAutoConfiguration` 문서는: > manager itself is constructed inside the persistence leaf ... composition root owns the decision whether to install it 라고 설명하지만, 현재 persistence root/import/config 어디에도 실제 installation code가 없다. `commitFailureClassifier()` factory도 production consumer가 없다. 따라서 `SpringJpaTransactionExecutor`가 `TransactionEvidenceContext.begin()`으로 frame을 만들더라도 일반 `PlatformTransactionManager`는 그 frame을 `ACTIVE/COMMITTING/COMMITTED`로 advance하지 않는다. frame은 기본 `NOT_STARTED` 상태로 남는다. ##### 23.2 실제 commit-ack-loss classification probe 현재 compiled executor에 normal fake `PlatformTransactionManager`를 넣고 commit에서: ```text TransactionSystemException cause -> SQLException SQLSTATE 08006 ``` 를 발생시켰다. 현재 vendor translator까지 포함한 결과: ```text type=ConnectionUnavailableException category=CONNECTION_UNAVAILABLE completionUnknown=false retryable=false ``` Raw: - `evidence/raw/050a-jpa-commit-ambiguity-probe.java` - `evidence/raw/050-jpa-commit-ambiguity-probe.txt` 이 결과는 중요한 두 면을 가진다. ###### 안전하게 남은 부분 `CONNECTION_UNAVAILABLE`은 current PostgreSQL translator에서 retryable=false다. 따라서 이 probe의 lost commit ack가 coordinator에서 자동 duplicate retry되는 것은 확인되지 않았다. ###### 깨진 부분 하지만 commit call 중 connection을 잃었다는 **phase-sensitive 의미가 사라졌다.** caller는 “DB에 연결할 수 없었다”와 “COMMIT을 보냈고 결과를 모른다”를 구분할 수 없다. 이 구분을 위해 만들어진 `CommitFailureClassifier`/`TransactionCompletionUnknownException`이 composition에서 동작하지 않는다. ##### 23.3 reconciliation record production path = 0 `CompletionUnknownRecord`와 `CompletionUnknownRecorder`는 current production에서 자신들의 정의 외 consumer/implementation이 없다. 그런데 documentation은 훨씬 강한 계약을 선언한다. support matrix: ```text Commit completion evidence = Stable Automatic reconciliation unsupported. The platform records; the domain resolves. ``` runbook: ```text Signal: - jpa.transaction.completion.unknown incremented - a CompletionUnknownRecord in the reconciliation channel ``` 그리고 operator procedure는 그 record의 `transactionKey`를 사용하라고 한다. 현재 이 record를 실제로 쓰는 production channel은 확인되지 않았다. ##### 23.4 completion-unknown metric도 현재 transaction path에서 호출되지 않는다 `JpaTransactionObservation.recordCompletionUnknown(...)` 구현은 존재한다. 하지만 production에서: - `JpaObservabilityAutoConfiguration` construction = 0 - `JpaTransactionObservation.recordCompletionUnknown(...)` call = 0 - `recordCommitted/recordRolledBack/recordTimedOut` call도 0 이다. `JpaPlatformRuntimeAutoConfiguration`이 만드는 default `RetryEventListener`도 empty implementation이며, `JpaObservabilityAutoConfiguration`을 통해 metric listener로 합성하지 않는다. 따라서 runbook의 `jpa.transaction.completion.unknown` signal은 현재 source wiring으로는 생성 근거를 찾지 못했다. 이 observability factory 전체의 reachability 문제는 later observation/baseline capability sub-scope에서 다시 exhaustive하게 확인한다. 여기서는 completion-unknown path의 cross-scope evidence로만 기록한다. ##### 23.5 canonical application boundary의 mitigation 이 defect가 곧 “현재 모든 use case가 unknown commit을 duplicate retry한다”는 뜻은 아니다. canonical `SpringPolicyTransactionPort`는 독립적인 Spring synchronization sentinel을 사용해 commit exception을 `TransactionResult.Indeterminate`로 반환하며 replay하지 않는다. 즉 current application path의 **automatic retry safety는 별도 mechanism으로 유지**된다. 하지만: - JPA platform public executor/coordinator가 advertised completion evidence를 제공하지 못함 - durable record 없음 - runbook metric 없음 - application `Indeterminate`도 reconciliationReference는 empty 이므로 advertised operator reconciliation contract는 충족되지 않는다. 우선순위: **P1 — reliability / data-integrity operations contract** 범위 한정: - current canonical PolicyTransactionPort는 no-auto-retry safety를 유지한다. - P1은 “commit ambiguity를 stable semantic + durable reconciliation evidence로 표면화한다”는 JPA platform 약속이 실제 composition에서 빠진 점이다. - real PostgreSQL lost-ack end-to-end behavior는 later PostgreSQL integration lane에서 추가 qualification해야 한다. Tech-Log: CASE + DECISION 강한 후보. #### 24. dual transaction stack의 architecture drift commit `2f5d2fc`에서 old `@RetryableJpaTransaction` interceptor가 삭제됐다. 그 diff는 이유를 명시한다. - application service에 outbound adapter annotation을 붙이면 dependency direction 역전 - canonical boundary는 `PolicyTransactionPort.inTransaction(...)` 여기까지는 Clean Architecture와 일치한다. 문제는 이어지는 문장이: > the retry coordinator below is what implements it, not a second way to ask for the same thing 이라고 말한다는 점이다. 실제 type graph는 그렇지 않다. ```text PolicyTransactionPort <- SpringTransactionPort FullTransactionRetryCoordinator X implements PolicyTransactionPort 아님 ``` 또 runtime auto-configuration은 coordinator를 별도 bean으로 계속 만든다. 따라서 현재 code/doc 관계는: - annotation-based retry path는 제거됨 - application-core canonical port implementation은 별도로 생김 - old/public JPA executor+coordinator model도 남음 - 문서는 coordinator가 canonical port를 구현한다고 잘못 설명 이다. 이것은 단순 문장 오타보다 architecture transition이 완전히 정리되지 않은 흔적이다. 우선순위: **P2 architecture consistency** 결정이 필요하다. 1. JPA executor/coordinator를 진짜 external platform API로 유지한다면 canonical application port와 역할 차이를 명시하고 runtime bean/export 정책을 분리 2. application-core port로 완전히 수렴한다면 coordinator/profile registry 등 old path를 deprecate/remove하고 completion evidence를 canonical path로 이식 현재처럼 “한 경로라고 문서화했지만 실제 두 경로가 존재”하는 상태는 유지보수자가 어느 retry/evidence system을 고쳐야 하는지 혼란을 만든다. #### 25. P3 — `TransactionProfileRegistry`는 declarative retry 제거 후 legacy residue 후보 `TransactionProfileRegistry`는 current production reference가 0이다. history를 보면 initial design에서는: ```text @RetryableJpaTransaction -> RetryableJpaTransactionInterceptor -> TransactionProfileRegistry -> FullTransactionRetryCoordinator ``` 형태였다. commit `2f5d2fc`에서 annotation/interceptor와 그 test를 삭제했지만 registry는 남았다. 현재: - production consumer 0 - dedicated unit test만 존재 - `api/**` intended external surface가 아니라 implementation `transaction` package - root bean wiring도 없음 이 evidence 범위에서는 **confirmed unused production implementation candidate**로 볼 수 있다. 단 repository 밖 reflection/external direct construction은 source search로 알 수 없으므로 즉시 삭제 가능성까지 확정하지 않는다. module의 non-api package는 intended external이 아니라는 architecture policy와 함께 보면 cleanup 우선순위는 높아진다. 우선순위: **P3 cleanup** #### 26. zero-reference지만 dead가 아닌 `JpaTransactionConfig` 반대로 `JpaTransactionConfig`도 direct production reference는 거의 없다. 하지만 이 class는: ```java @Configuration @EnableConfigurationProperties(JpaTransactionSettings.class) ``` 이고 `JpaAdapterComponentsConfig`가 transaction package를 component scan한다. 따라서 direct Java call/import가 0이어도 runtime reachability가 있다. 이 class source 자체도 historical reason을 기록한다. - root `@ConfigurationPropertiesScan`에서 optional persistence tree 제외 - JPA on 상태에서도 settings가 아무도 bind하지 않던 문제 발생 - transaction port construction 실패 - package-local configuration으로 JPA master switch 안에서만 settings enable 이 사례는 mandatory public-reachability probe가 필요한 이유를 잘 보여준다. static reference count만으로 dead code를 찾으면 Spring discovery path를 오탐한다. #### 27. 두 failure translator 계열은 현재 역할이 다르다 이 scope에는 이름이 비슷한 두 translation mechanism이 있다. ##### `PersistenceFailureTranslatorChain` input: ```text raw persistence/provider failure ``` output: ```text JpaPersistenceException hierarchy ``` consumer: ```text SpringJpaTransactionExecutor / retry semantics ``` 목적은 SQLSTATE/optimistic conflict를 retry/completion semantics에 필요한 stable persistence failure로 바꾸는 것이다. ##### `failure.PersistenceExceptionTranslator` input: ```text RuntimeException / SQLSTATE mapping ``` output: ```text shared OperationalError / PersistenceFailureException ``` consumer는 adapter/application error boundary 쪽이다. 따라서 동일 이름 영역을 다루지만 current evidence로는 competing duplicate implementation이 아니다. **transaction retry algebra와 platform operational error mapping이라는 서로 다른 output contract**를 가진다. PostgreSQL vendor translator와 exact SQLSTATE catalog correctness는 vendor sub-scope에서 계속 검증한다. #### 28. conditional-wiring probe transaction 관련 configuration은 세 종류로 나뉜다. ##### 28.1 component-scan-owned - `SpringTransactionPort` - `PersistenceExceptionTranslator` - `JpaTransactionConfig` `PersistenceJpaRootAutoConfiguration`이 JPA master switch ON일 때 `JpaAdapterComponentsConfig`를 import하고, 그 narrow scan이 이들을 찾는다. ##### 28.2 runtime bean-factory-owned `JpaPlatformRuntimeAutoConfiguration`: - `SpringJpaTransactionExecutor` — `PlatformTransactionManager`가 있을 때 - `FullTransactionRetryCoordinator` — executor가 있을 때 - default empty `RetryEventListener` ##### 28.3 현재 설치되지 않는 specialized implementation - `EvidenceAwareJpaTransactionManager` - `CompletionUnknownRecorder` - `JpaTransactionObservation` call path 이 셋은 문서상 completion-evidence/operability의 핵심이지만 current root assembly에서 provider가 없다. 이 sibling comparison으로 “class가 있으니 feature가 있다”는 판단을 피했다. #### 29. documentation drift transaction docs에는 현재 서로 다른 세 시대의 설계가 겹쳐 있다. ##### current source truth application-core canonical: ```text PolicyTransactionPort -> SpringTransactionPort ``` public JPA runtime: ```text SpringJpaTransactionExecutor + FullTransactionRetryCoordinator bean도 별도 존재 ``` ##### `JpaTransactionAutoConfiguration` javadoc canonical port와 coordinator가 같은 구현인 것처럼 설명 — current type graph와 불일치. ##### `docs/jpa/transaction-guide.md` application service가 `TransactionPort or JpaTransactionExecutor`로 boundary를 연다고 설명 — 두 public usage model을 함께 유지하는 설명. ##### `support-matrix.md` / runbook completion evidence Stable, platform records unknown, metric + reconciliation record가 있다고 설명 — current wiring과 불일치. 따라서 transaction documentation은 단순 오래된 class 이름 수준이 아니라 **어떤 transaction model이 canonical인지와 Stable capability가 무엇을 실제 제공하는지**를 재정렬해야 한다. #### 30. fresh verification과 실제 증명 범위 ##### 30.1 transaction/failure focused tests fresh: ```text :adapter:outbound:persistence-jpa:test --tests transaction.* --tests failure.* --rerun-tasks ``` 결과: BUILD SUCCESSFUL. 이 19개 dedicated test가 강하게 증명하는 것: - retry backoff math - commit failure classifier 자체의 commit-unknown 분류 - completion record value construction - EvidenceAware manager 자체의 phase marking - nested evidence scope cleanup - FullTransactionRetryCoordinator 자체의 retry/no-retry decisions - CallBudget/timeout calculations - application policy port의 determinate/indeterminate/commit result - root-only tx rejection - SQLSTATE operational error mapping 그러나 **custom manager의 production installation**은 이 test들이 증명하지 않는다. ##### 30.2 root wiring tests fresh app-bootstrap: - `JpaPlatformAddonAssemblyTest` - `JpaPlatformRuntimeAutoConfigurationTest` - `CapabilityEntityScanRegistrationTest` 결과: BUILD SUCCESSFUL. 이들은: - old class-level `@ConditionalOnBean(DataSource)` ordering regression 방지 - DataSource/PTM이 있을 때 executor/coordinator bean assembly - persistence root import/entity scan shape 를 증명한다. 하지만 `PlatformTransactionManager`가 `EvidenceAwareJpaTransactionManager`인지, completion record/metric이 실제 transaction path에서 발생하는지는 assert하지 않는다. ##### 30.3 real lost-ack qualification은 아직 아님 `050` probe는 fake manager commit failure로 classification path를 isolate한 것이다. 실제 PostgreSQL server가 commit을 적용한 직후 client ack/network를 끊는 시나리오까지 재현한 것은 아니다. 그 수준의 evidence는 `postgresqlIntegrationTest` sub-scope에서 별도 qualification해야 한다. #### 31. transaction/failure findings backlog ##### P1 — completion-evidence Stable contract가 actual composition에 연결되지 않음 - `EvidenceAwareJpaTransactionManager` implementation/test는 존재하지만 production construction 0. - normal manager commit 08006 probe -> `CONNECTION_UNAVAILABLE`, `completionUnknown=false`. - `CompletionUnknownRecorder` implementation/consumer 0. - `JpaTransactionObservation` record call 0; observability composition helper construction도 0. - support matrix/runbook은 Stable evidence + metric + reconciliation record를 약속. - canonical application path는 `Indeterminate`로 no-auto-retry safety는 유지하지만 durable reconciliation reference를 채우지 않음. - **우선순위:** P1 reliability/operability, real DB lost-ack integration qualification 필요. ##### P2 — custom `JpaRetryPolicy`가 silently ignored - public composition overload가 application-supplied policy를 받음. - valid TransactionProfile은 retryProfile non-null 강제. - coordinator는 profile이 non-null이면 fallback/custom policy를 사용하지 않음. - runtime probe: customPolicyCalls=0, workCalls=2. - **우선순위:** P2 functional contract. ##### P2 — canonical transaction boundary documentation과 실제 dual stack 불일치 - docs/source comment는 coordinator가 PolicyTransactionPort를 구현한다고 설명. - 실제 구현체는 SpringTransactionPort. - coordinator/runtime bean은 별도로 계속 존재. - **우선순위:** P2 architecture consistency / Decision 필요. ##### P3 — TransactionProfileRegistry legacy residue - declarative retry interceptor 제거 후 production consumer 0. - non-api implementation package. - **우선순위:** P3 cleanup candidate. ##### Cross-scope candidate — JPA observability composition 전체 reachability - `JpaObservabilityAutoConfiguration` current production construction 0. - query/transaction/retry observation 중 일부가 별도 경로에서 살아 있을 수도 있으므로 observation owning sub-scope에서 다시 exhaustive 확인. - transaction scope에서는 completion-unknown runbook signal 부재 evidence로만 사용. #### 32. Sub-scope 03 완료 조건 확인한 것: - production 32 / 32 FULL_READ - dedicated tests 19 / 19 FULL_READ - unclassified 0 - application canonical transaction implementation trace - public JPA executor/coordinator trace - retry/deadline/CallBudget algebra - completion evidence state machine - root/component-scan/conditional wiring - current public reachability - duplicate retry/failure mechanism comparison - documentation drift - declarative-retry migration history - custom-policy runtime probe - commit-ambiguity runtime classification probe - focused tests fresh - app-bootstrap wiring tests fresh 남긴 경계: - actual PostgreSQL lost-commit-ack network qualification - PostgreSQL SQLSTATE/constraint translator 전체 correctness - observation package 전체 reachability/metrics completeness - pool runtime behavior 이 후속 항목은 각각 vendor/integration/observation-performance sub-scope에서 다시 확인한다. --- #### 33. Sub-scope 04 — Spring Data + Hibernate + Querydsl > 내부 상태: COMPLETE — 42 production + 11 dedicated test, **53 / 53 FULL_READ** > 범위: `persistence/hibernate/**`, `persistence/springdata/**`, `persistence/querydsl/**`와 matching dedicated tests > 핵심 질문: JPA query/batch 최적화 helper가 실제로 어떤 비용 경계를 강제하는지, Stable/Advanced 기능이 runtime과 release evidence에서 어디까지 살아 있는지. ##### 33.1 숫자 지도 | package | production | dedicated test | 역할 | |---|---:|---:|---| | `hibernate` | 22 | 2 | provider policy, statistics, SQL naming, JDBC batch, bulk DML, StatelessSession | | `springdata` | 17 | 8 | repository fragment, entity graph, sort, keyset, stream, Specification policy | | `querydsl` | 3 | 1 | optional dynamic-query integration | | **합계** | **42** | **11** | **53** | 53개 source/test를 모두 FULL_READ했다. 이 scope의 integration semantics를 확인하기 위해 PostgreSQL-backed batch/ID strategy/collection-fetch contract, release-registry/task mapping, app-bootstrap architecture rule도 cross-scope evidence로 읽고 fresh 실행했다. #### 34. 이 sub-scope는 하나의 query framework가 아니라 세 단계의 정책층이다 현재 code shape는 대략 다음처럼 읽는 것이 맞다. ```text application-owned query/repository contract | v springdata/** - allowlisted sort - keyset assembly/predicate - fetch-plan catalog - bounded stream lifetime - Specification safety | v hibernate/** - provider/version facts - real Statistics/JDBC batch evidence - statement naming - batch/bulk/stateless provider optimization | +----------------------+ | v v JPA/Hibernate runtime querydsl/** optional Advanced helper ``` 중요한 점은 `springdata`와 `querydsl`이 application-core의 repository contract를 대체하는 generic CRUD layer가 아니라는 것이다. `JpaRepositoryFragmentSupport`에는 범용 `save/findAll/delete`가 없고, domain-owned adapter가 필요한 query mechanism만 조합하게 설계돼 있다. 이 방향은 support matrix의 “platform-owned generic CRUD repository는 unsupported”와 일치한다. #### 35. Hibernate provider policy는 declared baseline과 실제 runtime을 분리한다 `HibernateProviderPolicy`는 상수로 선언된 Stable provider baseline과 실제 classpath에서 읽은 runtime version을 구분한다. 이 설계가 필요한 이유는 repository가 과거 “7.4를 Stable baseline이라고 문서화하면서 실제 Spring Boot BOM은 7.1.x를 resolve”한 상태를 경험했기 때문이다. 현재는: - declared baseline: policy constant - runtime provider: `org.hibernate.Version`에서 읽음 - drift 여부: `driftsFromDeclaredBaseline()` - app-bootstrap capability/report가 runtime value를 사용 으로 나뉜다. 즉 “문서 상수와 같은 상수를 assert해서 green”인 self-fulfilling test는 피한다. 이 sub-scope에서 outside-leaf production consumer가 명확히 존재하는 핵심 Hibernate type도 `HibernateProviderPolicy`다. app-bootstrap이 이를 composition/report에 사용한다. #### 36. 통계 수집은 configuration이 아니라 실제 실행 evidence를 보려 한다 `HibernateStatisticsCollector`와 `HibernateStatisticsSnapshot`은 다음을 분리해서 측정한다. - prepared statements - entity loads/fetches - collection fetches - flushes - JDBC batches 특히 JDBC batch count를 Hibernate `Statistics` 값으로 추정하지 않고 `JdbcBatchCounter`를 별도 주입한다. 이것은 중요한 설계 선택이다. ```text hibernate.jdbc.batch_size = 50 ``` 은 batching을 “요청한 설정”이지 실제 driver/JDBC batch가 실행됐다는 증거가 아니다. 실제 PostgreSQL integration test도 `delta.jdbcBatches()`를 보고 sequence entity와 IDENTITY entity의 차이를 측정한다. 또 statistics가 disabled면 0을 반환하지 않고 실패한다. 0을 “실제 쿼리가 없었다”와 “측정 자체가 꺼져 있었다” 사이에서 공유하지 않는다. #### 37. batch executor — 과거 data-loss 회귀는 현재 수정돼 있다 `HibernateJpaBatchExecutor`의 기본 전략은: ```text persist -> 주기적 flush -> 주기적 clear -> final flush -> final clear ``` 이다. Persistence Context를 clear해야 heap growth를 제한할 수 있지만, flush하지 않은 managed entity를 clear하면 INSERT 자체가 사라질 수 있다. 과거 구현은 `flushSize=100`, `clearSize=150`처럼 경계가 어긋날 때 150번째 clear에서 101~150 rows를 detach해 버릴 수 있었다. executor는 processed=300을 반환하는데 DB에는 250 rows만 남는 형태였다. 현재 코드는: ```java if (flushDue || clearDue) { entityManager.flush(); } if (clearDue) { entityManager.clear(); } ``` 로 바뀌었다. 즉 clear는 항상 flush barrier를 동반한다. PostgreSQL-backed `HibernateJpaBatchExecutorIntegrationTest`에도 다음 regression이 존재한다. - mismatched flush/clear boundary에서도 모든 rows 보존 - clear가 flush보다 멀리 있어도 row loss 없음 - flush size보다 적은 rows도 final flush로 보존 - 중간 failure 시 전체 transaction rollback - active transaction 밖 batch 거부 - real JDBC batch count > 1 - Persistence Context max entity count bounded 이번 fresh 선택 실행에서도 이 integration class 7 tests가 skip/failure 없이 통과했다. #### 38. Confirmed P2 — property-access `IDENTITY` entity가 batch guard를 우회한다 `HibernateBatchConfigurationGuard`는 batching-required profile에서 `GenerationType.IDENTITY`를 거부한다. 그 이유 자체는 실제 PostgreSQL evidence가 있다. - sequence fixture: JDBC batches > 1 - IDENTITY fixture: JDBC batches = 0 문제는 guard의 annotation 탐색 방식이다. 현재 `usesIdentityGeneration(Class)`은 class hierarchy의 **declared fields**만 읽는다. ```text field @Id -> field @GeneratedValue -> strategy == IDENTITY ? ``` 하지만 JPA는 field access뿐 아니라 property access도 허용한다. 즉 다음과 같은 mapping도 정상적인 JPA mapping이다. ```java @Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long getId() { ... } ``` ##### 실행 probe getter에 `@Id + @GeneratedValue(IDENTITY)`를 선언한 entity class를 현재 compiled guard에 전달했다. 결과: ```text propertyIdentityDetected=false propertyIdentityValidation=PASSED ``` 즉 `batchingRequired=true` profile이어도 legal property-access IDENTITY entity를 통과시킨다. 현재 repository production entity search에서는 property-access ID mapping이 확인되지 않았으므로 **현재 shipped entity가 이 결함을 밟는 evidence는 없다.** 그러나 이 class는 generic JPA platform guard이고, IDENTITY를 fail-closed로 거부한다고 문서화한다. 따라서 adopter가 property access를 사용하면 guard의 핵심 안전 계약이 깨진다. 우선순위: **P2 — provider guard correctness / adoption safety** 수정 방향: - JPA metamodel로 actual id attribute/access strategy를 해석하거나 - field/getter 모두 검사하되 duplicate/override access 규칙까지 JPA access semantics와 맞추거나 - 지원 mapping을 field access로 제한한다면 그 제한을 architecture rule로 강제 단순히 getter reflection을 추가하는 것만으로 mixed access/`@Access`까지 완전히 해결됐다고 보기는 어렵다. Raw: `evidence/raw/053-jpa-query-hibernate-boundary-probe.txt` #### 39. `BatchExecutionResult.batched()`는 작은 실행에 false-negative가 있다 `BatchExecutionResult` javadoc은 `jdbcBatches`를 “batching happened at all”을 판단하는 값으로 설명한다. 그런데 convenience method는: ```java return jdbcBatches > 1L; ``` 이다. probe에서: ```text jdbcBatches=1 batched()=false ``` 가 확인됐다. 다만 이것을 즉시 높은 우선순위 defect로 올리지는 않는다. 한 번의 `executeBatch`가 여러 statement를 묶었다면 “batching은 발생했다”고 말할 수 있지만, counter가 단지 executeBatch call count만 제공한다면 `1`만으로 그 batch에 몇 row가 묶였는지는 알 수 없다. 현재 real integration contract도 1,000-row run에서 `jdbcBatches > 1`을 강한 evidence로 사용한다. 따라서 현재 판단은: - **P3 semantic/naming edge** - `batched()`가 “at least one JDBC batch call”인지 “multiple measured batch executions”인지 API 의미를 명확히 할 필요 이다. #### 40. bulk DML과 StatelessSession은 일반 repository path와 다른 비용 모델을 명시한다 ##### 40.1 Hibernate bulk DML `HibernateBulkDmlExecutor`는 arbitrary JPQL string을 아무 데서나 실행하는 helper가 아니다. - operation name 등록 - affected-row expectation - persistence-context cleanup - transaction requirement 를 contract로 둔다. bulk DML은 managed entity lifecycle을 우회하므로 ordinary entity save와 같은 audit/lifecycle guarantee를 기대하면 안 된다. support matrix도 이를 Advanced capability로 분리한다. 현재 production business consumer는 확인되지 않았고 PostgreSQL integration fixture에서 실제 behavior를 qualification한다. 따라서 “runtime에서 사용 중”이라고 주장하지 않는다. ##### 40.2 StatelessSession `HibernateStatelessSessionRunner`는 오히려 이 platform에서 transaction ownership 예외를 명시적으로 드러낸다. 일반 repository adapter: ```text application transaction boundary에 참여 ``` StatelessSession runner: ```text 새 StatelessSession -> 자체 physical transaction -> registered work만 허용 -> affected row cap 확인 -> 초과 시 rollback ``` 과거 review에서는 caller가 선언한 maxRows가 실제 affected rows와 연결되지 않는 문제가 있었다. 현재는 `StatelessWorkResult(value, affectedRows)`를 요구하고 cap 초과 시 commit 전에 rollback한다. 즉 과거의 “이름만 row cap” 문제는 현재 코드에서 수정돼 있다. #### 41. Spring Data repository support는 generic CRUD보다 query execution policy에 가깝다 `JpaRepositoryFragmentSupport`는 domain-specific repository adapter가 사용할 공통 실행 support다. 제공하는 것은 대략: - `EntityManager` access - query name context - fetch plan application - bounded query observation scope 이고 범용 business repository contract는 제공하지 않는다. 이 구조는 Clean Architecture 관점에서 의미가 있다. application-core가 `JpaRepository`, `EntityManager`, `Specification`을 알 필요가 없고, 실제 domain repository port를 구현하는 outbound adapter 내부에서만 Spring Data/JPA mechanics를 사용한다. #### 42. entity graph catalog는 EntityManager-affinity를 피한다 `EntityGraphCatalog`는 이미 만들어진 `EntityGraph` instance를 전역 보관하지 않는다. 대신 factory를 등록하고 현재 `EntityManager`에서 graph를 생성한다. 그 이유는 JPA graph/object가 provider/session/entity-manager lifetime에 묶일 수 있기 때문이다. `FetchPlanApplier`는 registered fetch plan을 조회해서: - fetch graph - load graph hint를 구분해 query에 적용한다. 이 역시 raw client path를 받지 않고 registered name을 통해 query behavior를 선택한다. #### 43. sort는 allowlist + total order를 강제한다 `SafeSortMapper`의 핵심 invariant는 두 개다. 1. client field를 entity path로 그대로 넘기지 않는다. 2. ordering 끝에 unique tie-breaker를 붙인다. ##### 43.1 allowlist `SafeSortRegistry`가 public sort name -> `SafeSortField` mapping을 가진다. unknown name은 fail-closed다. 따라서 `JpaSort.unsafe(clientString)` 같은 raw ORDER BY path가 없다. ##### 43.2 tie-breaker direction historical fix 과거 mapper는 request가 tie-breaker를 생략하면 무조건 DESC를 붙였다. 예: ```text createdAt ASC ``` 요청이 실제로는: ```text createdAt ASC, id DESC ``` 가 되었다. 이는 caller가 고르지 않은 mixed ordering이고 single-direction keyset logic과 충돌했다. 현재는 registry 자체가 `tieBreakerDirection`을 선언하고 mapper가 그 값을 사용한다. 이 regression은 현재 수정된 상태다. #### 44. keyset predicate는 mixed type / mixed direction을 표현하도록 진화했다 `KeysetPredicateBuilder`는 conjunction이 아니라 lexicographic predicate를 만든다. 예를 들어 `(createdAt ASC, id DESC)`라면 cursor 뒤는 개념적으로: ```text createdAt > cursorTime OR (createdAt = cursorTime AND id < cursorId) ``` 이다. 현재 `KeysetTerm`는 각 term마다: - expression - cursor value - direction 을 가진다. 그래서 `(Instant, UUID)`처럼 term type이 다르고 direction도 다른 ordering을 표현할 수 있다. source history에는 과거 one-type/one-direction API가 mixed order에서 rows를 skip/repeat했던 이유가 주석으로 남아 있고, 현재 code/test는 이를 보완했다. ##### 44.1 남는 contract boundary builder는 “마지막 term이 unique tie-breaker여야 한다”고 문서화하지만 runtime에서 uniqueness를 증명할 metadata는 받지 않는다. 검사할 수 있는 것은: ```text terms.size() >= 2 ``` 정도다. 따라서 uniqueness는 caller/registry contract다. 현재 evidence만으로 이를 defect라 단정하지 않는다. platform이 이를 fail-closed invariant로 승격하려면 unique-key metadata까지 contract에 포함해야 한다. #### 45. keyset execution은 `size + 1`로 hasNext를 판정하고 count query를 제거한다 `JpaKeysetQuerySupport`는: ```text query.setMaxResults(page.fetchSize()) // size + 1 -> result -> KeysetSliceAssembler ``` 형태다. 반환은 최대 `size`개이고 추가 1개로 `hasNext`를 판단한다. 이 path에는 `COUNT(*)`가 없다. 즉 keyset을 도입해 OFFSET full-walk 비용을 줄여 놓고 total count로 다시 full-work를 추가하는 구조를 피한다. 실제 PostgreSQL readiness query도 `(occurred_at,id) > (?,?) ORDER BY ... LIMIT ?` 형태와 representative index 사용을 별도 integration lane에서 검증한다. 해당 entire integration lane 자체는 later sub-scope 11의 denominator이므로 여기서는 cross-scope evidence로만 사용한다. #### 46. stream helper는 resource lifetime을 return type shape로 제한한다 `JpaStreamExecutor`의 핵심은 `Stream`를 외부로 반환하지 않는 것이다. ```text active read-only transaction 확인 -> supplier가 stream open -> consumer에 bounded stream 전달 -> consumer result 생성 -> stream close -> query scope close -> result만 반환 ``` `ScrollPolicy.maxRows()`를 `stream.limit()`에 적용하고 실제 소비 row count를 observation에 기록한다. 또 supplier type이 현재는: ```java Function> ``` 이라 fetch-size policy가 query 생성 지점까지 전달될 수 있다. 과거에는 plain `Supplier>`여서 executor가 가진 fetch-size가 stream-opening query에 도달하지 않는 문제가 있었고, 현재는 수정됐다. reactive Publisher를 결과로 반환하는 것도 hierarchy name 기반으로 거부한다. Reactor/Reactive Streams dependency를 blocking JPA module compile classpath에 직접 추가하지 않고도 application-declared Publisher implementation까지 탐지하려는 방식이다. #### 47. Confirmed P2 — `SpecificationPolicy`는 `Specification.unrestricted()`를 bounded로 오인한다 `SpecificationPolicy`의 문서 계약은 명확하다. > a specification with no predicate is a full table scan wearing a builder's clothing 그리고 predicate가 없으면 explicit `allow-unbounded-scan` token이 필요하다고 설명한다. 하지만 구현은 다음만 확인한다. ```java if (specification == null && !allowToken) { reject } ``` 즉 **Specification object의 존재와 predicate의 존재를 동일시**한다. ##### 47.1 Spring Data 4.0.7 자체가 non-null unrestricted Specification을 제공한다 현재 resolve된 `spring-data-jpa:4.0.7` bytecode를 확인했다. ```text Specification.unrestricted() -> non-null Specification lambda -> lambda toPredicate(...) returns null ``` 따라서 이건 인위적인 edge case가 아니다. 현재 dependency가 공식적으로 제공하는 representation이다. ##### 47.2 실행 probe ```java Specification noPredicate = (root, query, cb) -> null; SpecificationPolicy.requireBounded(noPredicate, page, null); ``` 결과: ```text nonNullNullPredicateSpecification=PASSED ``` 이다. 즉 policy가 막겠다고 문서화한 **predicate-free bounded-page full scan**이 explicit opt-in 없이 통과한다. 현재 repository production consumer search에서는 `SpecificationPolicy` 사용자가 0이므로 shipped business path 영향은 관찰되지 않았다. 그러나 intended Spring Data safety helper로서 자기 계약을 만족하지 않는다. 우선순위: **P2 — safety-contract correctness before adoption** 주의할 점은 generic `Specification`을 실행 전에 평가해 predicate null 여부를 확인하려면 Criteria context가 필요하다는 것이다. 단순 reflection으로 해결하기 어렵다. 가능한 방향: - raw `Specification`을 safety boundary로 받지 않고 platform-owned bounded predicate descriptor를 사용 - explicit “unrestricted” 여부를 caller가 별도 contract로 선언 - repository execution helper 안에서 실제 Criteria predicate 생성과 policy validation을 결합 Raw: - `evidence/raw/053-jpa-query-hibernate-boundary-probe.txt` - `evidence/raw/061-spring-data-specification-unrestricted-contract.txt` #### 48. Querydsl integration은 production runtime classpath를 강제로 오염시키지 않는다 `QuerydslJpaSupport`는 Advanced opt-in으로 설계돼 있다. build: ```gradle compileOnly 'com.querydsl:querydsl-jpa:5.1.0:jakarta' testImplementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' ``` lockfile에서 Querydsl은: - compileClasspath - test/integration/performance classpaths 에는 나타나지만 production `runtimeClasspath` configuration에는 포함되지 않는다. 따라서 JPA leaf를 사용하는 것만으로 Querydsl runtime dependency가 Stable deployment에 따라오는 구조는 아니다. `QuerydslJpaSupport`도: - bounded page size <= 500 - null predicate는 explicit unbounded opt-in 없으면 거부 - registered `QueryName`을 Hibernate comment hint로 적용 한다. 현재 production consumer는 확인되지 않았다. 이는 Advanced opt-in helper의 미채택 상태로 기록하며 dead-code defect로 단정하지 않는다. #### 49. SQL query naming mechanism은 구현은 있으나 shipped composition wiring을 찾지 못했다 `QueryNameContext`와 `NamedStatementInspector`의 설계는 다음과 같다. ```text registered QueryName을 thread-local scope에 bind -> Hibernate StatementInspector -> SQL prefix/comment에 bounded query identity 추가 ``` 이렇게 하면 raw SQL text가 아니라 registered query identity로 DB statement와 application observation을 연결할 수 있다. 문제는 current production source/config 전체에서: - `NamedStatementInspector` construction = class definition 외 0 - Hibernate `statement_inspector` property registration = 0 이라는 점이다. `JpaRepositoryFragmentSupport`는 `QueryNameContext`를 사용하지만 실제 Hibernate statement inspector가 설치되지 않으면 그 name은 SQL layer까지 내려가지 않는다. 따라서 query/observability documentation에서 “registered query name이 generated SQL에 연결된다”는 설명은 current shipped composition evidence가 없다. 이것은 transaction scope에서 확인한: - `JpaObservabilityAutoConfiguration` production construction 0 - transaction observation call path 0 와 같은 방향의 증거다. **Cross-scope finding:** JPA observability capability의 구현 클래스들은 존재하지만 composition completeness가 부족하다. 최종 severity는 later observation/config owning sub-scope에서 전체 mechanism을 다시 읽고 확정한다. 여기서는 SQL query naming path가 현재 unwired라는 observed evidence만 추가한다. #### 50. 대부분의 optimization helper가 production에서 직접 소비되지 않는다는 사실은 이미 repository가 알고 있다 negative-space search에서 다음 implementation roots는 repository production consumer가 확인되지 않았다. - `HibernateJpaBatchExecutor` - `JpaBatchProfileRegistry` - `HibernateBulkDmlExecutor` - `HibernateStatelessSessionRunner` - `FetchPlanApplier` - `JpaKeysetQuerySupport` - `JpaRepositoryFragmentSupport` - `JpaStreamExecutor` - `SpecificationPolicy` - `QuerydslJpaSupport` 하지만 이것을 곧바로 “dead code가 대량 존재한다”라고 해석하면 안 된다. 이 repository의 기존 study/review 문서도 이미 JPA platform helper가 **구현/qualification되어 있지만 sample production path가 대부분 채택하지 않은 상태**라고 기록한다. 또한 batch/bulk/stateless helper는 real PostgreSQL integration tests에서 직접 실행된다. 따라서 현재 판단은 capability별로 나눈다. ##### implemented + qualified + not adopted 예: - batch - bulk DML - stateless 이들은 library capability로 유지할 수 있다. ##### implemented but production composition itself가 필요한데 wiring 없음 예: - `NamedStatementInspector`처럼 global Hibernate hook이 필요한 기능 이 경우는 “아무 use case가 안 쓴다”와 다르다. feature를 사용하려면 composition이 먼저 존재해야 한다. ##### old mechanism이 consumer 제거 후 남은 경우 transaction scope의 `TransactionProfileRegistry`처럼 history를 통해 실제 residue로 판정해야 한다. 즉 `grep refs=0`은 finding의 시작점이지 결론이 아니다. #### 51. export boundary는 현재 split SSOT다 이 leaf는 하나의 jar 안에 많은 public implementation type이 존재한다. 그래서 “public Java modifier”와 “architecturally exported package”를 별도로 관리하려 한다. ##### 51.1 leaf-local `EXPORTED_PACKAGES` `JpaModuleBoundaryTest`에는 다음 export set이 있다. - api - notification.configuration - transaction - security - observation - migration - hibernate - fileserver - failure - config `springdata`, `querydsl`은 여기 없다. ##### 51.2 실제 app-bootstrap consumer rule은 별도 allowlist를 다시 가진다 `CleanArchitectureTest.BOOTSTRAP_USES_ONLY_THE_PERSISTENCE_EXPORT_SURFACE`는 또 다른 `EXPORTED` set을 정의한다. 여기에는 root composition이 vendor entry point를 import해야 하므로: - postgresql - h2 까지 추가돼 있다. 즉 두 목록은 이미 동일하지 않다. ##### 51.3 leaf list 자체는 outside consumer를 검사하지 않는다 `JpaModuleBoundaryTest`의 local export test는: - export package가 실제 존재하는지 - 새 top-level package가 governance 대상인지 를 보지만 repository의 outside consumer import를 직접 스캔하지 않는다. 실제 consumer restriction은 app-bootstrap의 별도 ArchUnit rule이 담당한다. 따라서 current architecture fitness function은: ```text leaf export declaration A X shared SSOT 아님 bootstrap allowed imports B ``` 형태다. fresh architecture tests는 모두 통과했다. 이것은 현재 import graph가 각자의 rule을 만족한다는 뜻이지 **A와 B가 서로 drift하지 않는다는 증명은 아니다.** 우선순위: **P2/P3 architecture-governance hardening** 권장 방향은 exported package registry를 한 곳으로 옮기고 leaf package DAG와 consumer ArchUnit rule이 같은 데이터를 읽게 하는 것이다. #### 52. Confirmed P1 — `collection-fetch-pagination` blocking release gate가 실제 위험을 증명하지 않는다 이번 sub-scope에서 가장 중요한 finding이다. support matrix는 다음 gate를 blocking release gate로 선언한다. ```text collection-fetch-pagination ``` 목적: > paged collection fetch가 전체 table을 읽고 memory에서 pagination하는 provider regression을 차단 이다. 이 위험의 특성상 **returned page size는 증거가 아니다.** provider가 모든 rows를 읽고 Java에서 20개만 반환해도 결과는 정확하기 때문이다. 실제 `HibernateCollectionFetchPaginationContractTest` javadoc도 정확히 이 점을 알고 있다. > assertion is therefore on the generated SQL, not on the returned page size 그러나 실제 test body는 그 설명을 구현하지 않는다. ##### 52.1 실제 collection-fetch test가 SQL limit을 보지 않는다 `oneCollectionPageIsBoundedInSql()`의 핵심 assertion은: ```text returned page size <= expected max expected.requiresDatabaseLimit() == true ``` 뿐이다. 다음을 검사하지 않는다. - generated SQL의 LIMIT/FETCH FIRST/subquery shape - StatementInspector capture - query AST - provider warning/failure - `hibernate.query.fail_on_pagination_over_collection_fetch` repository search에서도 해당 fail-on-pagination setting의 runtime configuration은 확인되지 않았다. 다른 같은 class tests도: - prepared statement count <= 2 - N+1 comparison - fixture 전체 row amplification bound 을 검증할 뿐 parent selection이 SQL에서 제한됐는지 증명하지 않는다. 이번 real PostgreSQL fresh run에서 해당 class 4 tests는 전부 통과했다. 하지만 **green은 현재 assertion이 green이라는 뜻**이지 documented risk가 차단됐다는 뜻이 아니다. ##### 52.2 release registry가 가리키는 producer task는 그 test를 실행하지도 않는다 더 큰 문제는 provenance mapping이다. `config/jpa/release-registry.json`: ```text collection-fetch-pagination -> :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest ``` 그런데 Gradle task registration은: ```text jpaPlatformQueryPlanTest -> includeTags "jpa-queryplan" ``` 이다. 실제 collection-fetch class는: ```text @Tag("jpa-contract") ``` 이고 `PostgreSqlQueryPlanContractTest`만 `@Tag("jpa-queryplan")`이다. ##### 52.3 exact registry task fresh 실행 결과 이번에 registry가 지정한 task 자체를 fresh 실행했다. 결과: ```text BUILD SUCCESSFUL executed XML suites: PostgreSqlQueryPlanContractTest ``` `HibernateCollectionFetchPaginationContractTest`는 이 task의 executed suite에 존재하지 않았다. 즉 release registry가 **이 gate의 evidence producer라고 지목한 task가 gate scenario를 실행하지 않는다.** ##### 52.4 현재 gate-validator도 이 mismatch를 잡지 못한다 `verifyJpaReleaseGateTasks`는 registry의 각 gate에 대해: - absolute Gradle path인가 - project가 존재하는가 - task가 존재하는가 - `Test` task인가 까지만 검사한다. fresh 실행 결과: ```text verifyJpaReleaseGateTasks: OK — 6 gate task(s) resolve to real Test tasks. BUILD SUCCESSFUL ``` 이다. 즉 지금처럼 **의미상 완전히 다른 tests를 실행하는 Test task**도 valid producer로 인정한다. ##### 52.5 aggregate release task가 collection test도 실행한다는 점은 mitigation이지 provenance fix가 아니다 `jpaPlatformReleaseGate` aggregate는 `jpaPlatformContractTest`와 `jpaPlatformQueryPlanTest` 둘 다 dependsOn 한다. 따라서 full aggregate를 실행하면 `@Tag("jpa-contract")`인 collection test 자체는 다른 lane에서 실행될 수 있다. 하지만 이것은 registry mapping을 올바르게 만들지 않는다. - gate별 evidence provenance가 틀림 - gate task를 단독 재검증하면 target scenario 미실행 - collection test가 실행돼도 target behavior assertion 부족 이므로 false evidence 문제가 두 겹이다. ##### 52.6 역사 - collection-fetch contract test: initial JPA platform commit `0e61f86`에서 이미 현재 SQL-inspection 없는 shape로 추가 - release registry gate mapping: 이후 integration commit `2f5d2fc`에서 추가 즉 최근 refactor regression이라기보다 **초기 evidence design부터 존재한 gap**이다. 우선순위: **P1 — blocking release evidence integrity** 이유: support matrix 자체가 release gate를 “tests가 pass해도 production에서 틀릴 수 있는 경우를 막기 위한 것”이라고 정의한다. 그런데 이 gate는 바로 그 종류의 false green을 허용한다. 수정 조건은 둘 다 필요하다. 1. gate producer가 실제 collection-fetch scenario를 실행하도록 registry/task/tag 연결 수정 2. test가 generated SQL 또는 fail-closed provider signal로 **DB-side pagination**을 직접 검증 둘 중 하나만 고치면 gate는 여전히 불완전하다. Raw: - `evidence/raw/056-persistence-jpa-collection-fetch-gate-provenance.txt` - `evidence/raw/057-persistence-jpa-query-hibernate-postgresql-contracts.txt` - `evidence/raw/058-persistence-jpa-queryplan-gate-task.txt` - `evidence/raw/059-verify-jpa-release-gate-tasks.txt` - `evidence/raw/060-persistence-jpa-query-hibernate-history.txt` #### 53. 기존 review finding 중 현재 해결된 것과 남은 것을 분리한다 기존 `docs/reviews/2026-08-14-jpa-module-code-review.md`에는 이 영역의 여러 문제를 이미 지적했다. 현재 source와 대조하면 다음은 **해결됨**으로 관찰된다. - batch clear가 unflushed entity를 버리던 문제 -> clear 전 flush - stream fetch-size가 query supplier에 전달되지 않던 문제 -> `Function` - stateless row cap이 실제 affected rows와 연결되지 않던 문제 -> `StatelessWorkResult.affectedRows` - sort tie-breaker direction 고정 문제 -> registry-declared direction - keyset mixed type/direction 표현 문제 -> per-term type/direction 반면 이번에 확인한: - property-access IDENTITY guard bypass - `Specification.unrestricted()` bypass - collection-fetch release false evidence - StatementInspector composition 부재 - split export SSOT 는 current snapshot에 남아 있다. 이 분리를 하지 않으면 과거 review의 defect를 현재 defect처럼 중복 보고하거나, 반대로 “이미 review했으니 해결됐다”고 잘못 가정하게 된다. #### 54. fresh verification과 증명 범위 ##### 54.1 dedicated unit tests fresh command: ```text :adapter:outbound:persistence-jpa:test --tests hibernate.* --tests springdata.* --tests querydsl.* --rerun-tasks ``` 결과: ```text BUILD SUCCESSFUL in 23s 18 actionable tasks: 18 executed ``` 이 11 dedicated tests는 현재 helper behavior를 확인하지만 다음 새 경계는 포함하지 않는다. - property-access IDENTITY - non-null null-predicate Specification - one JDBC batch convenience semantics - release registry provenance ##### 54.2 architecture tests fresh: - `JpaModuleBoundaryTest` - app-bootstrap `CleanArchitectureTest` 결과: ```text BUILD SUCCESSFUL in 1m 48s 100 actionable tasks: 100 executed ``` 현재 package DAG와 bootstrap import graph는 rules를 만족한다. 하지만 두 export allowlist가 같은 SSOT인지까지 검증하지 않는다. ##### 54.3 selected real PostgreSQL contracts fresh `jpaPlatformContractTest`에서 다음 classes를 직접 선택했다. - `HibernateCollectionFetchPaginationContractTest`: 4 tests - `HibernateJpaBatchExecutorIntegrationTest`: 7 tests - `IdStrategyContractTest`: 4 tests 총 15 tests: ```text skipped=0 failures=0 errors=0 BUILD SUCCESSFUL ``` 이는 실제 PostgreSQL 위에서 current assertions가 통과함을 증명한다. 특히 batch/ID strategy evidence에는 의미가 크다. 반면 collection-fetch의 SQL-limit 부재는 assertion design 문제라 이 green 결과로 해소되지 않는다. ##### 54.4 exact query-plan gate task fresh `jpaPlatformQueryPlanTest`: ```text 3 tests PostgreSqlQueryPlanContractTest only BUILD SUCCESSFUL ``` registry mapping mismatch를 runtime result XML까지 확인했다. ##### 54.5 release-task existence validator fresh `verifyJpaReleaseGateTasks`도 성공했다. 이 success는 오히려 validator limitation의 evidence다. task semantic coverage/tag를 검사하지 않기 때문이다. #### 55. Sub-scope 04 findings backlog ##### P1 — blocking `collection-fetch-pagination` release gate false evidence - registry producer = `jpaPlatformQueryPlanTest` - producer actual suite = `PostgreSqlQueryPlanContractTest` only - target collection-fetch class는 `jpa-contract` tag - target test 자체도 generated SQL limit을 검사하지 않음 - current gate validator는 task existence/Test type만 검증해 mismatch를 허용 - **P1 release-evidence integrity** ##### P2 — property-access IDENTITY가 batching-required guard를 우회 - guard field annotation만 탐색 - legal getter/property access entity probe가 `usesIdentityGeneration=false` - validation passes - current production entity exposure는 field access라 shipped-hit evidence 없음 - **P2 platform guard correctness** ##### P2 — `SpecificationPolicy`가 unrestricted non-null Specification을 허용 - current Spring Data 4.0.7 `Specification.unrestricted()`는 non-null + null predicate - policy는 object null만 검사 - explicit allow token 없이 predicate-free scan 통과 - current production consumer 0 - **P2 safety contract before adoption** ##### Cross-scope P1/P2 — query SQL naming/observability composition 부재 - `NamedStatementInspector` runtime registration 0 - QueryNameContext는 존재하지만 SQL layer bridge가 확인되지 않음 - transaction observation wiring gap과 함께 later observation/config scope에서 최종 판정 ##### P2/P3 — export surface split SSOT - leaf export list와 app-bootstrap consumer list가 중복 정의되고 이미 다름 - current tests pass하지만 두 목록 간 drift를 막는 single-source rule 없음 - **architecture governance hardening** ##### P3/open — `BatchExecutionResult.batched()` one-batch semantics - jdbcBatches=1 -> false - method naming/javadoc 의미를 더 명확히 해야 함 - current large-run integration evidence에는 영향 없음 ##### acknowledged, not newly promoted defect — unadopted platform helpers - many springdata/hibernate/querydsl executors have no production business consumer - repository docs/review already record platform implementation vs sample adoption gap - integration qualification이 존재하는 helper도 있으므로 refs=0만으로 dead code라 하지 않음 #### 56. Sub-scope 04 완료 조건 확인한 것: - production 42 / 42 FULL_READ - dedicated tests 11 / 11 FULL_READ - unclassified 0 - package DAG / export policy - external production reachability - Querydsl production-runtime optionality - provider statistics / batch measurement - batch flush/clear lifecycle - ID generation guard - bulk/stateless execution model - repository fragment / fetch-plan mechanism - safe sort / keyset predicate / keyset slice - stream lifecycle / fetch-size path / reactive rejection - Specification safety contract - NamedStatementInspector/QueryName wiring - history against prior review - blocking release gate provenance - focused unit tests fresh - architecture tests fresh - selected PostgreSQL contracts fresh - exact registry query-plan task fresh - release-task validator fresh 남긴 경계: - PostgreSQL vendor-specific translator/native-query/type implementation 전체 - complete PostgreSQL integration/readiness source set - full observation/config package composition - real provider behavior under an intentionally regressed collection-fetch pagination implementation 이 항목들은 각각 sub-scope 05, 06/11에서 다시 owning-scope 기준으로 확인한다. #### 57. Sub-scope 05 범위와 denominator 이번 sub-scope의 소유 범위는 PostgreSQL vendor 구현과 root vendor migration이다. | 구분 | 범위 | 파일 수 | 판정 | |---|---|---:|---| | production Java | `src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/**` | 55 | FULL_READ | | dedicated unit test | `src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/**` | 9 | FULL_READ | | vendor migration | `src/main/resources/db/migration/postgresql/*.sql` | 9 | FULL_READ | | 합계 | | **73** | **73 / 73 FULL_READ** | unclassified file은 0이다. `evidence/raw/064-postgresql-vendor-manifest-reachability.txt`에 현재 revision의 tracked blob과 production construction/reachability snapshot을 남겼다. 이 sub-scope는 PostgreSQL 전용 SQLSTATE/constraint translation, native write/COPY, work claiming, JSON/array/range support, owner-safe idempotency, same-store inbox, polling outbox와 vendor migration을 소유한다. 반면 전체 `postgresqlIntegrationTest` source set의 완전독해는 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 정확한 integration lane만 실행했다. #### 58. PostgreSQL failure translation: SQLSTATE 분류는 맞지만 `40003` 의미가 translator에서 소실된다 `PostgreSqlFailureClassifier`는 PostgreSQL SQLSTATE를 bounded `FailureCategory`로 분류한다. serialization failure, deadlock, lock-not-available, constraint family, timeout, connection failure, schema/data 문제를 문자열 메시지가 아니라 SQLSTATE/structured server field 기준으로 다루는 방향은 적절하다. constraint 이름도 server error field에서 꺼내 catalog로 번역하므로 localized message parsing에 의존하지 않는다. 문제는 `COMPLETION_UNKNOWN`이다. 현재 `PostgreSqlExceptionTranslator.translate()`는 classifier 결과가 `COMPLETION_UNKNOWN`이어도 `JpaFailureContext`의 `completionUnknown`을 항상 `false`로 만들고, switch에서 `COMPLETION_UNKNOWN`을 `UNKNOWN`과 함께 일반 `JpaPersistenceException(FailureCategory.UNKNOWN, ...)`으로 강등한다. 직접 probe에서 SQLSTATE `40003`은 다음처럼 변환됐다. ```text type=JpaPersistenceException category=UNKNOWN sqlState=40003 completionUnknown=false retryable=false ``` 여기서 단순 진단 정보만 사라지는 것이 아니다. 현재 `DefaultJpaRetryPolicy`는 `TransactionCompletionUnknownException` 또는 `FailureCategory.COMPLETION_UNKNOWN`을 가장 먼저 검사해 `RECONCILE`로 보낸다. 그런데 실제 translator를 통과시키면 focused policy probe 결과가 다음과 같다. ```text translated.category=UNKNOWN translated.completionUnknown=false decision.disposition=FAIL decision.reason=failure was classified as non-retryable ``` 즉 **재실행은 막지만, commit 결과를 확인해야 하는 reconciliation 경로도 잃는다.** fail-closed라는 이유로 안전하다고 볼 수 없는 이유다. commit이 실제로 적용됐는지 알 수 없는 상태를 terminal failure로 바꾸면 caller는 설계된 recovery protocol을 실행할 근거를 잃는다. 현재 revision에서는 이 translator가 실제 composition에 들어온다. `PostgreSqlPersistenceConfig`가 `VendorFailureTranslator`를 제공하고, `JpaPlatformRuntimeAutoConfiguration`이 이를 `SpringJpaTransactionExecutor`의 `PersistenceFailureTranslatorChain`에 넣는다. 따라서 예전 리뷰의 “vendor translator caller 없음” 문제는 현재 wiring에서 해소됐지만, 그 결과 `40003` 의미 손실은 이제 실제 transaction path에 도달 가능한 문제다. 2026-08-14 JPA review도 completion-unknown에 대해 **body replay 0회 + reconciliation key 보존**을 기대 계약으로 이미 기록했다. 따라서 이 finding은 새로운 정책 제안이 아니라 기존 recovery contract와 현재 구현 간 불일치다. **판정: P1 — production completion-unknown recovery contract violation.** 필요한 수정 방향은 `40003`/`COMPLETION_UNKNOWN`을 `TransactionCompletionUnknownException` 또는 최소한 `FailureCategory.COMPLETION_UNKNOWN + completionUnknown=true`로 보존하고, translator → retry policy까지 한 테스트에서 `RECONCILE`을 고정하는 것이다. #### 59. PostgreSQL Idempotency V2: owner/CAS 구조는 강하지만 replay 경계가 두 군데 어긋난다 `PostgreSqlOwnerSafeIdempotencyStore`는 row lock, owner token, attempt, state revision, operation id와 transition digest를 결합해 claim/renew/fail/complete를 보호한다. `renew`와 `markFailed`는 동일 operation id replay에서도 semantic argument를 digest에 넣어 `SAME_ARGUMENTS`와 `DIFFERENT_ARGUMENTS`를 분리한다. 이 구조 자체는 강하다. 현재 revision에서는 `PostgreSqlIdempotencyProviderConfig`가 이 store를 production provider로 실제 생성하므로 아래 두 finding은 dormant helper 문제가 아니다. ##### 59.1 P1 — `inspect()`와 `claim()`이 만료된 COMPLETED row를 동시에 다른 상태로 해석한다 `inspect()`는 row가 `COMPLETED`이고 response payload가 있으면 `replayUntil`이 이미 지난 값인지 확인하지 않고 무조건 `COMPLETED_REPLAY`를 반환한다. 반면 claim path는 DB time과 expiry를 보고 만료된 row를 takeover 가능 상태로 처리한다. 실제 PostgreSQL 16에서 replay TTL 25ms로 완료한 뒤 50ms를 기다린 probe 결과: ```text expiredInspect.outcome=COMPLETED_REPLAY expiredInspect.replayUntil= expiredInspect.claimAfterExpiry=TakenOverClaimed ``` 즉 같은 시점의 같은 row가: ```text inspect -> "이전 응답을 replay하라" claim -> "이전 replay window는 끝났으니 새 실행을 소유할 수 있다" ``` 로 갈린다. Application의 `IdempotencyExecutorV2`는 reconciliation에서 `COMPLETED_REPLAY`를 실제 저장 응답 반환 신호로 사용한다. 따라서 이 불일치는 단순 introspection 문제가 아니라 **만료 후 새 실행이 허용된 시점에도 이전 응답을 reconciliation 결과로 반환할 수 있는 lifecycle correctness 문제**다. JPA 설계 문서가 동일 Idempotency V2 contract를 구현한다고 참조하는 Redis state machine도 `COMPLETED -> [*] : replay TTL expires`로 수명을 끝낸다. JPA `inspect()`만 이 만료를 무시한다. **판정: P1 — production idempotency lifecycle/reconciliation inconsistency.** 수정 시 `inspect()`도 claim과 같은 DB-time 기준 expiry semantics를 사용해야 하며, `replayUntil <= dbNow` 이후에는 더 이상 `COMPLETED_REPLAY`를 반환하지 않는 real-PostgreSQL boundary test가 필요하다. ##### 59.2 P2 — `complete()`의 replay 판정이 `replayTtl` 변경을 무시한다 첫 completion에서는 transition digest에 다음이 들어간다. - transition kind - operation id - owner tuple - response digest - `replayTtl.toMillis()` 코드 주석도 “completion이 replay window도 결정하므로 transition digest에 포함해야 한다”고 설명한다. 하지만 이미 `COMPLETED`인 동일 operation replay branch는 full transition digest를 비교하지 않고 **operation id + response digest만** 비교한다. 따라서 response는 같고 replay TTL만 바뀌면 `ALREADY_COMPLETED_SAME_RESULT`가 나온다. 실제 PostgreSQL probe: ```text completeReplayTtl.first=COMPLETED completeReplayTtl.secondDifferentTtl=ALREADY_COMPLETED_SAME_RESULT completeReplayTtl.storedSeconds=3600 ``` 첫 호출은 1시간, 두 번째 호출은 동일 operation/response에 9시간을 전달했다. 두 번째 호출은 semantic argument가 다른데도 same-result로 판정됐고 DB에는 최초 1시간 window가 그대로 남았다. `IdempotencyDigestPolicyTest`는 이미 “replay window가 다르면 completion digest가 다르다”는 정책을 테스트한다. 또한 같은 integration test suite의 renew/markFailed는 동일 operation id + 다른 TTL/retention을 conflict로 검증한다. complete만 대응하는 replay-argument test가 빠져 있다. **판정: P2 — production idempotency replay semantic mismatch.** 동일 operation replay에서도 첫 적용과 같은 complete transition digest를 계산해 `replayTtl`까지 비교해야 한다. #### 60. Same-store inbox / polling outbox: 구현 계약은 강하지만 현재 미조립 candidate에 replay holes가 있다 `PostgreSqlSameStoreInboxAdapter`와 `PostgreSqlPollingDeliveryAdapter`는 `application-core`의 owner-safe transition contract를 구현하지만, 현재 production composition에서 bean construction이나 stereotype은 확인되지 않았다. 따라서 아래 finding은 **현재 배포 기본 경로의 즉시 장애가 아니라, 이 candidate adapter를 채택할 때 활성화되는 latent defect**로 분리한다. ##### 60.1 P2 latent — inbox `markProcessing()` duplicate replay가 owner 검증보다 먼저 persisted owner를 반환한다 `markProcessing()`은 같은 `START + operationId`를 발견하면 `classifyMismatch()`보다 먼저 `owner(row)`를 반환한다. 이 때문에 scope/operation id만 맞춘 forged owner로 replay하면 DB에 저장된 실제 owner token을 돌려받을 수 있다. 실제 PostgreSQL probe: ```text inboxForgedReplay.outcome=PROCESSING_STARTED inboxForgedReplay.returnedActualToken=true inboxForgedReplay.returnedForgedToken=false inboxForgedReplay.completeWithReturnedOwner=COMPLETED ``` 즉 duplicate handling이 owner capability recovery oracle처럼 동작한다. 채택 전에는 duplicate replay에서도 persisted owner tuple/revision과 supplied owner를 먼저 검증하도록 고쳐야 한다. ##### 60.2 P2 latent — inbox retry/dead replay digest가 retention을 포함하지 않는다 `markRetryable`/`markDead`의 `retention`은 실제 SQL update에는 들어가지만 transition digest에는 들어가지 않는다. ```text inboxRetention.first=RETRYABLE inboxRetention.secondDifferentRetention=ALREADY_APPLIED_SAME_OPERATION inboxRetention.remainingHours=1.000 ``` 동일 operation id로 retention만 바꾼 replay가 same-operation으로 흡수된다. retention은 terminal row 보존 기간을 결정하는 semantic argument이므로 digest에 canonical millis를 포함해야 한다. ##### 60.3 P2 latent — outbox retry replay digest가 `nextAttemptAt`을 포함하지 않는다 `markRetryable()`은 `nextAttemptAt`을 DB에 기록하지만 transition digest는 kind + operation + owner + errorCode만 포함한다. ```text outboxRetry.first=RETRY_SCHEDULED outboxRetry.secondDifferentSchedule=ALREADY_APPLIED_SAME_OPERATION outboxRetry.storedEqualsFirst=true outboxRetry.storedEqualsSecond=false ``` 재시도 시각은 delivery scheduling 자체를 바꾸는 semantic argument다. 동일 operation replay consistency를 주장하려면 canonical instant를 digest에 넣어야 한다. #### 61. Native write, COPY, work claiming, JSON/array/range support ##### 61.1 확인된 안전 경계 native write와 COPY는 caller가 임의 SQL identifier를 조립하도록 두지 않고 registered statement/name boundary를 사용한다. 값은 JDBC parameter 또는 COPY stream으로 전달된다. COPY에는 format/size bound와 transaction requirement가 있고, work claiming은 등록된 queue definition과 PostgreSQL `FOR UPDATE ... SKIP LOCKED` 경계를 사용한다. JSON path/query support와 range query support도 registry/typed value boundary를 두고 실제 값은 bind한다. constraint translation 역시 structured SQLSTATE/server fields를 사용한다. 이번 sub-scope에서 이 영역의 새로운 SQL-injection/runtime-wiring defect는 확인되지 않았다. ##### 61.2 P2 latent — `PgRangeCodec`이 자신이 escape한 quote를 다시 parse하지 못한다 `format()`은 endpoint 내부 `"`와 `\`를 escape한다. 그런데 `separatorIndex()`는 backslash escape를 고려하지 않고 모든 `"` 문자를 quote-state toggle로 취급한다. self round-trip probe 결과: ```text comma.roundTrip=true quote.error=IllegalArgumentException:postgresql range literal has no endpoint separator quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound backslash-quote-comma.error=IllegalArgumentException:range lower bound exceeds upper bound ``` 즉 comma만 포함한 endpoint는 통과하지만 escaped quote가 포함되면 formatter가 만든 literal조차 parser가 읽지 못한다. 현재 `PgRangeTest`는 timestamp 중심이라 이 grammar boundary를 덮지 않는다. 현재 production consumer는 정적 reachability에서 확인되지 않았으므로 **P2 latent helper algebra defect**로 둔다. 채택 전에는 PostgreSQL quoted-range grammar에 맞게 escaped quote/backslash를 인식하는 tokenizer/state machine과 round-trip property test가 필요하다. #### 62. Vendor migrations 다음 9개 migration을 모두 읽었다. ```text V1__idempotency_record.sql V3__outbox_event.sql V4__int_lock.sql V5__int_lock_expired_after.sql V6__capability_schema_registry_adoption.sql V9__widen_capability_schema_stream.sql V10__idempotency_request_hash_varchar.sql V11__durable_operation.sql V12__live_event_log.sql ``` 확인한 경계는 다음과 같다. - idempotency owner/state/replay/transition metadata의 persisted shape - outbox claim/delivery/index shape - integer advisory/row-lock support table와 expiry extension - capability schema registry adoption/widening - request hash `char`/`varchar` drift 보정 - durable operation / live-event log schema real PostgreSQL probe에서 Flyway는 vendor 9 migrations를 모두 validate/apply했다. 이번 sub-scope에서 migration 순서, 현재 schema 제약, index 선언 자체로 승격할 신규 defect는 확인하지 못했다. capability-specific migration의 완전한 cross-stream adoption은 각 owning capability scope에서 다시 본다. #### 63. Production reachability와 이전 리뷰 대비 변화 현재 revision에서 reachability는 동일하게 취급하면 안 된다. | 구현 | 현재 production composition | 판정 의미 | |---|---|---| | `PostgreSqlExceptionTranslator` | 있음 — vendor failure bean → JPA transaction executor chain | `40003` finding은 production reachable | | `PostgreSqlOwnerSafeIdempotencyStore` | 있음 — PostgreSQL idempotency provider config | TTL/replay findings는 production reachable | | `PostgreSqlSameStoreInboxAdapter` | 확인 안 됨 | findings는 latent candidate | | `PostgreSqlPollingDeliveryAdapter` | 확인 안 됨 | findings는 latent candidate | | `PostgreSqlImmutableOutboxAppendAdapter` | 확인 안 됨 | candidate implementation | | `PgRangeCodec` | current production consumer 확인 안 됨 | finding은 latent helper | 이 구분은 중요하다. 2026-08-14 review에서는 vendor translator와 PostgreSQL idempotency store의 composition 부재가 별도 finding이었다. 이후 commit에서 runtime auto-configuration/provider wiring이 추가되어 그 “미조립” 문제 일부는 해결됐다. 따라서 현재 분석은 과거 finding을 그대로 복사하지 않고 **현재 revision의 wiring 이후 실제 semantics**를 다시 판정했다. history와 prior-review exact-term snapshot은 `evidence/raw/068-postgresql-vendor-history-review-provenance.txt`에 남겼다. #### 64. Fresh verification evidence ##### 64.1 PostgreSQL replay semantic probe `evidence/raw/062-postgresql-replay-semantic-probe.txt` - PostgreSQL 16.15 Testcontainers - vendor migration 9개 validate/apply - inbox forged-owner replay - inbox changed-retention replay - outbox changed-nextAttemptAt replay ##### 64.2 SQLSTATE `40003` `evidence/raw/063-postgresql-40003-probe.txt` - direct translator classification - `40003 -> UNKNOWN` - `completionUnknown=false` `evidence/raw/069-postgresql-40003-policy-probe.txt` - production translator → `DefaultJpaRetryPolicy` - 최종 `decision.disposition=FAIL` - expected recovery branch인 `RECONCILE`에 도달하지 못함 ##### 64.3 Range escaped-quote round trip `evidence/raw/065-pg-range-escaped-quote-probe.txt` - current source의 `PgRange`/`PgRangeCodec`만 격리 compile - comma는 통과 - escaped quote / quote+comma / backslash+quote+comma 실패 ##### 64.4 Idempotency real-PostgreSQL TTL boundaries `evidence/raw/066-postgresql-idempotency-replay-boundary-probe.txt` - exact `postgresqlIdempotencyIntegrationTest` lane - `complete()` changed replay TTL false-same replay 재현 - expired COMPLETED row의 `inspect()`/`claim()` lifecycle 불일치 재현 - temporary test는 실행 후 source에서 복원 - BUILD SUCCESSFUL ##### 64.5 Dedicated PostgreSQL unit test full fresh rerun `evidence/raw/067-persistence-jpa-postgresql-unit-tests.txt` 9 dedicated test classes를 `--rerun-tasks`로 실행했고 BUILD SUCCESSFUL이다. 이 green 결과는 current assertions의 통과를 증명하지만, 위 finding들의 boundary assertions가 기존 suite에 없다는 사실을 해소하지 않는다. #### 65. Sub-scope 05 findings backlog | 우선순위 | finding | 현재 reachability | |---|---|---| | **P1** | SQLSTATE `40003` completion-unknown이 translator에서 `UNKNOWN`으로 강등되어 retry policy가 `RECONCILE` 대신 `FAIL` | **production** | | **P1** | 만료된 COMPLETED idempotency row를 `inspect()`는 `COMPLETED_REPLAY`, `claim()`은 takeover 가능으로 동시에 해석 | **production** | | **P2** | idempotency `complete()` replay가 changed `replayTtl`을 same-result로 흡수 | **production** | | **P2 latent** | inbox `markProcessing()` duplicate replay가 owner mismatch 검증 전에 persisted owner를 반환 | candidate/uncomposed | | **P2 latent** | inbox retry/dead replay digest가 retention을 누락 | candidate/uncomposed | | **P2 latent** | outbox retry replay digest가 `nextAttemptAt`을 누락 | candidate/uncomposed | | **P2 latent** | `PgRangeCodec` escaped quote round-trip 실패 | current production consumer 미확인 | ##### 이번 scope에서 finding으로 승격하지 않은 항목 - registered native write/COPY의 SQL/value boundary - work-claim `SKIP LOCKED` 기본 구조 - structured SQLSTATE/constraint-name 추출 - array/json helper의 bounded value handling - polling outbox cutover sentinel의 transition별 반복 검사 차이: claim 자체가 immutable sentinel을 요구하고 current evidence만으로 stale claim이 cutover를 우회한다고 입증되지 않아 보류 - vendor migration 9개의 현재 적용 순서/문법 #### 66. Sub-scope 05 완료 조건 확인한 것: - production Java 55 / 55 FULL_READ - dedicated unit Java 9 / 9 FULL_READ - vendor migration SQL 9 / 9 FULL_READ - total 73 / 73 FULL_READ - unclassified 0 - PostgreSQL SQLSTATE/constraint translation - current vendor translator production composition - owner-safe idempotency implementation + production provider composition - same-store inbox / polling-outbox replay semantics + current non-composition 확인 - registered native write / COPY / work claim - JSON / array / range support - vendor migration chain - prior review 및 key-file history - real PostgreSQL replay probes - `40003` translator → retry-policy end-to-end policy probe - idempotency replay-TTL/expiry integration probes - dedicated PostgreSQL unit tests fresh rerun - temporary source probes 모두 복원 남긴 경계: - baseline capability stores/config/audit/cache/H2 등 나머지 production surface: sub-scope 06 - fileserver persistence: sub-scope 07 - notification persistence: sub-scope 08 - experimental platform: sub-scope 09 - testkit/fixture: sub-scope 10 - PostgreSQL integration/readiness source set 75 files의 완전독해: sub-scope 11 - pool/performance: sub-scope 12 따라서 **Sub-scope 05는 COMPLETE**로 닫는다. JPA module 전체는 아직 IN_PROGRESS다. #### 67. Sub-scope 06 범위와 denominator 이번 sub-scope는 baseline persistence capability와 그 주변의 configuration/audit/cache/H2/operation/live-event/outbox/security 구현을 소유한다. `evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt`에 고정한 denominator는 다음과 같다. | 구분 | 파일 수 | 판정 | |---|---:|---| | production Java | 61 | FULL_READ | | dedicated unit Java | 19 | FULL_READ | | capability migration SQL | 7 | FULL_READ | | 합계 | **87** | **87 / 87 FULL_READ** | 이번 범위는 `audit`, `auditing`, `cache`, `envers`, `h2`, `idempotency`, `liveevent`, `lock`, `migration`, `observation`, `operation`, `outbox`, `security`와 baseline configuration을 포함한다. PostgreSQL vendor-specific 구현은 sub-scope 05에서 이미 닫았고, complete PostgreSQL integration source set은 sub-scope 11이 소유한다. 여기서는 finding 검증에 필요한 real-PostgreSQL lane만 선택 실행했다. #### 68. Baseline composition을 먼저 분리해야 하는 이유 `JpaAdapterComponentsConfig`는 adapter 전체를 넓게 scan하지 않고 다음 package만 명시적으로 component scan한다. - `audit` - `failure` - `idempotency` - `lock` - `outbox` - `transaction` 따라서 같은 leaf 안에 있어도 reachability가 다르다. - `OutboxStoreAdapter`는 baseline scan에 들어가고 `app-bootstrap`의 `OutboxConfig`가 `OutboxStorePort`로 사용한다. - `DurableOperationStoreAdapter`, `JpaLiveEventReplayAdapter`는 현재 baseline component scan에 들어가지 않고 별도 production constructor/reference도 확인되지 않았다. - `HibernateCacheGuard`, `HibernateEnversHistoryReader`와 Spring Data auditing candidate도 default composition에 들어가지 않는다. - runtime-role verifier 자체는 app-bootstrap bean으로 구성되지만, policy를 적용하는 `requireSafe()` caller가 없다. 이 차이 때문에 아래 finding은 `production`, `conditional-production`, `latent`를 분리해 판정한다. 정적 composition snapshot은 `evidence/raw/072-baseline-capability-reachability.txt`에 남겼다. #### 69. P1 — Stable runtime-role verification이 startup에서 실제 policy를 적용하지 않는다 문서 계약은 명확하다. `docs/jpa/security.md`는 runtime role이 allowlist 밖이거나 schema/database `CREATE`를 가지면 **startup이 실패한다**고 적고, platform design도 `startup verifier`가 `current_user`, `search_path`, schema privilege를 검사한다고 정의한다. `JpaPlatformAutoConfiguration.capabilities()`도 `RUNTIME_ROLE_VERIFICATION`을 Stable로 광고한다. 구현에는 policy가 존재한다. - `PostgreSqlRuntimeRoleVerifier.verify()`는 `current_user`, `search_path`, schema/database CREATE privilege를 읽는다. - `DatabaseRolePolicy.requireSafe()`는 approved role, CREATE privilege, `SearchPathPolicy`를 검사한다. - `PostgreSqlRuntimeRoleVerifier.requireSafe(dataSource, policy)`는 둘을 연결한다. 하지만 production composition에서 이 마지막 경로가 호출되지 않는다. `JpaPlatformRuntimeAutoConfiguration.jpaPlatformStartupCheck()`가 refresh 시 실행하는 것은 `JpaDangerousConfigurationGuard.validate(environment)`뿐이며, runtime database role policy는 받지도 않는다. repository 전체 production source에서 `DatabaseRolePolicy`를 생성하거나 `requireSafe()`를 호출하는 caller 역시 없다. 해당 호출은 unit/integration test에만 존재한다. 더 나쁜 점은 actuator semantics다. `JpaPlatformReport.sanitized()`는 `runtimeRoleVerified`를 다음 한 조건으로 계산한다. ```text privileges != null && !privileges.holdsCreatePrivilege() ``` 즉 **wrong role name이나 unsafe `search_path`는 검사하지 않은 채 `runtimeRoleVerified=true`가 될 수 있다.** 문서가 말하는 “role passed verification”과 실제 boolean 의미도 다르다. 결과적으로 현재 Stable capability는: ```text startup fail-fast policy -> 미조립 actuator verification -> CREATE privilege 일부만 확인 ``` 상태다. **판정: P1 — production security/runtime-composition contract violation.** 필요한 수정 방향은 app-bootstrap이 실제 `DatabaseRolePolicy`/`SearchPathPolicy`를 구성해 startup `InitializingBean`에서 `roleVerifier.requireSafe(dataSource, policy)`를 실행하고, actuator의 `runtimeRoleVerified`도 동일 policy 결과를 기반으로 계산하도록 SSOT를 하나로 만드는 것이다. startup negative composition test는 wrong role, CREATE privilege, unapproved search_path를 각각 포함해야 한다. #### 70. P1 conditional-production — baseline outbox는 stale relay worker를 fence하지 못해 terminal state를 되돌릴 수 있다 baseline outbox는 현재 composition에 실제 들어간다. `OutboxStoreAdapter`는 `JpaAdapterComponentsConfig`의 `outbox` scan 대상이고, `OutboxConfig`는 `ca-skeleton.outbox.enabled=true`일 때 `OutboxStorePort`를 `PublishPendingOutboxEventsUseCase`에 전달한다. relay까지 켜면 다음 흐름이 된다. ```text Tx1: claimBatch() -> row IN_FLIGHT -> next_attempt_at = now + inFlightTimeout commit outside transaction: broker publish Tx2: markPublished / markFailed / markDead ``` PostgreSQL claim query의 `FOR UPDATE SKIP LOCKED`는 **동시에 claim하는 순간**만 직렬화한다. timeout이 지나면 `IN_FLIGHT` row도 다시 claim 가능하다. 그런데 baseline entity에는 owner token/claim revision이 없고, `markPublished(eventId)`, `markFailed(eventId, retryAt)`, `markDead(eventId)`는 event id로 row를 다시 읽어 현재 owner/attempt/state를 조건 없이 변경한다. 따라서 다음 race가 가능하다. 1. worker A가 attempt 1을 claim하고 broker I/O에서 오래 멈춘다. 2. visibility timeout이 지난 뒤 worker B가 같은 row를 attempt 2로 claim한다. 3. B가 publish 성공 후 `PUBLISHED`로 mark한다. 4. 늦게 돌아온 A의 failure path가 `markFailed()`를 호출한다. 5. 이미 `PUBLISHED`인 row가 `FAILED`로 되돌아가 다시 delivery 대상이 된다. focused probe에서 실제 adapter transition은 다음과 같이 재현됐다. ```text outboxStaleWorker.before=PUBLISHED outboxStaleWorker.after=FAILED outboxStaleWorker.retryAt=2026-06-11T10:00:30Z ``` 즉 이 문제는 일반적인 at-least-once의 “publish 성공 후 DB mark 실패” window와 별개다. **새 worker가 소유권을 이어받은 뒤에도 stale worker가 새 상태/terminal 상태를 덮어쓸 수 있는 fencing 부재**다. **판정: P1 conditional-production — `app.outbox.enabled` + relay 사용 시 delivery state corruption / duplicate publication risk.** V2 `PostgreSqlPollingDeliveryAdapter`에는 owner-safe transition 개념이 있지만 현재 default composition에 들어오지 않는다. baseline V1을 유지한다면 claim owner/attempt revision을 persisted state에 포함하고 모든 terminal/retry update를 CAS 조건으로 막아야 한다. 최소 regression은 stale attempt가 newer attempt 또는 `PUBLISHED` state를 변경하지 못함을 real PostgreSQL에서 고정해야 한다. Evidence: `evidence/raw/075-outbox-stale-worker-state-regression-output.txt`, `075a-outbox-stale-worker-state-regression-probe.java`. #### 71. P1 latent — durable operation은 lease가 만료돼도 takeover 전 stale owner가 완료할 수 있다 `DurableOperationJpaRepository`의 주석은 state-changing statement가 owner를 확인하고, lease를 잃은 worker가 결과를 기록하지 못해야 한다는 fencing contract를 설명한다. `heartbeat()`은 실제로 `lease_expires_at > :now`를 조건에 포함한다. 반면 `reportProgress()`, `succeed()`, `fail()`은 다음만 확인한다. - operation id - state = RUNNING - lease owner **lease expiry 자체는 확인하지 않는다.** 따라서 takeover가 아직 일어나 owner 문자열이 바뀌지 않은 짧은 window에서는 lease를 이미 잃은 worker가 상태를 확정할 수 있다. real PostgreSQL 16 probe 결과: ```text durableExpiredLease.completionAt=2026-08-25T09:02:01Z durableExpiredLease.leaseExpiredAtCompletion=true durableExpiredLease.succeedUpdatedRows=1 durableExpiredLease.finalState=SUCCEEDED ``` 기존 stale-worker test는 새 worker가 takeover해 owner가 이미 달라진 뒤를 검증하므로 이 **expiry-after / takeover-before** 경계를 덮지 않는다. 현재 `DurableOperationStoreAdapter`는 baseline component scan 및 다른 production constructor에서 확인되지 않았으므로 즉시 production reachable로 분류하지 않는다. **판정: P1 latent — durable-operation adapter 채택 시 lease fencing contract violation.** 수정 시 progress/succeed/fail에도 DB time 기준 `lease_expires_at > now`를 적용하거나 claim revision/fencing token을 도입해야 한다. Evidence: `evidence/raw/073-durable-operation-expired-lease-output.txt`, `073a-durable-operation-expired-lease-probe.java`. #### 72. P2 latent — live-event stream이 전부 sweep되면 position high-water mark가 사라져 position 1을 재사용한다 `JpaLiveEventReplayAdapter`는 sweep 뒤에도 position을 재사용하지 않아 cursor가 과거 event와 새 event를 혼동하지 않는다고 설명한다. 그러나 append allocator는 `LiveEventJpaRepository.highestEverAssigned(streamId)`를 사용하고, 이 query는 별도 high-water metadata가 아니라 **현재 `live_event_log` row의 `max(position)`**을 계산한다. 부분 sweep에서는 마지막 row가 남아 있으므로 문제가 숨는다. stream의 모든 row가 retention sweep으로 삭제되면 `max(position)`은 `null`이 되고 allocator는 다시 1부터 시작한다. real PostgreSQL 16 probe: ```text liveEventFullSweep.swept=1 liveEventFullSweep.highestAfterSweep=null liveEventFullSweep.nextPosition=1 liveEventFullSweep.payloadAtReusedPosition=new-event ``` 기존 `sweptPositionsAreNotReused()` contract test는 allocator를 호출하지 않고 test fixture가 직접 position을 지정하므로 이 경계를 검증하지 않는다. 현재 `JpaLiveEventReplayAdapter` 역시 baseline production composition에서 확인되지 않았다. **판정: P2 latent — live-event adapter 채택 시 monotonic cursor/position invariant violation.** 수정은 stream별 durable high-water row/sequence를 sweep 대상과 분리하거나, 삭제되어도 allocation state가 보존되는 구조가 필요하다. Evidence: `evidence/raw/071-liveevent-full-sweep-probe-output.txt`, `071a-liveevent-full-sweep-probe.java`. #### 73. 이번 sub-scope에서 finding으로 올리지 않은 항목 ##### 73.1 H2 idempotency와 V2 owner 필드 처음에는 `H2IdempotencyClaimRepository`의 MERGE/takeover가 V2 owner/transition field를 초기화하지 않는 점을 의심했다. 그러나 baseline `IdempotencyRecordEntity` 자체가 V1 field만 mapping하고, owner-safe V2는 PostgreSQL capability stream으로 분리돼 현재 별도 activation contract를 가진다. 서로 다른 schema generation의 field를 H2 V1이 reset하지 않는 것은 현 계약 위반이 아니다. ##### 73.2 `audit`와 `auditing` 두 경로 manual `AuditableEntity`/`AuditContextPort` 경로와 Spring Data `AuditMetadata`/`JpaAuditingConfiguration`이 함께 존재하지만 tests/docs가 후자를 candidate/dormant로 명시하고 default composition도 canonical manual audit 경로만 사용한다. 현재 중복 활성화 defect로 판정하지 않는다. ##### 73.3 cache / Envers `HibernateCacheGuard`, `HibernateEnversHistoryReader`는 Advanced/opt-in surface이며 default bean construction이 없다. 이 sub-scope에서 production mis-wiring으로 올릴 근거는 없었다. #### 74. Fresh verification evidence - `evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt` — 87-file exact denominator - `evidence/raw/072-baseline-capability-reachability.txt` — baseline package scan, runtime-role policy caller absence, outbox composition, latent adapter construction snapshot - `evidence/raw/071-liveevent-full-sweep-probe-output.txt` — real PostgreSQL full-sweep position reuse - `evidence/raw/073-durable-operation-expired-lease-output.txt` — real PostgreSQL expired lease completion - `evidence/raw/075-outbox-stale-worker-state-regression-output.txt` — baseline outbox terminal-state regression - `evidence/raw/076-persistence-jpa-baseline-unit-tests.txt` — fresh full `:adapter:outbound:persistence-jpa:test --rerun-tasks` BUILD SUCCESSFUL 세 probe 모두 temporary source replacement를 shell `trap`으로 복원했고 실행 뒤 code repository `git status --short`는 clean이었다. #### 75. Sub-scope 06 findings backlog | 우선순위 | finding | 현재 reachability | |---|---|---| | **P1** | Stable runtime-role verification이 startup에서 `DatabaseRolePolicy`/`SearchPathPolicy`를 적용하지 않고 actuator도 CREATE privilege 일부만으로 verified 판단 | **production** | | **P1** | baseline outbox stale worker가 newer/terminal state를 owner fencing 없이 덮어쓸 수 있음 | **conditional-production (`outbox` relay enabled)** | | **P1 latent** | durable operation lease 만료 후 takeover 전 stale owner가 progress/succeed/fail 가능 | adapter currently uncomposed | | **P2 latent** | live-event full sweep 후 high-water mark 소실로 position 재사용 | adapter currently uncomposed | Sub-scope 06은 **87 / 87 FULL_READ + targeted runtime verification 완료**로 닫는다. 다음 owning unit은 sub-scope 07 Fileserver persistence + migrations 29개다. #### 76. Sub-scope 07 범위와 denominator Fileserver persistence의 owning denominator는 `evidence/raw/077-persistence-jpa-fileserver-manifest.txt`로 고정했다. | 구분 | 파일 수 | 판정 | |---|---:|---| | production Java | 25 | FULL_READ | | Fileserver migration SQL | 4 | FULL_READ | | 합계 | **29** | **29 / 29 FULL_READ** | 구현 범위는 file/upload/verification/quota/cleanup/recovery entity와 repository, JPA adapters, schema activation, V1~V4 migration을 포함한다. `postgresqlIntegrationTest` source set 자체의 denominator는 sub-scope 11이 소유하지만, 이 sub-scope에서 발견한 Fileserver semantic boundary를 검증하기 위해 해당 real-PostgreSQL lane을 선택적으로 실행했다. #### 77. Fileserver composition과 schema lifecycle Fileserver persistence는 latent helper가 아니라 실제 opt-in production capability다. - `PersistenceJpaRootAutoConfiguration`이 `FileserverJpaPersistenceConfig`를 import한다. - `app.fileserver-platform.enabled=true`이면 Fileserver entity/repository/component scan이 열린다. - `FileserverStorageConfiguration.fileserverSchemaActivation()`은 `JdbcOperations`가 있으면 startup에서 `requireActive()`를 호출한다. - 따라서 schema activation, quota, cleanup, recovery adapter는 Fileserver capability가 켜진 배포에서 production-reachable하다. V1은 registry에 `jpa-fileserver-metadata-v1`, `feature_revision=1`, `INSTALLED_INACTIVE`를 기록하고, V2는 recovery schema를 추가한 뒤 revision을 2로 올린다. 이후 V3는 fenced cleanup lease column을, V4는 upload terminal lifecycle column을 추가하지만 registry revision은 더 이상 갱신하지 않는다. 이 차이는 아래 startup fail-open finding의 직접 원인이다. #### 78. P1 — persistent byte quota가 실제 admission에서 집행되지 않는다 Fileserver 설계와 deviation 문서는 quota를 단순 accounting이 아니라 **scope별 byte enforcement**로 설명한다. - `docs/fileserver/design-deviations.md`는 quota decision이 scope별 `reserved + committed` 합을 사용한다고 명시한다. - implementation plan은 DB conditional update로 quota byte를 보호하고, scope limit 초과를 `QuotaExceededException`으로 매핑한다고 정의한다. - `DefaultTransferAdmissionController`의 class-level 설명도 “scope over its ceiling”을 `QUOTA_EXCEEDED`라고 표현한다. 하지만 production call graph에는 그 ceiling이 없다. 1. `DefaultUploadApplicationService.create()`는 namespace를 `QuotaScope`로 만든 뒤 `admissionController.acquireUpload(scope, bytes)`를 호출한다. 2. admission controller가 검사하는 것은 단일 파일 최대 크기, global storage high-water, JVM-local scope/instance semaphore뿐이다. 3. 그 다음 `JpaFileQuotaService.reserve()`는 byte aggregate나 limit을 조회하지 않고 `QuotaReservationEntity`를 unconditional `save()`한다. 4. production source에서 `reservedBytes(scope)` / `committedBytes(scope)` 또는 repository의 aggregate query를 quota decision에 사용하는 caller는 0개다. 5. `FileserverPlatformSettings.Quota`에도 byte ceiling/tenant capacity가 없고 concurrency permits와 storage high-water만 있다. 즉 현재 DB quota ledger는 **사용량 기록**은 하지만 그 사용량을 기반으로 admission을 거절하지 않는다. namespace/tenant가 얼마나 많은 byte를 이미 예약·commit했든, 단일 파일 크기와 global storage high-water/동시성만 통과하면 새 reservation이 생성된다. 이는 단순 naming 문제가 아니다. per-scope quota는 multi-tenant resource isolation 경계인데, 현재 구현은 이를 JVM-local concurrent-upload 제한으로 대체하고 있다. 여러 인스턴스 배포에서는 scope semaphore 자체도 instance-local이다. **판정: P1 production cross-scope contract violation — persistent scope/tenant byte quota enforcement missing.** 수정 방향은 persistent quota와 transfer concurrency를 분리해야 한다. - explicit scope/tenant byte ceiling policy를 둔다. - `reserve/extend`가 `committed + live reserved + delta <= ceiling`을 DB에서 원자적으로 보장해야 한다. - 단순 `SUM()` 후 INSERT는 concurrent reservation race가 있으므로 scope별 aggregate row lock/CAS, advisory lock, 또는 동일 수준의 serialized invariant가 필요하다. - JVM semaphore는 local concurrency guard로 유지하되 durable byte quota의 대체물이 되어서는 안 된다. - regression은 두 인스턴스가 limit 직전에서 동시에 reserve하는 case, expired reservation 제외, committed usage 포함, unknown-length extend, cleanup reclaim을 포함해야 한다. Static evidence: `evidence/raw/079-fileserver-reachability-quota-schema-contract.txt`. #### 79. P1 conditional-production — schema activation이 V2를 current schema로 오인한다 `FileserverSchemaActivation`의 목적은 주석 그대로 **첫 user request에서 missing relation/column 500이 나기 전에 startup에서 fail closed**하는 것이다. 그러나 현재 gate는 다음만 요구한다. ```text capability_id = jpa-fileserver-metadata-v1 core_epoch = 1 feature_revision >= 2 lifecycle_state = ACTIVE ``` 문제는 current code가 V2보다 뒤의 schema를 필요로 한다는 점이다. - V3: `fs_cleanup_item.claim_owner`, `claim_token`, `lease_until`, `claim_fence` - V4: `fs_upload_session.lifecycle_state` 그런데 V3/V4는 registry revision을 올리지 않는다. 따라서 V2까지만 적용된 DB를 `ACTIVE`로 promote하면 현재 gate를 통과한다. 이를 PostgreSQL 16에서 별도 database로 재현했다. base/core migration 후 Fileserver Flyway를 **target 2**까지만 적용하고 registry를 ACTIVE로 만든 결과: ```text fileserverSchemaV2.featureRevision=2 fileserverSchemaV2.activationAccepted=true fileserverSchemaV2.cleanupClaimToken=false fileserverSchemaV2.uploadLifecycleState=false ``` 즉 startup activation은 성공했지만 현재 cleanup/upload repository가 요구하는 V3/V4 column은 존재하지 않았다. 이 문제는 Hibernate `validate`가 항상 구해주는 것도 아니다. `JpaDangerousConfigurationGuardTest`가 production에서도 `spring.jpa.hibernate.ddl-auto=none`을 허용하도록 고정하고 있기 때문이다. 이 profile에서는 activation이 사실상 deployment fail-fast gate인데 현재 V2를 허용한다. **판정: P1 conditional-production — Fileserver enabled + schema V2 ACTIVE + `ddl-auto=none`에서 startup fail-open / first-use SQL failure risk.** 이미 V3/V4가 배포된 migration history가 있을 수 있으므로 기존 migration 파일의 checksum을 바꾸는 방식은 피해야 한다. 안전한 수선은 새 forward migration에서 current schema revision marker를 올리고 activation이 그 revision 이상을 요구하게 하는 것이다. 그 뒤 V2 ACTIVE database가 startup에서 거부되는 regression을 고정해야 한다. Evidence: `evidence/raw/081-fileserver-schema-activation-v2-output.txt`, `081a-fileserver-schema-activation-v2-probe.java`. #### 80. P2 — quota reclaim은 최대 64개 committed row만 처리하고 남은 byte를 조용히 버린다 `JpaQuotaReclaimGateway.reclaim(scope, bytes)`는 `findCommittedWithBytes(scope, Limit.of(64))`를 **한 번만** 조회한다. 그 64개 row를 모두 소진한 뒤에도 `outstanding > 0`이면 추가 page/query를 하지 않고 method가 끝난다. real PostgreSQL에서 동일 scope에 1-byte committed row 65개를 만든 뒤 65 bytes reclaim을 요청한 결과: ```text fileserverQuotaReclaim.before=65 fileserverQuotaReclaim.requested=65 fileserverQuotaReclaim.after=1 ``` physical delete가 성공한 뒤 cleanup service가 이 gateway를 호출하므로, 64개보다 많은 ledger row에 걸친 reclaim은 실제 사용량보다 committed accounting을 높게 남긴다. 현재 byte quota enforcement가 빠져 있어 즉시 admission rejection으로 이어지지는 않지만, ledger 자체가 quota/reclamation SSOT라는 계약을 위반하고 향후 enforcement가 복구되면 capacity leak로 직결된다. **판정: P2 production accounting correctness defect.** 수정은 outstanding이 0이 될 때까지 bounded page를 반복하되 forward progress를 보장하거나, scope aggregate usage를 별도 row로 유지해 reclaim을 O(1) CAS로 만드는 편이 낫다. “최대 64개만 처리”를 의도한 batch boundary라면 caller가 remainder를 재-enqueue해야 하지만 현재 그런 contract는 없다. Evidence: `evidence/raw/078-fileserver-quota-boundary-probe-output.txt`, `078a-fileserver-quota-boundary-probe.java`. #### 81. P2 — direct `FileQuotaService.commit()`은 만료 reservation을 commit한다 `JpaFileQuotaService`의 own Javadoc은 “already expired or released reservation can never be extended or committed”라고 명시한다. `extend()` query는 실제로 `expiresAt > now`를 조건으로 둔다. 반면 `FileserverQuotaRepository.commit()`은 `status='RESERVED'`만 확인하고 expiry predicate가 없다. real PostgreSQL에서 reservation의 `expires_at`을 과거로 이동한 뒤 public `FileQuotaService.commit()`을 호출하면: ```text fileserverExpiredQuota.status=COMMITTED fileserverExpiredQuota.committedBytes=600 ``` 으로 전환됐다. 여기서는 수정 경계를 주의해야 한다. 별도 `JpaQuotaCommitGateway`는 upload가 TTL보다 오래 걸렸더라도 실제 durable byte를 under-count하지 않기 위해 expired upload usage를 기록하는 의도적 path를 가진다. 따라서 shared repository `commit()`에 무조건 expiry predicate를 추가하면 그 settlement contract까지 깨질 수 있다. **판정: P2 production API-contract defect.** 수정은 “live reservation direct commit”과 “expired upload durable usage settlement”를 별도 SQL/API로 분리해 전자는 expiry를 엄격히 거부하고 후자는 명시적 recovery/settlement 의미로 유지해야 한다. Evidence: `evidence/raw/078-fileserver-quota-boundary-probe-output.txt`. #### 82. P2 — recovery queue의 `enqueue()`는 concurrent upsert가 아니다 `JpaRecoveryQueue`는 Javadoc에서 enqueue를 upsert라고 정의하고 “same file reported twice updates the open item rather than adding a second one”이라고 설명한다. 구현은: ```text UPDATE existing PENDING if updated == 0: INSERT new PENDING ``` 이고 DB에는 `WHERE status='PENDING'` partial unique index가 있다. 최초 item이 없는 상태에서 두 transaction이 동시에 들어오면 둘 다 UPDATE 0을 보고 INSERT로 진행할 수 있다. unique index는 duplicate row는 막지만 loser transaction을 정상 upsert로 흡수하지는 않는다. real PostgreSQL concurrent probe 결과: ```text fileserverRecovery.concurrentFailures=1 fileserverRecovery.first=SUCCESS fileserverRecovery.second=org.springframework.dao.DataIntegrityViolationException fileserverRecovery.rowCount=1 ``` recovery enqueue는 finalize의 ambiguous commit path와 reconciliation worker 양쪽에서 production 호출되므로 동일 file에 대한 동시 report가 가능한 seam이다. 한 row만 남는 DB invariant는 지켜지지만 “enqueue request가 durable work item으로 합쳐진다”는 adapter contract 대신 caller 하나가 persistence exception을 받는다. **판정: P2 production concurrency/idempotency defect.** PostgreSQL native upsert가 partial unique predicate와 동일 semantics를 갖도록 구성하거나, insert unique conflict를 잡아 bounded update retry로 수렴시켜야 한다. regression은 barrier를 둔 two-transaction 최초 enqueue에서 둘 다 성공하고 open row는 1개임을 검증해야 한다. Evidence: `evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt`, `080a-fileserver-recovery-concurrent-enqueue-probe.java`. #### 82.1. P2 — cleanup crash-reclaim은 `MAXIMUM_ATTEMPTS`를 우회해 poison item을 무한 재시도할 수 있다 `JpaCleanupQueue`는 `MAXIMUM_ATTEMPTS = 8`을 두고, 정상적인 `markFailed()` 경로에서는 `item.attempt() + 1 >= 8`이면 `ABANDONED`로 전환한다. class Javadoc도 반복 실패한 poison item을 계속 재시도하지 않는 것이 이 queue의 명시적 계약이라고 설명한다. 그러나 worker crash는 다른 경로를 탄다. `DefaultCleanupService.runBatch()`는 매 batch 시작 시 `reclaimExpiredClaims()`를 먼저 호출하고, `FileserverCleanupRepository.reclaimExpiredClaim()`은 expired `IN_PROGRESS` row를 항상 다음 상태로 되돌린다. ```text status = FAILED attempt = attempt + 1 last_error_code = CLAIM_LEASE_EXPIRED claim fields = null ``` 여기에는 `MAXIMUM_ATTEMPTS` 또는 현재 `attempt`에 대한 terminal 조건이 없다. 따라서 worker가 physical cleanup 중 계속 crash하면 정상 실패 budget을 거치지 않고 lease expiry → reclaim → claim → crash를 반복할 수 있다. real PostgreSQL에서 claim 후 settlement 없이 lease expiry만 9회 반복한 결과: ```text fileserverCleanupCrash.maxAttempts=8 fileserverCleanupCrash.actualAttempt=9 fileserverCleanupCrash.status=FAILED fileserverCleanupCrash.lastError=CLAIM_LEASE_EXPIRED ``` 즉 명시된 최대 8회를 넘겼는데도 row는 `ABANDONED`가 아니라 다시 claim 가능한 `FAILED`로 남았다. 이는 cleanup queue의 poison-item bounded retry 계약을 깨고, 반복적으로 crash를 유발하는 cleanup item이 scheduler capacity를 계속 소비하게 만든다. **판정: P2 production liveness / bounded-retry defect.** 수정은 crash-reclaim과 normal failure가 동일한 attempt budget을 공유하게 해야 한다. `reclaimExpiredClaim()`에서 증가 후 attempt가 limit에 도달하면 `ABANDONED`로 전환하거나, repository가 next-state를 caller로부터 받되 DB CAS가 token과 attempt를 함께 검증하도록 구성할 수 있다. regression은 normal failure와 crash-reclaim을 섞어도 총 attempt budget을 넘으면 반드시 terminal `ABANDONED`가 되는지 고정해야 한다. Evidence: `evidence/raw/079-fileserver-cleanup-crash-budget-output.txt`, `079a-fileserver-cleanup-crash-budget-probe.java`. #### 83. 이번 sub-scope에서 finding으로 올리지 않은 항목 ##### 83.1 quota FIFO settlement 자체 reservation row가 upload id와 연결되지 않아 `JpaQuotaCommitGateway`가 scope의 가장 오래된 live reservation부터 정산하는 것은 `docs/fileserver/design-deviations.md`에 명시적으로 기록된 adaptation이다. row identity와 실제 upload identity가 1:1이 아닌 것 자체는 현재 설계 계약이다. 다만 그 문서가 전제로 둔 aggregate byte enforcement가 실제로 없다는 점은 §78의 별도 P1 finding으로 올렸다. ##### 83.2 cleanup fenced lease의 expiry-after / takeover-before window cleanup settlement query는 claim token을 fence하고 reaper takeover가 token을 교체한다. lease expiry 직후 아직 takeover 전인 worker가 settle할 수 있는 window는 보이지만, 새 owner가 생긴 뒤 stale worker가 상태를 덮어쓰는 race는 token CAS가 막는다. durable-operation과 달리 현재 계약만으로 “expiry 순간부터 절대 settle 금지”라고 확정할 충분한 근거가 없어 finding으로 올리지 않았다. ##### 83.3 과거 JPA-028 cleanup fencing finding 이전 review의 Fileserver cleanup owner/token/terminal-state 부재는 V3/V4와 현재 repository code에서 실제로 보완돼 있다. 이번 분석은 그 과거 finding을 중복 집계하지 않는다. #### 84. Fresh Fileserver verification evidence - `evidence/raw/077-persistence-jpa-fileserver-manifest.txt` — 25 production Java + 4 migration SQL, **29 / 29 FULL_READ** - `evidence/raw/079-fileserver-reachability-quota-schema-contract.txt` — production quota readers/ceiling absence, admission logic, schema revision/activation snapshot - `evidence/raw/078-fileserver-quota-boundary-probe-output.txt` — real PostgreSQL expired commit + 65-row reclaim truncation - `evidence/raw/080-fileserver-recovery-concurrent-enqueue-output.txt` — real PostgreSQL concurrent recovery enqueue race - `evidence/raw/081-fileserver-schema-activation-v2-output.txt` — real PostgreSQL V2-only ACTIVE activation acceptance - `evidence/raw/082-fileserver-official-readiness-lanes.txt` — original-source Fileserver migration/metadata/reclamation no-skip lanes, fresh `--rerun-tasks`, BUILD SUCCESSFUL, 21/21 tasks executed, git clean before/after - `evidence/raw/079-fileserver-cleanup-crash-budget-output.txt`, `079a-fileserver-cleanup-crash-budget-probe.java` — real PostgreSQL에서 crash-reclaim만으로 attempt 9 / `FAILED`를 재현해 8회 poison budget 우회를 확인 기존 temporary probe source는 실행 후 원본으로 복구했다. cleanup crash-budget probe는 Gradle test 자체는 `BUILD SUCCESSFUL` / exit 0이었지만 wrapper의 restore trap이 `cd src` 뒤 상대경로를 사용해 복원 단계만 실패했다. 직전 clean snapshot의 exact `HEAD` blob을 해당 analysis-owned test file 하나에 다시 기록한 뒤 worktree hash와 `HEAD` hash가 동일함을 확인했고, 최종 `git status --short`는 clean이었다. #### 85. Sub-scope 07 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1** | persistent `reserved + committed` byte quota를 실제 admission에서 읽거나 ceiling과 비교하는 경로가 없음 | **production when Fileserver enabled** | | **P1** | schema activation이 revision 2 ACTIVE를 허용하지만 current code는 V3/V4 columns를 요구 | **conditional-production; especially `ddl-auto=none`** | | **P2** | reclaim이 64 committed rows 이후 remainder를 처리하지 않아 usage accounting이 남음 | **production** | | **P2** | public direct quota commit이 expired RESERVED row를 COMMITTED로 전환 | **production** | | **P2** | concurrent first recovery enqueue 중 한 transaction이 unique violation으로 실패 | **production** | | **P2** | cleanup crash-reclaim이 `MAXIMUM_ATTEMPTS=8`을 적용하지 않아 attempt 9+도 `FAILED`로 재활성화됨 | **production** | Sub-scope 07은 **29 / 29 FULL_READ + targeted real-PostgreSQL boundary verification 완료**로 닫는다. 다음 owning unit은 sub-scope 08 Notification persistence + migrations 68개다. #### 86. Sub-scope 08 범위와 denominator Notification persistence의 owning denominator는 `evidence/raw/083-persistence-jpa-notification-manifest.txt`로 고정했다. | 구분 | 파일 수 | 판정 | |---|---:|---| | production Java | 53 | FULL_READ | | Notification migration SQL | 10 | FULL_READ | | dedicated unit Java | 5 | FULL_READ | | 합계 | **68** | **68 / 68 FULL_READ** | 범위는 notification schema activation/facade, payload/contact-point crypto, request/recipient/attempt/policy/admin/reconciliation/provider-event/inbox entities·repositories·stores, V1~V10 opt-in migration을 포함한다. `postgresqlIntegrationTest` notification classes는 sub-scope 11 denominator에 남겨 두되, 이번 finding의 DB semantics를 검증하기 위해 기존 contract/readiness lane과 별도 PostgreSQL probe를 선택적으로 실행했다. #### 87. Notification composition과 schema lifecycle Notification JPA capability는 production opt-in path로 실제 composition된다. - `PersistenceJpaRootAutoConfiguration`이 `NotificationJpaPersistenceFacade`를 import한다. - facade가 `NotificationJpaPersistenceConfig`를 import하고 entity/repository/store bean을 조립한다. - application-side worker/config가 recipient lease, reconciliation, provider-event ledger, admin operation store를 실제 소비한다. - `NotificationSchemaActivation`은 capability registry를 읽어 startup activation을 검사한다. schema stream은 V1~V10까지 진화했지만 registry는 V4에서 `jpa-notification-platform-v4`, `feature_revision=4`, `INSTALLED_INACTIVE`를 기록한 뒤 더 이상 revision을 올리지 않는다. 반면 current Java mapping과 SQL은 V5~V10에서 추가된 column/constraint에 실제 의존한다. 이 drift가 §88의 startup false-positive를 만든다. #### 88. P1 conditional-production — V4 ACTIVE schema가 current V10-compatible schema로 오인된다 `NotificationSchemaActivation`은 다음 조건이면 capability를 active로 인정한다. ```text capability_id = jpa-notification-platform-v4 core_epoch = 1 feature_revision >= 4 lifecycle_state = ACTIVE ``` 하지만 current code는 revision 4 이후 migration을 요구한다. 대표적으로: - V5: recipient `expires_at` - V6: delivery-attempt projection facts/version + suppression side-effect claim state - V7: request collapse fields - V8: admin `command_fingerprint`, `phase`, `claimed_at` - V9: provider execution-evidence certainty fields - V10: protected payload envelope constraint V5~V10 어느 migration도 capability registry revision을 5 이상으로 올리지 않는다. PostgreSQL 16에서 core V1/V2 + Notification V1~V4만 적용하고 registry row를 ACTIVE로 promote한 뒤 activation SQL을 그대로 실행했다. ```text activation_count = 1 feature_revision = 4 lifecycle_state = ACTIVE ``` 동시에 current code가 요구하는 column 존재 여부는 다음과 같았다. ```text notification_recipient_delivery.expires_at = false notification_delivery_attempt.projection_version = false notification_request.collapse_key = false notification_admin_audit.phase = false notification_delivery_attempt.request_started_certainty = false ``` 즉 startup gate는 성공하지만 first-use 시 current repository/entity SQL과 DB schema가 맞지 않을 수 있다. 기존 `postgresqlNotificationSchemaActivationIntegrationTest`는 fresh rerun으로 green이지만, 그 green은 현재 gate가 정의한 V4 lifecycle을 검증할 뿐 V10 mapping compatibility를 증명하지 않는다. **판정: P1 conditional-production schema fail-open.** Notification capability가 켜진 상태에서 V4까지만 적용된 DB가 ACTIVE라면 startup이 current schema 부재를 잡지 못한다. 기존 V1~V10 migration checksum을 수정하기보다 새 forward migration에서 current schema revision을 명시적으로 올리고 activation이 그 revision 이상을 요구하게 하는 편이 안전하다. regression은 V4 ACTIVE를 반드시 거부하고 current revision만 허용해야 한다. Evidence: `evidence/raw/084-notification-schema-v4-activation-probe.txt`, `089-notification-schema-official-readiness.txt`. #### 89. P1 — provider 호출 뒤 recipient projection write가 lease fencing을 우회한다 recipient lease 설계 자체는 owner + monotonic fence를 갖는다. claim은 `lease_fence`를 증가시키고 `stillHeld()` / `renewLease()`는 `lease_until > now`까지 검사한다. 문제는 **provider side effect 이후 결과를 저장하는 실제 production path**다. `NotificationDispatchService.dispatch()` 흐름은 다음이다. 1. provider call 직전에 `leases.stillHeld(lease)`를 확인한다. 2. provider call은 transaction 밖에서 실행한다. 3. call이 돌아오면 `DispatchOutcomeRecorder.record(...)`를 write transaction에서 실행한다. 4. recorder는 attempt result를 저장한 뒤 recipient projection에 **`recipients.save(updated)`**를 호출한다. 5. 이 `save()`는 row를 현재 시점에 ID로 다시 읽고 projection field를 변경할 뿐 owner/fence/expiry를 검증하지 않는다. 따라서 lease가 provider call 도중 만료되거나 다른 worker가 takeover해도 stale caller의 결과 write가 자동으로 거부되지 않는다. 실제 fenced helper도 완전하지 않다. `saveProjectionHeldBy()` / `transitionHeldBy()`는 `id + lease_owner + lease_fence`만 조건으로 두고 `lease_until > now`는 확인하지 않는다. PostgreSQL에서 이미 만료되어 `stillHeld` 조건이 0건인 row에 동일 owner/fence write를 실행하면 `UPDATE 1`이었다. 더 강한 takeover case도 재현했다. ```text # worker B가 takeover한 직후 state=DISPATCHING, owner=worker-b, fence=8, version=1 # stale worker A의 recorder/save와 동등한 ID-only projection write 뒤 state=RECONCILIATION_REQUIRED, owner=worker-b, fence=8, version=2 ``` 즉 새 owner의 lease identity는 그대로인데 **이전 provider call의 stale outcome이 recipient state를 덮을 수 있다.** 이는 fencing token을 둔 목적과 직접 충돌한다. **판정: P1 production concurrency/correctness defect.** provider side effect와 authoritative outcome write 사이의 lease handoff에서 stale writer가 살아남는다. 결과에 따라 중복 전송 위험 판단, retry/reconciliation state, attempt count가 새 holder의 흐름과 충돌할 수 있다. 수정은 provider completion 이후의 authoritative recipient mutation을 반드시 `RecipientLease`에 결박해야 한다. 최소한 `owner + fence + lease_until > completedAt/now`를 하나의 conditional write에서 검증하고, 0-row update는 superseded result로 처리해야 한다. recorder가 일반 `save()`를 호출하는 구조도 제거하거나 lease-aware recorder API로 바꿔야 한다. Evidence: `evidence/raw/085-notification-expired-lease-write-probe.txt`, `091-notification-stale-provider-overwrite-probe.txt`, `092-notification-reachability-test-gap.txt`. #### 90. P2 — reconciliation `FOR UPDATE SKIP LOCKED`는 worker 처리 구간을 claim하지 않는다 `JdbcReconciliationJobStore.claimDue()`는 due row를 다음 SQL로 읽는다. ```text SELECT ... FROM notification_reconciliation_job WHERE next_check_at <= ? ORDER BY next_check_at, id LIMIT ? FOR UPDATE SKIP LOCKED ``` 그러나 이 method는 별도 transaction boundary를 열지 않고 durable `owner/status/lease`도 기록하지 않는다. `ReconciliationJobWorker.reconcileOnce()`도 `claimDue()` 뒤 provider reconciliation을 수행한 다음에야 `complete()` 또는 `reschedule()`을 호출하며 전체 구간을 감싸는 `TransactionPort`/`@Transactional`이 없다. 따라서 normal JdbcTemplate autocommit에서는 SELECT가 반환되는 순간 row lock이 풀린다. PostgreSQL에서 worker A의 claim SELECT가 끝난 뒤 A가 아직 complete/reschedule하지 않은 상태를 유지하고 worker B가 같은 SQL을 실행하자 두 호출 모두 같은 job을 반환했다. ```text worker A -> job 5555... attempts=0 worker B -> job 5555... attempts=0 row state -> next_check_at unchanged, attempts=0, last_result=null ``` `SKIP LOCKED` 자체가 잘못된 것이 아니라 **lock lifetime과 work lifetime이 다르다.** 현재 형태는 동시에 SELECT statement를 실행하는 아주 짧은 순간만 중복 read를 피하고 provider 조회/정산 중복을 막지 못한다. **판정: P2 production multi-instance coordination defect.** reconciliation은 send 자체가 아니라 provider 상태 조회/상태 projection이어서 recipient dispatch P1보다 영향도를 낮게 잡지만, 두 worker가 같은 job을 처리할 수 있다는 class contract는 깨진다. 수정은 delivery claim처럼 durable owner/fence/lease를 기록하는 short claim transaction을 두거나, 전체 processing을 DB lock transaction 안에 두어야 한다. 외부 provider call을 긴 DB transaction에 넣는 것은 피하는 편이 좋으므로 전자가 더 적합하다. Evidence: `evidence/raw/086-notification-reconciliation-claim-probe.txt`, `092-notification-reachability-test-gap.txt`. #### 91. P2 — V8 atomic admin claim은 production service에 연결되지 않았고 completion 모델도 미완성이다 V8 migration과 `AdminOperationStorePort.claim()`의 설명은 문제를 정확히 알고 있다. 기존 `find -> act -> save` 구조에서는 두 caller가 모두 빈 상태를 읽고 같은 operation id의 action을 실행할 수 있으므로 `INSERT ... ON CONFLICT DO NOTHING`으로 먼저 claim해야 한다는 설계다. 하지만 current production call graph에서 `operations.claim(...)` 호출은 **0개**다. `NotificationAdminApplicationService`의 redrive/reconcile/suppress/provider-state 경로는 여전히 모두: ```text findByOperationId(operationId) ... action ... operations.save(...) ``` 를 사용한다. 게다가 현재 persistence completion model은 claim API를 단순히 연결하는 것만으로 끝나지 않는다. `claim()`은 `notification_admin_audit`에 `phase='CLAIMED'` row를 먼저 INSERT하지만, `JpaAdminOperationStore.save()`는 그 row를 update-to-COMPLETED하지 않고 동일 `operation_id`의 새 entity를 INSERT한다. PostgreSQL probe에서 claim 성공 후 현재 save 방식과 동등한 두 번째 INSERT는 unique violation이 났고 기존 row는 계속 `CLAIMED`였다. ```text claim -> INSERT 1, phase=CLAIMED save-style completion -> unique_violation final -> phase=CLAIMED ``` 기존 `AdminOperationClaimContractTest`는 atomic claim primitive 자체는 검증하고 fresh rerun도 green이지만, production service가 이를 쓰는지와 claim→completion lifecycle은 검증하지 않는다. **판정: P2 production idempotency/wiring defect.** V8에서 만든 fix가 dead path이며 completion state machine도 이어지지 않는다. DB transaction 안에서 수행되는 redrive/suppress 일부 경로는 마지막 unique conflict가 loser transaction을 rollback시켜 결과를 완화하지만, reconcile/provider runtime control처럼 action과 final audit insert가 하나의 동일 DB transaction으로 묶이지 않는 경로까지 전체적으로 exactly-once operation claim을 보장하지 못한다. 수정은 service entry에서 command fingerprint와 함께 atomic claim을 먼저 수행하고, owner가 아니면 CLAIMED/COMPLETED 상태를 명시적으로 해석해야 한다. winner는 동일 row를 `COMPLETED`로 update하면서 result snapshot을 저장해야 하며, 별도 duplicate INSERT로 완료해서는 안 된다. Evidence: `evidence/raw/087-notification-admin-claim-completion-probe.txt`, `088-notification-admin-claim-reachability.txt`, `092-notification-reachability-test-gap.txt`. #### 92. 이번 sub-scope에서 finding으로 올리지 않은 항목 ##### 92.1 provider-event replay의 중복 scan 자체 `ProviderEventReplayWorker`도 unmatched/pending event를 durable lease 없이 scan할 수 있지만, projection write는 transaction 안에서 수행되고 ledger의 applied transition과 suppression side-effect에는 별도 conditional claim이 존재한다. 동일 event가 두 worker에 보일 가능성만으로 중복 external side effect까지 현재 evidence에서 확정할 수 없어 이번 backlog에는 올리지 않았다. ##### 92.2 crypto envelope와 contact-point secret protection request variable payload는 `NotificationPayloadProtection`을 필수 collaborator로 받아 보호된 envelope를 저장하고, contact point는 ciphertext/nonce/lookup HMAC/key id로 분리된다. V10은 plaintext-looking request envelope를 DB constraint로도 거부한다. 이번 완독에서 이 경계 자체를 우회하는 production write path는 확인하지 못했다. ##### 92.3 tenant-bound repository guard tenant-sensitive lookup이 전부 완전하다고 corpus 전체 결론을 내리지는 않았지만, `TenantBoundRepositoryGuard`와 tenant-qualified repository method가 존재하고 이번 68-file owning scope에서 즉시 재현 가능한 cross-tenant bypass는 확정하지 못했다. 별도 inbound/application authorization 조합은 cross-scope 단계가 소유한다. #### 93. Fresh Notification verification evidence - `evidence/raw/083-persistence-jpa-notification-manifest.txt` — 53 production Java + 10 migration SQL + 5 dedicated unit Java, **68 / 68 FULL_READ** - `evidence/raw/084-notification-schema-v4-activation-probe.txt` — PostgreSQL 16에서 V4 ACTIVE activation은 통과하지만 current-required V5~V9 columns 5개가 모두 absent - `evidence/raw/085-notification-expired-lease-write-probe.txt` — `stillHeld=0`인 expired lease의 owner+fence write가 `UPDATE 1` - `evidence/raw/091-notification-stale-provider-overwrite-probe.txt` — replacement holder B/fence 8 이후 stale result의 ID-only projection write가 새 lease를 보존한 채 lifecycle을 overwrite - `evidence/raw/086-notification-reconciliation-claim-probe.txt` — autocommit SKIP LOCKED를 worker A/B가 순차 실행해 동일 reconciliation job을 둘 다 획득 - `evidence/raw/087-notification-admin-claim-completion-probe.txt` — atomic claim 뒤 current save-style second insert가 unique violation, row는 CLAIMED 유지 - `evidence/raw/088-notification-admin-claim-reachability.txt`, `092-notification-reachability-test-gap.txt` — production admin claim caller 0, dispatch/reconciliation actual call graph와 predicate snapshot - `evidence/raw/089-notification-schema-official-readiness.txt` — original-source `postgresqlNotificationSchemaActivationIntegrationTest --rerun-tasks`, **BUILD SUCCESSFUL in 40s, 19/19 tasks executed** - `evidence/raw/090-notification-existing-contracts-fresh.txt` — original `RecipientClaimContractTest` + `AdminOperationClaimContractTest`, fresh `jpaPlatformContractTest --rerun-tasks`, **BUILD SUCCESSFUL in 31s, 19/19 tasks executed** 모든 신규 semantic probe는 임시 PostgreSQL container와 repository의 existing migration/source를 읽어 실행했으며 source file을 수정하지 않았다. 최종 code repository `git status --short`는 clean이다. #### 94. Sub-scope 08 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1** | schema activation이 revision 4 ACTIVE를 current-compatible로 인정하지만 code는 V5~V10 schema를 요구 | **conditional-production when Notification JPA enabled** | | **P1** | provider call 결과 recorder가 lease-unaware `recipients.save()`를 사용해 expired/replaced holder의 stale projection이 새 holder state를 덮을 수 있음 | **production dispatch** | | **P2** | reconciliation `FOR UPDATE SKIP LOCKED` lock이 SELECT 종료와 함께 풀려 처리 중 동일 job을 다른 worker가 재claim 가능 | **production multi-instance worker** | | **P2** | V8 atomic admin claim API가 production caller 0이고 claim row를 current `save()`로 완료할 수도 없음 | **production admin operations** | Sub-scope 08은 **68 / 68 FULL_READ + targeted real-PostgreSQL verification + original contract/readiness fresh rerun 완료**로 닫는다. 다음 owning unit은 sub-scope 09 Experimental platform 38개다. #### 95. Sub-scope 09 범위와 denominator Sub-scope 09는 `experimental/**`가 소유하는 multi-tenancy, RLS, schema/database-per-tenant, read-replica routing, next-version compatibility/promotion surface를 분석한다. Owning denominator는 **38개**다. - production Java: **29** - dedicated unit Java: **8** - experimental RLS migration SQL: **1** 여기에 실제 PostgreSQL 의미를 확인하기 위해 `postgresqlIntegrationTest/.../platform/experimental`의 contract 5개를 verification dependency로 추가 완독했다. 따라서 이번 실행에서 읽은 파일은 **38 / 38 owning FULL_READ + 5 / 5 verification dependency FULL_READ = 43개**다. 이전 checkpoint의 “38개”와 현재 tree inventory가 처음에는 어긋나 보였지만, 차이는 이 5개 PostgreSQL contract를 owning denominator가 아니라 검증 의존성으로 분리한 데서 나온다. Evidence: `evidence/raw/093-persistence-jpa-experimental-manifest.txt`. #### 96. 현재 production composition은 Experimental을 실행하지 않지만 opt-in 경계는 완전히 구조적이지 않다 현재 repository 내부 production call graph에서는 `TenantDataSourceRegistry`, `TenantEntityManagerFactoryRegistry`, `SchemaMultiTenantConnectionProvider`, `ConsistencyAwareDataSourceRouter`, `RlsTenantSessionBinder`, `SchemaTenantMigrationOrchestrator` 등을 app-bootstrap이나 다른 production leaf가 조립하는 경로를 찾지 못했다. `backend.jpa.experimental.*` property도 production configuration에서 읽어 bean을 만드는 경로가 없고, 실제 문자열은 `ExperimentalFeature` enum의 property vocabulary에만 존재한다. 따라서 아래 semantic finding은 **현재 app-bootstrap runtime에서 즉시 활성화된 production defect가 아니라 latent experimental defect**로 분류한다. 이 구분은 중요하다. public API surface에 올라 있고 같은 artifact에 포함된 library code가 잘못된 것과, 현재 기본 애플리케이션이 그 code를 실제 실행하는 것은 다른 주장이다. 반면 structural opt-in은 완전히 닫혀 있지 않다. `PersistenceJpaConfig`의 Stable `@EntityScan`과 `@EnableJpaRepositories` 문자열 목록에는 이미 `dev.caskeleton.adapter.outbound.persistence.experimental`이 들어 있다. 현재 experimental package에는 `@Entity`, `@Repository`, `JpaRepository`, `@MappedSuperclass`가 없어서 당장 persistence unit에 들어오는 concrete JPA type은 없지만, 이후 experimental entity/repository 하나가 추가되면 별도 feature condition 없이 Stable persistence unit이 스캔한다. Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt`. #### 97. P1 latent — RLS verifier가 “반드시 보호돼야 하는 table”의 부재를 성공으로 인정한다 `RlsPolicyVerifier.requireEnforced(runtimeDataSource, tenantScopedTables)`의 이름과 Javadoc은 caller가 지정한 tenant-scoped table들이 실제로 RLS에 의해 보호되는지 증명하는 contract다. 구현은 runtime role의 `BYPASSRLS`를 확인하고, `current_schema()`의 실제 table들을 순회하면서 이름이 `tenantScopedTables`에 포함된 row만 검사한다. 문제는 반대 방향 검증이 없다는 것이다. 즉 caller가 요구한 table 이름이 실제 catalog 결과에 **한 번도 등장하지 않아도** 성공한다. ```text requested = [missing_tenant_scoped_table] actual catalog row = rls_item loop: rls_item ∉ requested -> continue loop end -> success ``` PostgreSQL 16에서 존재하지 않는 required table 하나를 넘긴 probe도 exception 없이 종료됐다. ```text experimentalRls.requiredTable=missing_tenant_scoped_table experimentalRls.verifierAcceptedMissingTable=true BUILD SUCCESSFUL ``` 이 경계가 위험한 이유는 단순히 “없는 table을 못 찾는다”가 아니다. tenant table rename/config drift/오타로 expected list가 stale해지면 verifier는 실제 tenant table을 검사하지 않은 채 startup evidence를 성공으로 만들 수 있다. security verifier가 coverage 대상 자체를 증명하지 못하는 fail-open이다. **판정: P1 latent security verification defect.** 현재 기본 composition에는 RLS capability가 연결되지 않아 latent지만, 기능을 활성화해 이 verifier를 startup guard로 사용하는 순간 잘못된 table inventory가 green으로 통과한다. 수정은 catalog에서 발견한 tenant-scoped 대상의 상태만 검사할 것이 아니라 `requested - discovered`가 비어 있음을 먼저 강제해야 한다. 가능하면 expected table inventory도 임의 문자열 list가 아니라 migration/schema registry의 SSOT에서 파생하고, missing/renamed table을 real-PostgreSQL regression으로 고정해야 한다. Evidence: `evidence/raw/098-experimental-rls-missing-table-probe.txt`. #### 98. P1 latent — database-per-tenant global connection budget이 새 pool 크기를 계산하지 않아 ceiling을 넘긴다 `TenantPoolBudget` 문서는 pool 개수와 전체 connection 합계를 모두 제한해야 한다고 명시한다. 특히 pool마다 크기가 다르기 때문에 connection total ceiling이 별도로 필요하다고 설명한다. 하지만 `TenantDataSourceRegistry.require()`의 순서는 다음이다. ```text 1. 현재 openPools / allocatedConnections 계산 2. budget.requireCapacity(currentOpenPools, currentAllocatedConnections) 3. 새 DataSource 생성 4. map에 추가 ``` `requireCapacity()` 역시 현재 값이 이미 ceiling 이상인지 확인할 뿐, **이번에 추가할 pool의 크기**를 인자로 받지 않는다. 따라서 `maxConnectionsAcrossPools=10`이고 현재 8 connections을 가진 pool 하나가 열려 있으면 `8 < 10`이므로 admission이 통과한다. 그 다음 5-connection pool을 열면 결과는 13이다. 실측 probe: ```text experimentalPool.maxConnections=10 experimentalPool.openPools=2 experimentalPool.allocatedConnections=13 BUILD SUCCESSFUL ``` 기존 `TenantPoolCapacityContractTest`는 모든 tenant pool 크기를 2로 고정하고 `4/8`, `2/4`처럼 정확히 boundary에 도달한 뒤 다음 tenant를 거부하는 case만 검증한다. 그래서 **remaining capacity보다 다음 pool이 더 큰 case**를 보지 못한다. **판정: P1 latent fleet-capacity defect.** 이 기능의 자체 문서가 connection ceiling 초과 시 한 tenant만이 아니라 전체 DB fleet이 connection refusal을 맞을 수 있다고 정의한다. 현재 app runtime에는 database-per-tenant registry가 조립되지 않아 latent지만, library contract 자체는 global ceiling을 보장하지 못한다. 수정은 admission이 `current + candidate`를 검사하게 해야 한다. 후보 pool size를 creation 전에 알 수 있는 profile metadata를 budget input으로 넣거나, 불가피하게 pool을 먼저 만들면 map에 publish하기 전에 size를 검증하고 초과 시 즉시 close해야 한다. regression은 heterogeneous pool sizes로 `8 + 5 > 10` 같은 부분 여유 case를 포함해야 한다. Evidence: `evidence/raw/095-experimental-pool-overshoot-probe.txt`. #### 99. P2 latent — replica evidence가 완전히 unavailable이어도 EVENTUAL read는 replica로 간다 `ReplicaLagMonitor`의 contract는 명확하다. - monitor down - replica unreachable - lag metric stale 같이 freshness evidence를 얻을 수 없으면 “I do not know”이고 router는 primary를 사용해야 한다. `satisfies()` Javadoc도 evidence가 없으면 false가 default라고 적는다. 그러나 구현은 consistency level별로 다음처럼 분기한다. ```text EVENTUAL -> true BOUNDED_STALENESS -> lag().map(...).orElse(false) PRIMARY_REQUIRED -> replayedThrough().map(...).orElse(false) ``` 즉 EVENTUAL만 evidence availability를 전혀 보지 않는다. `lag()`와 `replayedThrough()`가 모두 `Optional.empty()`인 monitor를 넣은 probe는 replica를 선택했다. ```text experimentalReplica.unavailableEvidence=true experimentalReplica.consistency=EVENTUAL experimentalReplica.target=REPLICA ``` EVENTUAL이 stale data를 허용하는 것과 **replica가 usable하다는 evidence 자체가 없는 것**은 다른 조건이다. 현재 contract는 후자를 primary fallback 조건으로 선언해 놓고 EVENTUAL path에서만 우회한다. **판정: P2 latent routing fail-open.** 현재 default runtime에는 router가 조립되지 않는다. 활성화될 경우 monitor outage/unknown state에서 eventual read가 replica target을 선택할 수 있다. 수정은 consistency satisfaction과 replica health/evidence availability를 분리하는 편이 명확하다. EVENTUAL은 staleness bound를 요구하지 않을 수 있지만, 최소한 replica가 현재 route 가능한 대상이라는 health/evidence gate는 공통으로 통과해야 한다. Evidence: `evidence/raw/097-experimental-replica-provider-probe.txt`. #### 100. P2 latent — Hibernate compatibility policy가 8만 blacklist하고 unknown major 9를 Stable 교체 가능으로 인정한다 `HibernateCompatibilityPolicy.mayReplaceStableProvider(version)`은 이름 그대로 특정 provider version이 promotion 없이 Stable provider를 대체해도 되는지를 답한다. 현재 구현은 `!providerPolicy.isExperimental(version)`이다. 그런데 `HibernateProviderPolicy`의 experimental provider list는 `List.of("8")` 하나뿐이다. 결과적으로 known Stable 7.x는 true, known Experimental 8.x는 false지만 **아직 어떤 compatibility evidence도 없는 9.x 같은 unknown major는 true**가 된다. probe: ```text experimentalHibernate.candidate=9.0.0.Final experimentalHibernate.mayReplaceStable=true ``` 이는 compatibility policy를 denylist로 모델링한 결과다. provider generation이 추가될수록 미측정 버전이 자동 허용되는 방향이라 promotion gate의 목적과 반대다. 기존 test도 7.x true와 8.x false만 검증해 unknown-major 경계를 놓친다. **판정: P2 latent compatibility fail-open.** 실제 classpath는 현재 Hibernate 7이고 Hibernate 8 workflow도 `NOT_EXECUTABLE`을 명시하므로 지금 Stable runtime이 9.x라는 주장은 아니다. 문제는 policy가 미래 unknown major를 자동 승인한다는 점이다. 수정은 “experimental이 아니면 Stable”이 아니라 **명시적으로 허용된 Stable generation만 true**가 되게 해야 한다. 현재 policy 의도대로라면 최소 `7.x` allowlist 외 major는 false로 닫고, 새 major는 compatibility lane + promotion evidence를 거쳐 allowlist를 바꾸는 방향이 맞다. Evidence: `evidence/raw/097-experimental-replica-provider-probe.txt`. #### 101. P2 latent — experimental opt-in이 세 entry point에만 강제되고 Stable scan은 experimental package를 이미 포함한다 experimental plan의 global constraint는 “모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다”이다. `ExperimentalEntryConsentTest`도 더 강하게 “behaviour-bearing entry point는 외부 package에서 public constructor로 만들 수 없어야 하고 gate-taking `enabledBy` factory만 제공해야 한다”고 선언한다. 그 test가 실제로 열거하는 class는 세 개뿐이다. ```text ConsistencyAwareDataSourceRouter RlsTenantSessionBinder SchemaTenantMigrationOrchestrator ``` 하지만 같은 experimental public API에는 flag 없이 바로 생성해서 behavior를 실행할 수 있는 type이 더 있다. - `TenantDataSourceRegistry` — public constructor + tenant pool open - `TenantEntityManagerFactoryRegistry` — public constructor + tenant EMF build - `SchemaMultiTenantConnectionProvider` — public constructor + connection `search_path` 변경 - `TenantEntityListenerGuard` — public constructor + `@PrePersist/@PreUpdate` tenant write guard `javap -public`로 이 constructor surface를 확인했고, 현재 repository production caller는 0이었다. 즉 지금 app-bootstrap이 우회하고 있다는 finding이 아니라 **consent test가 “all entry points”라고 부르는 집합 자체가 수동 3-class allowlist라 새/기존 activator를 놓친다**는 finding이다. 여기에 Stable `PersistenceJpaConfig`가 experimental package를 `@EntityScan`/`@EnableJpaRepositories`에 unconditional string으로 포함하는 구조가 겹친다. 현재 JPA stereotype이 0개라 즉시 bean activation은 없지만, 향후 experimental entity/repository가 추가되면 이 경로는 feature gate를 거치지 않는다. ArchUnit의 Stable→Experimental dependency rule은 bytecode type edge를 검사하므로 문자열 package scan을 잡지 못한다. **판정: P2 latent architecture/consent gap.** 현재 production wiring이 없어 latent지만 “presence on classpath is not consent”라는 핵심 방어가 type마다 일관되게 강제되지 않는다. 수정은 experimental capability를 Stable persistence scan에서 제외하고, 각 feature가 자기 gated configuration에서 필요한 entity/repository/bean을 조립하게 해야 한다. entry-point 검증도 수동 3-class list가 아니라 annotation/package convention 또는 explicit registry SSOT에서 exhaustive하게 파생해야 새 behavior-bearing type이 추가될 때 test가 fail-closed 해야 한다. Evidence: `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt`. #### 102. 이번 sub-scope에서 finding으로 올리지 않은 항목 ##### 102.1 JPA 4 / Hibernate 8 / PostgreSQL 19 workflow의 `NOT_EXECUTABLE` 세 workflow는 현재 target dependency/server를 실제로 resolve/run하지 않는다. 그러나 artifact에 `status=NOT_EXECUTABLE`과 이유를 명시하고 promotion checklist도 missing evidence를 통과로 취급하지 않는다. 따라서 “실행하지 않았는데 compatibility green으로 속인다”는 false-evidence finding으로 올리지 않는다. 실제 lane이 실행 가능해지기 전까지는 미검증 상태다. ##### 102.2 RLS tenant binding 자체 `RlsTenantSessionBinder`는 `set_config(..., true)`로 transaction-local tenant setting을 사용하고, existing real-PostgreSQL contract는 session-scoped setting이 pool reuse에서 leak하는 case와 transaction-scoped setting이 leak하지 않는 case를 구분한다. 이번 분석에서 binder 자체의 cross-tenant leak을 재현하지 못했다. ##### 102.3 schema identifier selection/reset `SchemaTenantRegistry`는 unquoted PostgreSQL identifier shape를 제한하고, connection provider는 schema 값을 statement text에 직접 붙이지 않고 bound `set_config`로 적용하며 release 시 neutral `pg_catalog`로 reset한다. 별도 failure-in-reset / pool-implementation semantics까지 corpus 전체 보장은 하지 않지만, 현재 happy-path isolation contract를 뒤집을 evidence는 없었다. ##### 102.4 tenant repository/listener guard가 곧 production isolation이라는 주장 `TenantAwareRepositoryGuard`와 `TenantEntityListenerGuard`의 local behavior는 fail-closed지만 현재 production repository/entity에 연결된 caller/listener registration은 없다. 따라서 이 type들이 존재한다는 이유만으로 현재 application의 tenant isolation이 보장된다고 쓰지 않는다. #### 103. Fresh Experimental verification evidence - `evidence/raw/093-persistence-jpa-experimental-manifest.txt` — **38 / 38 owning FULL_READ**(29 production + 8 unit + 1 SQL), 추가 PostgreSQL verification dependency **5 / 5 FULL_READ** - `evidence/raw/095-experimental-pool-overshoot-probe.txt` — connection ceiling 10에서 heterogeneous pools 8 + 5가 admission되어 total **13**으로 overshoot, BUILD SUCCESSFUL - `evidence/raw/097-experimental-replica-provider-probe.txt` — evidence가 모두 empty인 EVENTUAL read가 `REPLICA`, unknown Hibernate `9.0.0.Final`이 `mayReplaceStable=true`, BUILD SUCCESSFUL - `evidence/raw/098-experimental-rls-missing-table-probe.txt` — PostgreSQL 16에서 required missing table을 `RlsPolicyVerifier`가 성공으로 인정, `jpaPlatformSecurityTest` BUILD SUCCESSFUL - `evidence/raw/096-experimental-gate-reachability.txt`, `099-experimental-structural-optin-gap.txt` — omitted public activation constructors, production caller 0, Stable scan의 experimental package unconditional inclusion - `evidence/raw/100-experimental-original-unit-tests.txt` — original-source experimental 8 unit classes + `JpaModuleBoundaryTest` + `PersistenceEntityScanCoverageTest`, fresh `--rerun-tasks`, **BUILD SUCCESSFUL in 28s / 18 actionable executed**, git clean before/after - `evidence/raw/101-experimental-original-postgresql-contracts.txt` — original-source contract 3 classes **BUILD SUCCESSFUL in 30s / 19 executed**, schema migration **BUILD SUCCESSFUL in 5s**, RLS security **BUILD SUCCESSFUL in 4s**, git clean before/after - `evidence/raw/094-preflight-fixture-compile-failure.txt` — 최초 analysis-only DataSource fixture의 `@Override` 누락이 repository `-Werror`에 걸린 preflight 실패. 제품 code failure가 아니며 fixture 수정 후 095/097 clean probe로 대체했다. 모든 semantic probe는 temporary test source를 trap으로 복원했다. 최종 code repository `git status --short`는 clean이다. #### 104. Sub-scope 09 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1 latent** | RLS verifier가 requested tenant table의 존재/coverage를 확인하지 않아 missing/stale table inventory를 green으로 인정 | **experimental public API, current app runtime unwired** | | **P1 latent** | database-per-tenant admission이 candidate pool size를 반영하지 않아 global connection ceiling을 초과 가능 | **experimental public API, current app runtime unwired** | | **P2 latent** | replica evidence가 unavailable이어도 EVENTUAL read는 replica target을 선택 | **experimental public API, current app runtime unwired** | | **P2 latent** | Hibernate compatibility policy가 8.x만 deny해 unknown 9.x를 promotion 없이 Stable 교체 가능으로 판정 | **experimental compatibility policy, current Stable classpath 7.x** | | **P2 latent** | feature consent가 3개 entry point에만 구조적으로 강제되고 Stable JPA scan은 experimental package를 unconditional 포함 | **latent structural activation path; current experimental JPA stereotypes 0** | Sub-scope 09는 **38 / 38 owning FULL_READ + 5 / 5 PostgreSQL verification dependency FULL_READ + targeted semantic probes + original unit/contract/migration/security fresh rerun**으로 닫는다. #### 105. Sub-scope 10 범위와 denominator Sub-scope 10은 JPA platform의 testkit 자체와 그 testkit을 검증하는 fixture/unit test를 소유한다. 이 범위는 production persistence 동작이 아니라 **그 동작을 증명한다고 주장하는 architecture rule, query/plan assertion, failure injector, migration runner, release registry adapter, PostgreSQL matrix helper가 false-green evidence를 만들 수 있는지**를 본다. Owning denominator는 정확히 **62개**다. - `src/testkit/java/**`: **41 Java** - `src/test/java/**/testkit/**`: **21 Java** 이번 sub-scope에서 **62 / 62 FULL_READ**했다. Evidence: `evidence/raw/102-persistence-jpa-testkit-manifest.txt`. #### 106. Testkit reachability를 production guard와 self-test helper로 나눈다 같은 testkit package에 있어도 영향도는 동일하지 않다. production/release evidence에 실제 연결된 핵심 helper는 다음이다. - `JpaArchitectureRules` / `EntityExposureCondition` — `app-bootstrap`의 `JpaProductionArchitectureTest`가 실제 production graph에 적용한다. - `JpaAuditMechanismRule` — 동일 production architecture suite가 audit mechanism과 audited bulk update를 검사한다. - `PostgreSqlExplainRunner` / `QueryPlanAssertions` / `QueryPlanExpectation` — real PostgreSQL `PostgreSqlQueryPlanContractTest`가 사용하고 `jpaPlatformQueryPlanTest`라는 blocking release Test task 안에서 실행된다. - `MigrationContractRunner`, `CountingDataSource`, lifecycle/mapping fixture들은 실제 PostgreSQL integration lane에서 사용된다. 반대로 public surface지만 현재 repository source에서 defining file 외 reference가 0인 helper도 있다. - `CommitAmbiguityProxy` - `PostgreSqlContractExtension` `testkit.id.UuidV7Generator`도 simple name은 notification 모듈의 별도 production `UuidV7Generator`와 충돌하지만 **정확한 testkit FQN consumer는 0**이다. 현재는 자신의 unit test만 존재한다. 이 구분 때문에 아래 backlog는 “testkit code에 버그가 있다”만으로 승격하지 않고, **현재 production architecture/release evidence producer와 연결된 false-negative**를 우선한다. Evidence: `evidence/raw/105-testkit-public-reachability-sweep.txt`. #### 107. P1 latent — SELECT-only query-plan runner가 data-modifying CTE를 허용해 `EXPLAIN ANALYZE`가 실제 DML을 실행한다 `PostgreSqlExplainRunner`는 이 위험을 정확히 문서화한다. ```text EXPLAIN ANALYZE executes the statement; therefore this runner refuses anything but SELECT. ``` 하지만 실제 `requireReadOnly()`는 다음 두 prefix를 허용한다. ```text select... with... ``` PostgreSQL의 `WITH`는 read-only CTE만 의미하지 않는다. data-modifying CTE가 가능하다. ```sql WITH changed AS ( UPDATE plan_row SET bucket = 99 WHERE id = ? RETURNING id ) SELECT id FROM changed ``` 이 statement는 `WITH`로 시작하므로 guard를 통과하고, runner가 붙이는 `EXPLAIN (ANALYZE, ...)`는 실제 UPDATE를 실행한다. PostgreSQL 16 real-container probe에서 id 42의 `bucket`을 조회한 뒤 위 statement를 runner에 넣었다. ```text testkitExplain.guardAcceptedWithUpdateCte=true testkitExplain.bucketBefore=9 testkitExplain.bucketAfter=99 BUILD SUCCESSFUL ``` 즉 guard가 보호한다고 명시한 side-effect가 실제로 발생했다. 기존 `refusesNonSelect()` contract는 direct `UPDATE ...`만 넣기 때문에 green이다. 원본 `PostgreSqlQueryPlanContractTest` 전체도 fresh rerun에서 green이지만, 그것은 현재 input이 SELECT라는 증거이지 `WITH`가 read-only라는 증거가 아니다. **판정: P1 latent release-evidence safety defect.** 현재 committed query-plan tests는 SELECT만 사용하므로 지금 release run이 DB를 변경했다는 주장은 아니다. 그러나 이 helper는 blocking `jpaPlatformQueryPlanTest`가 사용하는 evidence producer이고, documented safety invariant를 우회하는 legal PostgreSQL syntax가 실제 mutation까지 재현됐다. 수정은 string prefix whitelist로 SQL read-only 여부를 판정하지 않는 방향이 필요하다. 최소한 data-modifying CTE를 fail-closed로 거부하는 parser/statement classification을 사용하고, query-plan lane 자체의 connection/transaction도 read-only defense-in-depth로 묶어야 한다. regression은 direct UPDATE뿐 아니라 `WITH ... UPDATE/DELETE/INSERT ... SELECT`를 포함해야 한다. Evidence: `evidence/raw/104-testkit-explain-dml-cte-probe.txt`, `106-testkit-original-verification.txt`. #### 108. P1 latent — production entity-exposure rule이 async/reactive wrapper 안의 JPA entity를 보지 못한다 `EntityExposureCondition`의 목적은 controller/web method가 persistence entity를 return graph 어디에서도 노출하지 못하게 하는 것이다. 직접 entity뿐 아니라 `List`, `Optional`, `Map<..., Entity>`까지 generic argument를 검사한다고 명시한다. 하지만 generic traversal은 raw return type이 다음 container일 때만 실행된다. ```text Collection Map Optional array ``` 따라서 다음처럼 실제 transport 계층에서 흔한 wrapper는 raw type 단계에서 즉시 `Optional.empty()`가 된다. ```text CompletableFuture CompletionStage ResponseEntity Mono Flux Page ``` analysis fixture로 `CompletableFuture`를 반환하는 `..web..` class를 넣고 **실제 production에서 사용하는 동일 `JpaArchitectureRules.noEntityFromWeb()`**를 실행했다. rule은 exception 없이 통과했다. ```text testkitArchitecture.wrapper=CompletableFuture testkitArchitecture.entityLeakAccepted=true ``` 이 문제는 가상의 wrapper family만의 이야기가 아니다. 현재 inbound web 코드도 `Mono>` 같은 nested transport wrapper를 실제로 사용한다. 지금 그 내부 payload는 persistence entity가 아니라 DTO/String이므로 current production violation은 확인되지 않았고, 원본 `JpaProductionArchitectureTest`도 fresh green이다. **판정: P1 latent architecture-enforcement false-negative.** controller가 persistence entity를 직접 노출하는 것은 repository의 HARD-STOP 계열 경계이고, 이 rule은 release-wide `app-bootstrap:test`에서 그 경계를 증명하는 production guard다. 현재 code가 위반 중이라는 finding이 아니라, **위반을 추가해도 대표적인 async wrapper 형태면 guard가 green일 수 있다는 것**이 finding이다. 수정은 container allowlist로 들어갈지 말지를 결정하지 말고 return `JavaType`의 generic graph를 재귀적으로 traverse하되 cycle을 방지하는 방식이 더 안전하다. 최소 regression에는 `CompletableFuture`와 실제 runtime stack의 `Mono>` 또는 동등한 nested wrapper를 포함해야 한다. Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`, `106-testkit-original-verification.txt`. #### 109. P2 latent — plan normalizer가 root node 하나의 estimate ratio만 읽어 child node의 큰 cardinality miss를 숨긴다 `PostgreSqlExplainRunner.normalize()`는 `Node Type`은 전체 JSON에서 반복 탐색한다. 반면 `Actual Rows`, `Plan Rows`, `Shared Read Blocks`는 `indexOf(key)`로 **첫 occurrence 하나만** 읽는다. PostgreSQL JSON plan은 root node 뒤에 child `Plans[]`가 중첩되는 구조이므로 현재 `estimateRatio`는 사실상 root node ratio다. analysis probe에 다음 plan을 넣었다. ```text root Nested Loop: Actual 10 / Plan 10 -> ratio 1 child Seq Scan: Actual 1000 / Plan 1 -> ratio 1000 ``` normalizer는 child node type은 발견하면서 estimate ratio는 1.0으로 보고했다. ```text testkitPlan.nodes=[Nested Loop, Seq Scan] testkitPlan.reportedEstimateRatio=1.0 testkitPlan.childActualToPlanned=1000.0 ``` `QueryPlanExpectation.estimateOnly(10)` 같은 assertion은 이런 plan을 estimate-quality 관점에서 green으로 통과시킬 수 있다. **판정: P2 latent query-plan false-evidence.** 현재 committed estimate assertion의 대표 query는 단순 index lookup이라 이 probe만으로 현재 release 결과가 거짓이라고 확대하지 않는다. 그러나 blocking query-plan lane의 normalized model이 “planner estimate error”를 plan 전체가 아니라 root 한 node로 축소하는 것은 명시적인 측정 공백이다. 수정은 node별 actual/planned pair를 구조적으로 parse하고, maximum symmetric error ratio 또는 명시한 aggregation policy를 `NormalizedPlan`에 보존해야 한다. raw JSON을 substring scanning하기보다 JSON tree parser로 node recursion을 수행하는 것이 node pairing과 buffer aggregation 모두 안전하다. Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`. #### 110. P2 latent — audited bulk-update guard가 audit column 이름을 “대입 대상”이 아니라 substring으로 찾아 false-green을 만든다 `JpaAuditMechanismRule.bulkUpdateViolation()`은 audited entity의 bulk update가 `updated_at`/`updatedAt`/`modified_at`/`modifiedAt`를 직접 stamp하는지 검사한다. 하지만 현재 판정은 `SET ... WHERE` 문자열 전체에 audit-column token이 **어디든 포함되는지**만 본다. ```text AUDIT_COLUMNS.stream().anyMatch(assignments::contains) ``` 따라서 실제 audit field를 변경하지 않고 parameter 이름에 token만 들어 있어도 통과한다. ```sql update WorkLogEntity w set w.status = :updatedAtValue where w.id = :id ``` probe 결과: ```text testkitAudit.decoyParameter=:updatedAtValue testkitAudit.violationPresent=false ``` 즉 `w.updatedAt = ...` assignment가 하나도 없는데 “stamp 있음”으로 해석했다. 현재 production graph에는 이 rule의 audited-entity bulk-update branch를 실제로 밟는 committed query가 없어 original architecture suite는 green이다. class 자체도 이 점을 Javadoc에서 인정하고 direct branch unit test를 둔다. 문제는 그 branch test가 real assignment와 완전 unstamped case만 보고 decoy token을 보지 않는다는 것이다. **판정: P2 latent audit architecture false-negative.** 현재 unstamped audited bulk update가 production에 있다는 주장은 아니다. 다만 future query가 parameter/함수/문자열 literal에 audit token을 포함하면 release architecture guard가 잘못 통과할 수 있다. 수정은 SQL/JPQL의 `SET` assignment left-hand side를 최소한 token boundary 기준으로 추출하여 audit property/column과 정확히 비교해야 한다. regression에는 parameter name, string literal, unrelated property suffix에 `updatedAt` token이 포함된 case를 넣어야 한다. Evidence: `evidence/raw/103-testkit-unit-boundary-probes.txt`, `106-testkit-original-verification.txt`. #### 111. 이번 sub-scope에서 finding으로 올리지 않은 항목 ##### 111.1 `UuidV7Generator` same-millisecond wrap 이 testkit generator는 12-bit counter를 같은 millisecond마다 `& 0x0FFF`로 증가시킨다. lower-half random seed는 wrap을 제거하지 않고 늦출 뿐이다. deterministic probe에서는 같은 millisecond **3,784번째**에 이전 UUID보다 작아지는 정렬 역전이 재현됐다. ```text testkitUuid.sameMillisecondGeneratedBeforeInversion=3784 testkitUuid.monotonicityBroken=true ``` 하지만 exact `dev.caskeleton.adapter.outbound.persistence.testkit.id.UuidV7Generator` FQN의 repository consumer는 현재 0이고, notification runtime이 사용하는 production `UuidV7Generator`는 다른 모듈의 별도 class다. 따라서 production UUID defect로 승격하지 않고 **self-tested unadopted test fixture defect**로 남긴다. 만약 이 fixture를 ID-strategy PostgreSQL contract에 실제 채택한다면 그 시점에는 same-ms exhaustion policy가 필요하다. ##### 111.2 `EntityState.REMOVED` `EntityState` enum은 `REMOVED`를 제공하지만 `EntityStateProbe.stateOf()` 구현은 `MANAGED`, `TRANSIENT`, `DETACHED` 세 값만 반환한다. `REMOVED` consumer/assertion도 현재 없다. API vocabulary와 probe capability가 어긋나지만 current evidence lane을 잘못 통과시키는 소비 경로가 없어 backlog 우선순위에는 올리지 않는다. ##### 111.3 `CommitAmbiguityProxy` / `PostgreSqlContractExtension` 두 public helper는 defining file 밖 exact FQN reference가 0이다. 특히 `PostgreSqlContractExtension.serverVersion()`은 이름과 달리 database의 `SHOW server_version`이 아니라 Docker image + container id를 반환하지만 현재 integration support는 별도 `JpaPlatformContractSupport.serverVersion()`로 실제 server version을 읽는다. 따라서 잘못된 current evidence로 분류하지 않고 dead/unadopted helper로 기록한다. ##### 111.4 `JpaReleaseManifest`의 regex parser Java testkit parser 자체는 정규식 기반이라 일반-purpose JSON parser가 아니다. 그러나 실제 registry 파일은 root Gradle `verifyJpaReleaseGateTasks`에서 `JsonSlurper`로 다시 parse되고 real task graph까지 resolve한다. 현재 malformed JSON을 Java regex parser 하나가 받아들일 가능성만으로 release fail-open을 별도 finding으로 중복 승격하지 않는다. #### 112. Fresh Testkit verification evidence - `evidence/raw/102-persistence-jpa-testkit-manifest.txt` — **62 / 62 FULL_READ** (41 testkit Java + 21 fixture/unit Java) - `evidence/raw/103-testkit-unit-boundary-probes.txt` — async wrapper entity leak accepted, audit decoy token accepted, child estimate miss hidden by root ratio, testkit UUID same-ms inversion 재현; analysis fixture는 trap으로 복원 - `evidence/raw/104-testkit-explain-dml-cte-probe.txt` — PostgreSQL 16에서 `WITH UPDATE ... SELECT`가 read-only guard를 통과하고 `EXPLAIN ANALYZE`로 row를 실제 `9 -> 99` 변경; BUILD SUCCESSFUL - `evidence/raw/105-testkit-public-reachability-sweep.txt` — public testkit symbol reachability와 zero-reference helper sweep - `evidence/raw/106-testkit-original-verification.txt` — 원본 source 상태에서 `:adapter:outbound:persistence-jpa:test --rerun-tasks` **BUILD SUCCESSFUL in 29s / 18 executed**, `JpaProductionArchitectureTest` **BUILD SUCCESSFUL in 1m47s / 98 executed**, original `PostgreSqlQueryPlanContractTest` **BUILD SUCCESSFUL in 25s / 19 executed**, git clean before/after 모든 analysis-only source/fixture 변경은 trap으로 복원했고 최종 code repository `git status --short`는 clean이다. #### 113. Sub-scope 10 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1 latent** | `PostgreSqlExplainRunner`가 모든 `WITH`를 read-only로 허용해 data-modifying CTE를 `EXPLAIN ANALYZE`로 실제 실행 가능 | **blocking query-plan evidence producer; current committed inputs are SELECT** | | **P1 latent** | production `noEntityFromWeb` architecture rule이 async/reactive/custom wrapper 안 entity를 탐색하지 않아 `CompletableFuture`가 green | **release-wide app-bootstrap architecture guard; current payloads are DTOs** | | **P2 latent** | query-plan estimate ratio가 root node first pair만 사용해 child cardinality miss를 숨김 | **blocking query-plan evidence producer** | | **P2 latent** | audited bulk-update guard가 audit column substring만 찾아 parameter/string decoy로 false-green | **production architecture guard; current audited bulk-update branch absent** | Sub-scope 10은 **62 / 62 FULL_READ + four focused unit false-negative probes + real-PostgreSQL DML-CTE probe + full original unit/production-architecture/query-plan fresh rerun**으로 닫는다. --- #### 114. Sub-scope 01 범위와 denominator > 내부 상태: COMPLETE — **11 / 11 FULL_READ** > 범위: leaf 최상위 4개 파일 + `package-info.java` + `config/**` production 3개 + root-level test 3개 > 역할: 이 leaf가 "무엇이고, 무엇을 always-install하며, 어떤 경계를 스스로 강제하는가"를 선언하는 층 | 구분 | 파일 | 라인 | |---|---|---:| | governance | `CLAUDE.md` | 295 | | rationale | `README.md` | 379 | | build | `build.gradle` | 356 | | build | `gradle.lockfile` | 224 | | production | `package-info.java` | 2 | | production | `config/JpaAdapterComponentsConfig.java` | 68 | | production | `config/PersistenceJpaConfig.java` | 95 | | production | `config/PersistenceVendorSettings.java` | 35 | | test | `CandidateAdapterCompositionTest.java` | 63 | | test | `JpaModuleBoundaryTest.java` | 411 | | test | `integration/.gitkeep` | 1 | denominator 근거는 `evidence/raw/107-persistence-jpa-governance-manifest.txt`다. 이 11개는 "다른 어떤 sub-scope manifest도 claim하지 않은 leaf tracked file"로 정의했다. 특히 `config/PersistenceEntityScanCoverageTest`, `config/PersistenceVendorSelectionTest`, `platform/PoolLaneClaimTest` 3개 test는 sub-scope 06 manifest(`070-...`)가 이미 자기 denominator에 넣었기 때문에 여기서 다시 세지 않는다. 대신 이 sub-scope는 그 test들이 **검사하는 대상**인 production type을 소유한다. 즉 소유 경계는 "production type은 01, 그 test의 계수는 06"으로 갈라져 있고, 이 문서는 그 사실을 명시한 뒤 내용 분석은 여기서 한다. `.gitkeep`은 빈 디렉터리 marker이므로 `STRUCTURAL_ONLY`가 아니라 `FULL_READ`(1바이트, 내용 없음)로 처리했다. `gradle.lockfile`은 224줄 전부를 읽었으나 해석은 configuration별 classpath 소속 확인에 한정했다. #### 115. governance는 세 겹이고, 세 겹의 강제력이 서로 다르다 이 leaf의 규칙은 세 곳에 나뉘어 있고 각각 강제 수단이 다르다. | 층 | 문서/코드 | 강제 수단 | 위반 시 실패 지점 | |---|---|---|---| | 정책 서술 | `CLAUDE.md` | 없음(산문) | 없음 — 읽는 사람만 안다 | | 근거 서술 | `README.md` | 없음(산문) | 없음 | | build 계약 | `build.gradle` | Gradle task | task 실행 시 | | package 경계 | `JpaModuleBoundaryTest` | ArchUnit + 파일시스템 대조 | `:test` 실행 시 | `build.gradle`은 산문이 아니라 실제 계약이다. `strictTestLanes`가 `postgresqlIntegrationTest` / `testkit` / `jpaPlatformPerformanceTest` 세 source set을 만들고, `testkitPublisher`가 testkit을 `test`와 `postgresqlIntegrationTest`에만 소비시킨다. 즉 "production module은 testkit에 의존하지 않는다"는 설계 주장은 여기서 **구조적으로** 참이 되고, `JpaModuleBoundaryTest.noProductionClassDependsOnTheTestkit()`가 bytecode 수준에서 다시 확인한다. 같은 주장을 두 층이 서로 다른 방식으로 잡는다. `JpaModuleBoundaryTest`는 이 leaf에서 가장 강한 governance 장치다. 24개 top-level package 각각의 허용 edge를 `PACKAGE_CATALOG`에 닫힌 집합으로 적고, 디스크의 실제 package 목록과 **정확히 같은 집합인지** 양방향으로 대조하며(`theCatalogNamesExactlyThePackagesThatExist`), 관측된 모든 package 간 edge가 선언된 edge인지 확인하고(`everyObservedEdgeIsDeclared`), 선언된 edge가 DAG인지 검사한다(`theDeclaredEdgesFormADag`). `importActuallyLoadedTheProductionClasses`는 `noClasses()` rule이 "아무것도 매칭되지 않아 vacuously 통과"하는 실패 모드를 명시적으로 막는다 — ArchUnit rule 모음에서 가장 자주 조용히 무너지는 지점을 이 파일은 알고 있다. 다만 catalog의 **값** 쪽은 key 쪽만큼 검증되지 않는다. `postgresql`의 허용 대상에 `"inbox"`가 들어 있는데 `..persistence.inbox`라는 top-level package는 존재하지 않는다(실제 inbox는 `postgresql.inbox` 하위 package라 `topLevelPackageOf()`가 항상 `postgresql`을 돌려준다). 즉 이 항목은 어떤 edge도 허용하지 않는 사문(死文)이다. `theCatalogNamesExactlyThePackagesThatExist`는 key만 대조하므로 이런 값은 잡히지 않는다. 방향은 안전한 쪽이다 — 존재하지 않는 이름은 rule을 **더 엄격하게** 만들 뿐 느슨하게 만들지 않는다 — 그래서 결함이 아니라 잔여 설정으로 기록한다. 근거: `evidence/raw/110-governance-doc-count-drift.txt` §I. #### 116. Confirmed P2 — vendor selector의 fail-fast 계약이 shipped composition에 설치돼 있지 않다 `PersistenceVendorSettings`는 자기 존재 이유를 javadoc에 명확히 적는다. > Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the two `@ConditionalOnProperty` vendor configurations would both stay off, and the first missing SPI bean would surface as a `NoSuchBeanDefinitionException` naming `OutboxClaimRepository` — a symptom several layers away from the misspelled value that caused it. `CLAUDE.md` §Vendor selection도 같은 주장을 한다. `application.yml:463` 주석은 한 발 더 나가 "PersistenceVendorSettings (adapter-persistence), which rejects any other value at startup"라고 쓴다. 문제는 이 타입이 **production에서 한 번도 `@ConfigurationProperties` bean으로 등록되지 않는다**는 것이다. - leaf 안의 `@ConfigurationProperties` 타입은 정확히 3개다: `LockSettings`, `JpaTransactionSettings`, `PersistenceVendorSettings`. - 그중 앞의 둘만 같은 package 안의 configuration이 `@EnableConfigurationProperties`로 켠다 — `DistributedLockPersistenceConfig`, `JpaTransactionConfig`. - `PersistenceVendorSettings`를 켜는 곳은 repository 전체에서 `PersistenceVendorSelectionTest`의 중첩 `@Configuration` 하나뿐이다. - composition root `CaSkeletonApplication`의 `@ConfigurationPropertiesScan` basePackages 21개 중 `dev.caskeleton.adapter.outbound.persistence`를 덮는 항목은 0개다. - `PersistenceJpaRootAutoConfiguration`은 이 사실을 알고 있고 javadoc에 적어 두었다 — vendor를 `Environment.getProperty(...)`로 직접 읽는다. 역설적인 것은 `JpaAdapterComponentsConfig`의 javadoc이 규칙 자체를 정확히 서술한다는 점이다. "Each package's `@ConfigurationProperties` type is enabled by a configuration inside that same package — `JpaTransactionConfig` for `JpaTransactionSettings`, `DistributedLockPersistenceConfig` for `LockSettings`". 셋 중 둘을 열거하고 셋째를 빠뜨렸는데, 그 셋째를 소유한 package가 바로 이 javadoc이 들어 있는 `config`다. ##### 실행 probe `evidence/raw/109-vendor-selector-shipped-shape-probe.txt` / `109a-...java`. shipped composition과 같은 모양(두 vendor configuration만 import, properties 타입은 켜지 않음)에서 `ca-skeleton.persistence.vendor=mysql`을 준다. ```text shipped.unknownVendor.contextFailed=false shipped.unknownVendor.vendorSettingsBeans=0 shipped.unknownVendor.sqlStateErrorMappingBeans=0 shipped.unknownVendor.postgreSqlConfigBeans=0 shipped.unknownVendor.h2ConfigBeans=0 ``` 같은 모양에 `@EnableConfigurationProperties(PersistenceVendorSettings.class)`만 추가하면 문서가 약속한 실패가 실제로 난다. ```text enabled.unknownVendor.contextFailed=true enabled.unknownVendor.mentionsProperty=true ``` (context가 남긴 예외: `ConfigurationPropertiesBindException: ... Could not bind properties to 'PersistenceVendorSettings' : prefix=ca-skeleton.persistence`) selector를 아예 주지 않은 경우에는 PostgreSQL 쪽이 활성화된다는 것도 같은 probe에서 확인된다 — Spring Data가 4개 repository interface를 스캔하고 `entityManagerFactory` 부재로 실패하므로, `@Import(PersistenceJpaConfig.class)` 사슬이 실제로 돌았다는 뜻이다. `matchIfMissing = true`는 살아 있다. `JpaAdapterComponentsConfig`까지 넣은 네 번째 case에서는 context가 실패하지만 **실패 메시지 어디에도 vendor property가 등장하지 않는다**. ```text components.unknownVendor.contextFailed=true components.unknownVendor.mentionsVendorProperty=false ``` **판정: P2 confirmed.** 오타 난 vendor 값은 startup을 실패시키기는 하지만, 그 실패는 property를 지목하지 않는다 — `PersistenceVendorSettings`가 막겠다고 선언한 바로 그 증상이다. app-bootstrap의 `PersistenceVendorProdSafetyValidator`도 도움이 되지 않는다. 그 validator는 prod profile에서 값이 `h2`인지만 보고 알 수 없는 값은 통과시킨다. 수정은 작다 — `config` package 안에 `@EnableConfigurationProperties(PersistenceVendorSettings.class)`를 가진 configuration을 두고 `PersistenceJpaRootAutoConfiguration`이 그것을 import하면, 이미 존재하는 두 sibling과 같은 모양이 된다. regression은 unknown vendor로 context를 띄워 실패 메시지가 property 이름을 포함하는지 보면 된다(위 probe가 그대로 red/green 쌍이다). 한계: probe context는 full application context가 아니다. 실제 배포에서 **첫 번째로** 실패하는 bean은 다를 수 있다. 증명된 것은 (a) unknown 값이 아무것도 bind하지 않고 두 vendor configuration을 모두 비활성으로 남긴다, (b) 그 경로의 실패가 property를 지목하지 않는다, (c) properties 타입을 켜면 지목하는 실패가 난다 — 세 가지다. #### 117. always-install scan과 opt-in scan의 경계는 실제로 지켜지고 있다 `PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. 빠진 것은 `config`, `h2`(JPA stereotype 없음)와 opt-in 두 개(`notification`, `fileserver`)다. 두 opt-in은 각자의 `@ConditionalOnProperty` configuration이 자기 package만 스캔한다. 이 배치의 이유는 javadoc과 `PersistenceEntityScanCoverageTest`에 기록돼 있다 — 과거에 root를 스캔해서 capability를 끈 배포가 `ddl-auto=validate`에서 `notification_request` / `fs_cleanup_item`을 요구하며 부팅에 실패했다. 측정 결과 always-install scan의 건전성은 유지되고 있다. `@Entity` 25개 중 opt-in package(`notification` 13, `fileserver` 6) 밖의 4개는 `idempotency_record`, `outbox_event`, `live_event_log`, `durable_operation`이고, 이 네 테이블은 모두 default location `db/migration/postgresql`(V1/V3/V11/V12)이 만든다. `postgresql` package는 scan 대상이지만 그 안의 candidate adapter들(`inbox`, `outbox` v2, `idempotency` v2)은 `@Entity`가 아니라 native SQL 기반이라 persistence unit에 들어오지 않는다. 즉 sub-scope 08이 발견한 "opt-in stream을 always-install scan이 끌고 들어온다" 유형의 결함은 현재 남아 있지 않다. 다만 `PersistenceEntityScanCoverageTest`가 지키는 범위에는 비대칭이 하나 있다. opt-in configuration 두 개에 대해서는 `@EntityScan` 목록과 `@EnableJpaRepositories` 목록이 **정확히 같은지** `containsExactly`로 검사한다("entities without repositories is half a scan, and fails at the first query"). 그런데 always-install `PersistenceJpaConfig`에 대해서는 `@EntityScan` 목록만 읽어 디스크와 대조하고, 두 목록의 일치는 검사하지 않는다. 현재 두 목록은 20개로 동일하다(`110-...` §H, diff 결과 identical). 그래서 지금은 무해하지만, 새 package를 `@EntityScan`에만 추가하는 실수는 이 test가 잡지 못한다 — 그 test가 opt-in 쪽에 대해서만 명시적으로 막고 있는 바로 그 실수다. P3. `CandidateAdapterCompositionTest`는 반대 방향을 지킨다. PostgreSQL 전용 candidate adapter 3종에 `@Repository`/`@Component`/`@Service`가 없는지, 그리고 이미 stereotype을 제거한 `PostgreSqlOwnerSafeIdempotencyStore`가 계속 그 상태인지 검사한다. sub-scope 05가 이 adapter들을 "미조립 candidate"로 분류한 근거가 이 test로 고정돼 있다. #### 118. Negative-space probes — governance scope 근거: `evidence/raw/108-governance-config-reachability.txt`. ##### 118.1 Public surface reachability `config` package의 public type 3개는 모두 leaf 밖 소비자가 있다. | type | leaf 밖 소비자 | |---|---| | `PersistenceJpaConfig` | `sample-portfolio`의 `SamplePostgreSqlPersistenceConfig`가 `@Import`. app-bootstrap은 직접 import하지 않고 두 vendor configuration을 통해 간접 도달 | | `JpaAdapterComponentsConfig` | `PersistenceJpaRootAutoConfiguration`의 `@Import` 목록 | | `PersistenceVendorSettings` | `PersistenceJpaRootAutoConfiguration`이 `VENDOR_PROPERTY` 상수만 사용(타입 자체는 bean 아님), `application.yml` 주석 | `config`는 `JpaModuleBoundaryTest.EXPORTED_PACKAGES`에 들어 있으므로 이 도달은 선언된 export를 통한 것이다. zero-reference public type은 없다. ##### 118.2 Conditional sibling comparison 두 축에서 비교했다. - **`@ConfigurationProperties` 3형제**: §116. 셋 중 하나만 enablement가 없다 — 비대칭이 확인된 결함이다. - **configuration 활성화 조건**: `PersistenceJpaConfig`와 `JpaAdapterComponentsConfig`는 조건이 없고, `PostgreSqlPersistenceConfig`/`H2PersistenceConfig`는 vendor 조건, `NotificationJpaPersistenceConfig`/`FileserverJpaPersistenceConfig`는 capability 조건을 갖는다. 무조건인 둘은 composition root의 JPA master switch(`ca-skeleton.persistence-jpa.enabled`) 뒤 `@Import`로만 도달하므로 "off는 구조적 사실"이라는 설계가 유지된다. ##### 118.3 Duplicate-mechanism sweep entity/repository/component scan 선언을 repository 전체에서 훑었다. persistence 관련 선언은 5곳이다 — leaf의 `PersistenceJpaConfig`(always), `NotificationJpaPersistenceConfig`, `FileserverJpaPersistenceConfig`, `JpaAdapterComponentsConfig`(component scan만), 그리고 `sample-portfolio`의 자기 package 전용 `JpaConfig`. app-bootstrap production에는 persistence entity/repository scan이 없다. test 쪽 2곳(`OutboxContainerTestSupport`, `FileserverRoundTripContractTest` 주석)은 harness 소유다. 경쟁 구현은 없다. ##### 118.4 Documentation / measured-count drift §119에서 따로 다룬다. #### 119. Confirmed documentation / measured-count drift 근거: `evidence/raw/110-governance-doc-count-drift.txt`. 이 repository는 leaf count drift를 잡는 전용 gate를 갖고 있다. root `build.gradle`의 `verifyDocumentedLeafCount`는 `(\d+)\s*(?:개\s*)?-?\s*(?:leaf|leaves)` 패턴을 찾아 registry의 실제 leaf 수와 비교하고, `check`에 연결돼 있다. 그 gate의 주석 자체가 "named list missed five module CLAUDE.md files and four leaf build.gradle headers, each restating 19-leaf from before the messaging platform's leaves were registered"라고 과거 사고를 기록한다. 그런데 gate의 탐색 domain은 `CLAUDE.md`와 (root를 뺀) `build.gradle` **두 파일명뿐**이다. `README.md`, `docs/**`, `*.java`는 들어가지 않는다. 그 사각지대에 stale claim이 그대로 남아 있다. | 항목 | 문서가 말하는 값 | 측정값 | 위치 | |---|---|---|---| | registered leaf | 19 | **44** | `docs/jpa/repository-adaptation.md:21`, `:101`, `JpaModuleBoundaryTest.java:20` | | 같은 claim(형제 leaf) | 19 | 44 | httpclient / mongo / cache-redis boundary test 3개 | | public top-level type | "318 of 324 production files" | **338 / 350** | `JpaModuleBoundaryTest.java:124`, `CleanArchitectureTest.java:1110` | | package root | `dev.caskeleton.adapter.persistence` | `dev.caskeleton.adapter.outbound.persistence` | `README.md:3`, `:77` | | module 이름 | `adapter-persistence-rdbms` | registry id `adapter-outbound-persistence-jpa` | `README.md:1` | `verifyDocumentedLeafCount`는 실제로 실행했고 **통과한다**(`114-governance-pool-original-verification.txt`, `BUILD SUCCESSFUL`, 9 actionable executed). 즉 gate가 green인 상태에서 leaf 자신의 module-boundary test와 leaf 자신의 adaptation 문서가 44개 registry를 19개라고 말하고 있다. `docs/jpa/repository-adaptation.md`는 leaf `build.gradle`이 `tasks.named('test') { inputs.file(...) }`로 up-to-date 입력에 명시한 **살아 있는** 문서라 더 눈에 띈다. public type 수는 확인 가능한 측정값이다. `docs/architecture/jpa-api-surface.txt`의 committed baseline이 스스로 `# types: 338`을 적고 있고 비주석 항목도 338개다. production Java 파일은 350개다. 문서의 318/324는 두 값 모두 과거치다. 반대로 **drift가 아닌 것**도 기록해 둔다. `CLAUDE.md` §Platform lanes가 나열한 7개 lane task 이름은 전부 실재하고, root `jpaReleaseGate`도 `build.gradle:1097`에 등록돼 있다. lane 이름 쪽 문서는 현재 정확하다. **판정: P3 documentation/count drift(확정).** 코드 동작에는 영향이 없다. 영향은 신뢰도다 — module boundary test의 도입 주석이 module registry 크기를 절반 이하로 말하고 있고, 그 숫자를 잡으려고 만든 gate는 java 파일을 보지 않는다. 수정은 두 갈래다. 숫자를 고치거나(값이 또 늙는다), gate가 권하는 대로 "registry가 목록의 소유자"라고 명사를 바꾸거나. gate의 탐색 domain을 `*.java`/`docs/**`로 넓히는 쪽이 근본적이지만, 그러면 위 6개 파일이 즉시 red가 되므로 함께 고쳐야 한다. #### 120. Sub-scope 01 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `PersistenceVendorSettings`가 production에서 `@ConfigurationProperties` bean으로 등록되지 않아, 문서가 약속한 "unknown vendor는 startup에서 property를 지목하며 실패" 계약이 shipped composition에 없다 | **shipped composition — 모든 JPA-on 배포** | | **P3** | `verifyDocumentedLeafCount`의 탐색 domain(`CLAUDE.md`/`build.gradle`) 밖에서 19-leaf claim 6곳이 생존, registry는 44 | 문서/주석; build는 green | | **P3** | `JpaModuleBoundaryTest`/`CleanArchitectureTest`의 "318 of 324 production files" 측정 주석이 현재 338/350과 불일치 | 주석 | | **P3** | `README.md`가 package root를 `dev.caskeleton.adapter.persistence`로, module을 `adapter-persistence-rdbms`로 적음 | 문서 | | **P3** | `PersistenceEntityScanCoverageTest`가 always-install configuration의 `@EntityScan` / `@EnableJpaRepositories` 목록 일치를 검사하지 않음(opt-in 쪽은 검사) | latent — 현재 두 목록 동일 | | **P3/기록** | `JpaModuleBoundaryTest.PACKAGE_CATALOG`의 `postgresql -> "inbox"` 항목이 존재하지 않는 top-level package를 가리켜 사문 | 무해(엄격 방향) | #### 121. Sub-scope 01 완료 조건 - denominator 11 / 11 FULL_READ (`107-...`) - public surface reachability / conditional sibling / duplicate mechanism 3종 probe 수행(`108-...`) - documentation/count drift 재측정 및 확정(`110-...`), gate 실행 결과 포함(`114-...`) - 실행 probe 1건(`109-...`, `109a-...`)으로 P2 확정, 원본 source 복구 후 `git status --short` clean - original source에서 leaf unit lane 재실행 green(`114-...`) --- #### 122. Sub-scope 12 범위와 denominator > 내부 상태: COMPLETE — **3 / 3 FULL_READ** > 범위: `src/jpaPlatformPerformanceTest` (268 Java LOC) > Gradle lane: `jpaPlatformPoolContractTest` | 파일 | 라인 | 컨테이너 | |---|---:|---| | `platform/pool/HikariPoolSaturationContractTest.java` | 119 | 자체 PG 16 | | `platform/pool/RequiresNewPoolPressureContractTest.java` | 100 | 자체 PG 16 | | `platform/pool/PoolPressureContractTest.java` | 49 | 없음 | manifest: `evidence/raw/111-persistence-jpa-pool-lane-manifest.txt`. `PoolLaneClaimTest`(lane 이름/문구 drift guard)와 `PoolMeasurement`, `PostgreSqlContainerFactory`는 각각 sub-scope 06·10 소유라 여기서는 cross-scope 참조로만 쓴다. #### 123. 이 lane의 역사는 이미 한 번 교정됐다 lane 이름은 `jpaPlatformPerformanceTest`였고, "pool pressure certification"을 한다고 서술됐으며, `performance.assertions.enabled` flag 뒤에 있었다 — 그런데 그 flag는 build에서도, 그것을 명시적으로 끄던 nightly job에서도 기본값이 false였다. `build.gradle`의 주석이 그 결과를 직설적으로 적는다. "the release gate depended on a lane whose only threshold assertion was that thresholds were not being asserted". 교정은 세 갈래로 이루어졌다. task 이름을 `jpaPlatformPoolContractTest`로 바꾸고, flag를 제거하고, `PoolLaneClaimTest`가 lane을 설명하는 6개 파일에 그 flag 이름과 "certification"/"machine bounds" 문구가 남아 있지 않은지 텍스트로 검사한다. flag 이름은 test 자신이 검색 대상이 되지 않도록 세 조각으로 나눠 상수를 만든다 — 자기 자신을 매칭하는 guard는 통과할 수 없다는 것까지 고려돼 있다. #### 124. 남아 있는 문제 — lane이 "행동 계약"이라고 부르는 것 중 둘은 산술 항등식이다 교정 뒤에도 lane의 8개 test 중 2개는 데이터베이스도, pool도 보지 않는다. `PoolPressureContractTest.requiresNewNeedsTwoConnectionsPerThread()`: ```java int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 8 * 2 + 1 assertThat(required).isEqualTo(17); ``` `RequiresNewPoolPressureContractTest.sizingRuleMatchesTheObservedRequirement()`: ```java int required = concurrentThreads * (1 + maxRequiresNewDepth) + 1; // 1 * 2 + 1 assertThat(required).isEqualTo(3); ``` 두 test 모두 공식을 test 안에서 다시 쓴 다음 그 결과를 상수와 비교한다. `CLAUDE.md`의 pool-sizing 공식이 바뀌어도 이 assertion은 실패하지 않는다. 특히 두 번째는 `@DisplayName`이 "the sizing rule matches the observed requirement"인데, 같은 class가 **관측한** 요구치는 2다(pool 1은 실패, pool 2는 성공). 공식의 답 3과 관측치 2를 비교하는 assertion은 없고, 주석이 "the rule adds headroom"이라고 차이를 설명할 뿐이다. `PoolPressureContractTest.reportsPendingAndAcquireLatencyTogether()`도 손으로 만든 `PoolMeasurement(4, 2, 3, 80ms)`의 accessor를 확인한다. 이는 record 계약 검증이지 pool 관측이 아니다. **판정: P3.** lane은 `jpaPlatformReleaseGate`의 구성원이므로 여기서 green이 나는 것이 release 판단에 들어간다. 다만 lane의 실질 가치는 나머지 6개(컨테이너 기반) test가 만들고, 이 2개는 그 위에 얹힌 항등식이다. flag를 없앤 교정이 "측정하지 않는 것을 측정한다고 말하지 않기"였다면, 이 두 개는 그 교정이 닿지 않은 잔여물이다. #### 125. Confirmed P2 — nightly workflow가 광고하는 세 가지 중 하나를 lane이 실제로 관측하지 않는다 `.github/workflows/jpa-nightly.yml:122-129`는 이 lane이 검사하는 것을 세 가지로 적는다. > that REQUIRES_NEW needs two connections per concurrent thread, that **a saturated pool reports its pending count**, that a caller waits rather than proceeding without a connection 세 번째와 첫 번째는 실제 assertion이 있다. 두 번째는 없다. lane 전체에서 `pending()`/`saturated()`를 assert하는 곳은 `PoolPressureContractTest`의 손으로 만든 record 하나뿐이고, 실제 pool에서 `getThreadsAwaitingConnection()`을 읽는 유일한 지점(`HikariPoolSaturationContractTest.measurementReportsPoolState`)은 **포화되지 않은** pool(size 2, 1개 점유)에서 읽은 뒤 `active()==1`과 `total()>=1`만 확인하고 pending에 대해서는 아무 assertion도 하지 않는다. ##### 실행 probe `evidence/raw/113-pool-lane-saturation-probe.txt` / `113a-...java`. 실제 PostgreSQL 16 + Hikari(size 2)에서 두 연결을 점유하고 세 번째 요청 스레드를 대기시킨 뒤 측정했다. ```text realPool.active=2 realPool.idle=0 realPool.pending=1 realPool.saturated=true realPool.waiterOutcome=acquired ``` 즉 이 주장은 **검증 가능하며 현재 검증되지 않고 있다**. 참고로 손으로 만든 fixture 상태(active=4, idle=2, pending=3)는 실제 포화 pool이 보여준 조합(idle=0)과 다르다. 같은 probe에서 acquisition 경계도 측정했다. ```text acquire.configuredTimeoutMillis=500 acquire.observedWaitMillis=504 acquire.outcome=SQLTransientConnectionException acquire.assertedUpperBoundMillis=2500 ``` `HikariPoolSaturationContractTest`의 javadoc은 "a further acquisition must fail **within the configured timeout**"이라고 쓰지만 assertion 상한은 `ACQUIRE_TIMEOUT.plusSeconds(2)` = 2,500ms — 설정값의 5배다. 실제 동작은 504ms로 설정값에 4ms 붙어 있다. CI 여유를 감안해도 상한이 400% 넓어서, 500ms 설정에도 2초를 기다리게 되는 회귀는 이 assertion을 통과한다. P3. #### 126. release gate 소속은 양방향으로 검증되지 않는다 근거: `evidence/raw/112-pool-lane-claim-registry-reachability.txt`. `config/jpa/release-registry.json`의 gate는 6개이고 모두 blocking이다. ```text postgresql-contract :adapter:outbound:persistence-jpa:jpaPlatformContractTest completion-unknown-no-retry :adapter:outbound:persistence-jpa:jpaPlatformFailureTest osiv-disabled :app-bootstrap:test flyway-validate :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest runtime-role-no-ddl :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest collection-fetch-pagination :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest ``` pool lane은 registry에도, `docs/jpa/support-matrix.md` §Release gates 6행에도, testkit `JpaReleaseGate.required()`에도 없다(세 곳 모두 grep exit=1). 그런데 `jpaPlatformReleaseGate`는 `dependsOn jpaPlatformPoolContractTest`를 갖고, root `jpaReleaseGate`가 그것을 다시 의존한다. `verifyJpaReleaseGateTasks`는 registry → task graph 한 방향만 검사한다(registry의 각 gate가 실제 `Test` task로 resolve되는가). 반대 방향 — release gate에 들어 있는 lane이 registry에 있는가 — 은 어디서도 검사되지 않는다. 따라서 `jpaPlatformReleaseGate`에서 pool lane 의존을 지워도 어떤 verifier도 반응하지 않고, 남는 실행 경로는 nightly workflow 한 줄뿐이다. 이것을 결함으로 올리지는 않는다. `jpaPlatformReleaseGate`의 주석이 밝힌 집계 기준은 "documented gate가 검증되지 않은 채 통과하게 만드는 lane"이고, pool lane은 문서화된 gate를 뒷받침하지 않으므로 기준상 registry에 없는 것이 일관적이다. 다만 그 결과로 이 lane의 release gate 소속만은 아무 계약도 보호하지 않는다는 사실을 기록한다. P3. #### 127. Fresh verification evidence — sub-scope 12 - `evidence/raw/114-governance-pool-original-verification.txt` — original source에서 `:adapter:outbound:persistence-jpa:test --rerun-tasks` **BUILD SUCCESSFUL in 28s / 18 executed**, `jpaPlatformPoolContractTest --rerun-tasks` **BUILD SUCCESSFUL in 24s / 18 executed**, root `verifyDocumentedLeafCount --rerun-tasks` **BUILD SUCCESSFUL in 3s / 9 executed**, git clean before/after - `evidence/raw/113-...` / `113a-...` — 실제 포화 pool의 pending/saturated 관측, acquisition 대기 504ms 대 설정 500ms 대 assertion 상한 2,500ms #### 128. Sub-scope 12 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | nightly workflow가 lane의 검증 항목으로 명시한 "a saturated pool reports its pending count"를 어떤 assertion도 실제 pool에서 확인하지 않음(손으로 만든 record만 확인) | release gate 구성 lane; 관측 가능함을 probe로 확인 | | **P3** | `PoolPressureContractTest`/`RequiresNewPoolPressureContractTest`의 sizing-rule assertion 2개가 공식을 test 안에서 재작성한 뒤 자기 자신과 비교하는 항등식 | 실패할 수 없는 assertion | | **P3** | saturation timeout assertion 상한이 설정값의 5배(2,500ms vs 500ms)여서 javadoc이 말하는 "within the configured timeout"을 강제하지 않음 | 회귀 탐지 폭 | | **P3/기록** | pool lane이 release gate 구성원이면서 release registry·support matrix·`JpaReleaseGate` 어디에도 없어, 소속이 양방향으로 검증되지 않음 | governance | #### 129. Sub-scope 12 완료 조건 - denominator 3 / 3 FULL_READ (`111-...`) - lane claim / release-gate 소속 / assertion 실체 3종 대조(`112-...`) - 실제 PostgreSQL probe 1건(`113-...`, `113a-...`), 원본 복구 후 git clean - original source lane 재실행 green(`114-...`) --- #### 130. Sub-scope 11 범위와 denominator > 내부 상태: COMPLETE — **75 / 75 FULL_READ** > 범위: `src/postgresqlIntegrationTest` (71 Java + 4 SQL, 13,977 lines) > 역할: 이 leaf에서 "실제 PostgreSQL이 답해야만 하는 주장"의 증거 생산자 전부 manifest: `evidence/raw/120-persistence-jpa-integration-lane-manifest.txt`. | package | 파일 | 성격 | |---|---:|---| | `platform/**` (experimental 5 포함) | 33 | design §11~§40 계약, tag 기반 lane | | `readiness/**` | 29 | readiness card producer + tag lane 혼재 | | `notification/**` | 8 | notification 저장소 계약 + fixture | | `operation/`, `liveevent/` | 2 | durable operation / live event 저장소 계약 | | `resources/db/readiness/**` | 4 SQL | Flyway 시나리오 fixture(중단·롤링) | 이 sub-scope는 sub-scope 10(testkit)과 같은 성격이다 — **production 동작이 아니라 evidence 생산자의 정확성**을 분석한다. 다만 규모가 다르다. testkit이 62파일 3,070 LOC였다면 여기는 75파일 13,977 LOC이고, 이 leaf가 "H2로는 만족시킬 수 없다"고 선언한 모든 계약이 여기에 있다. #### 131. 이 source set 안에 서로 다른 두 개의 evidence 세계가 있다 파일은 한 source set에 있지만 실행 경로는 둘로 갈라진다. **(1) tag lane.** `@Tag("jpa-contract" | "jpa-migration" | "jpa-failure" | "jpa-queryplan" | "jpa-security")`를 단 클래스는 `registerJpaPlatformLane`이 만든 5개 Test task가 `includeTags`로 고른다. 이 5개가 `jpaPlatformReleaseGate`와 release registry의 blocking gate에 연결된다. **(2) readiness card.** `@Tag`가 없는 클래스는 `registerPostgreSqlReadinessTest`가 클래스 이름으로 하나씩 등록한 14개 task가 `filter.includeTestsMatching`으로 고른다. 이 task들은 `config/jpa/readiness-cards.yaml`의 card가 `readiness-task` / `support-tasks`로 지목하고, `generateJpaEvidenceManifests`가 active card의 producer를 모두 `dependsOn`한다. 두 세계의 대응은 정확히 맞아떨어진다. `@Tag`가 없는 test 클래스는 13개, readiness task가 이름으로 지목하는 클래스는 14개이고, 그 차이 1개는 `PostgreSqlNotificationSchemaActivationIntegrationTest` — 유일하게 tag와 readiness task를 **둘 다** 가진 클래스다. 즉 orphan test class는 0개다. `--dry-run`으로 실제 task graph를 resolve해 확인했다. ```text $ ./gradlew :adapter:outbound:persistence-jpa:generateJpaEvidenceManifests --dry-run … postgresqlAggregateIntegrationTest … postgresqlFileserverMetadataIntegrationTest … postgresqlFileserverMigrationIntegrationTest … postgresqlFileserverReclamationIntegrationTest … (14개 전부 SKIPPED 로 등장) ``` Fileserver migration/reclamation 두 task는 card의 `readiness-task`가 아니라 `jpa-fileserver-metadata-v1`의 `support-tasks`로 들어와 있어서 실행은 되지만 `evidence.task-claims`가 비어 있어 어떤 required-evidence tag도 덮지 않는다. 실행은 fail-closed, 증거 연결은 없음 — 결함은 아니고 배선의 성격이다. 반대로 `jpaPlatformReleaseGate --dry-run`에는 readiness task가 **하나도** 없다. 그리고 `.github/workflows` 전체에서 `postgresql*IntegrationTest` task를 직접 부르는 곳도 없다(grep exit=1). readiness 세계의 유일한 자동 실행 경로는 `ci-quality-gates.yml`의 `jpa-candidate-evidence` job이 부르는 `verifyJpaCandidateEvidence`와 `jpa-r2-evidence.yml`의 `verifyJpaPrimaryFoundationEvidence` 둘뿐이다. 이 구조가 §132의 결함이 오래 보이지 않은 이유다. card 자체의 수치도 재측정했다. 총 17개 card 중 `not-implemented` 4개를 뺀 **13개가 active**(selected 7 + implemented-candidate 6)이고, `generateJpaEvidenceManifests`는 그중 `jpa-primary-foundation`을 제외한 **12개의 readiness-task**를 의존한다. `README.md`가 "active card 11개의 producer를 실행"이라고 적은 것과는 어긋난다(작은 count drift, P3). #### 132. Confirmed P1 — selected base card `jpa-flyway-migration`의 producer가 현재 revision에서 실패한다 `postgresqlMigrationIntegrationTest`는 `jpa-flyway-migration` card의 `readiness-task`다. 이 card는 `state: selected`이고 `jpa-primary-foundation`(R2 집계 gate)의 prerequisite 6개 중 하나다. 원본 소스, `--rerun-tasks`, git clean 상태에서 실행하면 **BUILD FAILED**다. 근거: `evidence/raw/117-flyway-migration-readiness-lane-failure.txt`, `118-readiness-task-result-matrix.txt`. ```text PostgreSqlMigrationIntegrationTest > adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() FAILED PostgreSqlMigrationIntegrationTest > freshCoreStreamInitializesWithoutLegacyHistory() FAILED taskExit=1 ``` 두 실패의 정체는 같다 — stream에 migration이 추가됐는데 그 stream의 applied set을 고정한 assertion이 갱신되지 않았다. ```text Expecting actual: ["1", "3", "4", "5", "6", "9", "10", "11", "12"] to contain exactly (and in same order): ["1", "3", "4", "5", "6"] but some elements were not expected: ["9", "10", "11", "12"] ``` ```text Expecting actual: ["1", "2"] to contain exactly (and in same order): ["1"] but some elements were not expected: ["2"] ``` 앞은 `db/migration/postgresql`(legacy adoption stream, 현재 9개 파일), 뒤는 `db/migration/jpa/core`(현재 2개 파일)다. history로 시점을 맞춰 보면 원인이 분명하다. ```text 2026-07-31 PostgreSqlMigrationIntegrationTest.java ← assertion 최종 수정 2026-08-15 db/migration/jpa/core/V2__widen_capability_schema_stream.sql 2026-08-15 db/migration/postgresql/V9__widen_capability_schema_stream.sql 2026-08-18 db/migration/postgresql/V10__idempotency_request_hash_varchar.sql 2026-08-28 db/migration/postgresql/V11__durable_operation.sql 2026-08-28 db/migration/postgresql/V12__live_event_log.sql ``` 4주에 걸쳐 5개 migration이 두 stream에 들어오는 동안 이 lane의 assertion은 한 번도 갱신되지 않았다. 같은 source set의 `PostgreSqlOptionalStreamLifecycle`은 이 실패 유형을 정확히 알고 있다 — notification stream의 버전 목록 주석에 "the version that landed without being added is why the lane failed the first time anybody ran it"라고 적혀 있다. 그 교훈이 base stream 쪽 lane에는 적용되지 않았다. **드러나지 않은 이유**는 §131의 구조다. 이 task는 5개 tag lane 어디에도 속하지 않고, `jpaPlatformReleaseGate`에도 없고, 어떤 workflow도 이름으로 부르지 않는다. 같은 실행에서 5개 tag lane은 **244 tests / 0 failures**로 전부 green이었다. 이 lane을 실제로 도는 자동 경로는 `verifyJpaCandidateEvidence` 하나뿐이고, 그것이 실패하면 `jpa-candidate-evidence` CI job이 red가 된다. 같이 실행한 나머지 13개 readiness task 결과는 다음과 같다(`118-...`). ```text task tests skip failures errors verdict postgresqlAggregateIntegrationTest 1 0 0 0 PASS postgresqlFileserverMetadataIntegrationTest 12 0 0 0 PASS postgresqlFileserverMigrationIntegrationTest 6 0 0 0 PASS postgresqlFileserverReclamationIntegrationTest 15 0 0 0 PASS postgresqlIdempotencyIntegrationTest 8 0 0 0 PASS postgresqlInboxIntegrationTest 5 0 0 0 PASS postgresqlLifecycleIntegrationTest 2 0 0 0 PASS postgresqlMigrationIntegrationTest 4 0 2 0 FAIL postgresqlNotificationSchemaActivationIntegrationTest 8 0 0 0 PASS postgresqlOutboxPollingIntegrationTest 5 0 0 0 PASS postgresqlOutboxStorageIntegrationTest 5 0 0 0 PASS postgresqlQueryIntegrationTest 1 0 0 0 PASS postgresqlSecurityBaselineIntegrationTest 3 0 1 0 FAIL postgresqlTransactionIntegrationTest 7 0 0 0 PASS ``` `postgresqlSecurityBaselineIntegrationTest`의 실패는 **분석 환경 제약**이지 결함이 아니다. `verifyFullAcceptsTrustedHostAndRejectsHostnameMismatchAndUntrustedCertificate`는 `PostgreSqlTlsMaterial`이 `CN=localhost` / `SAN=DNS:localhost`로 발급한 인증서를 `verify-full`로 검증하므로 컨테이너의 매핑 포트가 **테스트 JVM의 loopback**에서 열려 있어야 한다. 이번 분석은 Docker 소켓을 공유하는 형제 컨테이너 안에서 실행돼 매핑 포트가 Docker 브리지(172.17.0.1)에만 열렸고, 실패는 `java.net.ConnectException`이다. 이 lane은 skip이 아니라 실패하도록 설계돼 있으므로(no-skip) 동작 자체는 의도대로다. 다만 "no-skip"의 대가로 **Docker 호스트와 테스트 JVM이 loopback을 공유하는 환경**이 이 lane의 암묵적 전제가 된다는 사실은 기록해 둔다. **판정: P1 confirmed.** 수정은 assertion을 stream의 현재 applied set으로 갱신하는 것이고, 재발 방지는 `PostgreSqlOptionalStreamLifecycle`이 이미 쓰는 방식(stream별 버전 목록을 한 곳에 고정)을 base stream에도 적용하는 것이다. 더 근본적으로는 이 lane이 tag lane과 완전히 분리돼 있다는 구조 자체가 재검토 대상이다 — 5개 tag lane이 green이라는 사실이 readiness lane의 상태에 대해 아무것도 말해주지 않는다. #### 133. Confirmed P2 — selected base card 3개의 evidence tag가 production code 없는 fixture로 충족된다 card의 `required-evidence` tag는 `evidence.scenarios[].covers`로 실제 JUnit selector에 연결된다. 그 연결을 실제 test 본문과 대조하면 세 card에서 tag와 증거의 격차가 나온다. **`jpa-observability-lifecycle` — `observability`** `PostgreSqlLifecycleIntegrationTest.poolCapacityExhaustionAndShutdownAreBoundedAndObservable`이 이 tag를 덮는다. 관측 assertion은 다음 한 줄이다. ```java assertThat(saturated.boundedTags()) .containsExactlyInAnyOrderEntriesOf( Map.of("component", "postgresql-primary", "state", "saturated")); ``` `boundedTags()`는 같은 파일 안의 private record `PoolSnapshot`의 메서드이고, 비교 대상 literal도 같은 파일에 있다. `postgresql-primary`라는 문자열은 repository 전체에서 이 파일 두 줄에만 존재한다(`108-...` 계열 검색과 별개로 `git grep` 확인). production의 persistence metric tag는 `JpaMetricTags`가 만드는 `persistence.unit` / `persistence.operation` / `persistence.query` / `outcome` / `failure.category` 5종이고 `component` tag도, pool 상태 metric도 없다. 즉 이 card의 `observability` 증거는 production 계측이 하나도 없어도 그대로 green이다. pool 자체의 포화/종료 동작(active 2, 종료 후 0, closed)은 실제로 관측하므로 lifecycle 쪽 증거는 유효하다 — 문제는 `observability` tag가 그 위에 얹혀 있다는 점이다. **`jpa-query-model` — `query-contract`, `query-plan`** 유일한 scenario `PostgreSqlQueryIntegrationTest.boundedKeysetQueryUsesTheRepresentativeIndex`는 `readiness_query` 테이블·인덱스·쿼리를 test가 직접 만든다. production의 `KeysetPageRequest`/`KeysetSlice`/`springdata` keyset 실행 경로는 한 줄도 지나지 않는다. 게다가 plan 확인 직전에 `set enable_seqscan=off`를 실행한다 — 대안을 제거한 상태에서 "인덱스를 쓴다"를 확인하는 것이라, 인덱스나 쿼리 모양이 나빠도 index scan이 가능하기만 하면 통과한다. 같은 source set에는 production `PostgreSqlExplainRunner`/`QueryPlanAssertions`로 실제 plan 구조와 추정 오차를 보는 `PostgreSqlQueryPlanContractTest`가 있는데, card는 그쪽을 가리키지 않는다. **`jpa-aggregate-store` — `mapping`, `optimistic-conflict`** 유일한 scenario `PostgreSqlAggregateIntegrationTest.roundTripsUuidAndInstantAndDetectsExpectedVersionConflict`는 `readiness_aggregate` 테이블에 대해 raw JDBC로 UUID/timestamptz 왕복과 `update ... where version = ?`가 0행을 반환하는 것을 확인한다. JPA entity도, `@Version`도, Hibernate optimistic locking도, production repository도 없다. **JPA** aggregate store card의 `mapping` 증거가 JPA를 거치지 않는다. 이쪽 역시 같은 source set에 production 경로를 쓰는 `JpaValueMappingContractTest`(Hibernate + `MappingEntity`)와 `OptimisticRetryIntegrationTest`(production `OptimisticConflictTranslator` + 실제 버전 충돌)가 있다. 이 셋을 하나로 묶는 사실은 다음과 같다. **card scenario가 가리키는 클래스 12개는 전부 `readiness/**` 안에 있고, `platform/**`의 33개 계약 test 중 card가 가리키는 것은 0개다.** 강한 증거 생산자와 card evidence가 서로 다른 세계에 있고, tag는 약한 쪽에 붙어 있다. **판정: P2 confirmed.** 대비되는 반례가 같은 card 집합 안에 있다는 점이 판단을 쉽게 해 준다 — `jpa-transaction-runtime`의 7개 scenario는 production `SpringTransactionPort` + `PostgreSqlLocalTimeoutConfigurer` + `PersistenceExceptionTranslator`를 실제 서버에서 돌리고, deadlock 40P01, serializable 재시도, lock/statement timeout 경계, pool admission 거부, `pg_terminate_backend`로 만든 commit 유실의 `INDETERMINATE` 판정까지 확인한다. 즉 이 결함은 체계적인 것이 아니라 세 card에 국한된다. 수정은 tag를 옮기는 문제다 — 이미 존재하는 강한 test를 scenario로 등재하거나, 약한 scenario의 `covers`에서 과대 tag를 떼는 것. #### 134. notification contract fixture는 하나의 stream을 세 갈래로 다시 만든다 같은 source set 안에서 notification schema를 만드는 방법이 두 가지다. - `readiness/PostgreSqlNotification*`은 Flyway를 `classpath:db/migration/jpa/notification-platform`에 겨눈다 — stream 전체가 자동으로 따라온다. `PostgreSqlNotificationSchemaActivationIntegrationTest`는 한 걸음 더 나아가 production `NotificationSchemaStream`을 호출하며 그 이유를 적는다("a test that restates them proves that two authors agreed rather than that the stream is right"). - `notification/*ContractTest`는 migration 파일 이름을 **손으로 나열**해 `Statement.execute`로 돌린다. 그리고 그 목록이 세 벌 있고 셋 다 길이가 다르다. | 목록 소유자 | notification 버전 | 주석 | |---|---|---| | `NotificationFixtures.migrations()` | V1–V9 | "The whole notification stream, in order… Applying a subset certifies a schema nobody deploys" | | `RecipientClaimContractTest.migrations()` | V1–V8 | "The whole stream, in order. Applying a subset certifies a schema nobody deploys — … which is how the first version of this test discovered that it was testing a database that could not exist." | | `ProjectionFactDurabilityContractTest.migrations()` | V1–V6 | (주석 없음) | | (배포 stream) | **V1–V10** | `PostgreSqlOptionalStreamLifecycle.notificationPlatform()`이 applied set `0..10`으로 고정 | 세 목록 모두 "the whole stream"이라고 말하고, 셋 다 아니다. ##### 실행 probe `evidence/raw/119-notification-migration-ladder-probe.txt` / `119a-...java`. 각 사다리를 실제 PostgreSQL 16에 적용하고 `information_schema.columns`로 `notification%` 테이블의 컬럼 집합을 비교했다. ```text ladder.notificationVersions=6 columns=208 ladder.notificationVersions=8 columns=213 ladder.notificationVersions=9 columns=218 ladder.notificationVersions=10 columns=218 ladder.9.missing=[] ladder.8.missing=[notification_delivery_attempt.provider_acceptance, notification_delivery_attempt.provider_acceptance_certainty, notification_delivery_attempt.provider_response_received_certainty, notification_delivery_attempt.request_body_committed_certainty, notification_delivery_attempt.request_started_certainty] ladder.6.missing=[notification_admin_audit.claimed_at, notification_admin_audit.command_fingerprint, notification_admin_audit.phase, notification_delivery_attempt.provider_acceptance, notification_delivery_attempt.provider_acceptance_certainty, notification_delivery_attempt.provider_response_received_certainty, notification_delivery_attempt.request_body_committed_certainty, notification_delivery_attempt.request_started_certainty, notification_request.collapse_key, notification_request.collapse_scope] ``` 읽는 방법은 이렇다. - **V1–V9는 현재 배포 형상과 컬럼이 동일하다(218 = 218).** V10이 DDL 없는 guard(`variables_payload`가 base64 envelope가 아닌 행이 있으면 `RAISE EXCEPTION`)이기 때문이다. 그래서 `NotificationFixtures`를 쓰는 5개 test는 지금은 배포 형상 위에서 돈다. 다만 stream보다 한 칸 뒤에 있으므로 **다음에 DDL을 가진 migration이 들어오는 순간** 조용히 어긋난다. - **V1–V8은 evidence-certainty 5개 컬럼이 없다.** `RecipientClaimContractTest`는 `notification_recipient_delivery`만 다루므로 현재 false-green은 없다. - **V1–V6은 admin-claim 3 + evidence-certainty 5 + collapse 2, 총 10개 컬럼이 없다.** `ProjectionFactDurabilityContractTest`는 V6가 도입한 projection fact 컬럼만 다루므로 역시 현재 false-green은 없다. **판정: P3.** 현재 잘못된 통과를 만드는 경로는 확인되지 않는다. 문제는 유지보수 계약이다 — stream에 migration을 하나 추가하려면 네 곳(Flyway location은 자동, 나머지 세 목록은 수동)을 맞춰야 하고, 세 목록은 이미 각각 1·2·4 버전씩 뒤처져 있다. 그리고 세 목록의 주석이 모두 "subset은 아무도 배포하지 않는 schema를 인증한다"고 경고하고 있다. 수정 방향은 같은 source set이 이미 보여 준다 — Flyway location을 겨누거나 production `NotificationSchemaStream`을 호출하면 목록 자체가 사라진다. 부수적으로, `notification/*ContractTest`의 fixture는 `variables_payload`에 평문 `'{}'`를 넣는다. 이는 V10 guard가 거부하는 모양이다(guard는 migration 시점의 기존 행만 보므로 지금은 충돌하지 않는다). at-rest 계약이 "불가능하다"고 선언한 형상 위에서 contract test가 도는 셈이라, V10을 목록에 넣는 순간 fixture도 함께 바뀌어야 한다. #### 135. `JpaPlatformContractSupport`의 컨테이너 수명 서술은 실제와 다르다 클래스 javadoc은 이렇게 말한다. > The containers are shared for the JVM: the contracts verify server behaviour, which does not change between test classes, and **starting a server per class turns a three-version matrix into minutes of container startup.** 실제 사용은 정확히 그 "per class"다. `JpaPlatformContractSupport.start()` 호출 지점은 31곳이고 대부분 `@BeforeAll`에서 시작해 `@AfterAll`에서 `close()`한다. JVM 수준 공유 인스턴스나 static holder는 없다. `StablePostgreSqlMatrixContractTest`는 test마다, `JpaPlatformContractSupportOwnershipTest`는 test마다(5개) 컨테이너를 새로 띄운다. 실측치는 다음과 같다(`115-integration-lane-original-verification.txt`, XML의 Testcontainers 로그 집계). | lane | 클래스 | tests | skipped | failures | PostgreSQL 컨테이너 기동 | |---|---:|---:|---:|---:|---:| | jpaPlatformContractTest | 36 | 182 | 0 | 0 | 49 | | jpaPlatformMigrationTest | 10 | 43 | 0 | 0 | 33 | | jpaPlatformFailureTest | 2 | 6 | 0 | 0 | 2 | | jpaPlatformQueryPlanTest | 1 | 3 | 0 | 0 | 1 | | jpaPlatformSecurityTest | 2 | 10 | 0 | 0 | 2 | | **합계** | **51** | **244** | **0** | **0** | **87** | 한 번의 전체 tag lane 통과에 PostgreSQL 컨테이너가 87번 기동한다. 그럼에도 5개 lane 전체가 3분 10초에 끝났으므로 비용 주장이 무너지는 수준은 아니다. 기록하는 이유는 서술과 구현의 불일치다 — 클래스가 자기 설계 근거로 내세운 "JVM 공유"가 소비자 31곳 어디에서도 성립하지 않는다. P3. 같은 클래스의 다른 서술은 사실이다. multi-version 선택을 fail-closed로 거부하는 것(`start()`가 `selected.size() != 1`이면 예외), 그리고 "the CI matrix fans out"은 `jpa-release.yml`(16/17/18), `jpa-pr.yml`(16/18), `jpa-nightly.yml`이 `-Pjpa.matrix.versions`로 실제 fan-out하는 것으로 확인된다. `JpaPlatformContractSupportOwnershipTest`가 지키는 pool 소유권(호출당 새 pool을 만들어 참조를 잃던 과거 결함)도 실제 assertion으로 고정돼 있다. #### 136. 이 lane이 실제로 강한 지점 결함만 나열하면 이 corpus를 오해하게 된다. 다음은 "실서버가 아니면 성립하지 않는" 주장을 실제로 실서버에서 확인하는 사례이고, 대부분 production 클래스를 그대로 쓴다. - **commit ambiguity** — `CommitAmbiguityContractTest`가 `pg_terminate_backend`로 백엔드를 죽인 뒤 commit해서 실제 SQLSTATE가 `57P01`(class 08이 **아님**)임을 확인하고, production `CommitFailureClassifier`가 그것을 `TransactionCompletionUnknownException` / `retryable=false`로 번역하는지 본다. "연결이 끊기면 08일 것"이라는 합리적 추측이 왜 틀리는지가 주석에 적혀 있다. - **transaction runtime** — `PostgreSqlTransactionIntegrationTest`가 production `SpringTransactionPort`로 transaction-local timeout 적용/복원, 확정 롤백, serializable 충돌의 replay-safe 정책 한정 재시도(action 호출 3회), 결정적 deadlock 40P01 단일 희생자, lock/statement timeout 경계(55P03/57014), pool 고갈 시 **애플리케이션 작업 시작 전** 거부, commit 중 연결 유실의 `INDETERMINATE`+재시도 금지를 모두 실측한다. - **owner-safe idempotency / same-store inbox / immutable outbox / polling delivery** — 네 readiness test 모두 production adapter를 실제 transaction 안에서 돌리고, 가상 스레드로 두 caller를 경쟁시켜 "첫 business commit 전에는 경쟁자가 owner row를 통과하지 못한다"를 실제로 블로킹시켜 확인한다. - **RLS 실패 모드** — `RlsIsolationFailureTest`가 enable-but-not-forced일 때 소유자가 정책을 우회하는 고전적 false-green, session-scope 바인딩이 pool 반납 후 다음 차용자에게 새는 것, transaction-scope 바인딩은 새지 않는 것을 실제 pool로 구분한다. - **schema/mapping 정합** — `PostgreSqlDefaultPersistenceUnitIntegrationTest`가 `PersistenceJpaConfig`의 `@EntityScan` 목록을 **읽어서** `ddl-auto=validate`를 돌린다. 스캔 목록이 늘면 검사도 함께 늘어난다. notification 쪽도 같은 방식이 있고, 추가로 `jsonb_typeof`로 "JSON처럼 보이는 text"가 아닌지까지 본다. - **evidence certainty** — `EvidenceCertaintyContractTest`가 5×5×5×5 = 625조합을 실제 행에 왕복시키고, DB CHECK가 `UNKNOWN`인데 값이 true인 행과 모델 밖 certainty를 거부하는지 확인한다. `RecipientClaimContractTest`는 방법론 면에서 이 corpus의 모범이다 — production `RecipientClaimSql.CLAIM_BATCH` 텍스트를 그대로 가져와 placeholder만 바꿔 쓰고, 그 이유를 "Retyping the SQL here would prove that two authors agreed about a query rather than that the query is right"라고 적는다. #### 137. 이전 sub-scope 발견과의 교차 정합 이 sub-scope의 파일들은 앞선 sub-scope가 올린 결함이 **왜 lane에서 잡히지 않았는지**를 직접 설명한다. | 앞선 발견 | 이 lane 쪽 대응 사실 | |---|---| | §52 (sub-scope 04, P1) collection-fetch gate가 SQL limit을 보지 않음 | `HibernateCollectionFetchPaginationContractTest.oneCollectionPageIsBoundedInSql`은 반환 페이지 크기와 `expected.requiresDatabaseLimit()`(기대 객체 자신의 상수)만 확인한다. javadoc은 "The assertion is therefore on the generated SQL"이라고 쓴다. 파일 소유는 sub-scope 11, 결함 판정은 §52 — 중복 계상하지 않는다 | | sub-scope 10 (P1 latent) `PostgreSqlExplainRunner`가 data-modifying CTE를 허용 | `PostgreSqlQueryPlanContractTest.refusesNonSelect`가 확인하는 것은 평범한 `update` 한 건뿐이다. CTE 형태는 이 assertion의 사각지대 | | sub-scope 06 (P1) Stable runtime-role 검증이 startup에서 실제 policy를 적용하지 않음 | production `requireSafe` 호출자는 0. 유일한 호출자는 `PostgreSqlSecurityContractTest.policyAcceptsVerifiedRole`이고, 그 role과 policy는 test가 만든 것이다. blocking gate `runtime-role-no-ddl`이 green이라는 사실은 verifier가 동작한다는 뜻이지 배포가 그것을 부른다는 뜻이 아니다 | | sub-scope 09 (P1 latent) database-per-tenant budget이 이질적 pool 크기에서 ceiling 초과 | `TenantPoolCapacityContractTest`는 `POOL_SIZE_PER_TENANT = 2`로 **균일한** pool만 연다. 이질적 조합이 lane에 없다는 것이 그 결함이 green으로 남은 이유다 | | sub-scope 08 (P2) V8 atomic admin claim에 production caller 0 | `AdminOperationClaimContractTest`는 `INSERT ... ON CONFLICT DO NOTHING`을 **test가 직접 작성해** 검증한다(`RecipientClaimSql` 방식과 대조적). claim SQL이 옳다는 것과 production이 그것을 부른다는 것은 별개다 | #### 138. finding으로 올리지 않은 관찰 - **order-dependent test.** `ConstraintRaceContractTest.exactlyOneRowSurvives`는 앞선 test가 넣은 행에 의존한다. XML의 실행 순서상 현재는 race test가 먼저 돈다. JUnit 기본 순서는 결정적이지만 명세된 계약이 아니고, 같은 source set의 `PostgreSqlOutboxStorageIntegrationTest`는 `@TestMethodOrder(OrderAnnotation)`로 명시한다. 한 corpus 안에 명시적 순서와 암묵적 순서 의존이 공존한다. - **삼킨 예외.** `PostgreSqlUpsertContractTest.upsertConcurrently`는 `SQLException`/`InterruptedException`을 기록 없이 삼킨다. 두 스레드 중 하나가 죽어도 `count(*) == 1`은 성립하므로 "동시 upsert가 수렴했다"와 "하나만 돌았다"를 구분하지 못한다. - **display name과 assertion 불일치.** `PostgreSqlWorkClaimContractTest.claimsAreDeterministicallyOrdered`의 이름은 결정적 순서를 약속하지만 assertion은 `isNotEmpty()` 하나다. - **Docker 없이 도는 tag lane 클래스 2개.** 51개 중 `JpaAuditingContractTest`와 `JpaPlatformContractSupportTest`만 컨테이너를 하나도 띄우지 않는다. 후자는 그 사실을 javadoc에 적어 두었고, 전자는 순수 단위 assertion 4개다. blocking gate `postgresql-contract`("The real database ran the contract suite, not H2")의 test 수 182에는 데이터베이스를 만난 적 없는 assertion이 섞여 있다. - **credential fixture 3종.** `JpaPlatformContractSupport.generatedPassword()`는 "리터럴은 committed credential"이라는 이유로 존재하는데, 소비자는 `RlsIsolationFailureTest` 하나다. `PostgreSqlSecurityContractTest`(blocking gate 생산자)는 `password 'contract_runtime'` 리터럴을 쓰고, `PostgreSqlSecurityBaselineIntegrationTest`는 `UUID.randomUUID()`를 `pg_temp` 함수에 파라미터로 넘긴다. 세 형제가 세 방식을 쓴다. - **중복 assertion.** `commitAmbiguityHasThreeDistinctInjectionPoints`가 `CommitAmbiguityContractTest`와 `PostgreSqlConcurrencyFailureContractTest`에 거의 같은 형태로 두 번 있다(같은 `jpa-failure` lane). - **`PostgreSqlInboxCutoffIntegrationTest`의 SQL 재작성.** production `InboxItemJpaRepository.markAllRead`의 native `@Query`를 test가 다시 타이핑했다. 현재 두 문장은 술어 구조가 일치하지만, `RecipientClaimSql` 방식이 아니라 "두 저자가 합의했음"을 증명하는 형태다. - **죽은 helper.** `PostgreSqlNotificationInvariantIntegrationTest.unused(...)`는 `@SuppressWarnings("unused")`와 함께 의도적으로 남아 있다. #### 139. Fresh verification evidence — sub-scope 11 - `evidence/raw/115-integration-lane-original-verification.txt` — 원본 소스에서 5개 tag lane `--rerun-tasks` **BUILD SUCCESSFUL in 3m 10s**, 51 클래스 / 244 tests / 0 skipped / 0 failures / 87 컨테이너 기동, git clean before/after. 이어서 실행한 `verifyJpaCandidateEvidence`는 `:app-bootstrap:test`의 `ComposeMergeCharacterizationTest`에서 멈춘다 — 그 test는 `docker compose` 유무만 assume으로 확인하고 스크립트가 요구하는 `jq`는 확인하지 않으며, 분석 컨테이너에 `jq`가 없다(스크립트 직접 실행 시 `jq is required`). app-bootstrap 소유 사안이자 환경 제약이므로 이 sub-scope의 결함으로 계상하지 않는다 - `evidence/raw/116-readiness-lane-original-verification.txt` — 14개 readiness task `--rerun-tasks --continue`, git clean before/after - `evidence/raw/117-flyway-migration-readiness-lane-failure.txt` — `postgresqlMigrationIntegrationTest` 단독 `--rerun-tasks` **BUILD FAILED / taskExit=1**, 두 assertion 실패 원문, stream 파일 목록, migration/assertion 최종 수정일 대조 - `evidence/raw/118-readiness-task-result-matrix.txt` — 14 task × tests/skipped/failures/errors 표, 12 PASS / 2 FAIL - `evidence/raw/119-...` / `119a-...` — notification 사다리 4종의 컬럼 집합 실측과 차집합 - `evidence/raw/120-persistence-jpa-integration-lane-manifest.txt` — 75/75 파일 해시·라인수, tag 분포, 무-tag 클래스 목록, readiness task 등록 목록 모든 임시 분석 test는 실행 후 삭제했고 최종 `git status --short`는 clean이다. #### 140. Sub-scope 11 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1** | selected base card `jpa-flyway-migration`의 producer `postgresqlMigrationIntegrationTest`가 HEAD에서 실패(base/legacy stream 5개 migration 추가 후 applied-set assertion 미갱신). tag lane·release gate 어디에도 속하지 않아 5개 lane green과 무관 | **CI `jpa-candidate-evidence` job의 유일 실행 경로; `jpa-primary-foundation`(R2)의 prerequisite** | | **P2** | base card 3종(`observability` / `query-contract`+`query-plan` / `mapping`+`optimistic-conflict`)의 evidence tag가 production code를 지나지 않는 fixture로 충족. `query-plan`은 `enable_seqscan=off` 상태에서 판정 | selected card 3개; 같은 source set에 더 강한 생산자가 존재 | | **P3** | notification contract fixture의 migration 사다리가 3벌(V1–V9 / V1–V8 / V1–V6)로 갈라져 있고 배포 stream은 V1–V10. 실측 컬럼 차이 0 / 5 / 10 | 현재 false-green 경로 없음; 다음 DDL migration에서 어긋남 | | **P3** | `JpaPlatformContractSupport` javadoc의 "containers are shared for the JVM"이 소비자 31곳 어디에서도 성립하지 않음(실측 87 컨테이너 기동/전체 tag lane 1회) | 서술/구현 불일치 | | **P3** | `README.md`의 "active card 11개" 대 실측 active 13 / producer 12 | 문서 count drift | | **P3** | `postgresql-contract` blocking gate의 182 tests에 컨테이너를 띄우지 않는 클래스 2개가 포함 | gate 문구 대 구성 | | **P3** | order-dependent test 1건, 삼킨 예외 1건, display-name과 assertion 불일치 1건, credential fixture 3방식, 중복 assertion 1쌍 | §138 | | **환경 제약(결함 아님)** | `postgresqlSecurityBaselineIntegrationTest`의 TLS scenario는 컨테이너 매핑 포트가 테스트 JVM의 loopback에 열려 있어야 성립(인증서 SAN이 `localhost` 단일). 형제 컨테이너 실행 환경에서는 `ConnectException`으로 실패 | no-skip 설계의 암묵적 환경 전제 | #### 141. Sub-scope 11 완료 조건 - denominator 75 / 75 FULL_READ (`120-...`), 71 Java + 4 SQL - lane 소속을 tag/Gradle task/card 세 축으로 대조하고 orphan test 0을 `--dry-run` task graph로 확인 - negative-space probe: test 도달성(무-tag 클래스 ↔ readiness task 1:1), card evidence tag ↔ 실제 assertion 대조, 중복 mechanism(notification 사다리 4벌, SQL 재작성 대 production 상수), 문서/수치 drift(active card 수, 컨테이너 공유 서술, gate 문구) - 실행 evidence: 5개 tag lane 전량 재실행, 14개 readiness task 재실행, 실패 lane 단독 재현, notification 사다리 컬럼 실측 - 앞선 sub-scope 04·06·08·09·10 발견과의 교차 정합을 lane 쪽 사실로 설명 - 모든 임시 source 복구, `git status --short` clean --- #### 142. Module ledger 재조정과 module 완료 조건 ##### 142.1 최종 ledger | # | sub-scope | denominator | status | 주요 evidence | |---:|---|---:|---|---| | 1 | governance / build / docs / root boundary | 11 | **COMPLETE** | 107–110, 114 | | 2 | API contracts (`api/**`) | 55 | COMPLETE | 031–040 | | 3 | transaction + persistence failure | 51 | COMPLETE | 041–051 | | 4 | Spring Data + Hibernate + Querydsl | 53 | COMPLETE | 052–061 | | 5 | PostgreSQL vendor + vendor migrations | 73 | COMPLETE | 062–069 | | 6 | baseline capability stores/config/audit/cache/H2 등 | 87 | COMPLETE | 070–076 | | 7 | Fileserver persistence + migrations | 29 | COMPLETE | 077–082 | | 8 | Notification persistence + migrations | 68 | COMPLETE | 083–092 | | 9 | Experimental platform | 38 | COMPLETE | 093–101 | | 10 | testkit + fixture tests | 62 | COMPLETE | 102–106 | | 11 | PostgreSQL integration/readiness lane | 75 | **COMPLETE** | 115–120 | | 12 | pool/performance contract lane | 3 | **COMPLETE** | 111–114 | | | **TOTAL** | **605** | **12 / 12** | | 605 = leaf top-level 4 + `src/main` 381 + `src/test` 101 + `src/testkit` 41 + `src/postgresqlIntegrationTest` 75 + `src/jpaPlatformPerformanceTest` 3. 12개 sub-scope의 denominator 합도 605이며, 모든 tracked file이 정확히 하나의 sub-scope에 귀속된다. 중복 계상은 한 곳에서만 발생할 수 있었고(§114) sub-scope 06이 이미 계상한 3개 test를 sub-scope 01이 다시 세지 않는 것으로 정리했다. module 전체 disposition은 **605 FULL_READ / 0 STRUCTURAL_ONLY / 0 EXCLUDED / 0 UNCLASSIFIED**다. ##### 142.2 module-level 완료 조건 대조 - **정량 denominator**: 605 tracked file, `src/main` 350 Java ≈ 27,744 LOC, public top-level type 338, package 24, 독립 Flyway stream 7, 실서버 lane 5(tag) + 14(readiness) + 1(pool), release gate 6 - **coverage ledger**: 미분류 0 - **top-level package map**: 24개 전부 `JpaModuleBoundaryTest.PACKAGE_CATALOG`와 대조 완료 - **build/runtime wiring**: composition root → `PersistenceJpaRootAutoConfiguration` → vendor config → `PersistenceJpaConfig` / `JpaAdapterComponentsConfig` 사슬을 §116–§117에서 실행 probe로 확인 - **invariant / failure path**: sub-scope 02~10에서 문서화, sub-scope 11에서 실서버 증거와 대조 - **test ↔ claim 매핑**: sub-scope 11이 lane·card·gate 세 축으로 완료 - **rationale 분리**: 코드 주석·문서가 밝힌 역사(observed)와 분석 추론(inferred)을 각 절에서 구분 - **§8 4종 probe**: 12개 sub-scope 전부에서 수행 또는 비적용 사유 기록 - **dead/unwired/duplicate**: 정적 검색과 task graph resolve로 확인 - **documentation/count drift**: §119(leaf), §132·§134·§135(evidence 계층)에서 재측정 - **한계와 제외 영역**: 각 sub-scope 완료 조건에 기재. module 수준 한계는 §142.3 - **improvement backlog**: 12개 sub-scope backlog 유지 ##### 142.3 module 수준 한계 - 정적 도달성 분석은 reflection, service loader, 생성 코드 등록, **이 repository 밖의 adopter**를 증명하지 않는다. `api` package는 의도된 외부 surface이므로 내부 참조 0이 곧 dead를 뜻하지 않는다. - 실서버 증거는 PostgreSQL 16 단일 major에서 수집했다. registry가 Stable로 선언한 17·18은 CI matrix가 fan-out하며, 이 분석에서는 실행하지 않았다. - `postgresqlSecurityBaselineIntegrationTest`의 TLS scenario와 `:app-bootstrap:test`의 compose scenario는 분석 환경 제약으로 실행하지 못했다(§132, §139). 두 건 모두 원인을 특정했고 repository 결함으로 계상하지 않았다. - 성능·부하 특성은 이 분석의 대상이 아니다. pool lane은 행동 계약이지 측정 lane이 아니며(§123–§125), repository에도 threshold를 가진 성능 gate는 없다. ##### 142.4 module findings 요약 | 우선순위 | 건수 | 출처 sub-scope | |---|---:|---| | P1 (confirmed) | 8 | 03(completion-evidence 미설치), 04(collection-fetch gate false evidence), 07(quota 미집행, schema activation 오인), 08(V4 schema 오인, lease fencing 우회), 11(flyway-migration lane 실패) 외 | | P1 latent / conditional | 6 | 06, 07, 09, 10 | | P2 | 12+ | 02, 03, 04, 05, 08, 09, 11, 12, 01 | | P3 / 기록 | 다수 | 전 sub-scope | module 전체에서 반복되는 단일 주제가 하나 있다. **구현은 계약을 정확히 서술하는데, 그 계약을 실제 배포나 실제 gate에 연결하는 마지막 한 칸이 비어 있는 경우**다 — completion-evidence capability가 composition에 없고(§23), runtime-role verifier를 production이 부르지 않고(sub-scope 06), admin claim을 production이 쓰지 않고(sub-scope 08), vendor selector의 properties 타입이 켜지지 않고(§116), 그리고 그 미연결을 잡아야 할 evidence lane 자체가 4주간 실패한 채 아무도 실행하지 않았다(§132). 이 leaf의 다음 작업 우선순위는 새 기능이 아니라 이 마지막 한 칸들을 잇는 것이다. #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **230개** (main 143 · test 36 · 기타 51). ``` src/adapter/outbound/persistence-jpa/build.gradle src/config/architecture/modules.json (adapter-outbound-persistence-jpa 항목) main: src/main/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationName.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/CapabilitySupport.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/capability/JpaCapability.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConnectionUnavailableException.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintCode.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/ConstraintViolationDetails.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/FailureCategory.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaEntityNotFoundException.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContext.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceException.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/TransactionCompletionUnknownException.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/error/VendorFailureTranslator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/CursorCodec.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetPageRequest.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/KeysetSlice.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/NoopQueryObservation.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryName.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryObservation.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryScope.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SortDirection.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/JpaRetryPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryDecision.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryEventListener.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/RetryProfile.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionCompletionEvidence.java src/main/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfile.java src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditContextPort.java src/main/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntity.java src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/AuditMetadata.java src/main/java/dev/caskeleton/adapter/outbound/persistence/auditing/JpaAuditingConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/persistence/cache/HibernateCacheGuard.java src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java src/main/java/dev/caskeleton/adapter/outbound/persistence/envers/HibernateEnversHistoryReader.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalFeature.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantDataSourceRegistry.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantEntityManagerFactoryRegistry.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/database/TenantPoolBudget.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/next/HibernateCompatibilityPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ConsistencyAwareDataSourceRouter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/replica/ReplicaLagMonitor.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsPolicyVerifier.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/rls/RlsTenantSessionBinder.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaMultiTenantConnectionProvider.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantMigrationOrchestrator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/schema/SchemaTenantRegistry.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantAwareRepositoryGuard.java src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantEntityListenerGuard.java src/main/java/dev/caskeleton/adapter/outbound/persistence/failure/PersistenceExceptionTranslator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverJpaPersistenceConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/FileserverSchemaActivation.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaCleanupQueue.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaFileQuotaService.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaCommitGateway.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaQuotaReclaimGateway.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/JpaRecoveryQueue.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/entity/QuotaReservationEntity.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverCleanupRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/fileserver/repository/FileserverQuotaRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateProviderPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsCollector.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/HibernateStatisticsSnapshot.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/JdbcBatchCounter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/NamedStatementInspector.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/QueryNameContext.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/BatchExecutionResult.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateBatchConfigurationGuard.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/HibernateJpaBatchExecutor.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/batch/JpaBatchProfileRegistry.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/bulk/HibernateBulkDmlExecutor.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/HibernateStatelessSessionRunner.java src/main/java/dev/caskeleton/adapter/outbound/persistence/hibernate/stateless/StatelessWorkResult.java src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/entity/IdempotencyRecordEntity.java src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/DistributedLockPersistenceConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/lock/LockSettings.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationJpaPersistenceConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaActivation.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationSchemaStream.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/configuration/NotificationJpaPersistenceFacade.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JdbcReconciliationJobStore.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/RecipientClaimSql.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/TenantBoundRepositoryGuard.java src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/platform/inbox/InboxItemJpaRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaRetryObservation.java src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaTransactionObservation.java src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/package-info.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlLocalTimeoutConfigurer.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlExceptionTranslator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/error/PostgreSqlFailureClassifier.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/PostgreSqlOwnerSafeIdempotencyStore.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/inbox/PostgreSqlSameStoreInboxAdapter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlImmutableOutboxAppendAdapter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/outbox/PostgreSqlPollingDeliveryAdapter.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRange.java src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeCodec.java src/main/java/dev/caskeleton/adapter/outbound/persistence/querydsl/QuerydslJpaSupport.java src/main/java/dev/caskeleton/adapter/outbound/persistence/security/DatabaseRolePolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/security/PostgreSqlRuntimeRoleVerifier.java src/main/java/dev/caskeleton/adapter/outbound/persistence/security/SearchPathPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/EntityGraphCatalog.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/FetchPlanApplier.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaKeysetQuerySupport.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaRepositoryFragmentSupport.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/JpaStreamExecutor.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortField.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortMapper.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SafeSortRegistry.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/ScrollPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/SpecificationPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CommitFailureClassifier.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecord.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/CompletionUnknownRecorder.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/DefaultJpaRetryPolicy.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManager.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionConfig.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionSettings.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/OptimisticConflictTranslator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/PersistenceFailureTranslatorChain.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/RetryBudget.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionDeadlineCalculator.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceContext.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScope.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionProfileRegistry.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryBackoff.java src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionRetryClassifier.java test: src/test/java/dev/caskeleton/adapter/outbound/persistence/CandidateAdapterCompositionTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/api/PersistenceOperationNameTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaFailureContextTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/api/error/JpaPersistenceExceptionTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/QueryNameTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodecTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/api/transaction/TransactionProfileTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceEntityScanCoverageTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSelectionTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/experimental/ExperimentalEntryConsentTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/platform/PoolLaneClaimTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/idempotency/IdempotencyDigestPolicyTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/range/PgRangeTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/EvidenceAwareJpaTransactionManagerTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/TransactionEvidenceScopeTest.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/EntityExposureCondition.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaArchitectureRules.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/arch/JpaAuditMechanismRule.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/failure/CommitAmbiguityProxy.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/id/UuidV7Generator.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/jdbc/CountingDataSource.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityState.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/lifecycle/EntityStateProbe.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/mapping/MappingEntity.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/migration/MigrationContractRunner.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/pool/PoolMeasurement.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContractExtension.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/NormalizedPlan.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/PostgreSqlExplainRunner.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanAssertions.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/queryplan/QueryPlanExpectation.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseGate.java src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/release/JpaReleaseManifest.java 기타: CLAUDE.md README.md docs/architecture/jpa-api-surface.txt docs/fileserver/design-deviations.md docs/jpa/repository-adaptation.md docs/jpa/security.md docs/jpa/support-matrix.md docs/jpa/transaction-guide.md docs/reviews/2026-08-14-jpa-module-code-review.md src/build.gradle src/config/jpa/readiness-cards.yaml src/config/jpa/release-registry.json src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/AdminOperationClaimContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/EvidenceCertaintyContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationFixtures.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/ProjectionFactDurabilityContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/notification/RecipientClaimContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/CommitAmbiguityContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/ConstraintRaceContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateCollectionFetchPaginationContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/HibernateJpaBatchExecutorIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/IdStrategyContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaAuditingContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupport.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportOwnershipTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaPlatformContractSupportTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/JpaValueMappingContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/OptimisticRetryIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlConcurrencyFailureContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlQueryPlanContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlSecurityContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlUpsertContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/PostgreSqlWorkClaimContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/StablePostgreSqlMatrixContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/RlsIsolationFailureTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/platform/experimental/TenantPoolCapacityContractTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlAggregateIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlDefaultPersistenceUnitIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlInboxCutoffIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlLifecycleIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationInvariantIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlNotificationSchemaActivationIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOptionalStreamLifecycle.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlOutboxStorageIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlQueryIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTlsMaterial.java src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlTransactionIntegrationTest.java 해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등: 092-notification-reachability-test-gap.txt evidence/raw/103-testkit-unit-boundary-probes.txt evidence/raw/078-fileserver-quota-boundary-probe-output.txt evidence/raw/096-experimental-gate-reachability.txt 099-experimental-structural-optin-gap.txt evidence/raw/097-experimental-replica-provider-probe.txt 106-testkit-original-verification.txt evidence/raw/053-jpa-query-hibernate-boundary-probe.txt evidence/raw/070-persistence-jpa-baseline-capability-manifest.txt evidence/raw/072-baseline-capability-reachability.txt evidence/raw/075-outbox-stale-worker-state-regression-output.txt evidence/raw/073-durable-operation-expired-lease-output.txt ``` --- ## A06. adapter-outbound-persistence-mongo > 분석 중에는 `06-adapter-outbound-persistence-mongo.md` 파일이었다. 1,772줄. ### adapter-outbound-persistence-mongo 상세 분석 #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-persistence-mongo` - canonical state `analysisFile`: §A06 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/persistence-mongo` · Gradle `:adapter:outbound:persistence-mongo` - registry `allowed_dependencies`: **`[]`** - registry `runtime_memberships`: `["app-bootstrap"]` - coverage ledger: `FULL_READ` **497** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: COMPLETE > 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 분석 범위: `src/adapter/outbound/persistence-mongo` > Gradle path: `:adapter:outbound:persistence-mongo` #### 0. 왜 내부 sub-scope로 나누는가 이 leaf도 persistence-jpa와 같은 이유로 한 번에 훑지 않는다. tracked file은 **497개**, production Java만 351개(약 22,927 LOC)이고, 설계 원본은 이것을 19개 Stable + 12개 Advanced Gradle module로 모델링한다. 이 저장소의 fail-closed registry가 그 배치를 대체하므로 module 경계는 `dev.caskeleton.adapter.outbound.mongo` 아래 package가 되고, package graph 자체가 내부 module graph 역할을 한다. 따라서 파일이 정확히 하나의 내부 bounded sub-scope에 귀속되도록 ledger를 먼저 고정한다. ##### 전체 denominator - tracked leaf files: **497** - leaf top-level: `CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile` (4) - `src/main`: 353 files / 351 Java / 2 resources / 약 22,927 LOC - `src/test`: 104 files / 약 12,380 LOC - `src/testkit`: 35 files / 약 3,036 LOC - `src/mongoPerformanceTest`: 1 file / 194 LOC - public top-level type: **346** (committed baseline `docs/architecture/mongo-api-surface.txt`가 스스로 `# types: 346`을 적고, 비주석 항목도 346개) 근거: `evidence/raw/121-persistence-mongo-module-inventory.txt`. ##### 내부 bounded sub-scope ledger | # | sub-scope | main | test | testkit | 기타 | denominator | status | |---:|---|---:|---:|---:|---:|---:|---| | 1 | governance / build / root boundary / autoconfigure | 15 | 12 | – | 4 | **31** | **COMPLETE** | | 2 | `api/**` — framework-free core contract | 61 | 9 | – | – | 70 | **COMPLETE** | | 3 | `mapping` + `nativecap` + `geo` | 23 | 4 | – | – | 27 | **COMPLETE** | | 4 | `imperative` + `reactive` 실행 경로 | 47 | 14 | – | – | 61 | **COMPLETE** | | 5 | `query` + `aggregation` | 22 | 7 | – | – | 29 | **COMPLETE** | | 6 | `transaction` (+ `retry`, `session`) | 20 | 7 | – | – | 27 | **COMPLETE** | | 7 | `schema` + `migration` | 49 | 9 | – | – | 58 | **COMPLETE** | | 8 | `changestream` | 21 | 5 | – | – | 26 | **COMPLETE** | | 9 | `security` + `failure` + `observation` + `client` | 30 | 14 | – | – | 44 | **COMPLETE** | | 10 | `advanced/**` | 65 | 10 | – | – | 75 | **COMPLETE** | | 11 | testkit + architecture/rs/release/compat test + performance lane | – | 13 | 35 | 1 | 49 | **COMPLETE** | | | **TOTAL** | **353** | **104** | **35** | **5** | **497** | **11 / 11** | sub-scope 1의 main 15는 root package Java 4 + `autoconfigure/**` 9 + resources 2다. 합계는 497로 leaf tracked file 전체와 일치하며, 모든 파일이 정확히 하나의 sub-scope에 귀속된다. 이 ledger는 module completion 전까지 모든 tracked file의 최종 disposition(`FULL_READ` / `STRUCTURAL_ONLY` / `EXCLUDED`)을 추적하기 위한 내부 작업 단위다. module-level `state.json`은 11개가 모두 닫힐 때만 COMPLETE로 전환한다. #### 1. 모듈 구조의 1차 관찰 이 leaf는 **opt-in**이라는 한 가지 성질을 축으로 설계돼 있고, 그 성질이 나머지 모든 구조를 결정한다. - `allowed_dependencies`가 `[]`다. project dependency가 하나도 없고, 외부 의존은 Spring Boot의 Mongo starter(sync/reactive), autoconfigure, Micrometer, SLF4J뿐이다. `verifyCleanArchitectureDependencies`는 "실제 edge ⊆ 허용 edge"만 보므로 쓰이지 않는 허용은 영원히 통과한다 — 그래서 반대 방향을 보는 `MongoRegistryPermissionParityTest`가 따로 있다. - `runtime_memberships`는 `["app-bootstrap"]`이고, composition root가 실제로 이 leaf를 `implementation`으로 싣는다(reactive starter와 reactivestreams driver는 exclude). 즉 이 module은 **jar에 들어 있고 property가 스위치**다. CLAUDE.md/README가 이 선택을 명시적으로 방어한다 — "빠져 있는 모듈은 꺼진 모듈과 같은 계약이 아니다. 부재는 배포 시점에 되돌릴 수 없고, gating 결함을 전부 가린다." - JPA adapter와의 책임 분리가 선언돼 있다. idempotency / outbox / distributed lock은 Mongo에 재구현하지 않고 JPA에 남긴다. - production에 가짜 도메인(`Example*`)을 두지 않는다. 이 leaf가 제공하는 것은 client·template·**정책 표면**이고, document/repository/mapper와 port 구현은 fork가 추가한다. 이 선택은 뒤에서 반복적으로 나타난다 — 여러 계약이 "정책과 value object는 있으나 실행체는 fork가 공급한다"는 형태다. `docs/mongodb/repository-adaptation.md`가 설계의 module 배치를 이 leaf의 package로 매핑한 기록이고, package 간 방향은 `MongoModuleBoundaryTest`가 닫힌 edge matrix로 강제한다. 이 문서는 각 sub-scope를 닫아가며 그 주장들과 실제 source/build/test/runtime evidence를 대조한다. --- #### 2. Sub-scope 01 범위와 denominator > 내부 상태: COMPLETE — **31 / 31 FULL_READ** > 범위: leaf 최상위 4 + production root package 4 + `autoconfigure/**` 9 + auto-configuration 등록 resource 2 + 해당 test 12 > 역할: "이 애플리케이션이 MongoDB와 말하는가"를 결정하는 층 전체 | 구분 | 파일 | 라인 | |---|---|---:| | governance | `CLAUDE.md` | 167 | | rationale | `README.md` | 147 | | build | `build.gradle` | 283 | | build | `gradle.lockfile` | 192 | | production root | `MongoRootAutoConfiguration.java` | 37 | | production root | `MongoPersistenceConfig.java` | 27 | | production root | `MongoPersistenceSettings.java` | 38 | | production root | `MongoOptInAutoConfigurationImportFilter.java` | 59 | | production | `autoconfigure/**` 9개 | 1,382 | | resource | `META-INF/spring.factories` | 2 | | resource | `META-INF/spring/…AutoConfiguration.imports` | 1 | | test | root package 2 (`MongoNamespaceContractTest`, `MongoPersistenceConfigTest`) | 202 | | test | `autoconfigure/**` 10개 | 1,156 | manifest: `evidence/raw/122-mongo-governance-optin-manifest.txt`. #### 3. opt-in은 네 겹이고, 각 겹이 서로 다른 실패를 막는다 | 겹 | 무엇 | 왜 그 층이어야 하는가 | |---|---|---| | Boot import filter | `MongoOptInAutoConfigurationImportFilter` (`spring.factories` 등록) | Mongo starter는 classpath만으로 auto-configuration 후보를 등록한다. project condition은 후보 선정 **뒤에** 평가되므로, 후보 단계에서 9개 Boot Mongo auto-configuration을 빼지 않으면 평범한 `@EnableAutoConfiguration` 앱이 client와 template을 만든다 | | auto-configuration entry | `MongoRootAutoConfiguration` (`AutoConfiguration.imports` 등록) | 마스터 하나. 예전에는 filter·component-scan된 config·platform auto-config 셋이 각자 같은 property를 읽는 마스터였고, 서로가 꺼져 있다고 믿는 것을 조립할 수 있었다 | | infrastructure | `MongoPersistenceConfig` | `@ImportAutoConfiguration`은 **명시적** import라 `spring.autoconfigure.exclude`의 영향을 받지 않는다. 켠 프로필에서만 Mongo client/template을 다시 들여온다 | | platform | `MongoPlatformAutoConfiguration`, `MongoDriverObservabilityAutoConfiguration` | 정책 bean. 후자는 `MeterRegistry`가 있을 때만 driver listener를 붙인다 — publish할 곳 없는 listener는 모든 command에 비용만 얹는다 | 네 겹 모두 `ca-skeleton.persistence-mongo.enabled=true`라는 같은 조건을 읽는다(`evidence/raw/123-...` §8.2). 이것은 중복이 아니라 계층별 차단이다: filter는 Boot의 후보군, 나머지 셋은 자기 bean 그래프를 담당한다. `MongoPersistenceConfigTest`가 실제 `@EnableAutoConfiguration` context로 default/false에서 `MongoClient`·`MongoTemplate` 부재를, `enabled=true` + mock client에서 `MongoTemplate` 단일 bean을 확인한다. `MongoPlatformAutoConfiguration`(443줄)은 이 leaf에서 가장 밀도가 높은 파일이고, 거의 모든 `@Bean`의 javadoc이 **과거에 "shipped했지만 아무 configuration도 만들지 않던" 경로**를 기록한다 — atomic/bulk template, reactive 실행 경로 일체, change-stream source와 consumer, startup validator, client generation registry, health indicator. 이 leaf는 그 미연결들을 한 번 훑어 고친 이력을 갖고 있고, 그 사실이 이 sub-scope의 판단 기준을 바꾼다: 남아 있는 미연결은 "아직 안 한 것"이 아니라 "훑고도 남은 것"이다. startup 검증 쪽 설계도 눈여겨볼 만하다. `mongoPlatformStartupCheck`는 `MongoTopologyProbe` bean이 있을 때만 돌지만, 그 조건이 곧 탈출구가 되는 것을 막기 위해 `mongoTopologyProbeRequirement`가 **probe 조건 없이** 등록되어 "platform profile이 있는데 probe가 없으면" 실패시킨다. javadoc이 그 이유를 한 줄로 적는다 — "a requirement that only applies when the thing it requires is present is not a requirement". #### 4. Confirmed P2 — README가 제시하는 활성화 recipe를 그대로 따르면 애플리케이션이 시작되지 않는다 leaf README §활성화가 제시하는 전체 recipe는 두 줄이다. ```properties ca-skeleton.persistence-mongo.enabled=true spring.data.mongodb.uri=mongodb://localhost:27017/portfolio ``` 이 두 줄에는 서로 독립적인 문제가 둘 있다. **(1) 필수 property가 빠져 있다.** composition root의 `CapabilityDependencyValidator`는 Mongo가 켜져 있고 `ca-skeleton.persistence-mongo.active-profile`이 blank이면 violation을 만들고, `CapabilityDependencyStartupCheck`가 context refresh에서 그 violation으로 startup을 중단시킨다. 이 key는 `app-bootstrap/src/main/resources/application.yml:370`이 `${APP_PERSISTENCE_MONGO_ACTIVE_PROFILE:}`로 노출하고 `.env.local.example`과 `docs/registries/env-keys.yaml`도 required로 기록한다. 그런데 leaf에서 `active-profile`을 언급하는 파일은 **0개**다(`123-...` §8.3, exit=1). CLAUDE.md도 README도 이 key를 적지 않는다. `MongoPersistenceSettings`가 이 key를 bind하지 않는 것 자체는 일관적이다 — 그 클래스는 "모듈의 opt-in 스위치만 소유한다". 문제는 key가 이 module의 property namespace(`ca-skeleton.persistence-mongo.*`) 안에 있으면서 소유·문서화가 전부 leaf 밖에 있고, leaf의 활성화 문서가 그것을 모른다는 점이다. **(2) 폐기된 namespace를 지시한다.** §5에서 따로 다룬다. **판정: P2 confirmed.** leaf의 활성화 문서를 그대로 따른 배포는 뜨지 않으며, 실패 메시지는 leaf 문서 어디에도 없는 property를 지목한다. 근거는 `evidence/raw/125-...` §D이고, 규칙이 실제로 강제된다는 사실은 `app-bootstrap`의 기존 `CapabilityDependencyValidatorTest`를 원본 상태로 재실행해 확인했다(`126-...`, BUILD SUCCESSFUL). 수정은 README/CLAUDE.md의 recipe에 `active-profile`을 추가하고 유효한 값의 출처(= `ca-skeleton.persistence-mongo.platform.profiles`의 key)를 함께 적는 것이다. #### 5. Confirmed P3 — 폐기된 namespace guard의 탐색 domain이 operator가 읽는 두 문서를 덮지 않는다 `MongoNamespaceContractTest`(MNG-INT-002)는 정확히 이 문제를 위해 존재하고, javadoc이 막으려는 defect를 이렇게 정의한다. > A sentence recording that the old namespace is deprecated is the opposite of the defect — **the defect was a document telling an operator to use it.** 그 guard의 탐색 domain은 다음과 같다(`125-...` §C). - `adapter/outbound/persistence-mongo`와 `app-bootstrap` 아래 - 경로에 `/src/main/`을 포함하는 파일만 - `.java`는 **주석을 제거한 뒤**, `.yml`/`.properties`는 통째로 따라서 다음 세 곳은 domain 밖이고, 셋 다 `spring.data.mongodb.`를 담고 있다. | 위치 | 내용 | |---|---| | `README.md:37` | 붙여넣기용 예제 `spring.data.mongodb.uri=mongodb://localhost:27017/portfolio` | | `README.md:53`, `CLAUDE.md:25` | "URI/database/credential은 표준 `spring.data.mongodb.*` 설정을 사용한다" | | `src/test/.../MongoPersistenceConfigTest.java:20`, `:64` | 이 leaf 자신의 opt-in 대표 test가 `spring.data.mongodb.database=portfolio`를 사용 | `src/main` 쪽은 깨끗하다 — 유일한 매치는 `MongoPersistenceSettings`의 javadoc이고, 그것은 "예전에 이 javadoc이 폐기 키를 가리켰다"는 기록이라 guard가 주석을 제거하는 이유 그대로다. **판정: P3 confirmed.** guard가 막겠다고 명시한 형태(문서가 operator에게 폐기 키를 쓰라고 말하는 것)가 guard의 사각지대에서 그대로 살아 있고, 그중 하나는 복사해 쓰라고 제시된 예제다. 런타임은 영향받지 않는다 — Compose lane은 `SPRING_MONGODB_URI`를 공급하고, 폐기는 제거가 아니다. 수정은 두 문서의 키를 `spring.mongodb.*`로 바꾸고, guard의 domain에 leaf의 `*.md`를 추가하는 것이다(추가하면 위 세 곳이 즉시 red가 되므로 함께 고쳐야 한다). #### 6. Confirmed P3 — `change-streams=true`는 거부되지 않고 조용히 버려지며, 그 결과 startup validator의 한 분기가 production에서 도달 불가다 `MongoPlatformSettings`의 compact constructor는 세 입력을 서로 다르게 처리한다. ```java profiles = profiles == null ? Map.of() : Map.copyOf(profiles); // 흡수 changeStreams = false; // 무조건 덮어씀 if (requiredSecondaries < 0) { throw MongoOperationRejectedException.of(...); } // 거부 ``` `changeStreams` 자리의 주석은 이렇게 말한다 — "Accepting the flag and ignoring it would leave an operator believing it took effect, so **the value is refused rather than stored**: zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks honoured." 실제 동작은 refuse가 아니라 silent discard다. 임시 probe(`evidence/raw/124-...`, `124a-...`)로 세 입력을 실제 binding에 통과시켰다. ```text changeStreams.contextFailed=false changeStreams.boundValue=false transactions.contextFailed=false transactions.boundValue=true negativeSecondaries.contextFailed=true negativeSecondaries.failureType=dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException ``` 즉 같은 생성자 안에서 `required-secondaries=-1`은 예외로 거부되고, 형제 flag `transactions=true`는 그대로 보존되며, `change-streams=true`만 예외 없이 `false`가 된다. operator는 자기가 켠 것이 꺼졌다는 신호를 받지 못한다 — 주석이 막겠다고 한 바로 그 상태다. 파생 결과가 하나 더 있다. `MongoStartupValidator`는 `changeStreamsEnabled`가 참일 때 topology capability를 검사하는 분기를 갖는데(`MongoStartupValidator.java:104`), production 생성 지점은 `MongoPlatformAutoConfiguration.java:354` 하나뿐이고 거기서 넘기는 값은 `properties.changeStreams()`다. 그 값은 위에서 항상 `false`이므로 **이 분기는 shipped composition에서 도달할 수 없다**. 도달하는 유일한 경로는 validator를 직접 생성하는 `MongoStartupValidatorTest.java:143`이다. 근거: `123-...` §8.2b, §8.2c. **판정: P3 confirmed.** 현재 잘못된 동작을 만들지는 않는다 — change stream 실행체는 애초에 shipped되지 않는다고 CLAUDE.md가 명시한다. 문제는 (a) 문서가 refuse라고 말하는 것이 discard이고, (b) 그 결과 capability 검사 한 갈래가 test에서만 살아 있다는 점이다. 수정은 두 방향 중 하나다 — 값을 정말로 거부하거나(`requiredSecondaries`와 같은 형태), 아니면 flag를 record component에서 제거해 존재하지 않는 스위치로 만드는 것. #### 7. Negative-space probes — governance / opt-in scope 근거: `evidence/raw/123-mongo-optin-reachability-and-siblings.txt`. ##### 7.1 Public surface reachability 이 sub-scope의 production public type 13개 중 leaf 밖에서 참조되는 것은 둘뿐이다. | type | leaf 밖 참조 | |---|---| | `MongoPlatformHealthIndicator` | `app-bootstrap`의 `MongoPlatformHealthConfig`, `MongoPlatformHealthContributor` (+ 해당 test) | | `MongoRootAutoConfiguration` | `app-bootstrap`의 `ShippedRuntimeFacadePresenceTest` | | 나머지 11개 | 0 | zero-reference를 dead로 읽어서는 안 되는 경우가 여기 있다. `MongoRootAutoConfiguration`은 `META-INF/spring/…AutoConfiguration.imports`가, `MongoOptInAutoConfigurationImportFilter`는 `META-INF/spring.factories`가 이름으로 등록한다 — 두 resource 모두 이 sub-scope가 소유하며 manifest에 포함돼 있다. `MongoPersistenceConfig`/`MongoPlatformAutoConfiguration`/`MongoDriverObservabilityAutoConfiguration`은 root의 `@Import`로 도달하고, settings 세 종류는 `@EnableConfigurationProperties` 인자로 도달한다. 즉 이 sub-scope의 도달성은 Java import graph가 아니라 등록 metadata와 annotation 인자에 있으며, 정적 참조 검색만으로는 판단할 수 없다. ##### 7.2 Conditional sibling comparison 같은 master switch를 읽는 production 지점은 6곳이다 — root, persistence config, platform auto-config, driver observability auto-config, mapping configuration, advanced configuration. 앞의 넷은 §3의 계층별 차단이고, `MongoMappingConfiguration`과 `MongoAdvancedConfiguration`은 각각 sub-scope 3·10 소유이므로 그쪽에서 다시 본다. 이 sub-scope 범위에서는 조건 비대칭이 발견되지 않았다: 네 configuration이 모두 같은 prefix/name/havingValue를 쓴다. property record 쪽에서는 비대칭이 하나 있고 §6에서 다뤘다. ##### 7.3 Duplicate-mechanism sweep `ca-skeleton.persistence-mongo.*` namespace를 소유하는 주체가 셋이다. | key | 소유자 | 위치 | |---|---|---| | `.enabled` | `MongoPersistenceSettings` | leaf root | | `.platform.*` | `MongoPlatformSettings` | leaf `autoconfigure` | | `.advanced.*` | `MongoAdvancedSettings` / `MongoAdvancedCapabilityFlags` | leaf `advanced` (sub-scope 10) | | `.active-profile` | **없음** — `application.yml`이 노출하고 `CapabilityDependencyValidator`가 요구 | `app-bootstrap` | 경쟁 구현은 없다. 다만 마지막 행이 §4의 결함이다 — 한 namespace의 네 번째 key만 소유자가 leaf 밖에 있고 leaf 문서가 그것을 모른다. ##### 7.4 Documentation / measured-count drift §8에서 따로 다룬다. #### 8. Confirmed documentation / measured-count drift 근거: `evidence/raw/125-mongo-governance-doc-count-drift.txt`, `126-mongo-hermetic-lane-original-verification.txt`. | 항목 | 문서가 말하는 값 | 측정값 | 위치 | |---|---|---|---| | public top-level type / production 파일 | "311 of this leaf's 313 production files" | **346 / 351** | `build.gradle:260` | | hermetic contract test | "382 hermetic contract tests" | **526** (83 classes) | `build.gradle:87` | | registered leaf | 19 | **44** | `MongoModuleBoundaryTest.java:16`, `docs/mongodb/repository-adaptation.md:18`, `docs/adr/ADR-MONGO-001:61` | 앞의 두 건은 같은 파일 안에서 서로를 반박한다 — `build.gradle`은 311/313을 적으면서 그 아래 `apiSurface` 블록으로 `docs/architecture/mongo-api-surface.txt`를 baseline으로 지정하고, 그 baseline은 스스로 `# types: 346`을 적는다. `verifyMongoApiSurface`는 baseline과 실제 surface를 비교하므로 **green이면서 동시에** 주석의 숫자가 틀릴 수 있고, 실제로 그렇다(`126-...`: `verifyMongoApiSurface: OK — the committed public API surface is unchanged.`). contract test 수도 마찬가지다. 주석의 382는 두 lane이 겹쳐 돌던 시점의 값이고, 원본 상태에서 lane을 재실행한 측정값은 526이다. lane 분리 자체는 유효하다 — `verifyMongoTestLaneDisjointness`가 두 lane의 JUnit XML을 비교해 overlap 0을 확인하고 통과한다. 19-leaf claim은 persistence-jpa scope에서 확인한 것과 같은 사각지대다. `verifyDocumentedLeafCount`의 탐색 domain은 `CLAUDE.md`와 (root를 뺀) `build.gradle` 두 파일명뿐이라 `*.java`와 `docs/**`를 보지 않는다. 이 leaf 쪽 생존 지점 3곳이 그 domain 밖이다. **drift가 아닌 것도 기록한다.** README §의존성 경계는 "`MongoModuleBoundaryTest`(ArchUnit) 10개 규칙"이라고 쓰고 8개를 열거한다. 실제 파일의 `@Test`는 13개이며, 그중 10개가 방향 규칙(core-api framework 무의존, core-api ↛ 다른 platform package, Stable starter ↛ Advanced, Stable ↛ Advanced, imperative ↛ reactive, aggregation→query, production ↛ testkit, schema ↛ 실행 경로, observability→core-api only, migration ↛ engine adapter)이고 나머지 3개는 구조 검사(edge matrix가 디스크의 package 집합과 정확히 일치, 관측된 모든 edge가 선언된 것, 선언된 edge가 DAG)다. README의 "10개 규칙"은 방향 규칙 개수로 정확하다. #### 9. Sub-scope 01 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | leaf README의 활성화 recipe에 필수 `ca-skeleton.persistence-mongo.active-profile`이 빠져 있어, 그대로 따르면 `CapabilityDependencyStartupCheck`가 startup을 거부한다. 이 key를 언급하는 leaf 파일은 0개 | **문서를 따른 모든 신규 활성화** | | **P3** | `MongoNamespaceContractTest`의 domain(`src/main/**`의 java/yml/properties)이 leaf `CLAUDE.md`·`README.md`와 `src/test`를 덮지 않아, guard가 정의한 defect(문서가 operator에게 폐기 키를 지시)가 붙여넣기용 예제로 생존 | 문서 3곳 + 자기 leaf test 2곳; 런타임 영향 없음 | | **P3** | `MongoPlatformSettings`가 `change-streams=true`를 예외 없이 `false`로 덮어쓰면서 주석은 "refused"라고 서술. 형제 입력 `required-secondaries=-1`은 예외로 거부되고 `transactions=true`는 보존됨 | 모든 platform 설정 binding | | **P3** | 위의 결과로 `MongoStartupValidator`의 change-stream capability 분기가 production 생성 경로에서 도달 불가(production 생성 지점 1곳이 항상 `false`를 넘김) | test에서만 도달 | | **P3** | `build.gradle` 주석의 측정치 2건 drift — "311 of 313 production files"(실측 346/351), "382 hermetic contract tests"(실측 526) | 주석; gate는 green | | **P3** | 19-leaf claim 3곳(`MongoModuleBoundaryTest`, `docs/mongodb/repository-adaptation.md`, `ADR-MONGO-001`)이 registry 44와 불일치하며 `verifyDocumentedLeafCount`의 domain 밖 | 문서/주석 | #### 10. Fresh verification evidence — sub-scope 01 - `evidence/raw/126-mongo-hermetic-lane-original-verification.txt` — 원본 소스, `--rerun-tasks`, git clean before/after - `:adapter:outbound:persistence-mongo:test` — 14 classes / **72 tests** / 0 skipped / 0 failures - `:adapter:outbound:persistence-mongo:mongoStableContractTest` — 83 classes / **526 tests** / 0 skipped / 0 failures - `verifyMongoTestLaneDisjointness`, `verifyMongoReleaseContractLanes`, `verifyMongoApiSurface` 모두 통과(`verifyMongoApiSurface: OK — the committed public API surface is unchanged.`), 17 actionable tasks executed - `:app-bootstrap:test --tests '*CapabilityDependencyValidatorTest*'` — BUILD SUCCESSFUL (§4의 활성화 규칙이 실제로 강제됨을 확인) - `evidence/raw/124-...` / `124a-...` — platform settings binding probe 3 case, 임시 test는 실행 후 삭제하고 `git status --short` clean 확인 #### 11. Sub-scope 01 완료 조건 - denominator 31 / 31 FULL_READ (`122-...`) - opt-in 네 겹의 계층별 역할과 등록 metadata 도달성 확인(`123-...` §8.1) - conditional sibling(같은 master switch를 읽는 6개 production 지점, property record 3종)과 duplicate mechanism(`ca-skeleton.persistence-mongo.*` namespace 소유자 4주체) 비교 수행 - documentation/count drift 재측정(`125-...`)과 gate 실행 결과 대조(`126-...`) - 실행 probe 1건(`124-...`)으로 P3 확정, 원본 복구 후 git clean - original source hermetic lane 2종 + governance gate 3종 + 활성화 규칙 test 재실행 green #### 12. 다음 sub-scope로 넘긴 것 - `api/**` 61개 production type의 framework-free 계약과 `MongoModuleBoundaryTest`의 edge matrix 전수 대조 → sub-scope 2 - `MongoPlatformAutoConfiguration`이 등록하는 각 bean의 **구현** 정확성(consistency binder, imperative/reactive executor, atomic/bulk policy, budget enforcer, failure translator) → sub-scope 4·5·9 - change stream source/consumer 배선과 `changeStreams` flag의 관계 → sub-scope 8 - `MongoProfileProperties.validate()`가 강제하는 production 계약(TLS·인증·Stable API·topology·타임아웃)의 실제 검증 범위와 `security` package의 credential resolver → sub-scope 9 - Advanced capability gate(`@MongoAdvancedEntryPoint`, `MongoAdvancedRules`)와 flag binding → sub-scope 10 - testkit 35개와 6개 Docker lane, release contract manifest → sub-scope 11 --- #### 13. Sub-scope 02 범위와 denominator > 내부 상태: COMPLETE — **70 / 70 FULL_READ** > 범위: `src/main/java/**/api/**` 61개(2,687 LOC) + 전용 test 9개 > 역할: Spring·driver·BSON·Reactor 없이 platform의 의미론을 고정하는 core contract | sub-package | production | dedicated test | 역할 | |---|---:|---:|---| | `api` root | 7 | 2 | operation identity, 실행 context, profile 이름 | | `api.error` | 25 | 1 | 실행 결과·실패 분류·retry scope·예외 계층 | | `api.mapping` | 9 | 1 | BSON 표현 manifest | | `api.profile` | 5 | 1 | client plane, topology, Stable API 선언 | | `api.capability` | 5 | 2 | capability 보고 vocabulary | | `api.consistency` | 4 | 1 | consistency profile registry | | `api.schema` | 3 | 1 | document schema version 정책 | | `api.observation` | 3 | 0 | 관측 seam(no-op 포함) | | **합계** | **61** | **9** | **70** | manifest: `evidence/raw/127-mongo-api-scope-manifest.txt`. committed public API surface 346개 중 `...mongo.api.`로 시작하는 것은 **59개**다(61에서 `package-info.java`와 package-private `NoOpMongoOperationObserver`를 뺀 수). 즉 이 leaf가 공개하는 타입의 **17%만이 의도된 외부 계약**이고 나머지 287개는 build.gradle과 CLAUDE.md가 스스로 "implementation that has not been moved under an internal root yet"라고 부르는 것들이다. 이 숫자는 두 문서의 서술과 일치하며, `internal` root 이전이 끝났을 때 표면이 실제로 줄었는지 판정할 기준점이 된다. #### 14. framework-free 규칙은 ArchUnit과 별개로도 성립한다 `MongoModuleBoundaryTest.coreApiIsFreeOfSpringDriverBsonAndReactor()`가 이 규칙을 강제하지만, rule이 vacuous하게 통과하는 경우를 배제하기 위해 소스 자체를 직접 훑었다. ```text $ git grep -n 'import org\.springframework\|import com\.mongodb\|import org\.bson\|import reactor\.' -- '…/mongo/api' exit=1 ``` 61개 파일 전체에서 매치 0이다(`128-...` §8.1b). `api.observation`이 이 규칙의 비용을 가장 잘 보여 준다 — `MongoOperationObserver`는 core에 선언되고 Micrometer 구현은 경계 밖 `observation` package에 있으며, 그래서 실행 경로가 관측성 module에 의존하지 않고도 관측할 수 있다. `NoOpMongoOperationObserver`는 nullable 필드 대신 null object여서 "관측성 꺼짐" 경로가 켜짐 경로와 다른 코드로 갈라지지 않는다. `api/**`를 leaf 밖에서 참조하는 파일은 **0개**다(§8.1). 이것을 dead로 읽어서는 안 된다 — 이 leaf는 의도적으로 가짜 도메인을 두지 않고, README가 "실제 프로젝트가 자신의 document/repository/mapper와 port 구현을 추가한다"고 선언한다. 즉 `api`는 저장소 안에 소비자가 없는 것이 **설계된 상태**다. 한계는 그대로 남는다: 정적 검색은 이 저장소 밖 adopter를 증명하지도 반증하지도 않는다. #### 15. 이 sub-scope의 중심 설계 — 두 개의 모호한 결과를 무너뜨리지 않는 것 CLAUDE.md가 platform invariant로 못박은 문장이 여기 구현돼 있다 — "`MongoExecutionOutcome`'s two ambiguous values must not be collapsed into success or failure." `MongoExecutionOutcome`은 boolean이 아니라 7값 enum이고, `isAmbiguous()`(`WRITE_RESULT_UNKNOWN`, `TRANSACTION_COMMIT_UNKNOWN`)와 `forbidsBlindReplay()`(여기에 `PARTIAL_BULK_WRITE` 추가)를 구분한다. `READ_CONFIRMED`가 별도 값으로 존재하는 이유도 주석에 있다 — 두 executor가 성공한 `FIND`를 `WRITE_CONFIRMED`로 기록해 모든 read가 확인된 write처럼 보였던 과거 결함이다. 그리고 이 의미론이 무너지지 않게 하는 방어가 **예외 타입 두 개의 생성자**에 있다. - `MongoTransactionCommitUnknownException`은 context가 commit-unknown·ambiguous·non-retryable이 아니면 `IllegalArgumentException`으로 거부한다. - `MongoTransactionTransientException`은 반대로 context가 commit-unknown이거나 ambiguous이면 거부한다. 두 javadoc이 막으려는 과거 상태를 그대로 기록한다 — session factory가 `commitUnknown` context를 먼저 만든 뒤 classifier가 고른 예외로 감싸는 바람에 "body를 재실행하라"는 예외가 "unknown commit, not retryable, ambiguous"라는 context를 들고 다녔다. 지금은 factory와 생성자 검사가 그 조합을 불가능하게 만든다. production 경로도 일관적이다. `DefaultMongoFailureTranslator`는 `MongoFailureClassification`(category+outcome+retryScope 삼중항)을 먼저 만들고 `retryable`은 `classification.bodyReplayAllowed()`, `ambiguous`는 `classification.ambiguous()`에서 **파생**한다. 즉 두 boolean이 scope와 어긋날 여지가 production 경로에는 없다. #### 16. Confirmed P2 — schema version 실패는 두 경로 중 어느 쪽도 온전하지 않다 `MongoFailureCategory`에는 이 실패를 위한 전용 값 `SCHEMA_VERSION_UNSUPPORTED`("The stored document's schema version is outside the supported range")가 있고, 전용 예외 `MongoDataSchemaUnsupportedException`이 `documentVersion` / `minimumSupported` / `currentVersion` 세 정수를 공개 accessor로 노출한다. production 생성 지점은 정확히 둘이고, 각각 반쪽만 맞다. | 생성 지점 | category | 세 버전 값 | |---|---|---| | `MongoSchemaVersionPolicy:85` (버전을 실제로 아는 유일한 곳) | `MongoFailureContext.rejected(...)` → **`OPERATION_REJECTED`** / outcome `NOT_SENT` | 실제 값 | | `DefaultMongoFailureTranslator:111` (전용 category를 붙이는 유일한 곳) | **`SCHEMA_VERSION_UNSUPPORTED`** | **`-1, -1, -1`** | `MongoFailureCategory`의 클래스 javadoc은 category가 "the value that appears in metrics and dashboards"라고 명시한다. 따라서 실제로 발생하는 schema-version 실패는 대시보드에서 `OPERATION_REJECTED`(= 로컬 guardrail 거절) bin에 들어가고, `SCHEMA_VERSION_UNSUPPORTED` bin은 세 버전이 `-1`인 실패만 받는다. 두 신호 모두 운영자가 필요로 하는 답을 주지 못한다 — 앞은 "어떤 종류의 실패인가"를, 뒤는 "어떤 버전이 문제인가"를 잃는다. 근거: `evidence/raw/128-...` §8.2c. 수정은 작다 — `MongoSchemaVersionPolicy.unsupported(...)`가 `rejected(...)` 대신 category `SCHEMA_VERSION_UNSUPPORTED`를 가진 context를 만들면 되고, 그러면 translator 쪽 `-1` 경로는 도달 불가 분기로 정리할 수 있다. regression은 정책이 던진 예외의 `category()`가 `SCHEMA_VERSION_UNSUPPORTED`인지 보는 한 줄이다. 같은 형태가 하나 더 있다. `DefaultMongoFailureTranslator:106`은 `MongoDocumentTooLargeException`을 `-1L, -1L`로 만든다. `estimatedBytes()`/`budgetBytes()`의 javadoc은 "Estimated serialized size. A size, not content: safe to log."라고만 적고 값이 없을 수 있다는 말을 하지 않는다. driver가 보고한 실패에서는 그 두 수를 알 수 없으므로 sentinel 자체는 불가피하지만, 계약에 그 사실이 없다. P3. #### 17. Confirmed P3 — 예외 계층의 "cause를 붙이지 않는다" 규칙에 문서화되지 않은 예외가 하나 있다 `MongoPersistenceException`의 javadoc은 두 번째 규칙을 절대적으로 서술한다. > Second, **no constructor accepts a {@link Throwable} cause**: attaching the driver exception would re-expose everything the failure context deliberately dropped, through `getCause()` and through every stack trace printer. 하위 타입 20개 중 하나가 이 규칙을 벗어난다. `MongoTimeoutException`은 2-arg 생성자에서 `initCause(cause)`를 호출한다(`MongoTimeoutException.java:27`). 실제 유출 표면은 좁다. 그 생성자의 유일한 호출처는 `DefaultReactiveMongoExecutor:152`이고, 넘기는 값은 **Reactor 자신의** `java.util.concurrent.TimeoutException`이다 — driver 예외가 아니며 document·query·credential을 담지 않는다. 그리고 그렇게 감싸는 이유가 주석에 있다: 이전에는 raw `TimeoutException`이 그대로 새어 나가 operation도 outcome도 관측도 없이 호출자에게 도달했다. 문제는 계약 쪽이다. 규칙이 "어떤 생성자도 cause를 받지 않는다"로 쓰여 있으면 adopter는 `MongoPersistenceException`을 cause chain까지 통째로 로깅해도 안전하다고 읽는다. 그 판단의 근거가 되는 문장이 한 타입에 대해 거짓이고, 그 사실은 어디에도 적혀 있지 않다. 이 규칙을 검사하는 유일한 test는 `MongoFailureContextTest.exceptionsDoNotExposeADriverCause()`인데, 대상이 `MongoTransactionCommitUnknownException` — cause를 받는 생성자가 **없는** 타입이다. 즉 규칙은 그것을 깨지 않는 타입에 대해서만 단언되고, 유일하게 깨는 타입은 검사 밖이다. 근거: `128-...` §8.2b. 수정은 둘 중 하나다 — root javadoc을 "driver 예외를 cause로 붙이지 않는다"로 좁히고 `MongoTimeoutException`의 예외를 명시하거나, cause를 붙이지 않고 Reactor timeout의 정보를 failure context에 흡수시키는 것. 어느 쪽이든 test는 "모든 `MongoPersistenceException` 하위 타입에 대해 cause가 driver/BSON 타입이 아니다"로 넓혀야 규칙과 검사가 같은 것을 말한다. #### 18. Negative-space probes — api scope 근거: `evidence/raw/128-mongo-api-negative-space-probes.txt`. ##### 18.1 Public surface reachability `api/**` 참조는 leaf 밖에서 0이고(§14), 그것이 설계된 상태다. 대신 이 sub-scope에서 실제로 의미 있는 도달성 질문은 **api 타입을 소비하는 leaf 내부 경로가 존재하는가**였고, 확인한 것들은 다음과 같다: `MongoServerVersion` → `schema/validation/MongoValidatorApplyPolicy:54`(유일한 production 소비자), `MongoRetryScope` → `failure/MongoFailureClassification` + 두 transaction session factory + `transaction/retry/MongoRetryDecision`, `MongoFailureContext` factory 5종 → schema policy / type mapper / reactive executor / 두 session factory / retry coordinator. zero-consumer인 api 타입은 발견되지 않았다. ##### 18.2 Invariant sibling comparison 같은 성격의 타입들이 불변식을 얼마나 강제하는지 비교했다. | 타입 | 거부하는 것 | 거부하지 않는 것 | |---|---|---| | `MongoTransactionCommitUnknownException` | commit-unknown이 아닌 context | — | | `MongoTransactionTransientException` | ambiguous하거나 commit-unknown인 context | — | | `MongoFailureClassification` | `COMMIT_ONLY` + non-commit-unknown outcome | 그 외 조합 | | `MongoFailureContext` | null, attempt<1, 음수 elapsed | **outcome ↔ ambiguous 정합** | | `MongoConsistencyDescriptor` | causal session + non-majority concern | **secondaryPreferred + majority write** | | `MongoProfileProperties`(sub-scope 1) | production TLS/인증/topology/타임아웃 | — | 두 개의 빈칸이 이 sub-scope의 P3다. **(a) `MongoFailureContext`** — `outcome=WRITE_RESULT_UNKNOWN, ambiguous=false` 같은 조합을 canonical constructor가 막지 않는다. `MongoExecutionOutcome.isAmbiguous()`가 이미 있으므로 한 줄이면 강제된다. 다만 실제 위험은 제한적이다: production 경로는 classification에서 파생하고(§15), 가장 위험한 두 쌍은 예외 타입이 생성 시점에 거부한다. 남는 노출은 `api`가 외부 표면이라 adopter가 record를 직접 만들 수 있다는 점이다. **(b) `MongoConsistencyDescriptor`** — `MongoConsistencyProfile`의 javadoc은 "A caller that picks `majority` write concern and `secondaryPreferred` reads **has not chosen durability, it has chosen a bug**"라고 그 조합을 명시적으로 bug라 부른다. 그런데 record의 compact constructor는 causal-session 규칙 두 개만 검사한다. `MongoConsistencyRegistry.of(...)`는 public이고 javadoc이 "used by tests and by profile overrides"라고 적으므로, 그 조합을 담은 descriptor를 등록하는 경로가 타입 수준에서 열려 있다. `standard()`가 만드는 6개 profile은 모두 정합적이므로 현재 결함은 아니다. ##### 18.3 Duplicate-mechanism sweep **(a) 두 profile-name record가 검증 코드까지 동일하다.** `DatabaseProfileName`과 `CollectionProfileName`을 이름만 치환해 diff하면 남는 차이는 javadoc 문장뿐이고, `FORMAT`(`[a-z][a-z0-9-]{2,63}`)·`UUID_LIKE`·생성자 검사·`toString`이 모두 같다. 같은 규칙이 두 벌 유지되므로 한쪽만 강화하면 조용히 갈라진다. P3/기록. **(b) retry 의미론이 두 표현으로 존재한다.** `MongoRetryScope`의 javadoc은 "Encoding that as a scope rather than a `retryable` boolean is what stops the two from collapsing into one flag at the call site"라고 쓰는데, 같은 package의 `MongoFailureContext`는 정확히 `boolean retryable`을 필드로 갖는다. 다만 §15에서 확인했듯 production 경로에서 그 boolean은 scope에서 파생되고, 삼중항을 들고 다니는 타입(`MongoFailureClassification`)은 `api`가 아니라 `failure` package에 있다. 즉 이것은 결함이 아니라 **경계 배치의 결과**다 — framework-free core는 boolean만 들고, scope를 읽는 코드는 경계 밖에 있다. 기록만 한다. **(c) 자리표시자 profile 이름이 실제 이름의 값 공간을 공유한다.** `MongoOperationScope.UNSPECIFIED = "unspecified"`는 `DatabaseProfileName`의 `FORMAT`을 통과하는 평범한 값이라, `unspecified`라는 이름으로 실제 profile을 등록하면 `isProfileResolved()`가 그것을 미해결로 판정한다. 현재 그런 profile은 없다. P3/기록. ##### 18.4 Documentation / measured-count drift 이 sub-scope 범위에서 새로 확인된 drift는 없다. api 표면 기여 59/346은 §13에서 실측했고, build.gradle 주석의 311/313 drift는 sub-scope 01(§8)에서 이미 확정했다. #### 19. Sub-scope 02 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | schema version 실패의 두 생성 경로가 각각 반쪽만 맞다 — 버전을 아는 경로는 category `OPERATION_REJECTED`, 전용 category를 붙이는 경로는 버전 `-1,-1,-1` | production 두 경로 모두; 대시보드 bin과 공개 accessor 값 | | **P3** | 예외 계층의 "no constructor accepts a Throwable cause" 규칙을 `MongoTimeoutException`의 2-arg 생성자가 `initCause`로 벗어나며, 규칙을 검사하는 유일한 test는 cause 생성자가 없는 타입을 본다 | 유일 호출처의 cause는 Reactor `TimeoutException`이라 실제 payload 없음 | | **P3** | `MongoDocumentTooLargeException`이 translator 경로에서 `-1L, -1L`로 생성되며 accessor 계약이 값 부재를 말하지 않음 | driver 보고 실패 전체 | | **P3** | `MongoFailureContext`의 canonical constructor가 outcome ↔ ambiguous 정합을 강제하지 않음 | production은 classification에서 파생해 일관; 노출은 외부 adopter의 직접 생성 | | **P3** | `MongoConsistencyDescriptor`가 자기 enum javadoc이 "bug"라 부른 `secondaryPreferred` + `majority` write 조합을 거부하지 않음 | `MongoConsistencyRegistry.of(...)`는 public; `standard()`의 6개는 정합 | | **P3/기록** | `DatabaseProfileName`/`CollectionProfileName`의 검증 코드가 javadoc을 빼면 동일 | 한쪽만 강화하면 갈라짐 | | **P3/기록** | `MongoOperationScope.UNSPECIFIED` 자리표시자가 정상 profile 이름 값 공간과 겹침 | 현재 충돌하는 profile 없음 | #### 20. Sub-scope 02 완료 조건 - denominator 70 / 70 FULL_READ (`127-...`) - framework-free 규칙을 ArchUnit과 독립적으로 소스 전수 검색으로 재확인(매치 0) - public surface reachability(외부 0 — 설계된 상태이자 한계), invariant sibling 6종 비교, duplicate mechanism 3종, count 기여 59/346 측정 - 두 확정 finding(§16 P2, §17 P3)은 생성 지점·호출처·test 커버리지를 모두 지목해 근거화(`128-...`) - 이 sub-scope는 소스를 수정하지 않았고 별도 실행 probe도 필요하지 않았다 — 모든 판정이 정적으로 결정 가능하며, hermetic lane 재실행 결과는 sub-scope 01의 `126-...`이 이미 담고 있다 #### 21. 다음 sub-scope로 넘긴 것 - `MongoConsistencyBinder` / `ReactiveMongoConsistencyBinder`가 descriptor를 실제 driver 설정으로 번역하는 방식과 `MongoTemplateSupportContract` → sub-scope 4 - `failure` package의 classifier·translator·extractor 전체(§15에서 cross-scope 근거로만 읽었다) → sub-scope 9 - `MongoValidatorApplyPolicy`가 `MongoServerVersion`을 쓰는 방식과 schema/index manifest → sub-scope 7 - `mapping/type/PolicyAwareMongoTypeMapper`가 `MongoTypeRepresentationManifest`를 강제하는 실제 경로 → sub-scope 3 --- #### 22. Sub-scope 03 범위와 denominator > 내부 상태: COMPLETE — **27 / 27 FULL_READ** > 범위: `mapping/**` 13 + `nativecap/**` 5 + `geo/**` 5 (production 23, 1,502 LOC) + 전용 test 4 > 역할: api가 고정한 BSON 표현 manifest를 Spring Data 변환기에 실제로 강제하고, D3 native capability와 geospatial 경계를 정의한다 manifest와 probe: `evidence/raw/130-mongo-mapping-nativecap-geo-manifest-and-probes.txt`. 세 package의 배선 상태가 서로 다르다. 이것이 이 sub-scope를 읽는 축이다. | package | production 배선 | |---|---| | `mapping` | `MongoPlatformAutoConfiguration:48`이 `@Import(MongoMappingConfiguration.class)` — **platform이 켜지면 항상 조립된다** | | `geo` | 자기 package 밖 production 참조 **0** — bean도 소비자도 없다 | | `nativecap` | 자기 package 밖 production 참조 **0** — bean도 소비자도 없다 | #### 23. Confirmed P1 — shipped default 조합이 첫 write에서 예외를 던진다 세 사실이 겹친다. 1. `MongoMappingConfiguration.mongoTypeMetadataRegistry()`가 **비어 있는** `MongoTypeMetadataRegistry.empty()`를 기본 bean으로 등록한다. javadoc: "An empty registry so a deployment with no long-lived collection still starts." 2. `MongoTypeMetadataConfigurer.afterPropertiesSet()`가 `PolicyAwareMongoTypeMapper`를 **모든** `MappingMongoConverter`에 무조건 설치한다(`converters.forEach(converter -> converter.setTypeMapper(typeMapper))`). 3. `PolicyAwareMongoTypeMapper.writeType(...)`은 등록되지 않은 타입에 대해 **`IllegalStateException`을 던진다** — "no type metadata policy is registered for …; a stored document's type metadata outlives the class, so the policy is a decision to record rather than to default". 즉 module을 켜기만 하고 type metadata를 등록하지 않은 배포는 **시작은 하고 첫 write에서 실패한다.** ##### 실행 probe `evidence/raw/129-mongo-empty-type-registry-write-probe.txt` / `129a-...java`. 실제 `MappingMongoConverter`에 shipped default 조합(빈 registry + policy-aware mapper)을 설치하고 평범한 document를 썼다. ```text emptyRegistry.rootWrite=IllegalStateException: no type metadata policy is registered for …$ProbeDocument; … emptyRegistry.nestedWrite=IllegalStateException: no type metadata policy is registered for …$ProbeDocument; … springDefault.rootWrite=written keys=[_id, value, _class] ``` 같은 converter에 Spring 기본 type mapper를 두면 같은 write가 성공한다. 즉 실패는 문서·엔티티 형태가 아니라 이 leaf가 설치한 mapper에서 온다. ##### 같은 컴포넌트가 같은 질문에 세 가지로 답한다 probe는 그 불일치도 함께 측정했다. ```text emptyRegistry.policyFor=CLASS_METADATA_ALLOWED emptyRegistry.writeTypeRestrictions={"_class": {"$in": ["…$ProbeDocument"]}} emptyRegistry.writeType=IllegalStateException ``` | 물음 | 답 | 근거 | |---|---|---| | 미등록 타입의 정책은? | `CLASS_METADATA_ALLOWED` | `MongoTypeMetadataRegistry.policyFor` (javadoc: "unregistered types keep Spring Data's default") | | 미등록 타입으로 type-restricted **query**를 만들면? | Java class name을 `_class` predicate에 씀 | `PolicyAwareMongoTypeMapper:134` `orElse(CLASS_METADATA_ALLOWED)` | | 미등록 타입을 **write**하면? | 예외 | 같은 클래스 `:75` `orElseThrow(...)` | 읽기 경로와 쓰기 경로가 같은 정책 질문에 정반대로 답하고, 그중 어느 쪽도 registry가 스스로 문서화한 기본값과 일치하지 않는다. ##### 왜 지금까지 드러나지 않았나 이 leaf는 가짜 도메인을 두지 않으므로 저장소 안에 document type이 하나도 없고, 따라서 이 경로를 밟는 저장소 내부 코드가 없다. 그리고 `PolicyAwareMongoTypeMapperTest`는 mapper를 항상 **채워진** registry(`fromAnnotations(List.of(LongLivedOrder, ShortLivedAudit))`)로 만든다 — shipped default인 빈 registry로 `writeType`을 부르는 test는 없다. **판정: P1 conditional-production.** 저장소 안에서는 재현되지 않지만, README가 서술한 정상 사용법(`enabled=true` + fork가 자기 document를 추가)을 그대로 따르면 첫 write에서 반드시 발생한다. 수정 방향은 둘 중 하나이고 어느 쪽이든 세 답을 하나로 만들어야 한다 — `writeType`도 `policyFor`처럼 `CLASS_METADATA_ALLOWED`로 떨어뜨리거나(레거시 허용), 기본 bean을 "미등록이면 실패"가 아니라 "등록을 요구하는 명시적 opt-in"으로 바꾸거나. regression은 빈 registry로 `MappingMongoConverter.write(...)`를 부르는 한 줄이면 된다. #### 24. mapping의 나머지는 manifest를 실제로 강제한다 P1과 별개로, 이 package의 나머지는 api manifest를 말이 아니라 코드로 만든다. - `MongoCustomConversionsFactory.converters(...)`가 변환기를 **명시적 List 순서로** 조립한다. 이유가 주석에 있다 — Spring의 conversion service는 첫 매칭 변환기를 쓰므로 `Set`이나 classpath 스캔에서 조립하면 JVM 실행마다 다른 변환기가 선택될 수 있다. `fingerprint(manifest)`가 manifest fingerprint에 변환기 클래스 이름을 이어 붙여 golden BSON snapshot이 비교할 identity를 만든다. - 같은 factory가 `requireEveryAxisImplemented(...)`로 `LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER`를 startup에서 거부한다. enum 상수 자신이 "selecting this without registering the named converter is a startup failure"라고 적어 둔 규칙을 실제로 집행하는 지점이다. - `BigIntegerRepresentationConverters.forRepresentation(...)`은 manifest의 BigInteger 축을 세 변환기 쌍으로 컴파일한다. 주석이 과거 상태를 기록한다 — 이 축은 선언만 있고 컴파일되지 않아 `STRING`과 `DECIMAL128`이 동일한 document를 만들었고, 하나는 사전식으로 다른 하나는 수치로 정렬된다. - `LocalDateTimeMappingGuard`는 `MongoMappingConfiguration`이 **실제 등록된 변환기**로 만든다. javadoc이 이전 결함을 적는다 — guard를 `withoutConverters()`로 만들고 manifest를 검증하게 해서, 명명된 변환기를 등록한 배포와 등록하지 않은 배포를 똑같이 거부했다. - `BigDecimalToDecimal128Converter`는 driver 호출 전에 34 유효숫자·지수 범위를 검사한다. `Decimal128`은 초과 정밀도를 조용히 반올림하므로, 검사가 없으면 금액이 다른 값으로 저장되고 아무 오류도 나지 않는다. `PolicyAwareMongoTypeMapper`의 alias 규칙도 견고하다. alias에 점을 금지하고, 읽을 때 점의 유무로 "legacy class name"과 "alias"를 구분한다 — 그래서 미등록 alias가 class loading으로 fallback해 저장된 문자열이 어떤 클래스를 인스턴스화할지 결정하는 일이 없다. `readType(source, basicType)`은 저장된 타입이 caller의 기대 타입과 호환되지 않으면 조용히 caller 타입으로 읽지 않고 schema 오류를 던진다. #### 25. Confirmed P2 — D3 gateway가 문서화한 검사 순서에 존재하지 않는 단계가 있다 `PolicyAwareMongoNativeGateway`의 javadoc은 이렇게 쓴다. > Runs the design's stated sequence and stops at the first refusal: registration, capability, database profile, collection profile, **timeout**, category, then execution. README는 더 긴 목록을 제시한다. > `PolicyAwareMongoNativeGateway`가 capability → database profile → collection allowlist → operation name → **timeout** → **consistency** → **result limit** → **trace** → **redaction** → command category → D4 차단 순서를 고정한다. 실제로 `MongoNativeOperationPolicy.require(...)`가 수행하는 거부는 여섯 개다 — 등록 여부, 등록된 capability와 제출된 capability의 일치, capability support level, database profile allowlist, collection profile allowlist, category(ADMIN 차단). gateway 자신은 `policy.require(operation)` → body 실행 → audit 기록만 한다. 빠진 것 중 두 개는 `ApprovedMongoNativeOperation`이 **필드로 선언까지 해 둔** 값이다. ```text $ git grep -n 'operation.timeout()\|\.hasBody()' -- src/main …/nativecap/ApprovedMongoNativeOperation.java:64: public boolean hasBody() { ← 정의뿐, 호출자 없음 $ git grep -n 'operation.maxResults()' -- src/main exit=1 ``` `timeout`은 생성자에서 음수만 거부하고 어디서도 적용되지 않으며, `maxResults`는 production에서 한 번도 읽히지 않는다(같은 이름의 `maxResults()` 호출들은 전부 `MongoOperationBudget`이라는 **다른** 타입의 것이다). consistency·result limit·trace·redaction 단계는 코드에 존재하지 않는다. 현재 노출은 없다 — `MongoNativeCapabilityGateway`와 `PolicyAwareMongoNativeGateway`는 production 참조가 0이고 어떤 configuration도 bean으로 만들지 않는다(§22). 그러나 README는 이 클래스를 "D3는 raw client escape가 아니다"라는 주장의 근거로 제시한다. fork가 이것을 그대로 배선하면 문서가 약속한 11단계 중 6단계만 동작하고, 그 사실은 코드를 읽어야만 드러난다. **판정: P2.** 수정은 문서를 실제 검사로 줄이거나(정직), 선언된 `timeout`/`maxResults`를 gateway가 실제로 적용하도록 만드는 것이다. 후자를 택하면 `hasBody()`가 처음으로 호출자를 갖게 된다. #### 26. geo는 index 전제를 스스로 확인하지만 배선되지 않았다 `SpringMongoGeospatialOperations`는 dispatch 전에 manifest에서 해당 필드의 `2dsphere` index를 찾고 없으면 거부한다. 이유가 정확하다 — MongoDB는 index 없는 `$near`는 거부하지만 `$geoWithin`은 거부하지 않고 collection scan으로 조용히 성공한다. 두 경우를 같은 시점에 같은 메시지로 실패시키는 것이 이 검사의 목적이다. `MongoGeoPoint`는 GeoJSON의 longitude-first 순서를 record component 이름으로 못박고 범위를 검증한다. `MongoGeoDistance`는 단위를 타입에 넣는다 — spherical 연산자는 미터, legacy 연산자는 radian, Spring Data는 metric을 받으므로 맨 `double`은 600만 배 틀린 채로도 결과를 돌려준다. `toMeters()`와 `toSpringDistance()`의 두 단위 변환을 직접 검산했고 오류는 없다. `MongoGeoQuery`는 최대 거리와 결과 상한(≤500)을 둘 다 필수로 만든다. `$near`는 collection 전체를 거리순으로 정렬해 스트리밍하므로 거리 경계가 없으면 "가까운 것부터 반환하는 full scan"이 된다. 이 package 역시 production 참조 0이다. geo는 README의 package 지도에 "GeoJSON / 2dsphere"로만 적혀 있고 배선을 주장하지 않으므로, nativecap과 달리 **문서와 코드가 어긋나지는 않는다**. 기록만 한다. #### 27. Negative-space probes — sub-scope 03 - **8.1 reachability**: `mapping`은 platform auto-configuration이 import(배선됨), `geo`·`nativecap`은 production 참조 0(미배선). 세 결과 모두 `130-...` §8.1에 명령·exit code와 함께 있다. - **8.2 sibling comparison**: 같은 "미등록 타입" 질문에 대한 세 답(§23). 그리고 `mapping`의 두 guard(`LocalDateTimeMappingGuard`, `requireEveryAxisImplemented`)는 startup에서 거부하는 반면 type metadata 정책은 write 시점에 거부한다 — 같은 종류의 계약 위반이 서로 다른 시점에 잡힌다. - **8.3 duplicate mechanism**: 결과 상한을 뜻하는 `maxResults()`가 두 타입에 있다 — `ApprovedMongoNativeOperation`(미사용)과 `MongoOperationBudget`(query·aggregation·cursor에서 실제 사용). 이름이 같고 하나만 살아 있다. - **8.4 documentation drift**: §25의 D3 순서. 그 밖에 이 sub-scope 범위에서 새 수치 drift는 없다. #### 28. Sub-scope 03 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1 conditional-production** | shipped default(빈 type metadata registry + 무조건 설치되는 policy-aware mapper)에서 미등록 타입의 write가 `IllegalStateException`. 같은 컴포넌트가 미등록 타입에 대해 세 가지로 답한다 | platform을 켠 모든 배포의 첫 write; 저장소 안에는 document type이 없어 내부 재현 없음 | | **P2** | D3 gateway가 문서화한 검사 순서(javadoc 7단계 / README 11단계) 중 실제 존재하는 것은 6개. 선언된 `timeout`·`maxResults`는 production에서 한 번도 읽히지 않음 | gateway 자체가 미배선이므로 현재 노출 0 | | **P3/기록** | `geo` package가 완전히 미배선(bean 0, 소비자 0) — 다만 문서가 배선을 주장하지 않아 drift는 아님 | fork가 배선할 때 사용 | | **P3/기록** | `maxResults()`라는 같은 이름의 결과 상한이 두 타입에 존재하고 하나만 사용됨 | 혼동 | #### 29. Sub-scope 03 완료 조건 - denominator 27 / 27 FULL_READ (`130-...`) - reachability·sibling·duplicate·drift 4종 probe 수행 - P1을 실행 probe로 확정(`129-...`, `129a-...`), 임시 test 삭제 후 `git status --short` clean - geo 단위 변환 2종은 코드로 직접 검산했고 오류 없음을 기록 --- #### 30. Sub-scope 04 범위와 denominator > 내부 상태: COMPLETE — **61 / 61 FULL_READ** > 범위: `imperative/**` 34 + `reactive/**` 13 (production 47, 3,369 LOC) + 전용 test 14 > 역할: 모든 operation이 통과하는 실행 scope — collection 해석, consistency 바인딩, 관측, 실패 번역, 그리고 atomic/bulk/revision/cursor 경로 manifest와 probe: `evidence/raw/131-mongo-execution-paths-manifest-and-probes.txt`. 배선 상태(§8.1): | 타입 | production bean | |---|---| | `DefaultMongoImperativeExecutor` | ✓ `MongoPlatformAutoConfiguration:114` | | `MongoAtomicOperationsTemplate` | ✓ `:148` | | `MongoBulkExecutor` | ✓ `:166` | | `DefaultReactiveMongoExecutor` | ✓ `:293` (reactive template이 bean일 때) | | `VersionedMongoUpdater` | ✗ bean 없음 | | `MongoCursorGuard` | ✗ bean 없음 | #### 31. 실행 scope의 고정된 순서가 이 sub-scope의 중심이다 `DefaultMongoImperativeExecutor.executeInternal(...)`은 순서를 고정한다 — collection profile 해석 → observation 개시 → consistency 바인딩 → callback 실행 → 실패 번역(최대 한 번) → observation 종료. javadoc이 이유를 적는다: "Fixing it here is what makes the invariants hold for operations nobody has written yet." 세 가지 방어가 눈에 띈다. - 이미 번역된 `MongoPersistenceException`은 그대로 통과시킨다. 재번역하면 bulk partial failure나 guardrail 거절처럼 **그것을 던진 계층이 더 잘 아는** category를, driver 코드에서 유도한 일반 category로 덮어쓰게 된다. - Spring이 감싼 driver 예외를 `unwrap(...)`으로 되꺼낸다. Spring의 번역은 error label을 잃는데, label이야말로 replayable transaction과 unknown commit을 가르는 값이다. - `MongoCompletion.successOutcomeFor(operationType)`가 read와 write의 성공 outcome을 나눈다. 과거에는 두 executor 모두 성공을 `WRITE_CONFIRMED`로 기록해, "write가 acknowledge되고 있는가"를 답하는 지표가 read 트래픽의 함수가 됐다. `default` 분기가 `READ_CONFIRMED`로 떨어지는 것도 의도적이다 — "the honest answer is the one that claims least". `MongoCollectionProfileRegistry`가 "동적 collection 이름 금지"를 강제 가능하게 만드는 지점이다. 애플리케이션은 profile을 부르고 물리 이름은 이 registry만 안다. `ScopedAccess.collection(String)`은 요청된 collection이 scope의 것과 다르면 거부하고, `ScopedMongoOperations`의 어떤 메서드도 collection 인자를 받지 않으므로 그 검사를 우회할 방법이 없다. `MongoConsistencyBinder`는 profile마다 **파생 template**을 생성 시점에 한 번 만든다. `MongoTemplate.setWriteConcern`은 애플리케이션이 공유하는 bean을 변형하므로, 호출마다 설정했다면 다른 스레드의 durability를 바꿨을 것이다. 파생은 Spring Data의 public setter로 원본의 contract(entity callback, auditing, event publisher, write-concern resolver, write-result checking)를 옮긴다 — javadoc이 과거 결함을 기록한다: bare `new MongoTemplate(factory, converter)`로 파생해 같은 entity가 platform executor 경로와 repository 경로에서 서로 다른 document가 됐다. #### 32. Confirmed P2 — 서버 측 deadline이 경로마다 다르게 적용되고, 문서가 지목한 메커니즘은 production 호출자가 0이다 `BoundScopedOperations`의 javadoc은 이 클래스의 존재 이유를 명확히 쓴다. > Every query-shaped method also carries the operation's deadline as `maxTimeMS`, and **that is the difference between a deadline and a report about one**. The blocking executor could only measure elapsed time after the callback returned … so an operation that ran past its budget was detected, never stopped. Sent to the server, the same number ends the work. 측정 결과 이 메커니즘은 `MongoPlatformCollectionAccess.scoped()`를 통해서만 도달하고, **production에서 `scoped()`를 부르는 곳은 0개**다(`131-...` §8.2). 반면 platform이 소유한 세 executor는 전부 `rawOperations()`를 쓴다 — `MongoAtomicOperationsTemplate`(2곳), `MongoBulkExecutor`(1곳), `SpringMongoGeospatialOperations`(2곳). `rawOperations()`는 경계 없는 `MongoOperations`를 그대로 돌려준다. 서버 측 deadline을 실제로 붙이는 다른 경로들은 **다른 어휘**를 쓴다. | 경로 | 서버에 보내는 deadline | |---|---| | aggregation (`PolicyAwareMongoAggregationExecutor:96,106`) | `Math.min(registered.maxTimeMillis(), contextMillis)` — 둘을 조정 | | query builder (`PolicyAwareMongoQueryBuilder:200`) | `budget.maxTimeMillis()` 단독 | | reactive cursor (`MongoReactiveCursorPublisher:58`) | `budget.maxTimeMillis()` 단독 | | atomic / bulk / geospatial | **없음** | | caller callback via `scoped()` | `context.timeout()` — production 호출자 0 | 즉 `MongoOperationContext.timeout`(모든 operation이 반드시 선언하는 값)이 서버에 도달하는 경로는 aggregation 하나뿐이고, 그것도 budget과의 최소값으로만 도달한다. atomic·bulk·geospatial에서는 executor의 사후 elapsed 검사만 남는데, 그 검사의 주석 자신이 "detected, never stopped"라고 인정한다. **판정: P2.** 데이터 손상은 아니지만 platform이 스스로 선언한 자원 경계가 자신의 세 실행 경로에서 서버에 도달하지 않는다. 수정은 `MongoPlatformCollectionAccess`가 `rawOperations()` 대신 deadline이 붙은 접근자를 내보내거나, 세 executor가 query를 만들 때 `context.timeout()`을 붙이는 것이다. #### 33. P3 — timeout 초과 경로가 한 observation에 success와 failure를 모두 기록한다 같은 executor의 elapsed 검사 분기는 이렇게 쓰여 있다. ```java if (elapsed.compareTo(context.timeout()) > 0) { observation.success(outcome); throw MongoOperationRejectedException.of(...); } ``` `MongoOperationRejectedException`은 `MongoPersistenceException`의 하위 타입이고, 이 throw는 같은 `try` 블록 안에 있으므로 바로 다음 `catch (MongoPersistenceException alreadyTranslated)`가 잡아 `observation.failure(...)`를 호출한 뒤 다시 던진다. 결과적으로 하나의 observation에 `success`와 `failure`가 차례로 호출된다. shipped 구현에서는 무해하다. `MicrometerMongoOperationObserver`의 observation은 `success`/`failure`가 `outcomeTags` 필드를 덮어쓸 뿐이고 timer는 `close()`에서 한 번만 정지하므로, 마지막 호출인 failure의 tag로 한 번 기록된다. 문제는 계약이다 — `MongoOperationObservation` 인터페이스는 둘 중 하나만 호출해야 한다거나 마지막 호출이 이긴다는 규칙을 말하지 않는다. 두 호출을 각각 계수하는 구현을 fork가 만들면 이 경로의 operation이 두 번 계수된다. P3. #### 34. atomic / bulk / revision — 닫힌 우회로들 이 세 package는 과거에 열려 있던 우회로를 닫은 기록을 코드에 남긴다. - **bulk가 atomic의 정책을 우회하던 문제.** `MongoBulkExecutor`의 생성자 javadoc이 기록한다 — 단일 문서 경로는 filter/update를 collection 정책에 대조했고 bulk 경로는 정책을 보지 않았으며, 정책은 기본값 없음인 **선택적** 생성자 인자였다. 같은 update를 배치에 넣으면 보호 필드와 미등록 연산자에 도달할 수 있었다. 지금은 생성자가 하나뿐이고 배치 전체를 dispatch 전에 검증한다("an ordered batch that fails halfway leaves the earlier items applied"). - **bulk 실패에서 per-item 정보를 잃던 문제.** `catch (MongoBulkWriteException)`는 Spring Data가 감싼 실패를 놓쳤고, caller에게는 per-item index 없는 일반 오류 하나가 갔다 — 이 result 타입이 존재하는 바로 그 이유가 사라진 셈이다. 지금은 `RuntimeException`을 잡고 `SpringDataBulkFailureExtractor`로 안쪽의 driver 실패를 찾는다. - **unacknowledged bulk 결과.** `wasAcknowledged()`가 false면 성공 0으로 보고하지 않고 `MongoBulkResult.unknown(...)`을 돌려준다. 주석: "Reporting zero successes would be a claim, and re-sending on that claim duplicates whatever did apply." - **revision 재시도.** `VersionedMongoUpdater.applyWithRetry`는 시도마다 문서를 다시 읽고 caller의 계산을 다시 실행한다. 이전에 계산된 update를 재전송하는 재시도는 stale state에서 유도된 값을 쓰는 것이고, 그것이 revision predicate가 막으려던 lost update가 재시도 경로로 되돌아오는 형태다. **두 개의 빈 registry 기본값이 서로 다른 실패 모양을 갖는다**(§8.3). `MongoAtomicPolicyRegistry.empty()`는 `MongoPlatformAutoConfiguration`이 기본 bean으로 등록하고, javadoc이 "empty means every atomic and bulk operation is refused rather than permitted"라고 명시하며, 실제 거부도 platform 어휘인 `MongoOperationRejectedException`이다. 같은 configuration이 등록하는 `MongoTypeMetadataRegistry.empty()`는 §23에서 본 대로 Spring Data converter 깊은 곳에서 `IllegalStateException`으로 실패하고, 그 사실은 어디에도 적혀 있지 않다. 같은 설계 의도(미등록은 거부)가 한쪽에서는 문서화된 fail-closed로, 다른 쪽에서는 문서화되지 않은 런타임 예외로 나타난다. #### 35. reactive 경로가 명시적으로 배치한 세 가지 `DefaultReactiveMongoExecutor`의 javadoc이 blocking 경로가 공짜로 얻는 것과 여기서 직접 배치해야 하는 것을 대비한다. - observation scope를 Reactor 자원(`Mono.using`/`Flux.using`)으로 두어 완료·오류·**취소** 모두에서 닫는다. HTTP 클라이언트 연결 해제가 취소를 일으키므로 취소가 흔한 경우다. - timeout을 조립된 publisher에 적용한다. 구독 전에 적용하면 "람다를 만드는 데 걸린 시간"을 재게 된다. - context를 Reactor Context로 옮긴다(`ReactiveMongoContextKeys`). 체인은 operator 경계마다 스레드를 바꾸므로 구독 시점의 `ThreadLocal`은 driver 응답 시점에 이미 없다. 기록해 둘 관측 하나: `executeMany(...)`는 성공을 `doOnComplete`로 기록하므로 **취소된 stream은 success도 failure도 기록하지 않는다.** observation은 `close()`되고 초기 tag(`result=unknown`, `failureCategory=none`)로 한 번 계수된다. 취소가 흔한 경로라는 점을 감안하면 이는 의도된 분류로 보이지만, `result=unknown` bucket이 "취소"와 "관측 시작 직후 예외"를 함께 담는다는 사실은 계약에 없다. P3/기록. #### 36. Negative-space probes — sub-scope 04 - **8.1 reachability**: 6개 주요 타입 중 4개가 bean, `VersionedMongoUpdater`·`MongoCursorGuard`는 미배선(fork 공급). - **8.2 deadline**: §32. `scoped()` production 호출자 0, `rawOperations()` 5곳, `maxTime` 계열 6곳이 세 어휘로 갈림. - **8.2b observation**: §33. - **8.3 duplicate/sibling**: 두 빈 registry 기본값의 실패 모양 차이(§34). 그리고 atomic·bulk가 **같은** `MongoAtomicPolicyRegistry`를 공유하도록 강제된 것은 닫힌 우회로의 증거로 기록. - **8.4 drift**: 이 sub-scope 범위에서 새 수치 drift 없음. #### 37. Sub-scope 04 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `context.timeout()`이 서버에 도달하는 경로가 aggregation 하나뿐. atomic·bulk·geospatial은 `rawOperations()`로 deadline 없이 실행되고, 이를 위해 만들어진 `BoundScopedOperations`는 production 호출자가 0 | platform이 소유한 세 실행 경로 전부 | | **P3** | timeout 초과 분기가 한 observation에 `success`와 `failure`를 연달아 호출. 인터페이스는 어느 쪽이 이기는지 말하지 않으며 shipped observer만 마지막 호출로 해소 | 모든 timeout 초과 operation | | **P3/기록** | 취소된 reactive stream이 `result=unknown` bucket에 들어가며 그 사실이 계약에 없음 | 취소가 흔한 reactive 경로 | | **P3/기록** | 같은 configuration이 등록하는 두 빈 registry 기본값의 실패 모양이 다르다(atomic=문서화된 platform 거부, type metadata=문서화되지 않은 `IllegalStateException`) | §23의 P1과 같은 뿌리 | #### 38. Sub-scope 04 완료 조건 - denominator 61 / 61 FULL_READ (`131-...`) - reachability·deadline·observation·sibling 4종 probe 수행, 모든 명령과 exit code 보존 - P2는 `scoped()`/`rawOperations()`/`maxTime` 세 검색의 교차로 확정했고 실행 probe 없이 정적으로 결정 가능 - 소스 미변경, `git status --short` clean 유지 --- #### 39. Sub-scope 05 범위와 denominator > 내부 상태: COMPLETE — **29 / 29 FULL_READ** > 범위: `query/**` 17 + `aggregation/**` 5 (production 22, 2,082 LOC) + 전용 test 7 > 역할: 동적 query를 allowlist로 표현 가능하게 만들고, budget·keyset pagination·aggregation stage 정책을 고정한다 manifest와 probe: `evidence/raw/132-mongo-query-aggregation-manifest-and-probes.txt`. #### 40. 이 sub-scope의 설계는 "표현 가능한 query 집합 = 검토된 집합"이다 `MongoQueryPolicy`와 `PolicyAwareMongoQueryBuilder`가 이 leaf에서 가장 직접적인 보안 장치다. builder는 caller가 준 BSON/JSON을 **파싱하지 않는다**. 모든 predicate는 등록된 field path와 등록된 operator를 지목하고, 그 둘이 policy에 없으면 로컬에서 거부된다 — 그래서 NoSQL operator injection이 검증 문제가 아니라 표현 불가능성이 된다. denylist가 아니라 allowlist인 이유도 적혀 있다: "A denylist has to anticipate the next operator MongoDB adds; an allowlist does not." 세부도 촘촘하다. - `requireSortable`은 등록된 필드라도 sortable이 아니면 거부한다 — 인덱스 없는 sort는 메모리에서 수행되고 sort buffer를 넘기면 실패하기 때문이다. - `requireSkipWithinThreshold`는 deep skip(기본 1000 초과)을 keyset pagination으로 밀어낸다. - `build(budget)`가 유일한 종료 지점이고, 거기서 `limit` / `maxTimeMsec` / `cursorBatchSize`가 반드시 붙는다 — "a query without a result limit and a `maxTimeMS` is a query with no upper bound on what it can consume". - regex는 세 갈래로 나뉜다. `whereStartsWith`/`whereContains`는 caller의 텍스트를 `Pattern.quote`로 escape해 **문법을 기여할 수 없게** 만들고, 전자는 anchored(인덱스 사용 가능), 후자는 unanchored(scan)로 비용이 호출 지점에 드러난다. `whereMatches`만 문법을 받는다. `MongoRegexPolicy`의 정직함은 기록해 둘 만하다. javadoc이 nested-quantifier 검사가 **안전 증명이 아니라 필터**라고 명시하고, alternation·`?`·back-reference로 생기는 catastrophic backtracking을 보지 못한다고 스스로 적는다. 이런 자기 한정은 이 저장소 전체에서 드물지 않지만, 보안 경계에서 특히 유용하다. `MongoKeysetCursorCodec`도 마찬가지로 촘촘하다. cursor는 클라이언트를 왕복하는 attacker-controlled 입력이므로 HMAC-SHA256으로 서명하고 상수시간 비교로 검증하며, 실패 메시지를 하나로 통일해 오류로부터 키나 형식을 배우지 못하게 한다. 값은 **타입 태그 + 길이 프레이밍**으로 인코딩된다 — 과거에는 `toString()`으로 렌더링하고 `String`으로 복원해서, `Instant`/`ObjectId`/UUID/숫자가 텍스트로 비교되어 다음 페이지가 비거나 행을 건너뛰거나 반복했고 아무 오류도 나지 않았다. 구분자 대신 길이 프레이밍인 이유도 같다: "a delimiter chosen from an alphabet a value can contain is not a delimiter". `MongoKeysetQueryBuilder.resumeCriteria`는 사전식 "strictly after"를 전개해서 쓴다. javadoc이 흔한 축약형(`a <= A AND _id < I`)이 왜 틀리는지 적는다 — `a`가 더 작고 `_id`가 더 큰 행을 전부 잃고, 그 증상은 목록 중간에 행이 사라지는 형태라 production에서 오래 살아남는다. #### 41. Confirmed — 이 sub-scope는 정책과 값 객체이고, 배선된 것은 하나뿐이다 auto-configuration이 이 sub-scope에서 만드는 bean은 **`MongoBudgetEnforcer` 하나**다(`132-...` §8.1). `MongoQueryPolicy`·`PolicyAwareMongoQueryBuilder`·`MongoRegexPolicy`·`MongoBudgetPolicyRegistry`·`MongoKeysetCursorCodec`·`PolicyAwareMongoAggregationExecutor`는 bean도 아니고 `main` 안에 소비자도 없다(§8.1 세 번째 검색 exit=1). 그 하나조차 짝이 없다. `MongoBudgetEnforcer`의 유일한 production 소비자는 `PolicyAwareMongoAggregationExecutor`인데 그것이 미배선이므로, 배선된 enforcer는 현재 아무도 호출하지 않는다. `MongoKeysetCursorCodec`은 32바이트 이상 서명 키를 요구하는데 그 키를 공급하는 production 코드가 없다 — 생성자 호출은 test 3곳뿐이다. 이것 자체는 결함이 아니다. 이 leaf는 가짜 도메인을 두지 않고 collection profile·field descriptor·budget을 fork가 선언하도록 설계돼 있으며, CLAUDE.md가 "Real forks add their own document, repository, mapper"라고 명시한다. 기록하는 이유는 두 가지다. (a) README의 D1/D2 표는 "typed query, mapping manifest, atomic update, optimistic revision"을 노출 계층의 내용으로 제시하는데, 그중 typed query 계열은 배선 없이 fork가 조립해야 한다는 사실이 그 표에 없다. (b) §41의 다음 항목이 그 조립 시점에만 문제가 된다. #### 42. P2 — collection 이름 불변식이 aggregation executor의 서명에서 깨진다 `MongoCollectionProfileRegistry`의 javadoc은 이 leaf의 가장 강한 주장 중 하나를 편다. > A collection name assembled from a request value therefore cannot reach the driver, because **there is no path from a string to a collection that does not pass through here.** `PolicyAwareMongoAggregationExecutor.execute(...)`의 서명은 그 경로다. ```java public List execute( MongoOperationContext context, MongoAggregationProfile profile, MongoAggregationPlan plan, String collection, // ← registry를 거치지 않는다 Class outputType) … AggregationResults results = operations.aggregate(aggregation, collection, outputType); ``` `context`가 `collectionProfile`을 이미 들고 있는데도 collection은 별도 `String` 인자로 받고, 그 값이 그대로 `MongoOperations.aggregate(...)`에 간다. 같은 클래스가 `MongoOperations`를 **직접** 주입받으므로 imperative 실행 scope도 통과하지 않는다 — collection profile 해석, observation, 실패 번역이 모두 없다(`132-...` §8.2b: 이 클래스에 `observer`·`observation`·`translator` 참조 0). 현재 노출은 없다(§41: 미배선). 그러나 fork가 이 executor를 배선하는 순간 두 가지가 동시에 생긴다 — registry가 보장한다고 적힌 불변식의 예외 하나, 그리고 관측·실패번역 없이 도는 실행 경로 하나. **판정: P2.** 수정은 서명에서 `String collection`을 없애고 `context.collectionProfile()`을 registry로 해석하는 것, 그리고 실행을 `DefaultMongoImperativeExecutor.executeInternal(...)` 안으로 옮기는 것이다. 후자는 §32에서 본 deadline 문제도 함께 해결한다(현재 aggregation은 `maxTime`을 스스로 붙이므로 그 부분만은 이미 옳다). #### 43. P3 — `MongoRegexPolicy.forbidden()`은 금지하지 않는다 ```java public static MongoRegexPolicy forbidden() { return new MongoRegexPolicy(1, Set.of(), true); } ``` "금지"가 별도 상태가 아니라 **최대 길이 1**로 표현돼 있다. `validate(pattern, flags)`의 네 검사를 길이 1짜리 패턴 `^`에 대해 따라가면 — 길이 1 ≤ 1 통과, flags 없음 통과, `requireAnchored && startsWith("^")` 통과, `hasNestedQuantifier("^")`는 그룹이 없으므로 false 통과 — **수용된다**. 그리고 `^`는 모든 문자열에 매치된다. `prefixPattern`/`containsPattern`은 escape 결과가 항상 5자 이상이라 길이에서 걸리므로, 이 정책 아래서는 오히려 안전한 두 helper만 막히고 `whereMatches(path, "^", "")`는 통과한다. 도달하려면 해당 필드가 `MongoOperator.REGEX`를 등록해야 하므로 조합이 필요하지만, "regex를 금지했다"고 선언한 collection이 모든 문서에 매치되는 패턴을 받는 상태는 정책 이름이 약속하는 것과 다르다. **P3.** 수정은 policy에 명시적 "regex 불허" 상태를 두고 `validate`가 그것을 먼저 보게 하는 것이다. #### 44. Negative-space probes — sub-scope 05 - **8.1 reachability**: 배선된 bean은 `MongoBudgetEnforcer` 하나. 나머지 전부 미배선이고 그 하나의 소비자도 미배선(§41). - **8.2 collection 불변식**: §42. registry javadoc의 주장과 aggregation executor 서명의 대조. - **8.2b 실행 scope 이탈**: aggregation은 `MongoOperations`를 직접 받아 observation/translator 없이 실행. - **8.3 regex 정책**: §43. - **8.4 서명 키 출처**: `MongoKeysetCursorCodec`의 32바이트 키를 공급하는 production 코드 0 — cursor 서명은 fork가 키를 배선해야 성립한다. #### 45. Sub-scope 05 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `PolicyAwareMongoAggregationExecutor`가 collection을 `String`으로 받아 registry를 우회하고, `MongoOperations`를 직접 받아 실행 scope(관측·실패번역)도 우회한다. registry javadoc은 그런 경로가 없다고 적는다 | 현재 미배선; fork가 배선하는 순간 발생 | | **P3** | `MongoRegexPolicy.forbidden()`이 길이 1 정책이라 `^`(모든 문자열 매치)를 수용한다 | 필드가 REGEX operator를 등록한 경우 | | **P3/기록** | query·aggregation·keyset 전부 미배선이고 배선된 `MongoBudgetEnforcer`는 소비자가 없다. README D1/D2 표는 typed query를 노출 계층 내용으로 제시하나 조립이 fork 몫이라는 사실은 적지 않는다 | 문서/조립 | | **P3/기록** | keyset cursor 서명 키를 공급하는 production 경로 없음(생성자 호출은 test 3곳) | fork 배선 시점 | #### 46. Sub-scope 05 완료 조건 - denominator 29 / 29 FULL_READ (`132-...`) - reachability·불변식·실행 scope·regex 정책·키 출처 5종 probe 수행 - 두 finding 모두 정적으로 결정 가능하여 실행 probe 불필요, 소스 미변경 --- #### 47. Sub-scope 06 범위와 denominator > 내부 상태: COMPLETE — **27 / 27 FULL_READ** > 범위: `transaction/**` 20 (production, 1,617 LOC) + 전용 test 7 > 역할: body 재시도와 commit 재시도를 **서로 다른 루프**로 유지하는 것 — 이 leaf에서 가장 결과가 무거운 규칙 manifest와 probe: `evidence/raw/133-mongo-transaction-manifest-and-probes.txt`. #### 48. 설계의 중심 규칙이 실제로 구현돼 있다 `MongoTransactionRetryCoordinator`의 javadoc이 규칙과 그 대가를 함께 적는다. > `TransientTransactionError` means nothing was committed, so the body may run again — from a new session… `UnknownTransactionCommitResult` means the commit may already have succeeded, so the body must **not** run again… Getting this wrong does not fail loudly. It produces a second order, a double refund, or a duplicate ledger entry — during a failover, when nobody is reading the logs. 구현은 그 규칙을 구조로 만든다. - **두 루프.** `execute(...)`의 바깥 루프는 `MongoTransactionTransientException`에서만 `continue`하고, 매 시도마다 `sessions.open(profile)`로 **새 세션**을 연다. `commitWithRetry(...)`는 이미 계산된 `value`를 인자로 받아 그대로 반환하며, javadoc이 "nothing here may recompute it, because recomputing is indistinguishable from replaying"라고 못박는다. - **Spring의 transaction 추상화를 쓰지 않는다.** `SpringMongoTransactionSessionFactory`가 이유를 적는다 — `MongoTransactionManager`와 `TransactionTemplate`은 callback이 반환되면 암묵적으로 commit하므로 body와 commit을 한 단계로 접는데, 설계 전체가 그 둘이 **다르게 실패하고 다르게 재시도된다**는 데 서 있다. - **분류는 label이 살아 있는 경계에서 한다.** driver 실패는 session factory 안에서 분류되고, 위층 coordinator는 platform의 두 transaction 예외만 본다. `classify(...)`는 `Throwable`을 받는다 — Spring Data가 감싼 실패는 같은 label과 server code를 갖지만 다른 타입으로 도착해 분류를 통째로 건너뛰었고, 그래서 transient 오류가 terminal로 처리돼 재시도되지 않았다. - **context를 scope에서 유도한다.** commit-unknown context를 먼저 만들고 classifier가 고른 예외로 감싸는 대신, scope가 `COMMIT_ONLY`면 commit-unknown context를, `WHOLE_TRANSACTION`이면 transient context를 만든다(§15의 두 예외 생성자 불변식과 맞물린다). - **reactive도 같은 규칙.** `SpringReactiveMongoTransactionExecutor`는 body 재시도에서 caller의 publisher를 재구독하고 commit 재시도에서는 `commit()`만 재구독한다 — "re-subscribing a publisher is exactly how a reactive codebase replays work that may already have been committed". 정리(cleanup)도 phase-aware다: commit-unknown이면 `abort()`하지 않고 `release()`만 한다. 주변 결함 이력도 촘촘히 기록돼 있다. - `startTransaction()` 실패 시 세션을 닫지 않아 시도마다 pool 항목이 샜다 → 이제 실패 경로에서 close하고 close 실패는 원인에 suppressed로 붙인다. - `MongoTransactionScope.bind`가 `set`/`remove`였다 → 중첩 시 안쪽 `remove`가 바깥 body의 바인딩을 지워, 이후 `require()`가 실패하거나 평범한 template으로 fallback한 코드가 **transaction 밖에** 썼다. 지금은 이전 값을 복원한다. - reactive executor가 budget 검사에 `Duration.ZERO.plusNanos(1)`을 넘겨 `maxElapsed`가 영원히 도달 불가였다 → 이제 주입 가능한 `LongSupplier nanoTime`으로 실제 경과를 잰다. - `delayBefore`를 두 번 호출해 metric에 기록된 지연과 실제로 기다린 지연이 달랐다 → 한 번 계산해 재사용. - `MongoRetryBudget.allowsAttempt`가 첫 시도에도 `elapsed < maxElapsed`를 요구해, `none()`(maxElapsed=0)이 body 자체를 거부했다 → 첫 시도는 재시도가 아니므로 무조건 허용. `MongoTransactionProfile`은 secondary read profile을 생성자에서 거부하고 timeout이 서버의 `transactionLifetimeLimitSeconds`(기본 60초)를 넘지 못하게 한다. #### 49. Confirmed P2 — 이 subsystem 전체가 배선돼 있지 않은데, 그것을 켜는 flag는 startup 검사를 수행한다 `MongoPlatformAutoConfiguration`에서 `Transaction`/`CausalSession`/`RetryCoordinator`를 찾으면 **매치 0**이다(`133-...` §8.1, exit=1). transaction package 밖의 production 참조도 0이다. 즉 `MongoTransactionExecutor`·`MongoTransactionRetryCoordinator`·`SpringMongoTransactionSessionFactory`·causal session executor 어느 것도 bean이 아니고, 이 leaf의 다른 production 코드가 부르지도 않는다. 그런데 `MongoPlatformSettings.transactions`는 살아 있는 flag다. §6의 probe에서 `platform.transactions=true`가 그대로 bound되는 것을 확인했고, `MongoPlatformAutoConfiguration:362`가 그 값을 `MongoStartupValidator`에 넘기며, validator는 `transactionsEnabled && !capabilities.isStable(TRANSACTION)`이면 startup을 거부한다(`MongoStartupValidator:97`). 결과적으로 `ca-skeleton.persistence-mongo.platform.transactions=true`를 설정한 배포는 — topology probe와 나머지 startup 입력이 모두 갖춰졌다면 — **topology가 transaction을 지원하는지 검증받고, 그 다음 transaction을 실행할 bean은 하나도 받지 못한다.** flag는 capability 요구만 만들고 capability를 제공하지 않는다. 이것을 §6의 `changeStreams`와 나란히 놓으면 대비가 분명하다. change stream은 실행체가 없다는 사실을 인정하고 flag 값을 강제로 `false`로 만든다(그 방식의 문제는 §6에서 따로 지적했다). transaction은 실행체가 없는데 flag는 살아서 startup 요구를 만든다. 같은 상황에 대해 두 가지 다른 처리가 한 record 안에 있다. **판정: P2.** 데이터 위험은 없다 — 없는 것을 쓸 수는 없다. 위험은 운영자의 기대다. 수정은 셋 중 하나다: transaction executor를 조건부 bean으로 조립하거나, flag가 무엇을 켜는지(=startup 검증만) 문서에 적거나, `changeStreams`처럼 명시적으로 거부하거나. 셋 중 어느 것도 지금은 되어 있지 않다. #### 50. Negative-space probes — sub-scope 06 - **8.1 reachability**: 배선 0, cross-package 참조 0(§49). - **8.1b flag ↔ 조립 불일치**: §49. `transactions`는 검증만 만들고, `changeStreams`는 값을 삼키며, 둘 다 실행체가 없다. - **8.2 규칙 검증**: 두 루프의 분리를 코드 구조로 확인(§48). blocking·reactive 양쪽 모두. - **8.3 scope 바인딩**: 중첩 bind가 복원 방식인지 확인. 두 개의 `ThreadLocal`이 존재한다 — `MongoTransactionScope.CURRENT`와 `SpringMongoCausalSessionExecutor.CURRENT` — 서로 독립이고 각자의 `require*()`를 갖는다. causal session 안에서 transaction scope를 물으면 "no MongoDB transaction is active"가 나오고 그 반대도 마찬가지다. 의도된 분리로 보이나 두 scope가 겹칠 때 어느 쪽 operations를 써야 하는지에 대한 계약은 어디에도 없다. P3/기록. - **8.4 profile 경계**: 60초 서버 한계와 secondary read 거부 확인. #### 51. Sub-scope 06 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | transaction subsystem 전체가 미배선(bean 0, cross-package 참조 0)인데 `platform.transactions=true`는 startup에서 TRANSACTION capability를 요구한다 — 요구만 만들고 제공하지 않는 flag | flag를 켠 모든 배포 | | **P3/기록** | `MongoTransactionScope`와 `SpringMongoCausalSessionExecutor`가 각자 독립된 `ThreadLocal`을 갖고, 두 scope가 중첩될 때 어느 operations가 유효한지에 대한 계약이 없다 | fork가 둘을 함께 배선할 때 | #### 52. Sub-scope 06 완료 조건 - denominator 27 / 27 FULL_READ (`133-...`) - reachability·flag 정합·규칙 구조·scope 바인딩·profile 경계 5종 probe 수행 - 두 재시도 루프의 분리, 세션 수명, 실패 분류 경계를 blocking·reactive 양쪽에서 코드로 추적 - 소스 미변경 --- #### 53. Sub-scope 07 범위와 denominator > 내부 상태: COMPLETE — **58 / 58 FULL_READ** > 범위: `schema/**` 30 + `migration/**` 19 (production 49, 3,124 LOC) + 전용 test 9 > 역할: collection의 index·validator·문서 모델을 **선언**으로 만들고, migration을 lease와 ledger 위에서 한 번만 돌게 한다 manifest와 정적 probe: `evidence/raw/134-mongo-schema-migration-manifest-and-probes.txt`. 실행 probe: `evidence/raw/134a-mongo-schema-migration-execution-probes.txt`. #### 54. 설계의 두 축 — 선언이 진실이고, 적용은 D4다 `MongoCollectionManifest`의 javadoc이 첫 번째 축을 적는다. > Deliberately not derived from annotations. Spring Data's `@Indexed` can create an index as a side effect of a class being on the classpath, which means production index state depends on deployment order and on which module happened to be loaded. 그래서 index·validator·문서 모델이 전부 명시적 선언이고, 검증은 **집합이 다 모인 뒤에** `MongoManifestRegistry`에서 일어난다 — collection 이름 중복, 한 collection 안의 index 이름 중복, 문서 모델의 budget 초과는 선언 시점에는 조용하고 비교 시점에만 보이기 때문이다. `MongoIndexManifest`가 `expectedUsage`를 **APPLICATION 소유일 때 필수로** 요구하는 것도 같은 계열이다: "an index nobody can name a query for cannot be reviewed for removal later". 두 번째 축은 적용 권한이다. `MongoIndexApplyPolicy`는 APPLY → APPLY_WITH_DIFF → DIFF_WITH_APPROVED_APPLY → REPORT_ONLY 사다리를 두고 production에서 runtime의 index 변경을 금지한다. `MongoValidatorApplyPolicy.runtimeMayApply()`는 **항상 false**다 — validator 변경은 이후 모든 write의 수용 규칙을 다시 쓰므로 D4다. `MongoIndexRetirementState`는 DEPRECATED → USAGE_OBSERVED → HIDDEN → REGRESSION_CHECKED → APPROVED → DROPPED를 한 칸씩만 전진시키고, `successor()`를 ordinal이 아니라 switch로 적는 이유까지 남긴다("an ordinal-based successor silently changes meaning the moment someone inserts a constant, and this sequence is a safety procedure"). 문서 모델 쪽도 촘촘하다. `MongoDocumentSizeBudget`은 MongoDB의 16 MiB 한계가 아니라 그 1/4인 4 MiB를 상한으로 강제한다 — "the write that fails is the first symptom". `MongoDocumentModelValidator`는 위반을 전부 모아서 한 번에 던진다("a modelling review that surfaces one problem per run turns a five-minute fix into five rounds"). `EmbeddedCollectionDescriptor.unbounded()`는 **거부되기 위해** 존재한다 — "우리는 모른다"를 생략이 아니라 기록으로 표현하게 한다. `worstCaseDocumentBytes()`는 overflow 대신 포화한다("a silent wraparound would turn 'infinitely large' into 'comfortably small'"). `MongoValidatorApplyPolicy`의 `CERTIFIED_RELEASE_LINES`에는 이미 한 번 고쳐진 결함이 주석으로 남아 있다: 과거의 `Set.of("7.0","8.0").contains(serverVersion)`은 서버가 `"8.0.4"`를 보고하므로 **모든 실제 배포에서 false**였다 — "the certified lane was a lane nothing was ever in". 지금은 `MongoServerVersion.parse`로 major/minor를 비교한다(§18.1에서 본 `MongoServerVersion`의 유일한 production 소비자가 바로 이 줄이다). #### 55. migration은 fencing을 정면으로 다룬다 `MongoMigrationLock.fence()`의 javadoc이 이 sub-scope에서 가장 정확한 문장을 담고 있다. > A lease expiring is not the same as its holder stopping. A runner paused inside a long `execute` — a stop-the-world pause, a stalled network write — loses the lease on the server while its thread is still alive and still writing… **Refreshing more often does not fix that: the first runner is not running at the moment it would refresh.** 그래서 lease 위에 monotonic fencing token을 얹고, `MongoCollectionMigrationLock.tryAcquire`가 그 token을 **lease를 부여하는 같은 조건부 update 안에서 서버가 증가**시킨다("A token handed out anywhere else could be handed out twice"). `held()`는 owner 이름이 같아도 fence가 다르면 false를 반환한다 — 프로세스가 재시작했거나 운영자가 owner 문자열을 재사용한 경우다. `matchedCount`를 쓰는 이유(같은 값을 다시 쓰면 `modifiedCount`가 0이라 소유권 판정이 뒤집힌다)도 두 곳에 적혀 있다. `MongoMigrationHeartbeat`은 이미 고쳐진 결함의 산물이다: runner가 `execute`가 **반환된 뒤에** 한 번만 refresh했으므로, 40분짜리 `execute`는 35분 동안 만료된 lease를 들고 있었고 그 사이 두 번째 runner가 정당하게 획득해 같은 migration을 동시에 돌렸다. 이제 heartbeat이 migration에게 넘겨진다 — batch 경계를 아는 것은 migration뿐이기 때문이다. `MongoCollectionMigrationLedger.saveCheckpoint`에는 **두 개의** 결함 이력이 주석으로 남아 있다. upsert 하나로는 "매치할 게 없었다"와 "fence filter가 배제했다"를 구분할 수 없어 *모든 migration의 첫 checkpoint*가 "a newer migration runner owns the lease"로 거부됐고, 동시에 진짜 배제 경로는 unique index의 duplicate-key로 죽어 그 문장을 만드는 분기가 **도달 불가**였다. 지금은 replace-then-insert로 두 경우를 분리한다. `MongoMigration`에 `rollback`이 없는 것도 명시적 결정이다 — "A rollback method implies the reverse operation is always safe and always possible, and for a backfill that dropped a column's old values it is neither." 실패한 production 변경은 forward-fix migration으로 고친다. `mongoMigrationTest` lane은 HEAD에서 green이다: 1 class / **8 tests** / 0 failures (`134a-...` §8.4b). #### 56. P2 — `recordApplied`는 문서화된 fence 계약을 구현하지 않고, 보호를 역전시킨다 `MongoMigrationLedger.recordApplied`의 javadoc은 계약을 분명히 적는다. > Records a completed migration, **only if the fence is still the current one**… A ledger entry from a superseded runner says a migration completed when the work it describes was overwritten by the runner that replaced it. > `@throws MongoOperationRejectedException` when a newer acquisition exists 구현은 그렇지 않다. `MongoCollectionMigrationLedger.recordApplied:93`은 `requireCurrentFence(fence, …)`를 부르는데, 그 메서드가 하는 검사는 **`fence == UNFENCED`인지 하나뿐**이다(`134-...` §8.2). 저장된 fence와의 비교도, 서버측 조건도 없고, fence는 그냥 문서의 한 필드로 들어간다. 이름이 하는 말("current")과 코드가 하는 일("fenced")이 다르다. `FlamingockLedgerAdapter.recordApplied`는 fence 인자를 아예 무시한다. 실제 서버(MongoDB 8.0 replica set)에서 확인했다(`134a-...` PROBE A). live runner가 fence 5로 checkpoint `o-900`을 쓴 상태에서 fence 1을 든 superseded runner가 두 번 쓴다. ``` PROBE saveCheckpoint(fence=1 over stored 5) -> REFUSED MongoOperationRejectedException PROBE recordApplied(fence=1 over stored 5) -> ACCEPTED PROBE ledger entry now = { migrationId=20260829-001, checksum=superseded, operator=stale-runner, fence=1 } PROBE recordApplied(live fence=5, after stale wrote) -> REFUSED MongoWriteException: E11000 duplicate key error … index: migrationId_1 ``` 같은 fence 계약이 `saveCheckpoint`에서는 지켜지고 `recordApplied`에서는 지켜지지 않는다. 결과는 단순한 누락이 아니라 **역전**이다 — 밀려난 runner가 ledger를 차지하고, 실제로 작업한 runner는 platform의 lease 문장 대신 driver의 duplicate-key 예외를 받는다. 그리고 이것은 이 파일이 `saveCheckpoint`에서 **이미 한 번 고친 바로 그 형태**다(§55: "a superseded runner got a driver-level duplicate-key error instead of the sentence written for it"). 수정이 한쪽에만 적용됐다. **도달성.** 조립된 경로에서는 `MongoMigrationRunner.applyOne`이 `recordApplied` **직전에** `lock.refresh(...)`를 부르고, `MongoCollectionMigrationLock.refresh`는 owner+fence 조건부라 stale이면 던진다. 그래서 기본 조합에서는 인접한 다른 장치가 막아 준다 — 다만 (a) refresh와 insert 사이에 TOCTOU 창이 남고, (b) 그 보호는 `MongoCollectionMigrationLock`을 쓸 때만 존재하며, (c) `MongoMigrationLedger`는 fork가 구현하도록 공개된 인터페이스인데 그 인터페이스가 약속하는 보호는 어느 구현에도 없다. **판정: P2.** 수정은 `saveCheckpoint`와 같은 모양이다 — `recordApplied`도 저장된 fence를 조건으로 삼고, duplicate-key를 잡아 platform 예외로 번역하는 것. 지금은 test도 이 경계를 보지 않는다: `MongoMigrationFencingTest.ledgerWritesCarryTheirFence`는 fence 값이 **전달되는지**만 보고, `MongoMigrationLaneTest`의 superseded 테스트는 checkpoint만 다룬다. #### 57. P2 — index diff가 실제로 비교하는 것은 두 필드뿐이다 `MongoIndexManifest`는 14개 요소를 선언한다 — keys, unique, sparse, hidden, deprecated, partialFilterExpression, collationProfile, **expireAfter**, wildcardProjection, shardKeySupport, expectedUsage, owner, metadataOwnership. `MongoIndexDescriptorView`는 6개만 나르고, `MongoIndexDiffEngine.compare`가 실제로 비교하는 것은 **`keySignature`와 `unique` 두 개**다(`134-...` §8.2b, grep 결과 49–50행이 전부). 게다가 `hidden`은 **한 방향으로만** 본다: `declared.hidden() && !actual.hidden()`(55행). 반대 — 서버에서는 숨겨져 있는데 manifest는 보인다고 선언한 index — 에 해당하는 분기가 없다. 그것은 planner가 manifest가 살아 있다고 적은 index를 **쓰지 않고 있는** 상태이고, 정확히 은퇴 워크플로가 HIDDEN에 세워 둔 index를 다시 살리기로 한 뒤에 생기는 상태다. hermetic probe로 확인했다(`134a-...` PROBE B). 선언은 `ix_ttl`(expireAfter=30일)과 `ix_active`(sparse + partialFilter + collation, 보임), 서버는 같은 이름·같은 키·같은 uniqueness에 `ix_active`만 숨겨져 있다. ``` PROBE diff.isClean() -> true PROBE diff.render() -> [] (빈 문자열) ``` TTL 보존기간 변경, sparse/partialFilter/collation 변경, 그리고 "서버에서 숨겨진 채 선언은 보임"이 **전부 drift 없음**으로 렌더링된다. 이 중 TTL이 가장 무겁다 — 30일을 1일로 바꾸는 것은 대량 삭제이고, drift 보고서는 그것을 clean이라고 말한다. `MongoIndexDescriptorView`의 javadoc이 "reduced to the fields a diff can compare"라고 스스로 한정하는 것은 사실이지만, 그 축소의 **결과**(무엇이 감지 불가가 되는지)는 어디에도 적혀 있지 않고, `MongoIndexDiff.render()`가 CI artifact로 쓰이도록 설계돼 있으므로 "빈 보고서 = 일치"로 읽힌다. **판정: P2.** 최소 수정은 `MongoIndexDescriptorView`에 `expireAfter`와 `sparse`를 추가하고 `compare`에서 비교하는 것, 그리고 `actual.hidden() && !declared.hidden()`에 대한 `unhide` 항목을 두는 것이다. 그것이 과하다면 최소한 비교 대상 필드 집합을 diff 출력에 함께 적어 "빈 보고서"가 무엇을 뜻하는지 읽는 사람이 알 수 있게 해야 한다. #### 58. P3 — TTL이 두 곳에 선언되고, 규칙을 가진 쪽은 아무도 쓰지 않는다 TTL을 표현하는 방법이 이 sub-scope 안에 둘 있다. 1. `MongoIndexManifest.expireAfter(Duration)` — 검증은 생성자의 `isNegative()` 하나. 2. `MongoTtlPolicy` / `MongoTtlIndexDescriptor` + `MongoTtlPolicyValidator` — 세 가지 실질 규칙: 최소 보존기간 1분(그 아래는 한 번의 sweep으로 전체 population을 지운다), expiry 필드의 BSON 타입이 `date`인지(아니면 MongoDB가 **조용히 무시**한다), 그리고 읽기가 `expiresAt > applicationNow`를 거는지(TTL monitor는 임의 간격으로 돌므로 만료된 문서는 그때까지 계속 읽힌다). 둘 사이에 참조가 **하나도 없다**(`134-...` §8.3: `schema/ttl` 밖의 production 참조 검색 exit=1). `MongoIndexManifest.isTtlIndex()`와 `ttl()`은 선언부 말고 호출자가 아예 없다. 그래서 manifest 경로로 선언된 TTL index는 위 세 규칙 중 어느 것도 통과하지 않는다. probe로 확인: ``` PROBE MongoIndexManifest.expireAfter(1s) built -> PT1S isTtlIndex=true ``` `MongoTtlPolicyValidator.MINIMUM_SAFE_RETENTION`이 1분인데, manifest는 1초를 그대로 만든다. 그리고 `schema/ttl`의 네 타입은 이 leaf의 production 어디에서도 쓰이지 않는다 — 규칙을 가진 표현은 아무도 안 쓰고, 쓰이는 표현은 규칙이 없다. **P3.** (지금 결함이 아닌 이유는 §59와 같다: manifest를 조립하는 production 코드 자체가 없다. fork가 조립하는 순간 결함이 된다.) #### 59. P3 — Flamingock lease로는 어떤 migration도 실행할 수 없고, javadoc은 다르게 적는다 `FlamingockLockAdapter.fence()`는 `UNFENCED`(-1)를 반환하고, 그 이유를 정직하게 적는다 — 로컬 카운터로 fencing을 흉내내면 "look like fencing and protect nothing". 여기까지는 옳다. 문제는 그 다음 문장이다. > The runner refuses **resumable** migrations under an unfenced lease for exactly this reason. `MongoMigrationRunner.apply:82`의 검사는 stream보다 **앞에** 있고 migration의 성질을 보지 않는다. hermetic probe에서 checkpoint를 만들지 않는(=resumable이 아닌) migration을 넣어 확인했다(`134a-...` PROBE C). ``` PROBE FlamingockLockAdapter.fence() = -1 PROBE runner.apply(non-resumable migration, Flamingock lease) -> REFUSED MongoOperationRejectedException: this migration lease exposes no fencing token … ``` 즉 engine-agnostic 경로 전체 — Mongock을 새 프로젝트에서 채택하지 않겠다는 결정을 되돌릴 수 있게 만들어 둔 그 경계 — 는 `MongoMigrationRunner`를 통해 **아무것도 실행할 수 없다**. `FlamingockMongoMigrationAdapterTest`도 이 조합을 시험하지 않는다(adapter lock으로 `apply`를 부르는 테스트가 없다). **P3.** 수정은 둘 중 하나다: javadoc을 실제 동작("every migration")에 맞추거나, unfenced lease에서 non-resumable migration을 허용하도록 검사를 옮기거나. 전자가 정직하고 후자는 별도 판단이 필요하다. #### 60. Confirmed — 이 sub-scope도 선언 라이브러리이고, ledger의 유일성 장치는 production에서 만들어지지 않는다 auto-configuration이 `schema/**`·`migration/**`에서 만드는 bean은 **0개**다(`134-...` §8.1: `MongoPlatformAutoConfiguration`에서 걸리는 것은 `api.mapping.MongoTypeRepresentationManifest`와 `api.schema.MongoSchemaVersionRange`뿐 — 둘 다 sub-scope 02 소속). 그리고 정책 계층은 소비자조차 없다: | 타입 | production 소비자 | |---|---| | `MongoIndexApplyPolicy`, `requireRuntimeApplyAllowed` | **0** (test 1곳) | | `MongoValidatorApplyPolicy` | **0** (test 2곳) | | `MongoIndexDiffEngine`, `MongoValidatorDiffEngine` | **0** (`new`는 test에서만) | | `MongoTtlPolicyValidator` 외 `schema/ttl` 4종 | **0** | | `MongoManifestRegistry` | 1 — `geo/SpringMongoGeospatialOperations`(그 자체가 미배선, §26) | | `MongoMetadataOwnership` | `advanced/encryption/qe`, `advanced/search`(sub-scope 10) | | `MongoMigrationCheckpoint` | `advanced/tenancy/database` 2개(sub-scope 10) | | `MongoMigrationRunner`/`Ledger`/`Lock` | **0** | 즉 D4 admin plane의 "runtime은 index/validator를 바꿀 수 없다"는 규칙은 현재 **runtime이 그 코드를 부르지 않는 방식으로** 지켜지고 있다. 사다리는 만들어져 있고 올라서는 사람이 없다. 한 가지는 따로 적어 둘 만하다. `MongoCollectionMigrationLedger.ensureIndexes()` — javadoc이 "The unique index on the migration id is the part that matters"라고 말하고, 실제로 §56의 duplicate-key도 그 index가 만든 것이다 — 를 부르는 곳은 **test 6곳뿐**이다(`134-...` §8.3c). 생성자와 분리한 이유는 명시돼 있다("a ledger that silently creates indexes on first use is the auto-index-creation behaviour the platform refuses everywhere else"). 옳은 결정이지만, 그 결과 ledger의 중복 방지는 fork가 admin plane에서 명시적으로 만들어 줘야 성립하는 전제가 되고, 그 전제는 `MongoMigrationRunner`나 module README 어디에도 적혀 있지 않다. 만들지 않은 채 운영하면 §56의 경합은 duplicate-key 예외조차 없이 **두 개의 ledger 항목**으로 끝난다. P3/기록. #### 61. Negative-space probes — sub-scope 07 - **8.1 reachability**: bean 0, 정책 계층 소비자 0(§60). cross-package 소비자는 geo·advanced 계열뿐이고 그중 geo는 미배선. - **8.2 계약 ↔ 구현 대조**: `recordApplied`의 javadoc 계약과 두 구현(§56). 실서버 실행 probe로 확정. - **8.2b 비교 필드 집합**: 선언 14 vs 관측 6 vs 실제 비교 2(§57). hermetic 실행 probe로 확정. - **8.2c 조건부 형제**: `hidden`이 한 방향만 비교됨(§57). `saveCheckpoint`는 fence 조건부인데 `recordApplied`는 아님(§56) — 같은 파일 안의 형제 비교. - **8.3 중복 메커니즘**: TTL 두 표현(§58), ledger 두 구현·lock 두 구현(§56·§59), `ensureIndexes` 호출자 부재(§60). - **8.4 문서/개수 drift**: `mongoMigrationTest` lane은 build.gradle:119에 존재하고 tag는 `mongodb-migration`, HEAD에서 1 class / 8 tests / 0 failures. module README에는 manifest·runner 언급 없음. `docs/superpowers/plans/…-implementation-plan.md`는 이 코드를 `modules/mongodb/mongodb-migration-core` 아래 별도 모듈로 적고 있으나 실제 위치는 단일 leaf 안의 package다(§0의 모듈 배치 drift와 같은 계열). #### 62. Sub-scope 07 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `MongoMigrationLedger.recordApplied`의 javadoc은 fence 조건부 쓰기와 `MongoOperationRejectedException`을 약속하지만, `MongoCollectionMigrationLedger`는 `UNFENCED`만 검사하고 `FlamingockLedgerAdapter`는 fence를 무시한다. 실서버 probe에서 밀려난 runner가 ledger를 차지하고 live runner가 driver duplicate-key를 받는다 | runner 경로는 인접한 `lock.refresh`가 막아 줌(TOCTOU 창 존재); ledger를 직접 쓰거나 다른 lock 구현을 쓰는 fork는 무방비 | | **P2** | index diff가 비교하는 것은 `keySignature`·`unique` 둘뿐이라 TTL 보존기간·sparse·partialFilter·collation 변경과 "서버에서 숨겨짐 + 선언은 보임"이 전부 clean으로 보고된다 (probe: `isClean()=true`, `render()=""`) | drift 보고서를 CI artifact로 쓰는 모든 배포 | | **P3** | TTL이 `MongoIndexManifest.expireAfter`와 `MongoTtlPolicy` 두 곳에 있고 서로 참조가 없다. 규칙(최소 1분·BSON date·읽기 술어)을 가진 쪽은 production 소비자 0, 쓰이는 쪽은 `isNegative()`만 본다 (probe: 1초 TTL이 그대로 생성됨) | fork가 manifest를 조립하는 시점 | | **P3** | `FlamingockLockAdapter`의 javadoc은 runner가 "resumable migrations"만 거부한다고 적지만 실제로는 **모든** migration을 거부한다 — engine-agnostic 경로로는 아무것도 실행할 수 없다 (probe로 확인) | Flamingock 어댑터를 쓰려는 모든 시점 | | **P3/기록** | `ensureIndexes()`(ledger의 유일성 장치)의 호출자가 test뿐이고, admin plane에서 만들어야 한다는 전제가 문서화돼 있지 않다 | 운영 배포 시점 | | **P3/기록** | `schema`·`migration` 전체가 bean 0이고 apply policy·diff engine·TTL validator는 production 소비자 0. D4 규칙이 "runtime이 그 코드를 부르지 않는 방식"으로 지켜지고 있다 | 문서/조립 | #### 63. Sub-scope 07 완료 조건 - denominator 58 / 58 FULL_READ (`134-...` OWNED FILES) - reachability·계약대조·비교필드집합·조건부형제·중복메커니즘·문서drift 6종 probe 수행 - 정적으로 결정 불가한 세 지점(recordApplied fence, index diff 사각지대, Flamingock lease)을 실행 probe로 확정(`134a-...`) - 임시 probe class 2개 추가 후 제거, `git status --short` = 0 (`134a-...` 말미) --- #### 64. Sub-scope 08 범위와 denominator > 내부 상태: COMPLETE — **26 / 26 FULL_READ** > 범위: `changestream/**` 21 (production, 1,317 LOC) + 전용 test 5 (996 LOC) > 역할: at-least-once change stream 소비 — 저장된 위치에서 열고, 순서대로 투영하고, **투영이 성공한 뒤에** 위치를 쓴다 manifest와 정적 probe: `evidence/raw/135-mongo-changestream-manifest-and-probes.txt`. 실행 probe: `evidence/raw/135a-mongo-changestream-execution-probes.txt`. #### 65. 이 sub-scope는 이 leaf에서 유일하게 "조립까지 된" 대형 서브시스템이다 앞선 sub-scope들과 다르다. `MongoPlatformAutoConfiguration`이 두 개의 bean을 실제로 만든다. - `mongoChangeStreamSource`(209행) — `SpringReactiveChangeStreamSource`, 무조건. - `reactiveMongoChangeStreamConsumer`(235행) — fork만 공급할 수 있는 5종(`MongoChangeStreamSubscription`, `MongoResumeCheckpointStore`, `MongoResumeTokenCodec`, `MongoChangeProjector`, `MongoChangeDeduplicationStore`)에 `@ConditionalOnBean`. pipeline·runner·recovery policy·invalidate recovery는 auto-configuration이 직접 `new`한다. 즉 fork가 설계가 요구하는 다섯 개를 그대로 제공하면 **완성된 소비자가 돈다**. 이 사실이 아래 §67의 심각도를 결정한다. 설계 자체는 이 leaf에서 가장 정교한 축에 속한다. - **순서가 계약이다.** `MongoChangeStreamRunner`: 투영 먼저, checkpoint 나중. "Checkpointing first would mean a crash between the two loses the event permanently, with no trace." 그래서 중복을 택하고 중복을 제거한다. - **claim은 3-state다.** 과거 `alreadyProjected` + `markProjected`(읽고-쓰기)는 동시에 `false`를 읽은 두 subscriber가 둘 다 투영했다 — "the deduplication that exists precisely because redelivery is guaranteed did not survive concurrency". 지금은 `CLAIMED`/`ALREADY_COMPLETED`/`BUSY`의 원자적 전이다. - **빈 완료는 프로토콜 위반이다.** `Mono`이 empty로 완료되면 `flatMap`을 그냥 통과해 "투영도 checkpoint도 없이 아무도 문제를 보고하지 않는" 상태가 됐다. 이제 `switchIfEmpty(Mono.error(...))`로 잡는다. - **identity는 재전달에 안정적이고 documentKey를 감춘다.** SHA-256, 구분자는 ASCII unit separator(0x1F) — namespace/clusterTime/operationType에 나타날 수 없으므로 필드 재배열로 다른 이벤트의 identity를 위조할 수 없다. 한 transaction이 같은 문서를 두 번 고치면 앞 네 필드가 모두 같아지므로 `txnNumber`+`lsid` discriminator를 추가로 넣는다 — 없으면 두 번째가 첫 번째의 재전달로 **버려진다**. - **resume token은 절대 렌더링하지 않는다.** `MongoResumeCheckpoint.toString()`은 길이만 보고한다. token은 clusterTime과 documentKey를 인코딩하므로 로그에 찍는 순간 production write의 모양과 타이밍이 샌다. - **`MongoResumeTokenCodec`에는 기본 구현이 없다.** "a built-in that merely encoded would be worse than none: it would satisfy the type and none of the reason for it." - **`HISTORY_LOST`는 자동 복구하지 않는다.** "resuming from now… the projection then looks healthy and is quietly wrong, which is worse than a stopped consumer somebody has to look at." - **`MongoClusterTime`은 숫자로 비교한다.** 텍스트 비교는 `1700000000.10`을 `1700000000.9`보다 앞에 놓는데, 그것은 바쁜 1초가 정확히 만드는 경우다. #### 66. Confirmed — `MongoChangeStreamPipeline`은 존재 이유가 명확한 클래스다 javadoc이 자신이 고친 결함을 적는다: runner가 이벤트당 `runOne`만 노출하고 순서를 아무도 소유하지 않았으므로, 평범하게 `flatMap`으로 구독한 caller는 A가 투영 중일 때 B·C를 동시에 날렸고 각자 완료 시 checkpoint를 전진시켰다. B의 checkpoint 뒤 A 완료 전에 프로세스가 죽으면 resume 위치는 이미 A를 지나쳤다 — "**A was lost permanently and nothing recorded that it had been.**" `concatMap`이 그 순서를 파이프라인의 성질로 만든다. 그리고 그 위에 high-water mark를 얹어 뒤로 가는 checkpoint를 막는다. 두 장치 모두 의도가 옳다. #### 67. P1 — high-water mark가 재전달된 이벤트를 삼켜, failover 중이던 변경이 조용히 영구 소실된다 `MongoChangeStreamPipeline.processOne`은 이벤트를 받자마자 `advancesPosition(event.clusterTime())`을 부르고, 그 메서드는 `getAndAccumulate`로 **mark를 먼저 전진시킨 뒤** 전진 여부를 반환한다(49·58–63행). 즉 mark는 "**투영이 완료된 위치**"가 아니라 "**본 적 있는 위치**"다. 그리고 `ReactiveMongoChangeStreamConsumer.recoverFrom`은 resume 시 `Flux.defer(this::openAndConsume)`로 **같은 pipeline 인스턴스**를 다시 쓴다(199행) — mark는 그대로 남는다. 이 둘이 만나면, `MongoChangeStreamPipeline`이 고쳤다고 적은 바로 그 손실이 다른 경로로 돌아온다. **실행 probe C**(`135a-...`) — worker 하나, dedup은 항상 claim을 내준다(BUSY 없음). stream 1이 E(clusterTime 5.1)를 내보내고 projector가 200ms를 쓰는 동안, 50ms 시점에 primary가 내려앉는다(`errorLabels=[ResumableChangeStreamError]`, code 133). stream 2는 서버가 resume했을 때 보낼 것 — checkpoint가 E를 지나친 적이 없으므로 E를 재전달하고, 이어서 F(6.1)를 보낸다. ``` PROBE-C terminal=COMPLETED opens=2 PROBE-C projector started=2 completed=1 PROBE-C results=[MongoChangeProjectionResult[outcome=APPLIED, detail=]] PROBE-C checkpoints saved=[token-6] PROBE-C highWaterMark=6.1 PROBE-C state=RUNNING runbook= ``` E의 투영은 시작됐다가 failover에 취소됐다. resume 후 재전달된 E는 **pipeline이 삼켰다** — mark가 E의 첫 전달 때(투영 전에) 이미 5.1로 올라갔기 때문이다. 그 다음 F가 투영되고 checkpoint가 token-6으로 저장되면서, 저장 위치는 E를 지나쳤다. change stream은 checkpoint가 지나친 것을 다시 보내지 않는다. **E는 영구히 사라졌고, 구독은 `RUNNING`에 runbook은 비어 있고, caller의 `Flux`는 정상 완료한다.** 같은 손실이 다른 두 경로로도 확인된다. - **probe A**: E가 BUSY(다른 worker가 claim 보유)로 checkpoint 없이 지나간 뒤 resumable 실패 → resume → E 재전달 → 삼켜짐 → F가 checkpoint를 E 너머로 옮김. `token-5 projected? false ; checkpoint moved past it? true`. - **probe B**: **실패도 resume도 없이**. 하나의 정상 stream에서 E가 BUSY, 이어서 F가 성공. `checkpoints saved=[token-6]` — E의 checkpoint는 안 썼는데 F의 checkpoint가 E를 지나쳤다. `MongoChangeProjectionResult.busy()`의 javadoc이 명시한 불변식 — "The checkpoint must not advance past it: the holder may still fail, and a checkpoint that has passed the event is a change the stream will never replay" — 을 **바로 다음 이벤트가** 깬다. runner는 그 불변식을 지키고, pipeline이 무효화한다. **왜 test가 못 잡았나.** 세 테스트가 각각 절반씩 본다. `MongoChangeStreamRunnerTest.aBusyClaimNeverAdvancesTheCheckpoint`는 이벤트 **하나**만 돌려서 "그 이벤트의 checkpoint가 안 써졌다"까지만 본다. `MongoChangeStreamPipelineTest.anEventBehindTheHighWaterMarkIsDropped`는 늦은 이벤트를 버리는 것이 옳다고 단언하는데, 그 시나리오의 늦은 이벤트는 **이미 완료된** 위치 뒤에 있고, checkpoint store는 `NoOpCheckpoints`라 상호작용이 보이지 않는다. `ChangeStreamConsumerLifecycleTest.aResumableFailureReopensFromTheCheckpoint`는 첫 stream을 `Flux.error(...)`로 시작해 **이벤트를 하나도 전달하지 않고** 실패시키므로 mark가 설정되지 않는다. "본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 어느 테스트에도 없다. **판정: P1.** 조립된 bean에서, 특별한 전제 없이(worker 하나, 평범한 failover), 조용하고 영구적인 변경 소실이 일어나고 시스템은 스스로를 정상이라고 보고한다. 수정 방향은 mark의 의미를 "본 위치"에서 "**checkpoint가 저장된 위치**"로 바꾸는 것이다 — `runOne`이 `allowsCheckpointAdvance()`인 결과를 낸 뒤에만 mark를 올리고, `CLAIMED_ELSEWHERE`/`PARKED`가 나온 위치에서는 mark를 멈춘 채 이후 이벤트의 checkpoint 저장도 그 위치를 넘지 못하게 하는 것(= checkpoint를 순서대로만 전진시키는 것). 최소 수정만으로도 probe C는 막힌다: resume 시 pipeline의 mark를 저장된 checkpoint 위치로 되돌리면 된다. #### 68. P2 — `changeStreams` flag는 `false`로 고정돼 있는데, 소비자 bean은 그것과 무관하게 조립된다 `MongoPlatformSettings`의 compact 생성자가 `changeStreams = false`를 강제하고(55행), 그 주석은 이렇게 적는다. > The driver-side source — watch, resumeAfter/startAfter, cursor lifetime, reconnection — **is not shipped**; what exists is policy and value objects that do not add up to a running consumer… so the value is refused rather than stored: **zero beans, zero threads**. HEAD에서 그 전제는 더 이상 사실이 아니다. driver-side source는 `SpringReactiveChangeStreamSource`로 **출하돼 있고**(auto-configuration의 무조건 bean), 완전한 소비자도 조립된다(§65). 주석은 이 코드가 존재하기 전 상태를 서술한다. 결과는 §49의 transaction과 정확히 **거울상**이다. | | flag | startup capability 검사 | 실행체 | |---|---|---|---| | `transactions` | 살아 있음 | `TRANSACTION` 요구 | **bean 0** | | `changeStreams` | **강제 false** | 절대 실행 안 됨 | **bean 조립됨** | `MongoStartupValidator:104`의 `changeStreamsEnabled && !capabilities.isStable(CHANGE_STREAM)` 검사는 좌항이 영구히 false이므로 도달 불가다. 그래서 change stream을 지원하지 않는 topology(standalone 등)에 완성된 소비자를 배포해도 startup은 통과한다. 실패는 stream을 여는 시점에 driver 오류로 나타나고, `MongoChangeStreamRecoveryPolicy.onFailure`가 그것을 `FAILED` + `docs/mongodb/runbooks/failover.md`로 분류한다 — failover runbook은 "이 topology에는 change stream이 없다"를 설명하지 않는다. **판정: P2.** 수정은 셋 중 하나다: `changeStreams`를 실제 flag로 되살려 소비자 조립의 조건으로 쓰거나, 소비자 bean이 조립될 때 CHANGE_STREAM capability를 startup에서 검증하거나, 최소한 `MongoPlatformSettings`의 주석을 현재 사실("source는 출하됐고 소비자도 조립된다")로 고치는 것. 지금 주석은 운영자가 읽으면 틀린 결론에 도달한다. #### 69. P3 — recovery package에 쓰이는 어휘와 쓰이지 않는 어휘가 나란히 있다 `135-...` §8.3의 검색 결과를 정리하면, 소비자가 실제로 쓰는 것과 아닌 것이 갈린다. | 타입/메서드 | production 호출 | |---|---| | `MongoChangeStreamRecoveryPolicy.onFailure` | 1 (소비자) | | `MongoInvalidateRecovery.requireCorrectResumeOption` | 1 (소비자) | | `onHistoryLost`, `onResumableFailure`, `onInvalidate` | **0** (test만) | | `MongoInvalidateRecovery.checkpointFor` | **0** — 소비자는 `tokens.encode(..., START_AFTER)`로 직접 만든다 | | `MongoChangeStreamState.autoResumable()` | **0** — 소비자는 `decision.autoResume()`을 쓴다 | | `MongoChangeHistoryLostException` | **0 — 어디에서도 생성되지 않는다** | 마지막 항목이 가장 무겁다. 이 예외의 javadoc은 왜 전용 타입이어야 하는지를 설명한다("the recovery is a business decision, not a technical one"). 그런데 실제로 history lost가 감지되면(`onFailure` → server code 286/280) 소비자는 state를 `HISTORY_LOST`로 놓고 **driver의 원본 예외를 그대로 재방출**한다. lifecycle test가 그것을 고정한다: `verifyError(MongoQueryException.class)`. 그래서 caller가 `catch (MongoChangeHistoryLostException)`로 이 상황을 구분하려 하면 절대 잡히지 않는다. 그리고 소비자의 유일한 `requireCorrectResumeOption` 호출은 **자기 자신과 비교한다**(`ReactiveMongoChangeStreamConsumer:119`: `requireCorrectResumeOption(checkpoint, checkpoint.position())`). probe D로 확인했다 — 이 호출 형태는 구조적으로 던질 수 없고, 다른 `intended`를 넘기는 production 호출은 없다. 안전장치처럼 읽히지만 검사하는 것이 없다. **P3.** #### 70. Negative-space probes — sub-scope 08 - **8.1 reachability**: 이 sub-scope는 조립돼 있다 — source bean 무조건, consumer bean은 fork의 5종 SPI에 조건부(§65). platform이 제공하는 SPI 구현은 **0**(전부 test fixture) — 설계상 fork 몫. - **8.2 계약 ↔ 구현**: `busy()`가 선언한 불변식을 pipeline이 깬다(§67, probe B). `MongoChangeStreamPipeline` javadoc이 고쳤다고 적은 손실이 mark의 의미 때문에 되돌아온다(probe A·C). - **8.2b 테스트 사각지대**: "본 적 있지만 완료되지 않은 위치"가 세 테스트 어디에도 없다(§67). - **8.3 중복 메커니즘**: recovery의 두 어휘(§69). checkpoint 생성 경로 둘(`checkpointFor` vs `tokens.encode`). 자기 자신과 비교하는 guard. - **8.4 문서 drift**: `MongoPlatformSettings`의 "zero beans, zero threads" 주석이 현재 코드와 어긋난다(§68). 반면 policy가 지목하는 두 runbook(`docs/mongodb/runbooks/history-lost.md`, `failover.md`)은 **실재한다** — confirmed match. #### 71. Sub-scope 08 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1** | pipeline의 high-water mark가 "투영 완료 위치"가 아니라 "본 위치"이고 resume에도 유지되므로, failover 중이던 이벤트가 재전달 시 삼켜지고 이후 이벤트의 checkpoint가 그것을 지나친다 — 조용한 영구 소실, state는 `RUNNING` (probe C) | 조립된 소비자 + 임의의 resumable failover. worker 하나로 재현 | | **P1(동일 결함, 별 경로)** | 실패가 전혀 없어도 `CLAIMED_ELSEWHERE`(및 `PARKED`) 위치를 이후 이벤트의 checkpoint가 지나친다 — `busy()`의 javadoc이 명시한 불변식 위반 (probe B) | 다중 worker 배포 | | **P2** | `changeStreams`가 `false`로 고정돼 startup의 CHANGE_STREAM capability 검사가 도달 불가인데 소비자 bean은 조립된다. `MongoPlatformSettings`의 "not shipped / zero beans" 주석이 현재 코드와 어긋난다 | change stream 미지원 topology에 배포하는 모든 fork | | **P3** | `MongoChangeHistoryLostException`이 어디에서도 생성되지 않는다 — history lost는 driver 원본 예외로 재방출된다 | 이 상황을 타입으로 구분하려는 caller | | **P3** | `requireCorrectResumeOption(checkpoint, checkpoint.position())` — 자기 자신과 비교하는 guard | 소비자의 유일한 호출 | | **P3/기록** | `onHistoryLost`·`onResumableFailure`·`onInvalidate`·`checkpointFor`·`autoResumable()` production 호출 0 — 쓰이는 어휘와 쓰이지 않는 어휘가 나란히 있다 | 유지보수 | #### 72. Sub-scope 08 완료 조건 - denominator 26 / 26 FULL_READ (`135-...` OWNED FILES) - reachability·계약대조·테스트사각지대·중복메커니즘·문서drift 5종 probe 수행 - P1을 세 개의 독립적인 실행 probe(A·B·C)로 확정, auto-configuration과 동일한 조립으로 재현(`135a-...`) - 임시 probe class 2개 추가 후 제거, `mongoStableContractTest` 재실행 green, `git status --short` = 0 --- #### 73. Sub-scope 09 범위와 denominator > 내부 상태: COMPLETE — **44 / 44 FULL_READ** > 범위: `security/**` 13 + `failure/**` 8 + `observation/**` 7 + `client/**` 1 (production 30, 2,244 LOC) + 전용 test 14 (1,885 LOC) > 역할: 자격증명 분리와 D4 admin plane, driver 실패의 단일 번역 지점, 태그 allowlist 기반 관측, 그리고 프로파일 → driver 설정 변환 manifest와 정적 probe: `evidence/raw/136-mongo-security-failure-observation-client-probes.txt`. 실행 probe: `evidence/raw/136a-mongo-client-settings-execution-probe.txt`. #### 74. `failure`는 이 leaf에서 가장 잘 배선되고 가장 잘 논증된 부분이다 `MongoFailureClassifier`와 `MongoFailureTranslator`는 auto-configuration의 실제 bean이고(`MongoPlatformAutoConfiguration:85·92`), imperative·reactive 두 executor가 모두 그것을 통해 번역한다. 규칙 사슬도 순서까지 논증돼 있다 — **label → phase → 적용 가능성 → code table → fail closed**. > Phase sits above the code table because a failure that never reached a server is safe to repeat whatever code accompanies it, and a commit failure is unsafe to replay whatever code accompanies it — **both were decided by the code table before, and the code table knows neither.** 고쳐진 결함 이력이 촘촘하다. - **번역기가 phase를 버렸다.** `DefaultMongoFailureTranslator`가 operationType을 들고도 context-free overload를 불러서, "FIND의 응답 유실 = 재현 가능한 읽기 / UPDATE의 같은 유실 = 결과 불명 쓰기"라는 구분이 **transaction이 아닌 모든 경로에서** 버려졌다 — 즉 모든 평범한 연산에서. 실패한 읽기가 ambiguous write로 보고됐다. - **server-selection이 terminal이었다.** label도 code도 없는 실패가 `UNCLASSIFIED`로 떨어져 재시도 불가로 처리됐다 — 재시도가 명백히 안전한 유일한 경우인데. - **Spring 래핑이 분류를 통째로 건너뛰었다.** `MongoFailureExtractor`가 그 수리다. cause 사슬을 깊이 16까지, `IdentityHashMap`으로 순환 안전하게 탐색한다("a cycle is about the same object appearing twice"). - **message는 절대 읽지 않는다.** `MongoDriverFailureView`가 driver 예외를 label·code·boolean 둘로 좁히는 지점이고, 그 이후 어느 계층도 나머지에 닿을 수 없다 — "no later layer can reach the rest, because no later layer is ever handed it". `MongoFailureClassification`의 생성자가 `COMMIT_ONLY`를 `TRANSACTION_COMMIT_UNKNOWN`에만 허용하는 것도 §15의 불변식과 맞물린다. `security`도 대부분 배선돼 있다. `MongoStartupValidator:62`가 `MongoSecurityProfileValidator().validate(runtimeSecurity)`를, `:117`이 `requireDistinctCredentials`를 부른다. `MongoPlatformAutoConfiguration:341–350`은 셋 중 하나라도 없으면 **부분 검증 대신 startup을 거부**한다("A partial startup check reports success for the parts nobody supplied"). `MongoCredentialReference.fingerprint()`의 주석은 이 leaf에서 가장 좋은 결함 서술 중 하나다 — role을 해시에 섞은 탓에 "같은 secret, 다른 role"이 다른 지문을 냈고, 그 지문을 쓰는 유일한 검사인 `requireDistinctCredentials`는 **항상 runtime role과 admin role로 호출되므로 결코 발화할 수 없었다**. `observation`의 태그 allowlist와 `MongoObservationRedactor`의 allowlist 방향("a denylist would have to anticipate the next command MongoDB adds that happens to carry a secret")도 일관된다. driver 리스너는 `MongoDriverObservabilityAutoConfiguration`이 `MongoClientSettingsBuilderCustomizer`로 등록해 실제로 설치된다 — 그 파일의 javadoc이 자기 존재 이유를 적는다: "`MongoDriverObservabilityConfiguration` could add command, SDAM and pool listeners to a settings builder, **and nothing ever called it**… the pool-checkout, server-selection and primary-change metrics the operations documentation refers to were never emitted." #### 75. P1 — 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않는다 `MongoClientSettingsFactory`의 javadoc은 자신이 무엇을 고치려고 만들어졌는지 적는다. > The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout policy all existed and were all unit-tested. **None of them reached a `MongoClientSettings`**… A policy that nothing applies reads exactly like a policy that is applied — the tests pass, the record is populated, and the client connects with a three-second timeout it inherited from the driver rather than the two the profile states. HEAD에서 이 클래스는 **저장소 전체에서 호출자가 없다**(`136-...` §8.1b·§8.1e: 자기 파일과 자기 test 외의 참조 0, `app-bootstrap` 포함 repo-wide 0). bean도 아니다. `MongoCredentialResolver`도 production에서 한 번도 호출되지 않는다 — 유일한 외부 언급은 모듈 `CLAUDE.md`의 산문이다. 실제 client는 Spring Boot가 `spring.data.mongodb.uri`에서 만든다(모듈 README:37이 그 형태를 그대로 보여 준다). 즉 **수리 코드는 작성됐고 배선되지 않았다.** hermetic 실행 probe(`136a-...`)로 결과를 측정했다. ``` PROBE profile.tlsRequired=true -> validator ACCEPTED PROBE settings Boot builds from the README's URI: sslEnabled=false connectTimeoutMs=10000 serverSelectionTimeoutMs=30000 poolMaxSize=100 serverApi=null uuidRepresentation=UNSPECIFIED PROBE settings MongoClientSettingsFactory would build: sslEnabled=true ``` 가장 무거운 줄은 첫 두 줄이다. `MongoSecurityProfileValidator`는 production 프로파일이 TLS를 요구한다고 선언하면 통과시키고, 선언하지 않으면 startup을 거부한다 — 그리고 그 선언을 연결에 적용하는 코드는 없다. TLS가 켜지는 것은 오직 fork의 URI에 `tls=true`가 들어 있을 때뿐이다. **프로파일이 "TLS 필수"라고 말하고, 검증기가 그것을 확인하고, 연결은 평문으로 나갈 수 있다.** 나머지 줄들도 같은 성질이다 — 타임아웃·풀 상한·Stable API strict·고정 UUID 표현이 전부 driver 기본값이다(`serverApi=null`은 strict Stable API가 없다는 뜻이고, `uuidRepresentation=UNSPECIFIED`는 `MongoClientSettingsFactory`가 "a value that moves under a stored document is a migration nobody wrote"라며 고정하려던 바로 그 값이다). **같은 결함의 형제가 이미 고쳐져 있다는 점이 이 finding을 결정적으로 만든다.** 관측 쪽도 "설정 빌더에 적용하는 메서드에 호출자가 없다"는 똑같은 형태였고, 그쪽은 `MongoDriverObservabilityAutoConfiguration`이 `MongoClientSettingsBuilderCustomizer`를 등록해서 고쳤다. **동일한 메커니즘이 같은 패키지에 있고, 설정 절반에는 쓰이지 않았다.** 기존 test는 이 경계를 보지 못한다. `MongoClientSettingsFactoryTest`는 factory를 **직접 생성해서** 프로파일이 설정에 도달하는지 확인한다 — factory가 호출된다는 전제 아래. `MongoTlsLaneTest`는 `applyToSslSettings(ssl -> ssl.enabled(true))`로 **손수 만든 설정**으로 서버가 TLS를 강제하는지 확인한다(`:137`). 어느 쪽도 "프로파일의 `tlsRequired`가 실제 연결을 TLS로 만드는가"를 묻지 않는다. **판정: P1.** 수리는 이미 있는 형태를 따르면 된다 — `MongoClientSettingsBuilderCustomizer` bean 하나가 `MongoClientSettingsFactory`(또는 그 `build` 로직)를 Boot의 빌더에 적용하게 하는 것. 그때 `MongoCredentialResolver`도 비로소 경로에 들어온다. #### 76. P3 — admin gateway의 두 audit 경로 중 하나만 fail-closed다 `MongoAdminGateway.execute`는 모든 audit 쓰기를 `audit(...)` 헬퍼로 보내고, 그 헬퍼는 sink 실패를 `MongoOperationRejectedException`으로 바꾼다 — "an administrative operation that cannot be audited does not run". `MongoAdminAuditStateMachineTest.anUnauditableCommandDoesNotRun`이 그것을 고정한다. `dryRun(...)`(`:145–149`)은 `auditSink.accept(...)`를 **직접** 부른다. 헬퍼를 거치지 않으므로 sink 실패가 platform 예외로 번역되지 않고 raw로 전파된다. 그리고 dry run은 장식이 아니다 — 고위험 작업의 **전제 조건**이고, 그래서 "a first-class call rather than a flag somebody remembers to pass"로 만들어졌다. 감사되지 않은 dry run 위에 승인이 얹히면 승인 사슬의 첫 칸에 기록이 없다. **P3**(전파는 되므로 조용히 통과하지는 않는다; 다만 형제 경로와 동작이 다르고 그 차이가 문서화돼 있지 않다). #### 77. P3 — 태그 allowlist는 규약이지 강제가 아니다 `MongoObservationConvention`의 javadoc은 강제라고 말한다. > a tag not on this list cannot be attached, so **the mistake has to be made in this file** rather than at a call site. 실제로는 `requireAllowed(...)`를 부르는 production 코드가 **없다**(`136-...` §8.2). 네 개의 관측 클래스는 전부 `Tags.of("...", ...)`로 문자열을 직접 넣는다. 현재 값들은 모두 allowlist 안에 있으므로 지금은 어긋남이 없지만, 그 사실은 코드가 아니라 리뷰와 `MongoObservationConventionTest`가 지키고 있다. 새 리스너를 추가하는 사람은 이 파일을 열 이유가 없다. `MongoObservationRedactor.describe(...)`도 production 호출자가 0이다 — `MongoCommandObservationListener`는 `isAlwaysRedacted`만 쓴다. 세 갈래(안전/기본/항상 가림) 중 실제로 쓰이는 것은 "항상 가림" 하나다. **P3.** #### 78. Confirmed — 세 곳의 대비: 배선된 것, 부분적으로 배선된 것, 배선되지 않은 것 이 sub-scope는 앞선 sub-scope들과 달리 세 상태가 한 화면에 있다. | 패키지 | 상태 | |---|---| | `failure` | **완전 배선.** classifier·translator 모두 bean, 두 executor가 사용, `MongoFailureExtractor`는 두 session factory가 사용 | | `security` | **검증 경로 배선.** `MongoStartupValidator`가 profile validator와 자격증명 분리 검사를 실행. 다만 그 검증 대상 선언이 driver에 적용되지 않는다(§75) | | `observation` | **부분 배선.** driver 리스너는 customizer로 설치됨. allowlist 강제와 `describe`는 미사용(§77) | | `client` | **미배선.** 호출자 0(§75) | 호출자 없는 잔여물도 정리해 둔다: `MongoFailureClassification.unrecognisedServerCode()` 0, `MongoAdminAuditRecord.applied(...)`("legacy shape, kept for callers that do not build a command") production 0 / test 2, `MongoAdminRuntimeGuard.adminGatewayAllowed()` production 0, `MongoDriverObservabilityConfiguration.convention()` 0. 어느 것도 결함은 아니지만, 이 leaf가 "쓰이는 어휘와 쓰이지 않는 어휘를 나란히 둔다"는 §69의 패턴이 여기서도 반복된다. #### 79. Negative-space probes — sub-scope 09 - **8.1 reachability**: 네 패키지의 상태가 서로 다르다(§78). `MongoClientSettingsFactory` repo-wide 호출자 0(§75). - **8.2 계약 ↔ 구현**: `MongoClientSettingsFactory` javadoc이 서술한 결함이 그 클래스 자체에 대해 성립한다(§75). allowlist javadoc의 "cannot be attached"와 실제 강제 부재(§77). - **8.2b 조건부 형제**: 같은 결함(설정 빌더 메서드에 호출자 없음)의 두 수리 중 관측 쪽만 배선(§75). `execute`와 `dryRun`의 audit 경로 차이(§76). - **8.3 중복/미사용 메커니즘**: §78 말미 목록. redactor의 세 갈래 중 하나만 사용(§77). - **8.4 lane drift**: build.gradle에 6개 lane(`mongoReplicaSetTest`·`mongoFailoverTest`·`mongoMigrationTest`·`mongoCompatibilityTest`·`mongoSecurityIntegrationTest`·`mongoPerformanceTest`) 정의, tag는 각각 대응. `MongoTlsLaneTest`·`MongoSecurityIntegrationLaneTest`는 `mongodb-security-integration`, `MongoNetworkFaultLaneTest`는 `mongodb-failover` — 전부 정의된 lane에 매핑된다. **confirmed match.** #### 80. Sub-scope 09 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P1** | `MongoClientSettingsFactory`가 저장소 전체에서 호출되지 않아 프로파일의 `tlsRequired`·타임아웃·풀 상한·Stable API·UUID 표현이 driver에 도달하지 않는다. 검증기는 "TLS 필수" 선언을 통과시키고 연결은 평문일 수 있다 (probe: `tlsRequired=true` → validator ACCEPTED, Boot 설정 `sslEnabled=false`) | 이 leaf를 켠 모든 배포 | | **P3** | `MongoAdminGateway.dryRun`이 fail-closed `audit(...)` 헬퍼를 우회해 sink 실패를 raw로 전파한다 — `execute`와 동작이 다르다 | dry run을 감사하는 배포 | | **P3** | 태그 allowlist(`requireAllowed`)와 `MongoObservationRedactor.describe`의 production 호출자 0 — javadoc이 주장하는 강제는 규약과 test가 지킨다 | 리스너를 추가하는 시점 | | **P3/기록** | 호출자 없는 잔여 API: `unrecognisedServerCode()`, `MongoAdminAuditRecord.applied(...)`, `adminGatewayAllowed()`, `MongoDriverObservabilityConfiguration.convention()` | 유지보수 | #### 81. Sub-scope 09 완료 조건 - denominator 44 / 44 FULL_READ (`136-...` OWNED FILES) - reachability·계약대조·조건부형제·중복메커니즘·lane drift 5종 probe 수행 - P1을 hermetic 실행 probe로 확정하고, 기존 두 test(`MongoClientSettingsFactoryTest`·`MongoTlsLaneTest`)가 왜 그 경계를 보지 못하는지 코드로 확인(`136a-...`) - 임시 probe class 1개 추가 후 제거, `git status --short` = 0 --- #### 82. Sub-scope 10 범위와 denominator > 내부 상태: COMPLETE — **75 / 75 FULL_READ** > 범위: `advanced/**` 65 (production, 3,439 LOC) + 전용 test 10 (1,365 LOC) > 하위 영역: root(6) · autoconfigure(2) · bridge(7) · encryption/csfle(5) · encryption/qe(6) · gridfs(4) · search(5) · sharding(6) + sharding/admin(3) · tenancy/database(5) + tenancy/shared(4) · timeseries(6) · vector(5) > 역할: Stable lane이 갖지 못한 것(샤딩 클러스터·Atlas·KMS·별도 자격증명)을 요구하는 능력들을 **명시적 opt-in**으로 격리한다 manifest와 probe: `evidence/raw/137-mongo-advanced-manifest-and-probes.txt`. #### 83. opt-in 구조 자체가 이 sub-scope의 본체다 세 겹으로 되어 있다. 1. **분류 어노테이션 둘.** `@MongoAdvancedEntryPoint(capability)`는 *실행하는* 타입, `@MongoAdvancedPolicy`는 *판단·기술·검증만 하는* 타입. 후자를 flag 뒤에 두지 않는 이유가 적혀 있다 — "gating it behind a capability flag would only make a shard-key analysis or a manifest check unavailable to the very people deciding whether to turn the capability on." 2. **guard.** entry point는 `MongoAdvancedCapabilityGuard`를 생성자 인자로 받아 **자기 자신을 넘겨** 검사시킨다. 필요한 capability는 타입 위의 어노테이션에서 읽으므로 호출자마다 복사되지 않는다. 어노테이션 없는 타입이 guard에 물으면 `IllegalArgumentException`이다 — "defaulting to 'allowed' is how the invariant was lost in the first place." 3. **ArchUnit 규칙 둘.** `MongoAdvancedRules.everyAdvancedTypeIsClassified()`와 `everyEntryPointConsultsTheGuard()`. "Two rules, because one alone is escapable." `MongoAdvancedEntryPoint`의 javadoc이 이 구조가 왜 생겼는지 적는다. > The module documentation claimed that "every Advanced entry point refuses construction unless its capability is enabled". Of the concrete classes under this package **only four referenced the flags at all**; the rest — a change-stream-to-messaging bridge, a per-tenant client registry, a tenant migration coordinator — were constructible and runnable with every Advanced capability switched off. **The invariant was documentation, not behaviour.** 그리고 `MongoAdvancedSettings`가 그 위의 결함을 고친다 — flag는 "무엇이 켜졌나"를 답할 줄 알았지만 **그 property를 읽는 코드가 없었다**. 그래서 `ca-skeleton.persistence-mongo.advanced.sharding.enabled=true`를 설정해도 아무 일도 일어나지 않았다. 이제 `@ConfigurationProperties`로 바인딩되고, 바인딩 키가 `MongoAdvancedCapabilityFlags.propertyFor(...)`가 거부 메시지에 적는 경로와 같은지 test가 고정한다. `MongoAdvancedConfiguration`은 **의도적으로 auto-configuration이 아니다** — `AutoConfiguration.imports`에 없고(`137-...` §8.1: grep exit=1), 이 leaf의 `main` 안에서 `MongoAdvancedCapabilityGuard`를 참조하는 non-advanced 코드도 0이다. composition root가 이름으로 import해야 하고, 그 import 자체가 opt-in이다. #### 84. Confirmed — 분류 불변식이 실제로 성립한다 세어 봤다(`137-...` §8.1b·§8.1c). - `@MongoAdvancedEntryPoint` **7개**: `MongoChangeMessagingBridge`(CHANGE_STREAM), `MongoCsfleClientFactory`(CSFLE), `MongoQueryableEncryptionCollectionManager`(QUERYABLE_ENCRYPTION), `MongoGridFsMigrationJob`(GRIDFS_COMPATIBILITY), `MongoShardingAdminGateway`(SHARDING), `MongoTenantClientRegistry`·`MongoTenantMigrationCoordinator`(DATABASE_PER_TENANT). - `@MongoAdvancedPolicy` **11개**. - 어느 쪽도 아닌 구체 클래스 **1개**: `MongoAdvancedConfiguration`. 이것은 누락이 아니다 — `MongoAdvancedRules.concreteClass()`가 `@Configuration`을 명시적으로 제외하며 이유를 적는다: "A `@Configuration` class is the package's composition root: it builds entry points through the guard rather than being one, and **gating it would gate the thing that supplies the guard**." interface·enum·record·익명·private 중첩·abstract도 같은 방식으로 제외되고 각각 근거가 붙어 있다. 즉 §83이 말하는 불변식은 문서가 아니라 코드로 서 있다. 이 leaf에서 "문서가 주장하고 코드가 지키지 않는다"를 여러 번 본 뒤라, 여기서는 그 반대가 성립한다는 것을 명시해 둘 가치가 있다. 또 하나의 confirmed: **`throw new UnsupportedOperationException`만 하는 public 메서드를 값으로 바꾼 수리**가 두 곳에서 같은 형태로 이루어졌다. `MongoTimeSeriesCapabilityValidator`는 네 개의 던지기만 하는 메서드를 `supportFor(capability) → MongoTimeSeriesSupport(지원 여부 + 이유)`로 바꿨고, `MongoQueryableEncryptionProfile`은 세 개의 던지기만 하는 static factory를 `supportFor(MongoQueryShape) → MongoQueryShapeSupport`로 바꿨다. 근거도 동일하다 — "A factory that never returns is not an API: it cannot appear in working code, so its only reachable use is a test asserting that it throws, and the design-time question it was meant to answer is only answered by running it." #### 85. P2 — sharding admin gateway의 네 작업 중 셋은 어떤 입력으로도 완료될 수 없다 `MongoShardingAdminGateway`는 네 메서드 모두 `MongoAdminGateway`의 **5인자 편의 오버로드**를 부른다(`:64`, `:75`, `:86`, `:92`). 그 오버로드는 `MongoAdminCommand.routine(...)`을 만들고 **`approval = null`**을 넘긴다(`MongoAdminGateway:63–67`). 그리고 실제 실행 경로는 고위험 작업에 대해 `approval == null`이면 거부한다(`:94–97`). `MongoAdminOperation`에서 `SHARD_COLLECTION`·`REFINE_SHARD_KEY`·`RESHARD_COLLECTION`은 전부 `highRisk(true)`이고, `BALANCER_CONTROL`만 `false`다. 실행 probe로 확인했다(`137-...` PROBE). 입력은 통과할 수 있는 모든 증거를 갖췄다 — SHARDING capability 활성화, `MongoAdminAuthorization.approved(네 작업, "release-engineer")`(= 이름 있는 승인자 + 완료된 dry run), 승인된 `ShardKeyReadinessReport`, 완전한 `ReshardApproval`(승인된 readiness + dry run 완료 + 승인자 + 문서화된 forward strategy), shard key로 시작하는 지원 인덱스, 만료되지 않은 command clock. ``` PROBE shardCollection -> REFUSED: admin operation SHARD_COLLECTION destroys data or rewrites a collection; it runs under an approval bound to this exact command or not at all PROBE refineShardKey -> REFUSED (REFINE_SHARD_KEY, 같은 메시지) PROBE reshardCollection-> REFUSED (RESHARD_COLLECTION, 같은 메시지) PROBE controlBalancer -> APPLIED PROBE bodies actually executed = 1 of 4 ``` 구조적 원인은 **승인 어휘가 둘이라는 것**이다. sharding 모듈은 자기 몫의 완전한 승인 객체(`ReshardApproval`, 네 가지 증거)를 만들어 스스로 검사한 뒤, 실제로 결정하는 D4 plane에는 **그 중 아무것도 넘기지 않는다**. D4가 요구하는 것은 `MongoAdminApproval`(command digest에 바인딩된 단일 사용 승인)이고, 그것을 만드는 코드가 sharding 쪽에 없다. test도 이 경계를 보지 않는다: `MongoShardingAdminGateway`를 참조하는 곳은 **자기 선언 세 줄뿐**이다(`137-...` §8.2c). sharding 관련 test 둘(`ShardKeyAnalyzerTest`·`ShardAwareQueryValidatorTest`)은 policy 계층만 다룬다. **판정: P2.** 데이터 위험은 없다 — 거부는 fail-closed이고, 오히려 안전한 방향으로 틀렸다. 위험은 능력이 문서상 존재하고 실제로는 없다는 것이며, 그 사실이 발견되는 시점은 운영자가 프로덕션 클러스터에서 reshard를 실행하려는 순간이다. 수정은 세 메서드가 `MongoAdminCommand.over(...)` + `MongoAdminApproval.of(command, approver, expiry)`를 만들어 2인자 `execute`에 넘기고, `ReshardApproval`의 증거를 그 승인의 전제로 쓰는 것이다. #### 86. P3 — promotion 증거 어휘가 둘이고, gate는 하나만 검사한다 `MongoAdvancedPromotionEvidence.REQUIRED`는 여섯 범주다: `stable-platform`, `actual-topology`, `security`, `migration`, `failure`, `runbook`. `MongoAdvancedPromotionGate.verify(...)`가 그 여섯을 전부 검사한다 — 그리고 그 파일에는 고쳐진 결함이 주석으로 남아 있다: "`migration` was in `MongoAdvancedPromotionEvidence.REQUIRED` and not here, so the gate demanded five of the six categories it declares… which is the shape MNG-008 names: a gate that certifies more than it ran." 그런데 `MongoVectorSearchBenchmarkGate.requiredEvidence()`는 **완전히 다른 다섯 범주**를 반환한다: `index-readiness`, `recall`, `latency`, `memory`, `actual-topology`. 겹치는 것은 `actual-topology` 하나뿐이고, 이 집합을 읽는 production 코드는 없다(`137-...` §8.3). `MongoAdvancedPromotionGate`는 이 집합을 모른다. 그래서 vector search를 promotion하는 경로는 `MongoAdvancedPromotionGate.verify`를 통과할 수 있고, 그 통과는 recall·latency·index memory에 대해 **아무것도 말하지 않는다** — `MongoVectorSearchBenchmarkGate`의 javadoc이 정확히 그 위험을 적는데도: "Functional success is not evidence for vector search. An approximate index returns results for any query; whether they are the right results depends on recall." 방금 `migration` 누락으로 고쳤던 것과 같은 모양(선언한 것보다 적게 검사하는 gate)이 모듈 경계를 건너 다시 나타난다. **P3.** #### 87. P3/기록 — change stream checkpoint를 쓰는 곳이 둘이고, 서로를 모른다 `MongoResumeCheckpointStore.save(...)`를 부르는 production 코드는 둘이다(`137-...` §8.3c). - `MongoChangeStreamRunner:75` — 투영이 성공한 뒤. - `MongoChangeMessagingBridge:60·84` — 매핑하지 않은 변경(`:60`)과 broker가 수락한 변경(`:84`) 뒤. 둘 다 옳게 설계돼 있고(bridge는 `MongoPublishResult`가 broker의 실제 답을 나르게 만들어, 상수 때문에 두 분기가 모두 도달 불가였던 결함을 고쳤다), 각자 "손실보다 중복"을 택한다. 문제는 **한 subscription에 둘 다 배선되는 경우 서로의 진행을 모른다**는 것이다. 각자 자기 성공에서 checkpoint를 전진시키므로, bridge가 앞서면 projector가 아직 처리하지 않은 변경을 지나치고 그 반대도 마찬가지다. §67에서 본 pipeline의 high-water mark 문제와 합쳐지면 결과는 같은 방향 — 조용한 소실 — 이다. 두 클래스 어디에도 "한 subscription에 하나만 배선하라"는 진술이 없다. `MongoChangeMessagingBridge`가 `MongoChangeProjector`가 아니라 별도 타입이라는 사실 자체가 둘을 함께 쓸 수 있다는 신호로 읽힌다. **P3/기록** — fork의 조립 결정이므로 지금 결함은 아니지만, 계약이 어디에도 없다. #### 88. P3 — 구현 없는 4개의 계약 중 셋은 그 사실을 적고, 하나는 적지 않는다 `MongoSearchOperations`·`MongoTimeSeriesOperations`·`MongoVectorSearchOperations`는 모두 동일한 문단을 담는다. > **Scaffold.** This repository ships no implementation… Read a method signature as a specification, not as an available capability — an interface with no implementation cannot be injected, and treating it as shipped behaviour is how "the platform supports search" becomes true in a document and false in a deployment. 훌륭한 자기 한정이고, 이 leaf에서 반복적으로 필요했던 종류의 정직함이다. 그런데 `TenantScopedMongoOperations`도 구현이 **0**인데(`137-...` §8.3d: 네 interface 모두 `implements` 검색 exit=1) 그 문단이 없다. 그리고 이 넷 중 오해가 가장 비싼 것이 바로 그것이다 — javadoc이 "Operations that cannot run without a tenant predicate"라고 시작하므로, 능동적인 안전장치로 읽힌다. 실제로 그 보장을 제공하는 것은 `MongoTenantPredicateInjector`(policy, 구현 있음)이고, 이 interface는 fork가 구현했을 때만 그 injector를 부르게 되는 **형태**일 뿐이다. **P3.** #### 89. Negative-space probes — sub-scope 10 - **8.1 reachability**: guard bean은 `MongoAdvancedConfiguration`에만 있고 그것은 auto-load되지 않는다 — 저장소 안에 이것을 import하는 곳이 없으므로 **모든 Advanced entry point는 기본 배선에서 도달 불가**다. 이것은 설계이고 문서와 일치한다(confirmed). - **8.1b 분류 완전성**: 7 entry point + 11 policy + 1 의도적 제외 = 19개 구체 클래스 전부 설명됨(§84). ArchUnit 규칙이 양쪽을 강제. - **8.2 공개 표면 도달성**: `MongoShardingAdminGateway`의 4개 중 3개가 어떤 입력으로도 완료 불가(§85, 실행 probe). - **8.2b 중복 로직**: shard key ↔ 유니크 인덱스 호환성 검사가 `ShardKeyDescriptor.supportsUniqueIndexOn`과 `MongoShardingAdminGateway.shardCollection` 안에 각각 있다(후자는 전자를 부르지 않고 sublist 비교를 다시 쓴다). 두 구현의 결과는 현재 같다. - **8.3 중복 메커니즘**: promotion 증거 어휘 둘(§86), checkpoint 작성자 둘(§87), 승인 어휘 둘(§85). - **8.4 문서 drift**: 모듈 `CLAUDE.md:142`가 "`MongoAdvancedConfiguration` is imported by name, never auto-loaded"라고 적고 실제로 그렇다 — **confirmed match**. `build.gradle`에 advanced 전용 lane은 없고, advanced test는 hermetic `mongodb-contract` 레인에서 돈다. #### 90. Sub-scope 10 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `MongoShardingAdminGateway`의 `shardCollection`·`refineShardKey`·`reshardCollection`이 5인자 `execute`(approval=null)를 쓰므로 고위험 작업 거부에 걸려 **완료 불가**. 자기 몫의 `ReshardApproval`을 만들고도 D4가 요구하는 `MongoAdminApproval`은 만들지 않는다. gateway를 구동하는 test 0 | SHARDING을 켠 fork가 샤딩을 실제로 수행하려는 시점 | | **P3** | promotion 증거 어휘가 둘(`MongoAdvancedPromotionEvidence.REQUIRED` 6종 vs `MongoVectorSearchBenchmarkGate.requiredEvidence()` 5종, 교집합 1)이고 gate는 전자만 검사한다 — vector 승격이 recall·latency·memory 증거 없이 통과한다 | vector search 승격 절차 | | **P3** | `TenantScopedMongoOperations`는 구현이 없는데 형제 셋과 달리 Scaffold 고지가 없고, javadoc은 능동적 안전장치처럼 읽힌다 | 문서/조립 | | **P3/기록** | `MongoChangeStreamRunner`와 `MongoChangeMessagingBridge`가 같은 `MongoResumeCheckpointStore`를 독립적으로 전진시키며, 한 subscription에 둘을 배선하지 말라는 계약이 없다 | 두 소비자를 함께 배선하는 fork | | **P3/기록** | shard key ↔ 유니크 인덱스 호환성 검사가 두 곳에 중복 구현돼 있다 | 유지보수 | #### 91. Sub-scope 10 완료 조건 - denominator 75 / 75 FULL_READ (`137-...` OWNED FILES) - reachability·분류완전성·공개표면도달성·중복로직·중복메커니즘·문서drift 6종 probe 수행 - P2를 hermetic 실행 probe로 확정(모든 승인 증거를 갖춘 입력에서 4개 중 1개만 실행) - ArchUnit 분류 규칙의 예외(`@Configuration`)가 의도된 것임을 규칙 소스로 확인 - 임시 probe class 1개 추가 후 제거, `git status --short` = 0 --- #### 92. Sub-scope 11 범위와 denominator > 내부 상태: COMPLETE — **49 / 49 FULL_READ** > 범위: `src/testkit` 35 (3,036 LOC) + `src/test`의 미배정 13 (architecture 4, rs 2, compat 1, release 1, testkit-검증 3, 루트 2 — 1,466 LOC) + `src/mongoPerformanceTest` 1 (194 LOC) > 역할: 이 leaf의 **인증 장치** — 실제 토폴로지 fixture, 아키텍처 규칙, 릴리스 증거 검증 manifest와 probe: `evidence/raw/138-mongo-testkit-release-lanes-probes.txt`. #### 93. Confirmed — testkit은 흉내내지 않고 진짜를 만든다 이 sub-scope에서 가장 인상적인 것은 fixture들이 **어려운 쪽을 선택했다**는 점이다. - `MongoThreeNodeReplicaSet`은 `MongoDBContainer`를 **쓰지 않는다** — 그 컨테이너는 시작할 때 자기만의 단일 노드 set을 initiate하므로 "세 개를 띄우면 아무것도 선출하지 않는 세 개의 별도 클러스터"가 된다. 대신 `--replSet`만 주고 하나의 `rs.initiate`로 묶는다. primary는 **묻는다**(`db.hello().primary`), 어느 컨테이너가 살아 있는지로 추론하지 않는다 — "inferring it from which containers are still running produces a fixture that reports an election that never happened." - `ToxiproxyMongoNetworkFaultController`는 **응답 방향만** 끊는다(`ToxicDirection.DOWNSTREAM`). 그것이 `WRITE_RESULT_UNKNOWN`을 만드는 유일한 방법이다 — 컨테이너를 죽이면 클라이언트는 쓰기가 일어나지 않았음을 알게 되고, 그것은 이미 다루어진 쉬운 실패다. `MongoProxiedReplicaSetNode`는 같은 서버로 가는 **두 경로**(직접/프록시)를 둔다 — 주입한 결함이 서버 결함이 아니라 경로 결함임을 보이려면 프록시를 우회한 두 번째 클라이언트가 서버를 건강하다고 확인해 주어야 하기 때문이다. - `MongoAuthenticatedReplicaSetContainer`는 `--auth`와 keyfile을 컨테이너 안에서 생성한다 — "`MongoDBContainer` starts mongod without `--auth`. Users can be created on it and every one of them can do everything, so a least-privilege test against it passes no matter how wrong the roles are. **A security lane that cannot fail is not a security lane.**" root 비밀번호는 인스턴스마다 `SecureRandom`으로 만든다(과거에는 소스 상수였고, 그 주석이 왜 그것이 문제인지 적는다). - `MongoSingleReplicaSetContainer.providesFailoverEvidence()`는 **항상 false**를 반환하며 그 이유를 문서화한다 — 단일 노드 set은 선출을 하지 않는다. - `MongoBsonSnapshot`은 JSON으로 변환하지 않고 BSON 타입을 보존한 채 정규화한다 — JSON으로 가면 `Decimal128`과 문자열이 같아지고, missing과 explicit null이 같아진다. 키 집합을 정규형의 일부로 렌더링해 그 둘을 분리한다. `MongoAccessRules`의 존재 이유도 이 leaf의 반복 주제다: `MongoRepositoryArchitectureRules`는 타입 이름의 `Set`을 반환했고 그 test는 **집합의 내용만 단언했다**. "a controller must not hold a MongoTemplate"은 `Set`에 대한 통과하는 test였고 컨트롤러는 아무 규칙의 지배도 받지 않았다 — "and Boot's own auto-configuration supplies exactly those beans, so the injection was one constructor parameter away." 지금은 ArchUnit 규칙이 실제 클래스 그래프에 적용된다. `MongoModuleBoundaryTest`도 confirmed다. 닫힌 edge 행렬을 트리와 **정확히 일치**하는지 비교하고, DAG 밖의 네 간선(`reactive → imperative`, `reactive → query`, `transaction → reactive`, `geo → imperative`)을 **제거하는 대신 기록한다** — "Each is a real coupling the code relies on, and pretending otherwise is what the previous rules did; recording them makes the next one a decision instead of an accident." #### 94. P2 — 커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다 `MongoStableContractSuite`의 javadoc이 존재 이유를 적는다. > The report distinguishes a failed contract from a contract that never ran. A suite that reports "no failures" because half of it was skipped is exactly the shape of green build that certifies nothing, **so a missing contract is a failure here.** 구현은 그 구분을 만들 수 없다(`138-...` §8.2). ```java Set executed = new LinkedHashSet<>(); for (MongoReplicaSetContract contract : MongoReplicaSetContract.all()) { executed.add(contract); // ← 루프가 무조건 채운다 if (!contractRunner.test(contract)) { failures.add(...); } } Set missing = new LinkedHashSet<>(MongoReplicaSetContract.all()); missing.removeAll(executed); // ← 항상 비어 있다 missing.forEach(contract -> failures.add(... + " (not executed)")); ``` `executed`는 `all()`과 언제나 같으므로 `missing`은 언제나 비고, `(not executed)` 항목은 **어떤 입력으로도 생성되지 않는다**. `certified()`의 `executed.containsAll(all())`(78행)도 마찬가지로 항상 참이다. **조건부 형제**가 같은 testkit 안에 있다. `MongoChaosGate.report()`는 같은 일을 옳게 한다 — `executed`는 명시적 `record(scenario, passed)` 호출로만 채워지는 map이고, `missing`은 `all()`에서 기록되지 않은 것을 뺀 것이다. 그리고 그 test가 그것을 증명한다: `aScenarioThatNeverRanIsAFailureRatherThanASilence`는 13개 시나리오 중 **하나만** 기록하고 나머지가 `(not executed)`로 나타나는지 단언한다. contract suite의 대응 test는 그렇게 하지 않는다. `stableContractsRunOnEverySupportedLane`은 모든 contract에 `contract -> true`를 주고 나서 `report.executed()`가 전부를 담는지 단언한다 — 구조상 참인 명제다. **판정: P2.** 두 인증 lane(7.0/8.0)의 커버리지 주장이 무효다. 수정은 형제를 따르면 된다 — `run(...)`이 실행할 contract 집합을 인자로 받거나, runner가 실제로 호출된 것만 `executed`에 넣는 것. #### 95. P2 — release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다 이 leaf는 릴리스 증거 장치를 정성껏 만들었다. `MongoReleaseEvidenceVerifier`는 exit code 대신 **JUnit XML을 읽고**, testsuite 이름이 contract의 클래스와 일치하는지 확인하고, 파일이 실행 시작 시각보다 오래됐으면 거부하고, 전부 skip된 lane을 거부한다. 그 근거도 정확하다. > A Gradle test task exits zero when it runs the tests and also when its selector matched a different test… So "sharded topology certified" was satisfied by a hermetic unit test whose name happened to contain `Shard`. 그런데 그 장치가 실제로 지키는 목록을 열어 보면(`138-...` §8.3c, `src/config/mongodb/release-contracts.json`): | | id | task | class | topology | |---|---|---|---|---| | **blocking** | MONGO-REL-001 | `mongoStableContractTest` | `MongoModuleBoundaryTest` | none | | | MONGO-REL-002 | `mongoStableContractTest` | `MongoAdvancedRulesTest` | none | | | MONGO-REL-003 | `mongoStableContractTest` | `MongoTransactionRetryCoordinatorTest` | none | | **experimental** | MONGO-REL-010 | `mongoShardedTest` | `MongoShardedTopologyContractTest` | sharded | | | MONGO-REL-011 | `mongoAtlasTest` | `MongoAtlasContractTest` | atlas | | | MONGO-REL-012 | `mongoKmsTest` | `MongoKmsContractTest` | kms | 차단 계약 **셋 전부가 `topology=none`**, 즉 컨테이너가 필요 없는 hermetic 클래스다. experimental 셋은 **어느 build 파일에도 등록되지 않은 task**를 가리킨다(`grep mongoShardedTest build.gradle` → 매치 0; 스크립트가 그 사실을 스스로 적는다: "registered by no build file"). 그리고 컨테이너가 필요한 여섯 lane — `mongoReplicaSetTest`·`mongoFailoverTest`·`mongoMigrationTest`·`mongoCompatibilityTest`·`mongoSecurityIntegrationTest`·`mongoPerformanceTest` — 은 **차단 목록에 하나도 없다**. 그 위에 CI가 얹히지 않는다. `.github/workflows`에 26개 workflow가 있고 **mongo를 언급하는 것은 0개**다(`138-...` §8.4b, grep 매치 없음). 형제 leaf인 JPA는 일곱 개를 갖는다 — `jpa-pr`, `jpa-nightly`, `jpa-release`, `jpa-r2-evidence`, 그리고 `jpa-next-*` 세 개의 전방 호환 workflow. 여섯 mongo lane은 전부 기본 `test` task에서 제외돼 있으므로(§8.4), **사람이 손으로 부르지 않으면 아무 때도 돌지 않는다.** **판정: P2.** 이것은 개별 코드 결함이 아니라 이 leaf의 검증 지형이다. 그리고 앞선 sub-scope들에서 찾은 것들 — §67의 change stream 소실, §75의 TLS 미적용, §85의 sharding 미완료, §56의 fence 계약 — 이 왜 살아남았는지를 설명한다: **그것들을 잡을 lane은 릴리스를 막지 않고 CI에서 돌지 않는다.** 수정은 두 갈래다. (a) 컨테이너 lane 중 최소한 `mongoReplicaSetTest`·`mongoMigrationTest`·`mongoSecurityIntegrationTest`를 blocking contract로 승격하고, (b) JPA와 같은 형태의 workflow를 추가하는 것. #### 96. P3 — 소비자가 없는 fixture 셋 `138-...` §8.1의 소비자 계수에서 test·testkit 양쪽 모두 0인 타입이 셋이다. | 타입 | 무엇을 위한 것인가 | |---|---| | `MongoAtlasLocalContainer` | Atlas Local 컨테이너 — search·vector 계약의 빠른 피드백용. `MongoAtlasCapabilityContractSuite`(report 타입)는 test 1곳에서 쓰이지만, **실제 컨테이너를 띄우는 곳은 없다** | | `MongoChunkMigrationController` | 트래픽 중 청크 이동 — "production hits during a rebalance"를 재현하는 유일한 장치 | | `MongoRoundTripContract` | Java → BSON → **서버** → raw BSON → Java 왕복. javadoc: "Half a round trip proves nothing… only the raw BSON in the middle shows it" | 셋째가 가장 무겁다. `MongoReleaseContract`의 형제인 `MongoReplicaSetContract`는 `GOLDEN_BSON`을 열거된 계약으로 두는데, 그 계약을 실행하도록 만들어진 타입에 호출자가 없다. `MongoBsonSnapshot`·`MongoBsonSnapshotAssert`는 쓰이므로 **정규형 단언은 존재하지만 서버를 통과하는 왕복은 돌지 않는다** — 그리고 그 차이가 정확히 이 클래스가 존재하는 이유다. **P3.** #### 97. Negative-space probes — sub-scope 11 - **8.1 reachability**: 33개 testkit 타입의 소비자를 계수. 셋이 0(§96). 나머지는 test 또는 testkit 안에서 사용됨. - **8.2 조건부 형제**: 같은 testkit의 두 커버리지 gate 중 하나만 "실행되지 않음"을 표현할 수 있다(§94). 각자의 test가 그 차이를 그대로 반영한다. - **8.3 계약 목록의 소재**: `new MongoReleaseContract`는 test에만 있고, 정본은 `src/config/mongodb/release-contracts.json`(§95). experimental 셋은 존재하지 않는 task를 가리키며 스크립트가 그 사실을 명시한다 — **정직한 기록**이므로 결함이 아니라 confirmed. - **8.4 lane / CI drift**: 여섯 lane 정의는 있고 CI workflow는 없다(§95). build.gradle:87의 "382 hermetic contract tests"는 §0에서 측정한 **526**과 어긋난다(sub-scope 01의 문서 drift 항목과 동일 사안). #### 98. Sub-scope 11 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | release gate의 차단 계약 3개가 전부 `topology=none` hermetic 클래스이고, 컨테이너가 필요한 여섯 lane은 차단 목록에도 CI에도 없다(mongo workflow 0개, JPA는 7개) | 모든 릴리스 | | **P2** | `MongoStableContractSuite`의 `(not executed)` 분기와 `certified()`의 커버리지 검사가 구조적으로 도달 불가 — 형제 `MongoChaosGate`는 같은 일을 옳게 한다 | 7.0/8.0 인증 lane | | **P3** | 소비자 0인 fixture 셋: `MongoRoundTripContract`(GOLDEN_BSON 계약의 실행체), `MongoAtlasLocalContainer`, `MongoChunkMigrationController` | 해당 계약을 실제로 돌리려는 시점 | | **P3/기록** | `experimental_contracts`가 가리키는 세 task(`mongoShardedTest`·`mongoAtlasTest`·`mongoKmsTest`)가 어느 build 파일에도 없다 — 스크립트가 명시적으로 기록하고 있어 은폐는 아니다 | Advanced 승격 시점 | #### 99. Sub-scope 11 완료 조건 - denominator 49 / 49 FULL_READ (`138-...` OWNED FILES) - reachability(33종 소비자 계수)·조건부형제·계약목록 소재·lane/CI drift 4종 probe 수행 - 두 finding 모두 정적으로 결정 가능하여 실행 probe 불필요, 소스 미변경(`git status --short` = 0) --- #### 100. 모듈 원장 대조 `§0`의 denominator 497을 하위 범위 실측과 대조한다. | # | 하위 범위 | main | test | testkit | perf | 합 | 실측 근거 | |---|---|---|---|---|---|---|---| | 1 | governance / build / root / autoconfigure | 15 | 12 | – | 4 | 31 | `121`·`122` | | 2 | `api/**` | 61 | 9 | – | – | 70 | `127` | | 3 | `mapping`+`nativecap`+`geo` | 23 | 4 | – | – | 27 | `130` | | 4 | `imperative`+`reactive` | 47 | 14 | – | – | 61 | `131` | | 5 | `query`+`aggregation` | 22 | 7 | – | – | 29 | `132` | | 6 | `transaction` | 20 | 7 | – | – | 27 | `133` | | 7 | `schema`+`migration` | 49 | 9 | – | – | 58 | `134` | | 8 | `changestream` | 21 | 5 | – | – | 26 | `135` | | 9 | `security`+`failure`+`observation`+`client` | 30 | 14 | – | – | 44 | `136` | | 10 | `advanced/**` | 65 | 10 | – | – | 75 | `137` | | 11 | testkit + 미배정 test + perf | – | 13 | 35 | 1 | 49 | `138` | | | **합계** | **353** | **104** | **35** | **5** | **497** | | - main 353 = 351 Java + 2 비-Java(§0). 실측 LOC 합계 22,927. - test 104, testkit 35(3,036 LOC), perf 1(194 LOC), 기타 4(build/config/docs). - **unclassified 0, structural-only 0, excluded 0.** 11개 하위 범위 모두 FULL_READ. #### 101. 모듈 findings 종합 | 우선순위 | 개수 | 항목 | |---|---|---| | **P1** | 3 | §67 change stream pipeline의 high-water mark로 인한 조용한 영구 소실(실행 probe 3종) · §67의 두 번째 경로(실패 없이도 `CLAIMED_ELSEWHERE` 위치를 지나침) · §75 `MongoClientSettingsFactory` 미호출로 프로파일의 TLS·타임아웃·풀·Stable API가 driver에 도달하지 않음 | | **P2** | 9 | §42 aggregation executor의 collection registry·실행 scope 우회 · §49 transaction flag가 요구만 만들고 실행체 없음 · §56 `recordApplied`의 fence 계약 미구현(보호 역전) · §57 index diff가 두 필드만 비교 · §68 `changeStreams` 고정 false와 조립된 소비자의 불일치 · §85 sharding admin gateway 3/4 완료 불가 · §94 `MongoStableContractSuite` 커버리지 검사 도달 불가 · §95 release gate가 hermetic 3개만 차단하고 mongo CI workflow 0개 | | **P3 / 기록** | 20 | 각 sub-scope의 backlog 표 참조 | 가장 자주 반복된 형태는 셋이다. 1. **선언과 조립의 분리.** 정책·값 객체는 완성돼 있고 그것을 driver나 실행 경로에 붙이는 한 줄이 없다(§41·§60·§75·§78). 이 leaf가 fork를 위한 템플릿이라는 성격 때문에 상당 부분은 의도된 것이지만, §75처럼 **수리 코드 자체가 배선되지 않은** 경우와 §49·§68처럼 **flag와 실행체가 어긋난** 경우는 다르다. 2. **발화할 수 없는 guard.** `requireCorrectResumeOption`(자기 자신과 비교, §69), `MongoStableContractSuite`의 `(not executed)`(§94), 과거의 `requireDistinctCredentials`(role을 지문에 섞어 항상 통과 — 이미 수리됨, §74). 이 저장소는 이 패턴을 여러 번 스스로 찾아 고쳤고, 남은 것들은 같은 계열이다. 3. **문서가 코드보다 오래 산다.** `MongoPlatformSettings`의 "zero beans"(§68), build.gradle의 "382 hermetic tests"(실측 526), `FlamingockLockAdapter`의 "resumable migrations만 거부"(§59), README의 API surface 318/324(실측 338/350). 반대로 `MongoAdvancedEntryPoint`·`MongoAccessRules`·`MongoModuleBoundaryTest`는 문서였던 주장을 실행 가능한 규칙으로 바꾼 사례다(§84·§93). #### 102. 모듈 완료 조건 - denominator **497 / 497 FULL_READ** — 11개 하위 범위 전부 COMPLETE(§100) - 하위 범위마다 §8.1~§8.4 네 종 negative-space probe 수행, 증거는 `evidence/raw/121`–`138a` - 정적으로 결정 불가한 지점은 실행 probe로 확정: `124/124a`(설정 바인딩), `129/129a`(빈 타입 레지스트리 쓰기), `134a`(migration fence·index diff·Flamingock lease), `135a`(change stream 소실 3종), `136a`(TLS 미적용), `137`(sharding 4작업) - 임시 probe class는 모두 제거, 매 실행 후 `git status --short` = 0, `mongoStableContractTest` 재실행 green - 소스 미변경 — 문서화 작업만 수행 #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **230개** (main 180 · test 41 · 기타 9). ``` src/adapter/outbound/persistence-mongo/build.gradle src/config/architecture/modules.json (adapter-outbound-persistence-mongo 항목) main: src/app-bootstrap/src/main/resources/application.yml src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoOptInAutoConfigurationImportFilter.java src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceSettings.java src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoRootAutoConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityFlags.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityGuard.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedEntryPoint.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionEvidence.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGate.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/autoconfigure/MongoAdvancedConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/autoconfigure/MongoAdvancedSettings.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridge.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoPublishResult.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleClientFactory.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionProfile.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJob.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyDescriptor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/MongoShardingAdminGateway.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ReshardApproval.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyReadinessReport.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantClientRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantMigrationCoordinator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantPredicateInjector.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/TenantScopedMongoOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchBenchmarkGate.java src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/CollectionProfileName.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/DatabaseProfileName.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContext.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationScope.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoServerVersion.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyDescriptor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyProfile.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDataSchemaUnsupportedException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDocumentTooLargeException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoExecutionOutcome.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureCategory.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContext.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOperationRejectedException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoPersistenceException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoRetryScope.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTimeoutException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionCommitUnknownException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionTransientException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifest.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObservation.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObserver.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/NoOpMongoOperationObserver.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/package-info.java src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoDriverObservabilityAutoConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformHealthIndicator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformSettings.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoProfileProperties.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoTopologyProbe.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamPipeline.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamState.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamSubscription.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoClusterTime.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpoint.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStore.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeTokenCodec.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/ReactiveMongoChangeStreamConsumer.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/SpringReactiveChangeStreamSource.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeDeduplicationStore.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjectionResult.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjector.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunner.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeHistoryLostException.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoInvalidateRecovery.java src/main/java/dev/caskeleton/adapter/outbound/mongo/client/MongoClientSettingsFactory.java src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureTranslator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoDriverFailureView.java src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassification.java src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassifier.java src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureExtractor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureTranslator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoDistance.java src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPoint.java src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoQuery.java src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/SpringMongoGeospatialOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/BoundScopedOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/DefaultMongoImperativeExecutor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCompletion.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoConsistencyBinder.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoPlatformCollectionAccess.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoTemplateSupportContract.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/ScopedMongoOperations.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplate.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicPolicyRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkResult.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/SpringDataBulkFailureExtractor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdater.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigDecimalToDecimal128Converter.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigIntegerRepresentationConverters.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/LocalDateTimeMappingGuard.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoCustomConversionsFactory.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoTypeMetadataConfigurer.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapper.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLedger.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLock.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationCheckpoint.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationHeartbeat.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLedger.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLock.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunner.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLedgerAdapter.java src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLockAdapter.java src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/ApprovedMongoNativeOperation.java src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCapabilityGateway.java src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeOperationPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGateway.java src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MicrometerMongoOperationObserver.java src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoCommandObservationListener.java src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoDriverObservabilityConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConvention.java src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationRedactor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoOperator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoQueryPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoRegexPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilder.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcer.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetPolicyRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoOperationBudget.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursorCodec.java src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilder.java src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/DefaultReactiveMongoExecutor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoConsistencyBinder.java src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoContextKeys.java src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuard.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexApplyPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDescriptorView.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiff.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngine.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementState.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoCollectionManifest.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexManifest.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistry.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoMetadataOwnership.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/EmbeddedCollectionDescriptor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentSizeBudget.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlIndexDescriptor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicy.java src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiffEngine.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialReference.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialResolver.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminApproval.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditRecord.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuthorization.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminCommand.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminGateway.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminOperation.java src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuard.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionExecutor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfile.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionScope.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionSessionFactory.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionExecutor.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryBudget.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinator.java src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/SpringMongoCausalSessionExecutor.java test: src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoNamespaceContractTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfigTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidatorTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzerTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContextTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoAdvancedRulesTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoModuleBoundaryTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidatorTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamPipelineTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/consumer/ChangeStreamConsumerLifecycleTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunnerTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/client/MongoClientSettingsFactoryTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoNetworkFaultLaneTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapperTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationFencingTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLaneTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapterTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConventionTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityIntegrationLaneTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoTlsLaneTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditStateMachineTest.java src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinatorTest.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRules.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/arch/MongoAccessRules.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/arch/MongoAdvancedRules.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasCapabilityContractSuite.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasLocalContainer.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoStableContractSuite.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshot.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssert.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoRoundTripContract.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoChaosGate.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/release/MongoReleaseContract.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/release/MongoReleaseEvidenceVerifier.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetContract.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoChunkMigrationController.java 기타: CLAUDE.md README.md docs/architecture/mongo-api-surface.txt docs/mongodb/repository-adaptation.md docs/mongodb/runbooks/failover.md docs/mongodb/runbooks/history-lost.md docs/registries/env-keys.yaml src/build.gradle src/config/mongodb/release-contracts.json 해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등: evidence/raw/121-persistence-mongo-module-inventory.txt state.json evidence/raw/122-mongo-governance-optin-manifest.txt *.md evidence/raw/123-mongo-optin-reachability-and-siblings.txt application.yml evidence/raw/125-mongo-governance-doc-count-drift.txt 126-mongo-hermetic-lane-original-verification.txt *.java evidence/raw/126-mongo-hermetic-lane-original-verification.txt evidence/raw/127-mongo-api-scope-manifest.txt evidence/raw/128-mongo-api-negative-space-probes.txt ``` --- ## A07. adapter-outbound-identifier > 분석 중에는 `07-adapter-outbound-identifier.md` 파일이었다. 233줄. ### 07 · adapter-outbound-identifier #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-identifier` - canonical state `analysisFile`: §A07 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/identifier` · Gradle `:adapter:outbound:identifier` - registry `allowed_dependencies`: `["domain-core", "application-core"]` - registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]` - coverage ledger: `FULL_READ` **10** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: COMPLETE > revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 경로: `src/adapter/outbound/identifier` · Gradle: `:adapter:outbound:identifier` #### 0. Denominator와 coverage ledger 이 leaf는 tracked file **10개**다. 하위 범위로 나눌 크기가 아니라 한 범위로 처리한다. | 구분 | 파일 | LOC | 상태 | |---|---|---|---| | governance (`CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile`) | 4 | 297 | FULL_READ | | main Java (`HmacUserPrincipalPseudonymizer`, `RandomUploadIdentifierFactory`, `UuidCodec`, `package-info`) | 4 | 114 | FULL_READ | | test Java (`HmacUserPrincipalPseudonymizerTest`) | 1 | 112 | FULL_READ | | test Groovy (`UuidCodecSpec`) | 1 | 39 | FULL_READ | | **합계** | **10** | **562** | **10 / 10 FULL_READ** | structural-only 0 · excluded 0 · unclassified 0. manifest: `evidence/raw/139-identifier-module-inventory.txt`. probe(정적 + 실행): `evidence/raw/140-identifier-negative-space-probes.txt`. 레지스트리 항목: ```json { "id": "adapter-outbound-identifier", "gradle_path": ":adapter:outbound:identifier", "allowed_dependencies": ["domain-core", "application-core"], "runtime_memberships": ["app-bootstrap", "sample-portfolio"] } ``` #### 1. 이 모듈이 존재하는 이유 CLAUDE.md와 README가 같은 논거를 편다: **외부 시스템 연동이 없는(non-IO) 인프라 능력**만 모아 두어, `adapter-outbound`가 문서화된 의미("외부 HTTP / messaging / cache / notifications")를 유지하게 한다는 것이다. > Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is infrastructure, not an outbound integration point. 그 논거의 예시로 드는 능력이 **UUID id/codec**이다. §3에서 보듯 그 능력에는 production 소비자가 없고, 실제로 배선돼 도는 것은 나머지 둘(가명화, 업로드 식별자)이다. #### 2. Confirmed — `HmacUserPrincipalPseudonymizer`는 이 leaf에서 가장 잘 만들어진 부분이다 `UserPrincipalPseudonymizerPort`(application-core)의 유일한 구현이고, `app-bootstrap`과 `sample-portfolio`가 각자 싱글톤 빈으로 배선한다(`140-...` §8.1). 결정들이 코드와 문서 양쪽에 맞물려 있다. - **thread safety를 타입이 아니라 수명으로 푼다.** `Mac`은 thread-safe가 아니므로 `pseudonymize` 호출마다 새로 만든다 — 그래서 공유 싱글톤으로 안전하다. 주석이 그 이유를 그 자리에 적는다. - **salt를 스스로 조달하지 않는다.** `APP_PRIVACY_PSEUDONYMIZATION_SALT`에서 `app-bootstrap`이 공급하고(분류 secret, 90일 회전), 생성자는 null/빈 배열을 거부하며 거부 메시지가 그 환경변수 이름을 그대로 말한다. - **방어적 복사.** `salt.clone()`으로 호출자 배열을 붙들지 않는다. - **도달 불가 예외를 정직하게 감싼다.** `HmacSHA256`은 JCA 필수 알고리즘이라 `NoSuchAlgorithmException`·`InvalidKeyException`은 사실상 도달 불가이고, 호출부에 checked exception 잡음을 남기지 않으려 `IllegalStateException`으로 감싸며 주석이 "this should never happen on a compliant JDK"라고 적는다. - **Spring-free.** 어노테이션이 없고 빈 생성은 composition root 책임이다. test 11개가 경계를 실제로 나눠 덮는다 — 생성자 가드 2, null/blank/empty 입력 3, 같은 salt의 결정성(같은 인스턴스·다른 인스턴스) 2, salt 민감도 1, 단방향성 2(출력≠입력, 출력이 입력을 부분문자열로 포함하지 않음), 출력 포맷 1(`^[0-9a-f]{64}$`). 값 하나를 고정하는 golden test가 아니라 **성질**을 검사한다. `RandomUploadIdentifierFactory`의 판단도 기록해 둘 만하다. 파일 식별자가 공개 핸들이므로 시퀀스나 타임스탬프가 아니라 암호학적 난수에서 뽑는다고 적고, 그 대가까지 명시한다 — "A time-ordered identifier would be **the better database key**, and is deliberately not used: it would let anyone holding one id infer when neighbouring files were created and enumerate towards them." 결정과 그 결정이 포기한 것을 함께 적는 서술이다. 경계 규칙도 실재한다. CLAUDE.md가 이름을 대는 ArchUnit 규칙은 `CleanArchitectureTest`에 **대문자 상수** `IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP`로 존재하고, inbound web · persistence · bootstrap · Spring Data · JPA · Hibernate를 금지하며 형제 배제(`..adapter.outbound..` 중 자기 패키지 제외)까지 처리한다(`140-...` §8.4e). CLAUDE.md의 표기가 snake_case일 뿐 가드는 진짜다 — **confirmed match**. #### 3. P2 — 모듈의 존재 논거인 `UuidCodec`에 production 소비자가 없다 `140-...` §8.1·§8.1b의 저장소 전수 검색 결과: | 타입 | leaf 밖 production 소비자 | |---|---| | `HmacUserPrincipalPseudonymizer` | `app-bootstrap`, `sample-portfolio` (각 1) | | `RandomUploadIdentifierFactory` | `app-bootstrap` (1) | | **`UuidCodec`** | **0** | `UuidCodec.` 형태의 호출은 저장소 전체에서 **자기 Spock 스펙 5줄뿐**이다. 이름이 겹쳐 걸린 나머지 둘은 무관하다 — mongo testkit의 `org.bson.codecs.UuidCodec`(드라이버 타입)과 `sample-portfolio/README.md:333`의 산문 언급("`UuidCodec` 같은 공용…"). 그 자리를 대신 채우고 있는 것들이 있다. `UUID.fromString`을 직접 부르는 파일이 leaf 밖에 20개 이상이고(graphql `UuidScalar`, notification `JacksonRoutingPlanCodec`, jpa `PostgreSqlIdempotencyClaimRepository`, application-core `FileId`/`UploadId` …), CLAUDE.md가 `toUuid`/`fromUuid`의 목적으로 든 **PostgreSQL `uuid` 컬럼 변환(D10)**은 실제로는 Hibernate의 `@JdbcTypeCode(SqlTypes.UUID)`가 처리한다(`140-...` §8.4b, JPA 엔티티 다수). **판정: P2.** 코드 자체에는 결함이 없다 — 30줄짜리 유틸이고 자기 스펙을 통과한다. 문제는 §1의 논거다. 모듈을 `adapter-outbound` 밖에 두는 근거로 "UUID id/codec 능력"을 들고 있는데, 그 능력은 아무도 쓰지 않고 같은 일이 저장소 곳곳에서 각자 수행된다. 나머지 두 타입(가명화·업로드 식별자)만으로도 non-IO 능력 모듈의 논거는 성립하므로, 수정은 둘 중 하나다: `UuidCodec`을 실제 단일 경로로 만들거나(그러면 §4가 먼저 고쳐져야 한다), 모듈의 논거에서 빼는 것. #### 4. P2 — `normalize`는 canonical이 아닌 입력을 받아 다른 UUID로 조용히 바꾼다 `UuidCodec.normalize`의 계약은 Javadoc과 README 양쪽에 적혀 있다. > Accepts a **case-insensitive canonical UUID** string and returns the canonical 36-character lowercase form… `@throws IllegalArgumentException` on a **malformed** UUID. > (README) 형식 오류 UUID 에는 `IllegalArgumentException`. 구현은 `UUID.fromString(input).toString()` 한 줄이다. JDK의 `UUID.fromString`은 길이 36 fast path 밖에서 **대시로 나뉜 5개 hex 그룹을 길이 검사 없이** 받는다. 실행 probe로 확인했다(`140-...` EXECUTION PROBE). ``` PROBE normalize("0190BD6E-7C3E-7ABC-8DEF-0123456789AB") -> "0190bd6e-7c3e-7abc-8def-0123456789ab" ← 의도된 동작 PROBE normalize("1-1-1-1-1") -> "00000001-0001-0001-0001-000000000001" PROBE normalize("0-0-0-0-0") -> "00000000-0000-0000-0000-000000000000" PROBE normalize("1-2-3-4-5") -> "00000001-0002-0003-0004-000000000005" PROBE normalize("0190bd6e7c3e7abc8def0123456789ab") -> IllegalArgumentException PROBE normalize("not-a-uuid") -> IllegalArgumentException ``` `"1-1-1-1-1"`은 canonical UUID가 아니다. 계약대로면 `IllegalArgumentException`이어야 하는데, 수용된 뒤 **다른 문자열로 재작성되어** 반환된다. 결과적으로 서로 다른 두 입력(`"1-1-1-1-1"`과 `"00000001-0001-0001-0001-000000000001"`)이 같은 식별자로 정규화되고, 거부됐어야 할 값이 정상적으로 보이는 id가 된다. `normalize`는 D3 — 호출자가 준 텍스트를 저장 형태로 바꾸는 지점 — 이므로, 관대함이 남는 위치가 하필 신뢰 경계다. 기존 스펙이 이것을 놓친 이유도 코드에 있다. `UuidCodecSpec`의 거부 케이스는 `"not-a-uuid"` **하나**이고, 그 문자열은 대시 그룹이 5개가 아니라 관대한 경로에 닿지 않는다. **도달성.** 지금 이 메서드를 부르는 production 코드는 없다(§3). 그래서 현재 노출은 0이고, `UuidCodec`을 단일 경로로 승격하는 순간 결함이 된다. **판정: P2.** 수정은 `input.length() != 36`이거나 대시 위치가 8-13-18-23이 아니면 먼저 거부하는 것 — 또는 계약 문구를 실제 동작(JDK 관대 파싱)에 맞추는 것이다. 전자가 문서가 말하는 바다. #### 5. P2 — 문서는 UUIDv7이라고 말하고, 생성되는 것은 v4다 CLAUDE.md:21과 README:19가 같은 문장을 쓴다 — `UuidCodec`은 "JDK `java.util.UUID` (**RFC 9562 UUIDv7**)" 위에서 동작한다. README:12도 이 모듈의 능력을 "식별자 생성/인코딩(**UUIDv7**)"이라 적는다. 그리고 이 주장은 leaf 밖으로도 번져 있다 — `CleanArchitectureTest`가 이 패키지를 설명하는 세 곳(:948, :981, :1310)이 전부 "UUIDv7 id/event-id generation & codec"이라고 쓴다. 실행 probe(`140-...`): ``` PROBE newFileId version=4 variant=2 PROBE newUploadId version=4 variant=2 PROBE UUID.randomUUID() version=4 (RFC 9562 UUIDv7 would report version=7) ``` `java.util.UUID`에는 v7 생성기가 없고, 이 leaf의 유일한 생성기 `RandomUploadIdentifierFactory`는 `UUID.randomUUID()`(v4)를 쓴다. 더 분명한 것은 **그 클래스의 javadoc이 v7을 명시적으로 거부한다**는 점이다 — "A time-ordered identifier would be the better database key, and is **deliberately not used**." 즉 코드는 숙고된 결정을 내렸고, 모듈 문서와 아키텍처 test의 설명문이 **정반대 결정을 서술**한다. 저장소의 진짜 UUIDv7은 다른 곳에 있다: `sample-portfolio`가 `com.github.f4b6a3:uuid-creator`를 자기 `build.gradle`에 선언하고 `UuidCreator.getTimeOrderedEpochPlus1()`을 쓴다(`SampleOperationStore:27`). **판정: P2.** 런타임 동작은 옳다 — 잘못된 것은 세 문서와 한 test의 설명문이다. 그러나 "이 모듈이 UUIDv7을 제공한다"는 서술은 fork가 시간정렬 키를 기대하게 만들고, 그 기대는 인덱스 지역성과 열거 가능성 양쪽에서 반대 방향의 결과를 낳는다. #### 6. P3 — CLAUDE.md의 의존성 서술이 세 항목 모두 틀렸다 CLAUDE.md:31–33: > `:application-code`, `:domain-core`, `:shared-contract` (Gradle matrix). **Currently only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in build.gradle.** `build.gradle`의 실제 `dependencies` 블록은 두 줄이다. ```groovy implementation project(':application-core') testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' ``` - `:domain-core` — 선언돼 있지 **않다**. - `uuid-creator` — 선언돼 있지 **않다**(저장소 검색상 이 leaf의 classpath에 없다; `sample-portfolio`와 `app-bootstrap`의 lockfile에만 있다). - `:application-core` — 선언돼 **있는데** 문장은 언급하지 않는다. 레지스트리의 `allowed_dependencies`(`domain-core`, `application-core`)와 실제 선언(부분집합)은 정합한다. 어긋난 것은 CLAUDE.md의 서술뿐이다. **P3.** #### 7. P3 — README의 세 가지 사실 오류 | README | 실제 | |---|---| | :3 패키지 루트 `dev.caskeleton.adapter.identifier` | `dev.caskeleton.adapter.outbound.identifier` (CLAUDE.md:11은 정확) | | :59 "Spock 2.4 / **Groovy 4.0** variant" | `spock-core:2.4-groovy-**5.0**` | | :64 edge는 `src/build.gradle`의 `allowedProjectDependencies['**adapter-identifier**']`로 허용 | `build.gradle:1416`의 `allowedProjectDependencies`는 리터럴 맵이 아니라 `registry.modules.collectEntries { … }`로 **레지스트리에서 파생**되며, 이 모듈의 키는 `adapter-outbound-identifier`다 | 셋 다 메커니즘 자체는 실재하고 동작한다 — 틀린 것은 이름과 버전이다. **P3.** #### 8. P3 — CLAUDE.md가 대는 두 가드 중 하나는 저장소에 없다 CLAUDE.md:38–39가 금지 사항의 근거로 둘을 든다. 1. ArchUnit `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap` — **존재한다**(§2, 상수명은 대문자). confirmed. 2. `.claude/hooks/ca_import_gate.py` **G4가 쓰기 시점에 차단** — `.claude/` 디렉터리에는 `settings.local.json` 하나뿐이고 `hooks/` 하위 디렉터리도 `ca_import_gate.py`도 **tracked 되어 있지 않다**(`140-...` §8.4e). 개발자 머신에 로컬로 존재할 여지는 있으나, 저장소를 새로 clone한 사람에게 그 가드는 없다. "쓰기 시점에 차단된다"는 서술은 clone에서 성립하지 않는다. **P3.** #### 9. P3/기록 — 결정 SSOT가 이 revision에서 해석되지 않는다 CLAUDE.md와 README가 `UuidCodec`의 동작을 **D3**(normalize)와 **D10**(toUuid/fromUuid)로 지목하고, 모듈 분류 근거로 "feature-resource-identifier-contract §4 taxonomy"를 든다. `CleanArchitectureTest`도 같은 문서를 §4·D5·D9·D17로 네 곳에서 인용한다. 그 문서는 이 revision에 **파일로 존재하지 않는다**(`find -iname '*resource-identifier*'` 매치 0; `docs/`에서 걸리는 D3/D10은 전부 MongoDB의 무관한 노출 평면 표기다). `CleanArchitectureTest:2432`의 주석이 이유를 밝힌다 — "decision SSOT: **resource-identifier branch**". 즉 다른 브랜치에 있다. 은폐가 아니라 명시된 상태이므로 결함으로 올리지 않고 기록한다. 다만 이 leaf의 문서가 자기 동작의 근거로 대는 결정 ID들은 이 브랜치만 읽어서는 확인할 수 없고, §4·§5의 어긋남이 "구현이 결정을 벗어난 것"인지 "결정이 그 사이 바뀐 것"인지도 여기서는 판정 불가다. **P3/기록.** #### 10. Negative-space probes - **8.1 public-surface reachability**: 세 production 타입의 저장소 전수 소비자 계수. `UuidCodec` 0(§3), 나머지 둘은 composition root가 배선. - **8.2 조건부 형제 비교**: `UuidCodec`의 세 자매 메서드가 null을 다르게 다룬다 — `normalize(null)` → `null`(문서화됨), `toUuid(null)` → `NullPointerException`, `fromUuid(null)` → `NullPointerException`(둘 다 미문서화). 실행 probe로 확인. 계약 문구는 `normalize`에만 있다. **P3.** - **8.2b 계약 ↔ 구현 대조**: `normalize`의 "canonical만 수용" 주장과 JDK 관대 파싱(§4). 실행 probe로 확정. - **8.3 중복 mechanism sweep**: UUID 문자열 변환이 leaf 밖 20+ 파일에서 `UUID.fromString`으로 각자 수행되고, D10이 지목한 PostgreSQL `uuid` 컬럼 변환은 Hibernate `@JdbcTypeCode(SqlTypes.UUID)`가 담당(§3). 저장소의 UUIDv7 생성은 `sample-portfolio`의 `UuidCreator`(§5). - **8.4 문서/개수 drift**: §5(UUID 버전, leaf 문서 3곳 + 아키텍처 test 3곳) · §6(의존성 서술 3항목) · §7(패키지 루트·Groovy 버전·설정 키) · §8(가드 하나 부재) · §9(결정 SSOT 미해석). #### 11. Findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `UuidCodec.normalize`가 canonical이 아닌 5-그룹 입력(`"1-1-1-1-1"` 등)을 수용해 다른 UUID로 재작성한다 — Javadoc·README는 canonical만 받고 malformed는 거부한다고 적는다 (실행 probe) | 현재 호출자 0; `UuidCodec`을 단일 경로로 쓰는 순간 신뢰 경계 결함 | | **P2** | `UuidCodec`에 production 소비자가 0인데, 모듈을 `adapter-outbound` 밖에 두는 논거가 바로 이 "UUID id/codec 능력"이다. 같은 변환이 leaf 밖 20+ 파일에서 각자 수행되고 D10의 대상은 Hibernate가 처리한다 | 문서/모듈 경계 논거 | | **P2** | leaf 문서 3곳과 `CleanArchitectureTest` 설명문 3곳이 "RFC 9562 UUIDv7"이라 적지만 유일한 생성기는 v4를 만들고, 그 클래스의 javadoc은 시간정렬 id를 명시적으로 거부한다 (실행 probe: version=4) | fork가 시간정렬 키를 기대하는 경우 | | **P3** | CLAUDE.md:31–33의 build.gradle 선언 서술이 세 항목 모두 사실과 다르다 | 문서 | | **P3** | README의 패키지 루트·Spock/Groovy variant·`allowedProjectDependencies` 키 3건 오류 | 문서 | | **P3** | CLAUDE.md가 대는 write-time 가드 `.claude/hooks/ca_import_gate.py`가 저장소에 tracked 되어 있지 않다 | 새 clone | | **P3** | `UuidCodec` 세 자매 메서드의 null 처리 비대칭이 `normalize`에만 문서화돼 있다 | 호출 시점 | | **P3/기록** | 결정 SSOT `feature-resource-identifier-contract`(§4·D3·D5·D9·D10·D17)가 다른 브랜치에 있어 이 revision에서 해석되지 않는다 | 결정 대조 | #### 12. 완료 조건 - denominator **10 / 10 FULL_READ** — structural-only 0 · excluded 0 · unclassified 0 (§0) - §8.1(공개 표면 도달성) · §8.2(조건부 형제) · §8.3(중복 mechanism) · §8.4(문서/개수 drift) 네 종 probe 수행 - 정적으로 결정 불가한 세 지점(normalize의 실제 수용 범위, 세 자매의 null 처리, 생성되는 UUID 버전)을 실행 probe로 확정(`140-...`) - CLAUDE.md가 대는 두 가드를 각각 추적해 **하나는 실재(confirmed)**, 하나는 부재로 분리 판정 — 이름 표기 차이를 결함으로 올리지 않았다 - 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경 #### Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **8개** (main 3 · test 1 · 기타 4). ``` src/adapter/outbound/identifier/build.gradle src/config/architecture/modules.json (adapter-outbound-identifier 항목) main: src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java src/main/java/dev/caskeleton/adapter/outbound/identifier/RandomUploadIdentifierFactory.java src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java test: src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java 기타: CLAUDE.md README.md src/build.gradle src/sample-portfolio/README.md 해석되지 않은 인용 (3종) — 외부 타입·문서상 약칭 등: evidence/raw/139-identifier-module-inventory.txt evidence/raw/140-identifier-negative-space-probes.txt settings.local.json ``` --- ## A08. adapter-outbound-fileserver > 분석 중에는 `08-adapter-outbound-fileserver.md` 파일이었다. 768줄. ### 08 · adapter-outbound-fileserver #### SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-fileserver` - canonical state `analysisFile`: §A08 (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/fileserver` · Gradle `:adapter:outbound:fileserver` - registry `allowed_dependencies`: `["application-core", "shared-contract"]` - registry `runtime_memberships`: `["app-bootstrap"]` - coverage ledger: `FULL_READ` **119** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) — `EVD-334`의 로케일 finding이 이 리프의 것이다 > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: COMPLETE > revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 경로: `src/adapter/outbound/fileserver` · Gradle: `:adapter:outbound:fileserver` #### 0. Denominator와 coverage ledger tracked file **119개** — main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 약 24.7k LOC. `build.gradle`에 별도 source set이나 test lane 선언이 없다(`main`/`test`뿐). 레지스트리: ```json { "id": "adapter-outbound-fileserver", "gradle_path": ":adapter:outbound:fileserver", "allowed_dependencies": ["application-core", "shared-contract"], "runtime_memberships": ["app-bootstrap"] } ``` leaf 밖 소비자는 `app-bootstrap` 하나다 — `CaSkeletonApplication` + `autoconfigure/fileserver/**` 6개 config 클래스, 그리고 test 3개. 패키지 배치(main): 루트 `fileserver` 31 · `platform/local` 33 · `platform/verification` 10 · `platform/security` 2 · `platform/audit` 2. ##### 하위 범위 원장 | # | 범위 | main | test | 합 | 상태 | |---|---|---|---|---|---| | 1 | governance / build / config / activation (+ governance 4) | 7 | 2 | 13 | **COMPLETE** | | 2 | control plane + control record codec + recovery verifier | 3 | 3 | 6 | **COMPLETE** | | 3 | publication — provider · adapter · journal · attestor · binding | 19 | 7 | 26 | **COMPLETE** | | 4 | `platform/local` IO primitive · gateway · store/publisher | 22 | 8 | 30 | **COMPLETE** | | 5 | `platform/local` failure·probe·health·orphan + verification + security + audit | 24 | 5 | 29 | **COMPLETE** | | 6 | payload operations · CSV export · testkit 계약 · crash matrix | 3 | 12 | 15 | **COMPLETE** | | | **TOTAL** | **78** | **37** | **119** (governance 4 포함) | **6 / 6** | manifest: `evidence/raw/141-fileserver-module-inventory.txt`. --- #### 1. Sub-scope 01 범위와 denominator > 내부 상태: COMPLETE — **13 / 13 FULL_READ** > 범위: governance 4 + config/activation main 7 + 전용 test 2 > 역할: R1(CSV export)과 R2(local-persistent publication) **두 개의 opt-in 선택자**를 서로 혼동될 수 없게 분리하고, 잘못된 조합을 파일시스템에 손대기 전에 거부한다 manifest와 probe: `evidence/raw/142-fileserver-config-activation-probes.txt`. #### 2. 선택자 세 개가 각자 다른 것을 켠다 이 leaf는 이름이 비슷한 세 능력을 명시적으로 갈라 둔다(CLAUDE.md). | namespace | 무엇을 켜는가 | 소유 | |---|---|---| | `app.fileserver.*` | R2 publication (`local-persistent`) | 이 leaf | | `app.file-export.*` | R1 CSV export (+ `legacy-enabled`로 덮어쓰기 가능 legacy port) | 이 leaf | | `app.fileserver-platform.*` | HTTP Fileserver **플랫폼**(업로드/다운로드/수명주기 라우트) | `app-bootstrap` | 셋 다 기본 off이고, R1과 R2 동시 활성화는 파일시스템 초기화 **전에** 실패한다. `FileserverActivationValidator.rejectAmbiguous(environment)`가 세 bean factory 메서드의 **첫 줄**에서 호출되고(`FileExportConfig:33`·`:49`, `FileserverR2Config:31`), `Binder`로 두 selector를 직접 읽으므로 bean 정의 순서에 의존하지 않는다. test가 그 순서를 고정한다 — `enablingLegacyR1AndR2TogetherFailsBeforeEitherFilesystemIsMutated`는 실패 후 R2 루트의 `.ca-fileserver`·`data`와 R1/legacy 루트가 **모두 존재하지 않음**을 단언한다. R2 쪽 조립은 fail-closed가 촘촘하다. `FileserverR2Config.routingFilePublicationPort`는 destination을 컴파일하고, 서로 다른 provider ID가 같은 루트를 소유하는 조합을 거부하고(`rejectSharedRootAcrossProviderIds`), provider ID별로 **하나의** attestor/control plane/payload 런타임을 만들어 같은 provider를 지목한 모든 destination이 공유하게 한다. test 둘이 그 공유/분리를 각각 확인한다(`destinationsBoundToOneProviderReuseOneProviderRuntime`, `destinationsBoundToDifferentProvidersUseDifferentProviderRuntimes`). `FileserverR2Validation`은 값 검증을 한곳에 모은다 — ID는 `[a-z][a-z0-9-]{0,62}`이고 **이미 정규화돼 있어야** 하며, 경로는 **이미 절대·정규화**돼 있어야 하고, sentinel 이름은 `.`/`..`/구분자/제어문자를 거부한 뒤 UTF-8 인코딩 길이 255바이트와 `getNameCount()==1`까지 확인한다. `maximum-root-mode`는 네 자리 8진수만 받고 **group/world write를 별도로 거부**한다(`(group & 2) != 0 || (others & 2) != 0`). #### 3. Confirmed — 비활성 상태에서 부작용이 없다는 것을 test가 실제로 확인한다 `disabledR2CreatesNoPortOrFilesystemSideEffect`는 bean 부재만이 아니라 **설정된 루트가 생성되지 않았음**(`assertThat(absentRoot).doesNotExist()`)까지 단언한다. `unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored`도 실패 후 `.ca-fileserver`·`data` 부재를 확인한다. "비활성이면 아무 일도 없다"를 bean 목록이 아니라 파일시스템으로 검증하는 형태다. `rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink`는 심볼릭 링크로 우회한 루트 겹침까지 본다 — `canonicalDirectory`가 `toRealPath()`로 정규화한 뒤 `startsWith`로 양방향 포함을 검사하기 때문에 잡힌다. #### 4. P2 — README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean이 있다 README:103–105의 guarantee boundary 문단이 이렇게 끝난다. > Cross-node producer fencing, background reconciliation/reaping, retention, quota/backpressure, readiness/health, metrics, tracing, and audit are also not implemented. **No setting or bean for those capabilities is exposed.** `app-bootstrap`이 그중 넷에 대해 이 leaf의 타입으로 bean을 만든다(`142-...` §8.4f). | README가 "없다"고 한 것 | 실제 bean | 만드는 곳 | |---|---|---| | audit | `StructuredAdminAuditAdapter`, `StructuredFileserverAuditAdapter` | `FileserverSecurityConfiguration:71`·`:77` | | readiness/health | `LocalStorageHealthAdapter` | `FileserverStorageConfiguration:179` | | background reconciliation/reaping | `LocalOrphanScanAdapter`, `LocalReconciliationContentProbe` | `FileserverStorageConfiguration:189`·`:203` | | quota | `LocalStorageUsageProbe` | `FileserverStorageConfiguration:196` | **공정하게 볼 지점.** 코드 배치 자체는 앞뒤가 맞는다. 이것들은 R2 publication이 아니라 **HTTP Fileserver 플랫폼**(`app.fileserver-platform.*`, CLAUDE.md가 "owned by `app-bootstrap`"이라 적는 별개 능력)의 부품이고, `build.gradle`의 description도 이 leaf가 "the local filesystem content platform behind the HTTP Fileserver"를 함께 담는다고 밝힌다. main 78개 중 **67개가 `platform/**`**라는 사실이 그 비중을 보여 준다. 잘못된 것은 문단의 범위다. "No setting or bean for those capabilities is exposed"에는 한정어가 없고, 이 문단은 독자가 **이 모듈이 무엇을 제공하고 무엇을 제공하지 않는지** 확인하러 오는 자리다. 그 자리에서 "audit은 구현돼 있지 않다"를 읽은 사람은 감사 기록이 없다고 결론짓는데, 같은 저장소가 두 개의 audit adapter를 bean으로 만든다. **판정: P2.** 수정은 문단을 R2 publication 범위로 한정하고, 같은 leaf가 담는 플랫폼 부품이 별도 namespace로 조립된다는 사실을 그 자리에 적는 것이다. (이 finding의 나머지 절반 — 그 bean들이 실제로 무엇을 보장하는가 — 은 `platform/**`을 읽는 sub-scope 05에서 다룬다.) #### 5. P3 — R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다 같은 leaf 안의 두 selector가 설정을 다르게 다룬다. | | R2 `app.fileserver.*` | R1 `app.file-export.*` | |---|---|---| | 바인딩 타입 | `record` + **`ignoreUnknownFields = false`** | 가변 JavaBean, 기본값(**미지의 키 무시**) | | 루트 경로 | `requireAbsoluteNormalizedPath` — 이미 절대·정규화여야 함 | `Path.of(v).toAbsolutePath().normalize()` — 상대 경로 허용, CWD 기준 절대화 | | 기본 루트 | 없음(필수) | `./.data/fileserver`, `./.data/fileserver-legacy` | | 디렉터리 생성 | 하지 않음(attestation이 별도로 요구) | `Files.createDirectories(root)`로 **생성** | | 미지 키 test | `unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored` | **없음** | R2에서는 `strict-path-securty` 같은 오타가 컨텍스트를 실패시킨다. R1에서는 `app.file-export.maximum-rowz=10` 같은 오타가 조용히 무시되고, 설정했다고 믿는 상한이 적용되지 않은 채 기본값 1,000,000이 쓰인다. 두 selector가 같은 leaf의 같은 성격 설정인데 한쪽만 fail-closed다. **P3** — R1은 문서상 "compatibility only"이므로 우선순위를 낮춘다. #### 6. P3 — 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다 - README는 R2 selector가 "`app-bootstrap/application.yml`에서 `false`로 기본값을 갖는다"고 적는다. 그 파일에 `app.fileserver.enabled`도 `app.file-export.enabled`도 **없다**(`142-...` §8.4e; `app.fileserver`로 걸리는 두 줄은 주석이다). 실효 기본값은 "속성 부재 → `@ConditionalOnProperty` 미매치 → bean 없음"이고 동작은 옳지만, 문서가 가리킨 자리에는 그 키가 없다. - README의 Tests 목록 첫 항목 `FilePublicationContractTest`는 이 leaf가 아니라 `application-core`에 있다. #### 7. Confirmed — 적재 경로는 auto-configuration이 아니라 명시적 component scan이다 이 leaf에는 `META-INF/spring/…AutoConfiguration.imports`가 **없다**(`142-...` §8.1). `FileExportConfig`/`FileserverR2Config`를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(`OptionalAdapterBeanGatingTest`)다. 실제 적재는 `CaSkeletonApplication`의 명시적 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.fileserver`를 목록에 올려서 이루어진다(`:75`). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 **classpath 존재만으로 bean이 생기지 않는다**는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 `@ConditionalOnProperty`로 막힌다"이다. fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다. #### 8. Negative-space probes — sub-scope 01 - **8.1 reachability**: auto-configuration 등록 metadata 0, 적재는 명시적 component scan(§7). 세 bean 모두 `@ConditionalOnProperty` 게이트. - **8.2 조건부 형제**: R1 vs R2의 설정 엄격도·경로 규칙·디렉터리 생성·test 커버리지 비대칭(§5). - **8.3 중복 mechanism**: `rejectAmbiguous` 호출 3곳은 중복이 아니라 **각 진입점의 첫 줄**이라는 배치다 — `Binder`로 환경을 직접 읽으므로 bean 순서에 무관하고, app-bootstrap의 `FileserverStartupValidator`는 R1/R2 selector가 아니라 플랫폼 저장소 probe 결과를 검증하는 별개 장치다(중복 아님). - **8.4 문서/개수 drift**: §4(가장 무거움) · §6(기본값 위치, test 목록). #### 9. Sub-scope 01 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reconciliation/reaping·quota 넷에 대해 사실과 다르다 — 모두 `app-bootstrap`이 이 leaf의 타입으로 bean을 만든다 | 이 문단을 근거로 능력 유무를 판단하는 독자 | | **P3** | `FileExportSettings`에 `ignoreUnknownFields=false`가 없어 `app.file-export.*` 오타가 조용히 무시된다(R2는 거부하고 test도 있다) | R1을 켠 배포의 설정 오타 | | **P3** | R1 루트는 상대 경로를 허용해 CWD 기준으로 절대화하고 디렉터리를 생성하는데, R2는 이미 절대·정규화된 경로만 받는다 — 같은 leaf의 두 selector가 다른 규칙 | R1 배포 | | **P3** | README가 지목한 selector 기본값 위치(`app-bootstrap/application.yml`)에 해당 키가 없고, Tests 목록의 `FilePublicationContractTest`는 `application-core` 소속이다 | 문서 | #### 10. Sub-scope 01 완료 조건 - denominator 13 / 13 FULL_READ (`142-...` OWNED FILES) - §8.1(적재 경로)·§8.2(R1/R2 형제)·§8.3(중복 아님 확인)·§8.4(문서 drift) 네 종 probe 수행 - §4는 app-bootstrap의 bean 생성 지점을 직접 확인해 판정했고, 그 bean들이 무엇을 보장하는지는 sub-scope 05로 이월 - 소스 미변경 --- #### 11. Sub-scope 02 범위와 denominator > 내부 상태: COMPLETE — **6 / 6 FULL_READ** > 범위: `LocalPersistentControlPlane` 1,470 + `FileserverControlRecordCodec` 855 + `LocalPersistentRecoveryVerifier` 210 (main 3, 2,535 LOC) + 전용 test 3 (3,240 LOC) > 역할: R2의 **강제된(forced) 제어 평면** — 세 종류의 canonical 제어 레코드를 저장·검증하고, 상태 전이를 인접 행렬로 강제하며, 협력 프로세스를 JVM+OS 락으로 직렬화한다 manifest와 probe: `evidence/raw/143-fileserver-control-plane-probes.txt`. test/main 비율이 **1.28**이다. 이 sub-scope에서 찾은 결함은 없고, 아래는 왜 없는지에 대한 기록이다. #### 12. Confirmed — codec이 "canonical"을 왕복으로 강제한다 `FileserverControlRecordCodec`은 세 레코드와 receipt snapshot에 대해 **decode 직후 재encode해 바이트를 비교**한다(`requireCanonical(bytes, encodeOperation(record))`). 그래서 "파싱은 되지만 우리가 쓰지 않았을 형태"가 전부 거부된다 — 공백, 필드 재배열, `A` 같은 이스케이프, `-0`/선행 0 같은 숫자 표기, 후행 콘텐츠. 파서 자체도 좁다. - 필드 집합을 **정확히 일치**시킨다(`values.keySet().equals(allowedFields)`) — 누락도 미지 필드도 거부. - 중복 키를 거부한다(`putIfAbsent`). - UTF-8 디코딩이 `REPORT` 모드라 malformed 바이트가 대체문자로 조용히 바뀌지 않는다. - `\b \f \n \r \t` 이스케이프를 **문법 수준에서 거부**한다("control characters are forbidden") — 제어문자가 이스케이프로 밀입되는 경로를 닫는다. - 짝 없는 서로게이트를 거부한다(`requireWellFormedUnicode`). - `Instant.parse` 후 `result.toString().equals(value)`로 **canonical UTC 표기**만 받는다. - receipt snapshot은 `rsv1.` 접두사 + unpadded base64url이고, 디코딩 후 **재인코딩 문자열 비교**로 alias(후행 비트가 0이 아닌 변형)를 거부한다. test가 그 하나하나를 이름으로 고정한다 — `canonicalDecoderRejectsWhitespaceReorderingEscapesNumbersUtf8AndTrailingContent`, `receiptSnapshotRejectsBase64urlAliasWithNonZeroTrailingBits`, `canonicalCodecRoundTripsSupplementaryUnicodeInOpaqueText`, `formulaMitigationCountCannotExceedCellsAndUsesOverflowSafeBounds`. 마지막 것은 코드에서도 확인된다. `requireFormulaCountWithinCells`가 `rowCount * columnCount` 곱을 하기 전에 `rowCount <= Long.MAX_VALUE / columnCount`를 먼저 본다 — 오버플로가 상한 검사를 무력화하는 경로를 닫는다. #### 13. Confirmed — 상태 전이가 인접 행렬이고 terminal이 진짜 terminal이다 `validateOperationTransition`이 여섯 가지를 순서대로 강제한다: requestFingerprint 불변 → 불변 identity 10개 필드 불변 → `stateRevision` 감소 금지 → 동일 revision 다른 내용 금지 → **정확히 +1** 증가 → 인접 전이 행렬. 행렬은 README가 적은 사슬과 일치하고, 모든 비terminal 상태에서 `QUARANTINED`로만 이탈할 수 있으며 `PUBLISHED`·`QUARANTINED`는 후속 전이가 없다(`case PUBLISHED, QUARANTINED -> false`). 봉인 이후 사실은 얼어붙는다 — `requireSealedFactsUnchanged`가 byteSize·rowCount·columnCount·sha256·formulaMitigatedCount·sealedAt을 고정하고, `MANIFEST_PUBLISHED` 이후에는 `manifestDigest`, `REFERENCE_PUBLISHED` 이후에는 `referenceDigest`도 고정된다. `current.equals(candidate)`는 전이가 아니라 **복구(repair)**로 취급된다 — 부모 디렉터리를 다시 force하고 정확 read-back만 수행한다. 크래시 후 같은 레코드를 다시 쓰는 재시도가 conflict가 되지 않게 하는 처리이고, `parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry`가 이를 고정한다. #### 14. Confirmed — 두 개의 락 형태가 각자의 쓰기 원시연산에 맞춰져 있다 한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다. | | operation 레코드 | manifest / reference 레코드 | |---|---|---| | 커밋 원시연산 | `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)` — **배타적이지 않음** | `Files.createLink` — 이미 있으면 `FileAlreadyExistsException`, **OS 수준 배타** | | JVM 락 | `OPERATION_LOCK_STRIPES`, 키에 **root 범위 포함**(`operationLockRootKey + "\0" + token`) | `IMMUTABLE_LOCK_STRIPES`, 키는 `"manifest:"+fileId` — root 범위 **없음** | | OS 락 | `FileChannel.lock()` (`.lock` 파일, 0600, 소유자·FileStore 검증) | 없음 | 즉 배타성이 필요한 쪽(replace)에는 OS 락을 두고, 원시연산 자체가 배타적인 쪽(create-link)에는 JVM 스트라이프만 둔 것이다. 후자의 root 미포함은 **과잉 직렬화** 방향이라(다른 root의 같은 fileId가 같은 스트라이프를 공유) 배타 누락으로는 이어지지 않고, `fileId`는 `SecureRandom` 16바이트라 실질 충돌도 없다. 결함이 아니라 설계로 기록한다. collision 경로도 닫혀 있다 — `createLink`가 충돌하면 임시 파일을 정확히 지우고, 기존 레코드를 읽어 identity와 내용 동등성을 확인한 뒤 같으면 repair, 다르면 `CONFLICT`다. `concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage`가 그 분류를 고정한다. #### 15. Confirmed — poisoning은 root 범위이고, 읽기를 막지 않는 것이 의도다 OS 언락을 **증명하지 못한** 경우에만 `POISONED_OPERATION_LOCK_ROOTS`에 root 키가 들어간다. release와 close 중 **하나라도** 성공하면 poison하지 않는다(`releaseProvedUnlock || closeProvedUnlock`). `requireOperationLockRootHealthy()`는 6곳에서 호출되는데 전부 쓰기 경로(`storeOperation`·`acquireOperationLock`×3·`storeManifest`·`storeReference`)이고, `findOperation`/`findStoredOperation`/`findManifest`/`findReference` 어디에도 없다. 처음에는 누락으로 보였으나 test 이름이 그것이 의도임을 못박는다 — **`poisonedRootBlocksEveryWriteIncludingHeldLockFastPathButAllowsReads`**. 이미 획득한 락의 fast path(`heldTokens.contains(...)`)조차 poison에 걸린다는 것까지 이름에 들어 있다. poison을 해제하는 경로는 없다(집합은 static이고 제거 호출이 없다). 프로세스 수명 동안 그 root는 쓰기 불가로 남는다 — "OS 락이 풀렸는지 증명할 수 없다"에 대한 fail-closed 응답이고, `operationLockClosePoisonsOnlyTheAttestedRootWhenUnlockCannotBeProven`이 범위가 해당 root에 한정됨을 확인한다. 두 개의 형제 test(`releaseFailureWithSuccessfulChannelCloseReportsStorageWithoutPoisoning`, `successfulReleaseWithChannelCloseFailureReportsStorageWithoutPoisoning`)가 "증명 하나면 충분" 규칙을 양쪽에서 고정한다. #### 16. Confirmed — 파일시스템 접근이 전부 `SecureDirectoryStream` 상대 연산이다 `SystemSecureRecordOperations`의 다섯 연산이 모두 `openSecure(topDirectory)` → `newDirectoryStream(shard, NOFOLLOW_LINKS)`를 거친다. `SecureDirectoryStream`이 아니면 스트림을 닫고 `IOException`을 던진다 — TOCTOU 우회 경로를 열어 두지 않는다. 세부가 촘촘하다. - 읽기는 `maximumBytes + 1` 버퍼로 읽어 **한 바이트 초과분**을 감지하고, 읽기 전후 `fileKey`와 `size`를 비교해 "읽는 중 신원이 바뀐" 경우를 integrity 실패로 만든다. - 임시 파일 생성은 `CREATE_NEW` + `NOFOLLOW_LINKS` + 0600이고, 쓴 뒤 `force(true)`, 그 다음 크기와 fileKey를 생성 시점과 대조한다. - 커밋 전후로 `requireCreatedTemporaryIdentity`가 **정확히 그 fileKey**만 지운다 — 다른 프로세스가 같은 이름으로 바꿔 둔 파일을 지우지 않는다. `cleanupPreservesAReplacementWhoseNoFollowFileKeyDiffersFromCreatedTemp`가 그 경계를 고정한다. - shard 디렉터리는 매번 소유자·0700 권한·FileStore 동일성을 재검증하고, 좌표는 `[0-9a-f]{2}`와 세 허용 디렉터리로 제한된다. - 모든 쓰기/읽기 단계 사이에 `verifyAttestedIdentity()`가 끼어 있다 — root가 도중에 바뀌면 즉시 멈춘다. `forceDirectory`는 디렉터리를 `READ`로 열어 `force(true)`한다. README가 `FILE_AND_DIRECTORY_SYNC`를 "attested local file/directory force boundary only"로 한정하는 것과 일치한다. #### 17. Confirmed — 세 타입 모두 leaf 밖으로 새지 않는다 `LocalPersistentControlPlane`·`FileserverControlRecordCodec`·`LocalPersistentRecoveryVerifier`는 전부 package-private이고, 저장소에서 이 leaf 밖 참조는 **0**이다(`143-...` §8.1). production 생성 지점은 `FileserverR2Config:51` 하나다. CLAUDE.md의 "Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`" 금지가 타입 가시성으로 뒷받침된다. R1 하위호환도 좁게 열려 있다 — `decodeStoredOperation`은 R2 codec을 먼저 시도하고, 실패하면 R1 journal codec으로 넘어가되 **terminal `PUBLISHED`만** 허용한다. CLAUDE.md의 "schema v1 is strict read-only compatibility"와 일치하고, `typedOperationLookupDispatchesCanonicalR2AndTerminalR1FromTheSameHashedPath`와 `typedOperationLookupRejectsMalformedNonCanonicalNonTerminalAndWrongIdentityR1`이 양쪽을 고정한다. #### 18. Negative-space probes — sub-scope 02 - **8.1 reachability**: 세 타입 모두 package-private, leaf 밖 참조 0, production 진입점 1개(§17). - **8.2 조건부 형제**: 한 클래스 안의 두 락 형태(§14) — 커밋 원시연산 차이로 설명됨. `requireOperationLockRootHealthy`의 쓰기/읽기 비대칭(§15) — test 이름이 의도임을 명시. - **8.3 중복 mechanism**: poison 집합에 해제 경로 없음(§15, 의도된 fail-closed). R1/R2 두 codec 경로는 dispatch 순서와 terminal 제약으로 분리(§17). - **8.4 문서/동작 대조**: README의 상태 사슬(`WRITING → SEALED → DATA_PUBLISHED → MANIFEST_PUBLISHED → REFERENCE_PUBLISHED → PUBLISHED`)과 `isAllowedAdjacentTransition`의 행렬이 일치. `FILE_AND_DIRECTORY_SYNC`의 한정 서술과 `forceDirectory` 구현이 일치. #### 19. Sub-scope 02 findings backlog | 우선순위 | finding | reachability | |---|---|---| | — | **없음.** 후보로 본 세 가지(immutable 락의 root 미포함, poison 해제 경로 부재, 읽기 경로의 health 게이트 부재)는 각각 커밋 원시연산·fail-closed 설계·명시적 test로 의도임이 확인됐다 | — | #### 20. Sub-scope 02 완료 조건 - denominator 6 / 6 FULL_READ (`143-...` OWNED FILES) — main 2,535 LOC 전수 판독 - §8.1~§8.4 네 종 probe 수행, 후보 finding 3건을 각각 코드·test로 추적해 결함 아님으로 판정 - 실행 probe 불필요 — 세 후보 모두 소스와 test 이름으로 정적 결정 가능 - 소스 미변경 --- #### 21. Sub-scope 03 범위와 denominator > 내부 상태: COMPLETE — **26 / 26 FULL_READ** > 범위: publication main 19 (provider·adapter·journal·attestor·binding·record, 약 4,400 LOC) + 전용 test 7 > 역할: 요청 → 스테이지 → 데이터 → manifest → reference → terminal 사슬을 **재개 가능한 상태 기계**로 만들고, 루트를 startup에 증명하며, R1 아티팩트를 읽기 전용으로만 복원한다 manifest와 probe: `evidence/raw/144-fileserver-publication-probes.txt`. #### 22. Confirmed — 19개 production 타입 중 leaf를 벗어나는 것이 하나도 없다 전수 검색 결과 `LocalPersistentPublicationProvider`·`LocalFilePublicationAdapter`·`RoutingFilePublicationAdapter`·`LocalPersistentRootAttestor`·`FileserverBindingCompiler`·`DurablePublicationRecord`·`PrivateFileManifest`·`PublishedReferenceRecord`·`LocalPublicationJournal` 어느 것도 이 leaf 밖에서 참조되지 않는다(`144-...` §8.1, exit=1). 전부 package-private이고, 애플리케이션이 보는 것은 `FilePublicationPort`와 그 값 타입뿐이다. CLAUDE.md의 금지 조항 — "Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`" — 이 문서가 아니라 **타입 가시성**으로 뒷받침된다. `FilePublicationProvider`(adapter 내부 provider 인터페이스)도 package-private이라 provider 개념 자체가 포트를 건너지 않는다. #### 23. Confirmed — 복구가 "어디서 끊겼든 그 자리에서" 재개하는 루프다 `recoverR2`는 저장된 상태에 따라 분기하는 `while(true)` 루프다. 각 단계가 증거를 다시 검증하고, 성공하면 다음 상태로 전이하며, 루프가 `PUBLISHED`에 도달하면 receipt를 복원한다. | 저장 상태 | 재개 동작 | |---|---| | `WRITING` | **격리**(`UNSEALED_WRITING`) — 봉인 전에 끊긴 것은 재개하지 않는다 | | `SEALED` | stage/data를 조사해 둘 다 없으면 integrity 실패, data가 있으면 디렉터리만 force, 없으면 stage를 hard-link로 publish | | `DATA_PUBLISHED` | manifest를 찾거나 생성해 저장 | | `MANIFEST_PUBLISHED` | reference를 찾거나 생성해 저장 | | `REFERENCE_PUBLISHED` | 모든 증거를 재대조하고 receipt snapshot을 넣어 terminal 기록 | | `PUBLISHED` | 전 필드 재검증 후 **저장된 receipt를 그대로** 반환 | | `QUARANTINED` | indeterminate | 핵심은 **producer를 다시 부르지 않는다**는 점이다. `publishNew`만 `streamRequest`를 호출하고, 그 이후의 모든 재개 경로는 이미 봉인된 바이트에서 진행한다. README의 "resumes from verified sealed bytes without replaying the producer"가 코드 구조로 성립한다. `resumeData`의 stage/data 이중 조사가 특히 촘촘하다. 둘 다 존재하면 `fileKey`가 같은지 확인해 — 즉 **같은 exclusive hard-link인지** — 확인하고, 다르면 `RecoveryIntegrityException`이다. hard-link 발행이 성공한 뒤 stage 삭제 전에 죽은 경우와, 전혀 다른 파일이 그 자리에 있는 경우를 구분한다. 실패 분류도 갈라져 있다. `RecoveryIntegrityException`과 payload의 `INTEGRITY`/`CAPACITY`는 **격리 후** indeterminate가 되고, 그 밖의 payload 실패는 격리 없이 indeterminate다. `quarantineAndIndeterminate`는 이미 `PUBLISHED`/`QUARANTINED`인 기록은 건드리지 않는다. #### 24. Confirmed — 루트 증명이 "설정을 믿지 않는" 형태다 `LocalPersistentRootAttestor.attestChecked`가 순서대로 확인한다: 절대·정규화 경로 → 심볼릭 루트/조상 거부 → `toRealPath()`가 설정 경로와 **정확히 일치** → 소유자 → 권한 상한 → FileStore 이름·타입 → mount sentinel의 SHA-256 → 내부 디렉터리 8개 생성/검증 → `SecureDirectoryStream` 가용성 → **실제 capability probe**. 마지막이 특징적이다. `runCapabilityProbe`는 실제로 파일을 만들고(`CREATE_NEW`+`NOFOLLOW_LINKS`+0600), 쓰고, `force`하고, **hard-link를 만들고**, 디렉터리를 force한 다음, 원본과 링크의 `fileKey`가 같은지 확인한다. 즉 "이 파일시스템이 배타적 hard-link 발행과 file/directory force를 실제로 할 수 있는가"를 startup에 시험한다 — 첫 publication에서 발견하지 않는다. 내부 디렉터리 생성에는 롤백이 붙어 있다. `rollbackCreatedDirectory`는 삭제 전에 부모 identity와 디렉터리 자신의 `fileKey`를 대조하고, 하나라도 바뀌었으면 **삭제를 거부**한다("refusing rollback because internal directory identity changed"). 실패 정리가 남의 디렉터리를 지우지 않는다. `verifyIdentity`는 attest가 끝난 뒤에도 control plane의 거의 모든 단계에서 재호출된다(§16). 증명은 시점이 아니라 불변식이다. #### 25. Confirmed — canonical digest가 길이 프레이밍이고, route token 충돌을 명시적으로 검사한다 `FilePublicationCanonicalDigests.digestOrderedValues`는 값 개수를 먼저 넣고, 값마다 **길이(4바이트) + 엄격 UTF-8 바이트**를 넣는다. 구분자를 쓰지 않으므로 값 안에 어떤 문자가 있어도 경계가 흐려지지 않는다. `FilePublishRequestFingerprint`도 같은 방식이다. `routeToken`은 정책 다이제스트의 앞 31자에 `r`을 붙인 것이라 **잘린 값**이다. 그래서 `FileserverBindingCompiler.deriveUniqueRouteTokens`가 컴파일 시점에 토큰 충돌을 검사하고, 충돌하면 두 destination 이름을 모두 담아 거부한다. 잘림이 만들 수 있는 유일한 문제를 그 자리에서 닫는다. 컴파일 후에도 `compiled.forEach`로 각 destination의 토큰이 레지스트리와 같은지 다시 확인한다. `CompiledFileDestination`의 compact 생성자는 넘겨받은 `effectivePolicyDigest`를 **다시 계산해 대조**하고, `routeToken`이 그 다이제스트에서 유도됐는지, `formatPolicyDigest`가 정본과 같은지도 확인한다. 값이 아니라 관계를 검증한다. #### 26. Confirmed — R1과 R2가 같은 일을 다른 엄격도로 하고, 그 사실이 선언돼 있다 두 계층이 나란히 있어 비교가 가능하다(`144-...` §8.2). | | R2 `LocalPersistentControlPlane` | R1 `LocalPublicationJournal` | |---|---|---| | 파일시스템 접근 | `SecureDirectoryStream` 상대 연산 (**17회**) | `Files.exists`/`isRegularFile`/`readAllBytes` (**0회**) | | 읽기 디코딩 | 엄격 UTF-8 `REPORT` + canonical 바이트 재대조 | `new String(bytes, UTF_8)` — malformed는 U+FFFD로 대체 | | 제어문자 이스케이프 | `\b \f \n \r \t`를 **문법에서 거부** | 다섯 개를 모두 **수용해 디코드** | | POSIX 권한 | 정확히 0700이 아니면 실패 | `UnsupportedOperationException`을 삼키고 진행 | | 임시 파일명 | `SecureRandom` 16바이트 hex | `UUID.randomUUID()` | | 락 | `ReentrantLock` 스트라이프 + OS `FileLock` + poison 래치 | `Semaphore` 스트라이프 + OS `FileLock` | 이것은 결함이 아니라 선언된 상태다 — CLAUDE.md는 R1을 "compatibility only"로, README는 "must not be used as R2 durability or cluster-safety evidence"로 못박는다. **중요한 것은 두 계층이 만나는 한 지점이다.** R2 control plane이 같은 해시 경로에서 R1 저널을 읽을 때(`decodeStoredOperation`) 쓰는 것은 관대한 `decode`가 아니라 **엄격한 `decodeCanonical`**이고, 그 위에 `state == PUBLISHED`까지 요구한다(`LocalPersistentControlPlane:634-638`). 즉 R1의 느슨함이 R2 경로로 흘러들지 않는다. 이 한 줄이 위 표 전체를 안전하게 만든다. R1 복원이 등급을 올리지 않는 것도 코드로 확인된다 — `restoreR1`은 receipt에 `DurabilityGuarantee.PROCESS_LOCAL_SYNC`를 그대로 넣고, 참조도 R2의 `fsr1.…` 형식이 아니라 R1의 `filepub::` 형식을 쓴다. README의 "never writes schema v1, creates an R2 manifest/reference for that artifact, or promotes its durability guarantee"와 일치한다. #### 27. Negative-space probes — sub-scope 03 - **8.1 reachability**: 19개 production 타입 전부 package-private, leaf 밖 참조 0(§22). production 진입점은 `FileserverR2Config`(R2)와 `FileExportConfig`(R1) 둘. - **8.2 조건부 형제**: R1/R2의 6개 축 엄격도 대조(§26), 그리고 두 계층의 접점이 엄격 경로를 쓰는지 확인. - **8.3 중복 mechanism**: 참조 형식 둘(`filepub:` / `fsr1.`)과 락 구현 둘 — 각각 R1/R2 경계에 대응하고 서로 침범하지 않음. `LocalPersistentPublicationProvider`와 `LocalFilePublicationAdapter`가 같은 `filepub:` 형식을 쓰는 것은 R1 receipt 호환을 위한 의도된 공유. - **8.4 문서/동작 대조**: README의 여섯 단계 사슬 ↔ `recoverR2` 분기, "producer를 재생하지 않는다" ↔ `publishNew`만 `streamRequest` 호출, R1 등급 비승격 ↔ `PROCESS_LOCAL_SYNC` 고정, route token 잘림 ↔ 컴파일 시 충돌 검사. #### 28. Sub-scope 03 findings backlog | 우선순위 | finding | reachability | |---|---|---| | — | **없음.** R1/R2 엄격도 격차는 문서가 선언한 상태이고, 두 계층이 만나는 유일한 지점(`decodeStoredOperation`)은 엄격 경로를 쓴다 | — | #### 29. Sub-scope 03 완료 조건 - denominator 26 / 26 FULL_READ (`144-...` OWNED FILES) — main 약 4,400 LOC 전수 판독 - §8.1~§8.4 네 종 probe 수행, R1/R2 접점을 코드로 추적해 느슨함이 전파되지 않음을 확인 - 실행 probe 불필요 — 판정 지점이 모두 정적으로 결정 가능 - 소스 미변경 --- #### 30. Sub-scope 04 범위와 denominator > 내부 상태: COMPLETE — **30 / 30 FULL_READ** > 범위: `platform/local` IO 원시연산 9 + gateway 7 + store·publisher 6 (main 22) + 전용 test 8 > 역할: HTTP Fileserver 플랫폼의 **로컬 콘텐츠 저장소** — 스테이징·추가·발행·읽기·삭제를 경로가 아니라 **디렉터리 서술자 상대 연산**으로 수행한다 manifest와 probe: `evidence/raw/145-fileserver-local-io-probes.txt`. #### 31. Confirmed — TOCTOU를 "검사를 더 하는" 방식으로 풀지 않는다 `SecureDirectoryWalk`의 클래스 javadoc이 이 sub-scope의 설계 명제를 그대로 적는다. > The pathname approach cannot be made safe by adding checks. Proving that no component of `${root}/content/ab/cd` is a symbolic link and then calling `FileChannel.open` on that string re-resolves every component from scratch… **More checks only narrow the window; they never close it.** 그래서 각 단계가 **이전 디렉터리의 서술자를 기준으로** 다음 디렉터리를 `NOFOLLOW_LINKS`로 연다. 열어 둔 서술자는 나중에 그 디렉터리가 교체돼도 영향받지 않는다 — "an attacker who swaps a component afterwards has swapped something nothing is looking at any more". 세부도 논리적이다. - **fallback을 두지 않는다.** `SecureDirectoryStream`이 없으면 startup capability probe가 실패로 처리한다 — "a silent fall back to pathnames would restore exactly the window this class exists to close". - **거부와 장애를 구분한다.** `NOFOLLOW_LINKS` 거부는 플랫폼이 일반 `FileSystemException`으로 보고하므로, 실패 시 같은 부모 서술자로 그 컴포넌트를 다시 읽어 심볼릭 링크인지 확인하고 `SymbolicComponentException`(영구 거부)과 스토리지 장애(재시도 가능한 503)를 나눈다. - **`FileChannel`이 아니면 거부한다.** `requireFileChannel`은 positional write·`truncate`·`force`·`transferTo`가 전부 `FileChannel` 연산이고 "cannot be emulated"라고 적으며 거부한다. - **경로 해석은 한 곳뿐.** `DefaultPhysicalPathResolver`가 유일하게 식별자를 경로로 바꾸고, 세 겹으로 막는다 — 서버 생성 형태 정규식(`[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}`), 정규화, 영역 루트 `startsWith` 재확인. 클라이언트 파일명은 어느 단계에도 들어오지 않는다(`resolutionNeverDependsOnAClientFilename`가 고정). `LocalAppendEngine`의 롤백 설계도 촘촘하다. 실패하면 누산 다이제스트를 **먼저 버리고**(이미 버려질 바이트를 흡수했으므로), `truncate` → `force` → `size` 재확인으로 물리 길이가 append 이전으로 돌아왔음을 **증명한 뒤에야** 원래 실패를 그대로 던진다. 증명하지 못하면 `AmbiguousCompletionException`으로 격상해 reconciliation에 넘긴다. 선언된 content length는 사후 검사가 아니라 **읽기 상한**으로 쓰이고(`buffer.limit(min(capacity, contentLength - appended))`), 잉여는 1바이트 probe read로 감지해 버린다. `LocalBlockingContentStore`는 20곳 전부 `channels.*`(서술자 상대)를 쓰고 `Files.*`를 한 번도 부르지 않는다(`145-...` §8.2). #### 32. P3 — 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다 `platform/local`에 남은 `java.nio.file.Files.*` 호출을 전수 조사했다(`145-...` §8.2). 대부분은 정당하다 — `SecureDirectoryWalk.openRoot`(문서가 "the one unavoidable pathname resolution"이라 적는 루트 열기), `LocalStorageCapabilityProbe`(startup probe, 격리된 probe 영역), `LocalOrphanScanAdapter`·`LocalStorageHealthAdapter`·`LocalStorageUsageProbe`(sub-scope 05). 문제는 **쓰기 경로에 남은 다섯 호출**이다. ``` AtomicMoveContentPublisher:53 Files.move(staging, target, ATOMIC_MOVE) ← 발행 rename AtomicMoveContentPublisher:113 Files.exists(staging, NOFOLLOW_LINKS) ← 실패 분류 AtomicMoveContentPublisher:114 Files.exists(target, NOFOLLOW_LINKS) ← 실패 분류 ContentPublishVerification:53 Files.size(target) ← 발행 크기 MetadataPointerContentPublisher:107 Files.deleteIfExists(staging) ``` 그리고 `AtomicMoveContentPublisher:50`이 그 rename 직전에 부르는 것은 `channels.requireNoSymlinkBetween(root, target.getParent())` — 즉 **경로 기반 사전검사**다. 그 메서드의 javadoc이 스스로를 이렇게 설명한다. > **Retained for the capability probe**, which still reasons about pathnames. Production access no longer relies on it: descending descriptor by descriptor with `NOFOLLOW_LINKS` refuses a symlinked component by construction, **which a precheck could only ever approximate.** 즉 "production은 더 이상 이것에 의존하지 않는다"고 적힌 메서드를, 콘텐츠를 **보이게 만드는 바로 그 단계**가 유일한 보호로 쓴다. `SecureDirectoryWalk`의 "More checks only narrow the window; they never close it"이 겨냥한 패턴 그 자체다. 같은 불일치가 파일 길이에서도 보인다. `LocalAppendEngine.currentLength`는 여덟 줄짜리 javadoc으로 왜 `Files.size`가 틀렸는지 설명하고 `channels.readAttributes(root, staging)`를 쓴다 — "an attacker who swaps the parent for a symlink gets this check to report the size of their own file". `ContentPublishVerification.sizeOf`는 같은 질문에 `Files.size(target)`으로 답한다. **판정: P3.** 실제 악용에는 스토리지 루트 **안쪽** 쓰기 권한이 필요하고, 이 leaf가 그 루트의 소유자·권한을 증명하는 것은 R2 경로(`LocalPersistentRootAttestor`)뿐이며 플랫폼 저장소 루트의 증명은 `app-bootstrap`의 startup validator 몫이다. 그래서 도달성은 배포 형상에 달려 있다. 심각도를 P3로 두는 이유는 그것이고, 그럼에도 기록하는 이유는 **모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다**는 점이다. 수정은 발행 rename을 `SecureDirectoryWalk.inParentOf`로 옮겨 부모 서술자 상대 `move`를 쓰고, `sizeOf`를 `channels.readAttributes`로 바꾸는 것이다. #### 33. Confirmed — 두 발행 전략이 probe 결과로 선택되고, 각자 다른 실패를 다르게 분류한다 `selectPublisher`는 설정이 아니라 **probe가 증명한 것**에서 전략을 고른다. `ATOMIC_MOVE_REQUIRED`는 원자적 이동을 증명하지 못하면 fail-closed, `ATOMIC_MOVE_PREFERRED`는 pointer 발행으로 강등된다. `ContentPublisherTest`가 양쪽을 고정한다(`requiredAtomicModeFailsClosedWhenTheProbeCouldNotProveIt`, `preferredModeDegradesToPointerPublishWhenAtomicMoveIsUnproven`). 두 전략 모두 **`REPLACE_EXISTING`을 쓰지 않는다** — 기존 대상은 조용한 덮어쓰기가 아니라 충돌이다. 그리고 결과를 증명할 수 없으면 성공도 실패도 아닌 `AmbiguousCompletionException`이다. `AtomicMoveContentPublisher.forceDirectoryEntries`의 근거가 특히 정확하다 — 스테이징 파일을 force하는 것은 **내용**을 지속시킬 뿐 그것을 가리키는 **디렉터리 엔트리**에 대해서는 아무 말도 하지 않는다. 크래시 후 객체가 완전히 쓰였으면서 동시에 두 디렉터리 어디에도 없고 메타데이터는 READY라고 말하는 상태가 가능하다. rename은 두 디렉터리를 바꾸므로 둘 다 sync하고, sync 실패는 무시가 아니라 ambiguous로 격상한다. `MetadataPointerContentPublisher`는 복사 후 **디스크에서 다시 다이제스트를 계산해** 스테이지 다이제스트와 비교한다. `ContentPublishVerification`의 javadoc이 그 원칙을 적는다 — "recomputed from the bytes actually on disk rather than trusted from the streaming accumulator, so a publish can never advertise a hash the stored object does not have". #### 34. P3 — `TransferBufferPool.maxBorrowedBytes()`가 자기 회귀 test를 지목하는데 그 test가 읽지 않는다 `TransferBufferPool`은 대여 중 바이트의 최대치를 추적하고 javadoc에 이렇게 적는다. > Peak simultaneously-borrowed bytes; **the bounded-memory regression asserts on this.** `145-...` §8.4의 전수 검색에서 `maxBorrowedBytes`는 `TransferBufferPool.java` 세 줄에만 나타나고, `LocalAppendMemoryTest`에도 `LargeFileBoundedMemoryTest`에도 없다. 즉 회귀 test는 이 값을 읽지 않는다. 기능 자체는 옳게 동작한다 — `borrow`가 `bufferSize`만큼 증가시키고 `release`가 되돌리며, 최대치를 `accumulateAndGet(_, Math::max)`로 누적한다. 그리고 경계 자체(전송이 파일 크기에 비례해 메모리를 쓰지 않음)는 다른 방식으로 검증되고 있다. 문제는 javadoc이 존재하지 않는 결합을 서술한다는 것이고, 그 서술 때문에 이 계측이 지켜지고 있다고 읽힌다. **P3.** #### 35. Negative-space probes — sub-scope 04 - **8.1 reachability**: `platform/local`의 15개 타입이 public이고, leaf 밖에서는 `app-bootstrap`의 fileserver autoconfigure 5개 클래스가 참조한다. 나머지(`SecureDirectoryWalk`·`SafeFileChannelFactory`·`DefaultPhysicalPathResolver`·publisher 3종·`LocalUploadHandle` 등)는 package-private — `Path`가 SPI를 건너지 않는다는 주장이 가시성으로 성립. - **8.2 조건부 형제**: 파일 길이를 묻는 두 방식(§32), 서술자 상대 vs 경로 기반 쓰기(§32). - **8.3 중복 mechanism**: 두 발행 전략은 중복이 아니라 probe 결과로 배타 선택되고 `usesAtomicMove()`로 자기 성격을 보고한다(§33). - **8.4 문서/동작 대조**: `maxBorrowedBytes` javadoc의 회귀 test 결합 부재(§34). `LocalCopyContentGateway`의 "A copy is not a link" 근거와 실제 스테이징 경유 복사 구현 일치. `LocalZeroCopyDownloadGateway`의 짧은 전송 재시도와 부분 전송 보고 일치. #### 36. Sub-scope 04 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P3** | 발행 rename(`Files.move`)과 그 실패 분류(`Files.exists`), 발행 크기(`Files.size`)가 경로 기반이고, 유일한 보호는 이 모듈이 "a precheck could only ever approximate"라고 적은 `requireNoSymlinkBetween`이다 | 스토리지 루트 안쪽에 쓰기 권한을 가진 주체 — 루트 증명은 배포 형상에 달려 있다 | | **P3** | `TransferBufferPool.maxBorrowedBytes()`의 javadoc이 "the bounded-memory regression asserts on this"라고 적지만 어떤 test도 읽지 않는다 | 계측/문서 | #### 37. Sub-scope 04 완료 조건 - denominator 30 / 30 FULL_READ (`145-...` OWNED FILES) - §8.1~§8.4 네 종 probe 수행, `platform/local`의 `Files.*` 호출을 전수 조사해 정당한 것과 남은 것을 분리 - 두 finding 모두 정적으로 결정 가능(호출 지점과 javadoc 대조)하여 실행 probe 불필요 - 소스 미변경 --- #### 38. Sub-scope 05 범위와 denominator > 내부 상태: COMPLETE — **29 / 29 FULL_READ** > 범위: `platform/local` 실패분류·probe·health·orphan 10 + `platform/verification` 10 + `platform/security` 2 + `platform/audit` 2 (main 24) + 전용 test 5 > 역할: 콘텐츠 **검증 사슬**, 역할 기반 인가, 감사 기록, 그리고 파일시스템 실패를 "일어났는가"로 분류하는 계층 manifest·정적 probe·실행 probe: `evidence/raw/146-fileserver-verification-security-audit-probes.txt`. #### 39. P2 확정 — §4의 README 주장이 여덟 개의 port 구현과 여덟 개의 bean 앞에서 성립하지 않는다 sub-scope 01(§4)에서 이월한 판정을 여기서 닫는다. README:103–105는 audit·readiness/health·reconciliation/reaping·quota가 "not implemented"이고 "**No setting or bean for those capabilities is exposed**"라고 적는다. 실제로는 이 sub-scope의 타입들이 `application-core` port를 구현하고, `app-bootstrap`이 그 전부를 bean으로 만든다(`146-...` §8.1). | port | 구현 | bean 생성 | |---|---|---| | `AdminAuditPort` | `StructuredAdminAuditAdapter` | `FileserverSecurityConfiguration:71` | | `FileserverAuditPort` | `StructuredFileserverAuditAdapter` | `:77` | | `FileAccessPolicy` | `RoleBasedFileAccessPolicy` / `UnenforcedFileAccessPolicy` | `:51` / `:90` | | `StorageHealthPort` | `LocalStorageHealthAdapter` | `FileserverStorageConfiguration:179` | | `OrphanScanPort` | `LocalOrphanScanAdapter` | `:189` | | `StorageUsageProbe` | `LocalStorageUsageProbe` | `:196` | | `ReconciliationContentProbe` | `LocalReconciliationContentProbe` | `:203` | 스텁이 아니다. 감사 어댑터는 전용 로거 카테고리(`dev.caskeleton.fileserver.audit`)로 쓰고, 실패한 동작을 성공과 **같은 레벨로** 남긴다("a refused force-delete is the entry a reviewer most needs to find"). health 어댑터는 원자적 이동 가능 여부를 설정이 아니라 **probe가 증명한 사실**에서 보고한다. usage probe는 매 호출마다 `FileStore`를 다시 읽고, 읽을 수 없으면 0%도 100%도 아닌 **빈 답**을 낸다("a synthetic 0% would silently disable the high-water guard, and a synthetic 100% would take the capability down over a failed syscall"). 즉 코드 쪽은 잘 만들어져 있고, 틀린 것은 README 한 문단이다. §4에서 적은 대로 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼의 부품이지만, 그 문단에는 한정어가 없다. **P2 확정.** #### 40. P2 — scriptable 콘텐츠 탐지가 접두사 **시작**에만 고정돼 있어 BOM·NUL·주석으로 우회된다 `ScriptableContentPolicy`의 javadoc은 이 검사의 목적을 분명히 적는다. > Guards content that a browser would execute if it were ever served inline. **Detection is on content, not on the claimed type or the extension, because both are attacker controlled.** 구현은 1,024바이트 접두사를 소문자로 만든 뒤 `stripLeading()`하고, 여섯 마커(` -> QUARANTINE / SCRIPTABLE_CONTENT PROBE plain -> QUARANTINE / SCRIPTABLE_CONTENT PROBE leading whitespace + -> QUARANTINE / SCRIPTABLE_CONTENT PROBE uppercase