diff --git a/docs/clean-architecture-backend-template/README.md b/docs/clean-architecture-backend-template/README.md deleted file mode 100644 index 13eb96f..0000000 --- a/docs/clean-architecture-backend-template/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# - -- codebase: `/shared/codebase/` -- analysis owner: `/shared/document-detail/` -- Tech Log output: `/shared/Tech-Log-Document/` - -## Current analysis scope - -`state.json`을 정본으로 사용한다. 대형 코드베이스에서는 한 실행에 한 module/subsystem을 우선한다. diff --git a/docs/clean-architecture-backend-template/analysis/00-project-overview.md b/docs/clean-architecture-backend-template/analysis/00-project-overview.md deleted file mode 100644 index 20db56f..0000000 --- a/docs/clean-architecture-backend-template/analysis/00-project-overview.md +++ /dev/null @@ -1,141 +0,0 @@ -# Project Overview - -> **이 문서는 분석 시작 시점의 sizing 스냅샷이다.** 최종 결과는 `final/document.md`, -> 교차 스코프 종합은 `analysis/99-cross-scope.md`, 모듈별 확정 수치는 각 `analysis/NN-*.md`의 -> 커버리지 원장이 정본이다. 아래 파일/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는 영향받지 않는다 - (`analysis/20-grpc-platform.md` §0, `analysis/99-cross-scope.md` §8). -- working tree: clean - -## 최종 커버리지 - -| | | -|---|---| -| 등록 leaf (최종) | **62** (최초 스냅샷 시점 44) | -| COMPLETE | **61** | -| EXCLUDED | 1 — `sample-portfolio` (사용자 지시 2026-08-30) | -| PENDING | 0 | -| 모듈 문서 | `analysis/01`–`20` (20개). messaging 25 leaf는 `19`, gRPC 18 leaf는 `20`으로 통합 | -| 교차 스코프 | `analysis/99-cross-scope.md` | -| 최종 문서 | `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** (사용자 지시) | -| `analysis/20`–`44`: messaging leaf 25개 각각 | **`analysis/19-messaging-platform.md`** 하나로 통합 | -| (계획에 없음 — 분석 도중 추가된 가족) | **`analysis/20-grpc-platform.md`** — 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에 형태별로 정리돼 있다. diff --git a/docs/clean-architecture-backend-template/analysis/01-domain-core.md b/docs/clean-architecture-backend-template/analysis/01-domain-core.md deleted file mode 100644 index 37797d5..0000000 --- a/docs/clean-architecture-backend-template/analysis/01-domain-core.md +++ /dev/null @@ -1,260 +0,0 @@ -# domain-core 상세 분석 - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `domain-core` -- canonical state `analysisFile`: `analysis/01-domain-core.md` (이 문서) — 이 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 - -``` diff --git a/docs/clean-architecture-backend-template/analysis/02-shared-contract.md b/docs/clean-architecture-backend-template/analysis/02-shared-contract.md deleted file mode 100644 index a219c83..0000000 --- a/docs/clean-architecture-backend-template/analysis/02-shared-contract.md +++ /dev/null @@ -1,233 +0,0 @@ -# shared-contract 상세 분석 - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `shared-contract` -- canonical state `analysisFile`: `analysis/02-shared-contract.md` (이 문서) — 이 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 - -``` diff --git a/docs/clean-architecture-backend-template/analysis/03-application-core.md b/docs/clean-architecture-backend-template/analysis/03-application-core.md deleted file mode 100644 index c012f3b..0000000 --- a/docs/clean-architecture-backend-template/analysis/03-application-core.md +++ /dev/null @@ -1,454 +0,0 @@ -# application-core 상세 분석 - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `application-core` -- canonical state `analysisFile`: `analysis/03-application-core.md` (이 문서) — 이 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 - -``` diff --git a/docs/clean-architecture-backend-template/analysis/04-adapter-outbound-support.md b/docs/clean-architecture-backend-template/analysis/04-adapter-outbound-support.md deleted file mode 100644 index fe50123..0000000 --- a/docs/clean-architecture-backend-template/analysis/04-adapter-outbound-support.md +++ /dev/null @@ -1,703 +0,0 @@ -# adapter-outbound-support 상세 분석 - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `adapter-outbound-support` -- canonical state `analysisFile`: `analysis/04-adapter-outbound-support.md` (이 문서) — 이 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와 마지막 `analysis/99-cross-scope.md`가 남아 있으므로 계속 `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 - analysis/99-cross-scope.md - -``` diff --git a/docs/clean-architecture-backend-template/analysis/05-adapter-outbound-persistence-jpa.md b/docs/clean-architecture-backend-template/analysis/05-adapter-outbound-persistence-jpa.md deleted file mode 100644 index 8721db0..0000000 --- a/docs/clean-architecture-backend-template/analysis/05-adapter-outbound-persistence-jpa.md +++ /dev/null @@ -1,4950 +0,0 @@ -# adapter-outbound-persistence-jpa 상세 분석 - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `adapter-outbound-persistence-jpa` -- canonical state `analysisFile`: `analysis/05-adapter-outbound-persistence-jpa.md` (이 문서) — 이 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 - -``` diff --git a/docs/clean-architecture-backend-template/analysis/06-adapter-outbound-persistence-mongo.md b/docs/clean-architecture-backend-template/analysis/06-adapter-outbound-persistence-mongo.md deleted file mode 100644 index 11fac87..0000000 --- a/docs/clean-architecture-backend-template/analysis/06-adapter-outbound-persistence-mongo.md +++ /dev/null @@ -1,1772 +0,0 @@ -# adapter-outbound-persistence-mongo 상세 분석 - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `adapter-outbound-persistence-mongo` -- canonical state `analysisFile`: `analysis/06-adapter-outbound-persistence-mongo.md` (이 문서) — 이 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 - -``` diff --git a/docs/clean-architecture-backend-template/analysis/07-adapter-outbound-identifier.md b/docs/clean-architecture-backend-template/analysis/07-adapter-outbound-identifier.md deleted file mode 100644 index 282ad1c..0000000 --- a/docs/clean-architecture-backend-template/analysis/07-adapter-outbound-identifier.md +++ /dev/null @@ -1,233 +0,0 @@ -# 07 · adapter-outbound-identifier - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `adapter-outbound-identifier` -- canonical state `analysisFile`: `analysis/07-adapter-outbound-identifier.md` (이 문서) — 이 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 - -``` diff --git a/docs/clean-architecture-backend-template/analysis/08-adapter-outbound-fileserver.md b/docs/clean-architecture-backend-template/analysis/08-adapter-outbound-fileserver.md deleted file mode 100644 index 0782a65..0000000 --- a/docs/clean-architecture-backend-template/analysis/08-adapter-outbound-fileserver.md +++ /dev/null @@ -1,768 +0,0 @@ -# 08 · adapter-outbound-fileserver - - -## SSOT identity — 2026-08-31 재검증 - -- registered leaf id: `adapter-outbound-fileserver` -- canonical state `analysisFile`: `analysis/08-adapter-outbound-fileserver.md` (이 문서) — 이 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