Files
document-haness/docs/clean-architecture-backend-template/analysis/06-adapter-outbound-persistence-mongo.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

188 KiB
Raw Blame History

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 검증 쪽 설계도 눈여겨볼 만하다. mongoPlatformStartupCheckMongoTopologyProbe bean이 있을 때만 돌지만, 그 조건이 곧 탈출구가 되는 것을 막기 위해 mongoTopologyProbeRequirementprobe 조건 없이 등록되어 "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는 두 줄이다.

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.exampledocs/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-mongoapp-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는 세 입력을 서로 다르게 처리한다.

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에 통과시켰다.

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는 자기가 켠 것이 꺼졌다는 신호를 받지 못한다 — 주석이 막겠다고 한 바로 그 상태다.

파생 결과가 하나 더 있다. MongoStartupValidatorchangeStreamsEnabled가 참일 때 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-bootstrapMongoPlatformHealthConfig, MongoPlatformHealthContributor (+ 해당 test)
MongoRootAutoConfiguration app-bootstrapShippedRuntimeFacadePresenceTest
나머지 11개 0

zero-reference를 dead로 읽어서는 안 되는 경우가 여기 있다. MongoRootAutoConfigurationMETA-INF/spring/…AutoConfiguration.imports가, MongoOptInAutoConfigurationImportFilterMETA-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의 계층별 차단이고, MongoMappingConfigurationMongoAdvancedConfiguration은 각각 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 두 파일명뿐이라 *.javadocs/**를 보지 않는다. 이 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.mdsrc/test를 덮지 않아, guard가 정의한 defect(문서가 operator에게 폐기 키를 지시)가 붙여넣기용 예제로 생존 문서 3곳 + 자기 leaf test 2곳; 런타임 영향 없음
P3 MongoPlatformSettingschange-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하게 통과하는 경우를 배제하기 위해 소스 자체를 직접 훑었다.

$ 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가 성공한 FINDWRITE_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 경로도 일관적이다. DefaultMongoFailureTranslatorMongoFailureClassification(category+outcome+retryScope 삼중항)을 먼저 만들고 retryableclassification.bodyReplayAllowed(), ambiguousclassification.ambiguous()에서 파생한다. 즉 두 boolean이 scope와 어긋날 여지가 production 경로에는 없다.

16. Confirmed P2 — schema version 실패는 두 경로 중 어느 쪽도 온전하지 않다

MongoFailureCategory에는 이 실패를 위한 전용 값 SCHEMA_VERSION_UNSUPPORTED("The stored document's schema version is outside the supported range")가 있고, 전용 예외 MongoDataSchemaUnsupportedExceptiondocumentVersion / 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:106MongoDocumentTooLargeException-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 내부 경로가 존재하는가였고, 확인한 것들은 다음과 같다: MongoServerVersionschema/validation/MongoValidatorApplyPolicy:54(유일한 production 소비자), MongoRetryScopefailure/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) MongoFailureContextoutcome=WRITE_RESULT_UNKNOWN, ambiguous=false 같은 조합을 canonical constructor가 막지 않는다. MongoExecutionOutcome.isAmbiguous()가 이미 있으므로 한 줄이면 강제된다. 다만 실제 위험은 제한적이다: production 경로는 classification에서 파생하고(§15), 가장 위험한 두 쌍은 예외 타입이 생성 시점에 거부한다. 남는 노출은 api가 외부 표면이라 adopter가 record를 직접 만들 수 있다는 점이다.

(b) MongoConsistencyDescriptorMongoConsistencyProfile의 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가 검증 코드까지 동일하다. DatabaseProfileNameCollectionProfileName을 이름만 치환해 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"DatabaseProfileNameFORMAT을 통과하는 평범한 값이라, 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
  • MongoValidatorApplyPolicyMongoServerVersion을 쓰는 방식과 schema/index manifest → sub-scope 7
  • mapping/type/PolicyAwareMongoTypeMapperMongoTypeRepresentationManifest를 강제하는 실제 경로 → 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를 썼다.

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는 그 불일치도 함께 측정했다.

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에서 반드시 발생한다. 수정 방향은 둘 중 하나이고 어느 쪽이든 세 답을 하나로 만들어야 한다 — writeTypepolicyFor처럼 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 축을 세 변환기 쌍으로 컴파일한다. 주석이 과거 상태를 기록한다 — 이 축은 선언만 있고 컴파일되지 않아 STRINGDECIMAL128이 동일한 document를 만들었고, 하나는 사전식으로 다른 하나는 수치로 정렬된다.
  • LocalDateTimeMappingGuardMongoMappingConfiguration실제 등록된 변환기로 만든다. 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 → timeoutconsistencyresult limittraceredaction → 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필드로 선언까지 해 둔 값이다.

$ 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 단계는 코드에 존재하지 않는다.

현재 노출은 없다 — MongoNativeCapabilityGatewayPolicyAwareMongoNativeGateway는 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이 스스로 선언한 자원 경계가 자신의 세 실행 경로에서 서버에 도달하지 않는다. 수정은 MongoPlatformCollectionAccessrawOperations() 대신 deadline이 붙은 접근자를 내보내거나, 세 executor가 query를 만들 때 context.timeout()을 붙이는 것이다.

33. P3 — timeout 초과 경로가 한 observation에 success와 failure를 모두 기록한다

같은 executor의 elapsed 검사 분기는 이렇게 쓰여 있다.

if (elapsed.compareTo(context.timeout()) > 0) {
  observation.success(outcome);
  throw MongoOperationRejectedException.of(...);
}

MongoOperationRejectedExceptionMongoPersistenceException의 하위 타입이고, 이 throw는 같은 try 블록 안에 있으므로 바로 다음 catch (MongoPersistenceException alreadyTranslated)가 잡아 observation.failure(...)를 호출한 뒤 다시 던진다. 결과적으로 하나의 observation에 successfailure가 차례로 호출된다.

shipped 구현에서는 무해하다. MicrometerMongoOperationObserver의 observation은 success/failureoutcomeTags 필드를 덮어쓸 뿐이고 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에 successfailure를 연달아 호출. 인터페이스는 어느 쪽이 이기는지 말하지 않으며 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 집합 = 검토된 집합"이다

MongoQueryPolicyPolicyAwareMongoQueryBuilder가 이 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(...)의 서명은 그 경로다.

public <T> List<T> execute(
    MongoOperationContext context,
    MongoAggregationProfile profile,
    MongoAggregationPlan plan,
    String collection,          // ← registry를 거치지 않는다
    Class<T> outputType)

AggregationResults<T> results = operations.aggregate(aggregation, collection, outputType);

contextcollectionProfile을 이미 들고 있는데도 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()은 금지하지 않는다

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가 이유를 적는다 — MongoTransactionManagerTransactionTemplate은 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.bindset/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.CURRENTSpringMongoCausalSessionExecutor.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/기록 MongoTransactionScopeSpringMongoCausalSessionExecutor가 각자 독립된 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 초과는 선언 시점에는 조용하고 비교 시점에만 보이기 때문이다. MongoIndexManifestexpectedUsageAPPLICATION 소유일 때 필수로 요구하는 것도 같은 계열이다: "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'").

MongoValidatorApplyPolicyCERTIFIED_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로 두 경우를 분리한다.

MongoMigrationrollback이 없는 것도 명시적 결정이다 — "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:93requireCurrentFence(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.applyOnerecordApplied 직전에 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가 실제로 비교하는 것은 keySignatureunique 두 개다(134-... §8.2b, grep 결과 4950행이 전부).

게다가 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. 최소 수정은 MongoIndexDescriptorViewexpireAftersparse를 추가하고 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.MongoTypeRepresentationManifestapi.schema.MongoSchemaVersionRange뿐 — 둘 다 sub-scope 02 소속). 그리고 정책 계층은 소비자조차 없다:

타입 production 소비자
MongoIndexApplyPolicy, requireRuntimeApplyAllowed 0 (test 1곳)
MongoValidatorApplyPolicy 0 (test 2곳)
MongoIndexDiffEngine, MongoValidatorDiffEngine 0 (new는 test에서만)
MongoTtlPolicyValidatorschema/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을 약속하지만, MongoCollectionMigrationLedgerUNFENCED만 검사하고 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.expireAfterMongoTtlPolicy 두 곳에 있고 서로 참조가 없다. 규칙(최소 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<Boolean>이 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.101700000000.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())을 부르고, 그 메서드는 getAndAccumulatemark를 먼저 전진시킨 뒤 전진 여부를 반환한다(49·5863행). 즉 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가 저장된 위치"로 바꾸는 것이다 — runOneallowsCheckpointAdvance()인 결과를 낸 뒤에만 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:104changeStreamsEnabled && !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 changeStreamsfalse로 고정돼 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에서 가장 잘 배선되고 가장 잘 논증된 부분이다

MongoFailureClassifierMongoFailureTranslator는 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_ONLYTRANSACTION_COMMIT_UNKNOWN에만 허용하는 것도 §15의 불변식과 맞물린다.

security도 대부분 배선돼 있다. MongoStartupValidator:62MongoSecurityProfileValidator().validate(runtimeSecurity)를, :117requireDistinctCredentials를 부른다. MongoPlatformAutoConfiguration:341350은 셋 중 하나라도 없으면 부분 검증 대신 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 리스너는 MongoDriverObservabilityAutoConfigurationMongoClientSettingsBuilderCustomizer로 등록해 실제로 설치된다 — 그 파일의 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=UNSPECIFIEDMongoClientSettingsFactory가 "a value that moves under a stored document is a migration nobody wrote"라며 고정하려던 바로 그 값이다).

같은 결함의 형제가 이미 고쳐져 있다는 점이 이 finding을 결정적으로 만든다. 관측 쪽도 "설정 빌더에 적용하는 메서드에 호출자가 없다"는 똑같은 형태였고, 그쪽은 MongoDriverObservabilityAutoConfigurationMongoClientSettingsBuilderCustomizer를 등록해서 고쳤다. 동일한 메커니즘이 같은 패키지에 있고, 설정 절반에는 쓰이지 않았다.

기존 test는 이 경계를 보지 못한다. MongoClientSettingsFactoryTest는 factory를 직접 생성해서 프로파일이 설정에 도달하는지 확인한다 — factory가 호출된다는 전제 아래. MongoTlsLaneTestapplyToSslSettings(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(...)(:145149)은 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이다 — MongoCommandObservationListenerisAlwaysRedacted만 쓴다. 세 갈래(안전/기본/항상 가림) 중 실제로 쓰이는 것은 "항상 가림" 하나다. 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). executedryRun의 audit 경로 차이(§76).
  • 8.3 중복/미사용 메커니즘: §78 말미 목록. redactor의 세 갈래 중 하나만 사용(§77).
  • 8.4 lane drift: build.gradle에 6개 lane(mongoReplicaSetTest·mongoFailoverTest·mongoMigrationTest·mongoCompatibilityTest·mongoSecurityIntegrationTest·mongoPerformanceTest) 정의, tag는 각각 대응. MongoTlsLaneTest·MongoSecurityIntegrationLaneTestmongodb-security-integration, MongoNetworkFaultLaneTestmongodb-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는 네 메서드 모두 MongoAdminGateway5인자 편의 오버로드를 부른다(:64, :75, :86, :92). 그 오버로드는 MongoAdminCommand.routine(...)을 만들고 **approval = null**을 넘긴다(MongoAdminGateway:6367). 그리고 실제 실행 경로는 고위험 작업에 대해 approval == null이면 거부한다(:9497).

MongoAdminOperation에서 SHARD_COLLECTION·REFINE_SHARD_KEY·RESHARD_COLLECTION은 전부 highRisk(true)이고, BALANCER_CONTROLfalse다.

실행 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에 하나만 배선하라"는 진술이 없다. MongoChangeMessagingBridgeMongoChangeProjector가 아니라 별도 타입이라는 사실 자체가 둘을 함께 쓸 수 있다는 신호로 읽힌다. 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.supportsUniqueIndexOnMongoShardingAdminGateway.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 MongoShardingAdminGatewayshardCollection·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/기록 MongoChangeStreamRunnerMongoChangeMessagingBridge가 같은 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들이 어려운 쪽을 선택했다는 점이다.

  • MongoThreeNodeReplicaSetMongoDBContainer쓰지 않는다 — 그 컨테이너는 시작할 때 자기만의 단일 노드 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<String>을 반환했고 그 test는 집합의 내용만 단언했다. "a controller must not hold a MongoTemplate"은 Set<String>에 대한 통과하는 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).

Set<MongoReplicaSetContract> executed = new LinkedHashSet<>();
for (MongoReplicaSetContract contract : MongoReplicaSetContract.all()) {
  executed.add(contract);                       // ← 루프가 무조건 채운다
  if (!contractRunner.test(contract)) { failures.add(...); }
}
Set<MongoReplicaSetContract> missing = new LinkedHashSet<>(MongoReplicaSetContract.all());
missing.removeAll(executed);                    // ← 항상 비어 있다
missing.forEach(contract -> failures.add(... + " (not executed)"));

executedall()과 언제나 같으므로 missing은 언제나 비고, (not executed) 항목은 어떤 입력으로도 생성되지 않는다. certified()executed.containsAll(all())(78행)도 마찬가지로 항상 참이다.

조건부 형제가 같은 testkit 안에 있다. MongoChaosGate.report()는 같은 일을 옳게 한다 — executed는 명시적 record(scenario, passed) 호출로만 채워지는 map이고, missingall()에서 기록되지 않은 것을 뺀 것이다. 그리고 그 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의 형제인 MongoReplicaSetContractGOLDEN_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/121138a
  • 정적으로 결정 불가한 지점은 실행 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