# 12 · adapter-outbound-messaging ## SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-messaging` - canonical state `analysisFile`: `analysis/12-adapter-outbound-messaging.md` (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/messaging` · Gradle `:adapter:outbound:messaging` - registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]` - registry `runtime_memberships`: `["app-bootstrap"]` - coverage ledger: `FULL_READ` **69** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: IN_PROGRESS > revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 경로: `src/adapter/outbound/messaging` · Gradle: `:adapter:outbound:messaging` ## 0. Denominator와 coverage ledger tracked file **69개** — main 46 (Java 34 / 4,246 LOC + resource 12), test 19 (Java 16 / 3,670 LOC + resource 3), governance 4. 총 약 7.9k LOC. ```json { "id": "adapter-outbound-messaging", "gradle_path": ":adapter:outbound:messaging", "allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"], "runtime_memberships": ["app-bootstrap"] } ``` 앞의 두 모듈(cache-redis · httpclient)과 달리 이 leaf는 작고, 무게가 **하나의 성질**에 몰려 있다 — JSON Schema 검증 런타임을 **닫는 것**. `build.gradle`이 그 규율을 세 겹으로 표현한다. - `configurations.configureEach`가 `tools.jackson.dataformat:jackson-dataformat-yaml`·`org.yaml:snakeyaml`·`org.snakeyaml:snakeyaml-engine`을 **전 configuration에서 제외**한다. - `json-schema-validator:3.0.2`에서 `jackson-dataformat-yaml`을 다시 개별 제외한다. - `verifyJsonSchemaRuntimeGraph` 태스크가 **런타임 그래프를 실제로 해석해** YAML 계열과 Jackson 2 `core`/`databind`가 없는지, 그리고 잠긴 세 모듈(`json-schema-validator:3.0.2`·`tools.jackson.core:jackson-core:3.0.2`·`jackson-databind:3.0.2`)이 있는지 확인한다. 이 태스크는 `check`에 붙어 있다. 마지막 주석이 예외를 정직하게 적는다 — "Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph." 그리고 qualification lane 둘이 `registerStrictQualificationTest`로 등록되며 각각 **필수 클래스 목록**을 갖는다 — compiled-contract 5종, JSON Schema v1 4종. 둘 다 루트의 `:prepareMessagingContractEvidence`에 의존하고 JUnit XML을 루트의 evidence 디렉터리로 낸다. main 패키지 배치(Java 34): `kafka` 5 · `envelope` 5 · `core` 5 · `outbox` 4 · `destination` 4 · `contract` 3 · `realtime` 2 · `config` 2 · `autoconfigure` 2 · 루트 2. 가장 큰 두 파일이 이 leaf의 중심이다 — `LocalJsonSchemaRegistry` 701줄, `DeterministicEnvelopeWriter` 548줄. ### 하위 범위 ledger | # | 범위 | main | test | 기타 | 합 | 상태 | |---|---|---|---|---|---|---| | 1 | governance + 루트 + `autoconfigure` + `config` + 활성화 resource | 6 | 2 | 6 | 14 | **COMPLETE** | | 2 | `envelope/**` + JSON Schema meta 리소스 | 5 | 3 | 13 | 21 | **COMPLETE** | | 3 | `contract/**` + `destination/**` + qualification test | 7 | 6 | – | 13 | **COMPLETE** | | 4 | `core` + `kafka` + `outbox` + `realtime` | 16 | 5 | – | 21 | **COMPLETE** | | | **TOTAL** | **34** | **16** | **19** | **69** | **4 / 4** | manifest: `evidence/raw/176-outbound-messaging-module-inventory.txt`. --- ## 1. Sub-scope 01 범위와 denominator > 내부 상태: COMPLETE — **14 / 14 FULL_READ** > 범위: governance 4 + 루트 2 + `autoconfigure` 2 + `config` 2 (main 6) + 활성화 resource 2 + 전용 test 2 > 역할: 이 leaf가 켜지는 방식과, 켜졌을 때 무엇이 조립되는가 manifest와 probe: `evidence/raw/177-outbound-messaging-activation-probes.txt`. ## 2. 스위치와 선택자를 분리한 기록 `MessagingBridgeRootAutoConfiguration`의 javadoc이 이 sub-scope의 설계 결정이다. > "Before this, whether `app.messaging.broker` was blank was the de-facto switch. **That is a selector doing a switch's job**, and it reads badly in both directions: a blank broker with the relay enabled took down startup, while a deployment that wanted no messaging at all still assembled settings, a Kafka adapter configuration and two publishers. The broker id now selects *which* transport, and this switch decides *whether* there is one." 지금은 `app.messaging.enabled=true`가 스위치이고 `app.messaging.broker=`가 선택자다. `MessagingConfig.resolveBroker`는 선택자가 설정됐는데 기여 bean이 없으면 **명시적 메시지로 startup을 실패**시키고(조용한 no-op 아님), settings의 id와 활성 bean의 `brokerId()`가 다르면 그것도 실패다. `MessagingOffAutoConfigurationImportFilter`가 더 흥미롭다. Boot의 Kafka/AMQP auto-configuration은 import metadata로 들어오므로 **클라이언트 라이브러리가 classpath에 있기만 하면** connection factory·template·listener container가 만들어진다 — "none of which any project condition was consulted about." 게다가 두 라이브러리가 모두 있으면 "both brokers would assemble at once simply because both libraries are present, which is a different bug the same filter prevents." 필터는 `spring.factories`에 `AutoConfigurationImportFilter`로 실제 등록돼 있고(`177-...` §8.1), 다섯 개 Boot 자동설정 클래스 이름을 명시적으로 막는다. `DisabledMessagePublisher`와 `DisabledOutboxMessagePublisher`가 별도 클래스인 이유도 README가 적는다 — 한 클래스가 두 포트를 모두 구현하면 `getBean(MessagePublisher.class)`가 모호해진다. ## 3. P2 — `check`에 붙은 `verifyJsonSchemaRuntimeGraph`가 실행되면 실패한다 이 leaf의 중심 규율은 JSON 검증 런타임을 닫는 것이고, 그것을 강제하는 태스크가 `check`에 붙어 있다. **실행 probe 결과 그 태스크는 실패한다.** ``` $ ./gradlew :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph FAILED > Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2 EXIT=1 ``` 원인은 태스크가 **정확한 패치 버전까지 하드코딩**한 데 있다. ```groovy [ 'com.networknt:json-schema-validator:3.0.2', 'tools.jackson.core:jackson-core:3.0.2', 'tools.jackson.core:jackson-databind:3.0.2' ].each { String required -> if (!modules.contains(required)) { throw new GradleException("… missing required locked module ${required}") } } ``` 그런데 잠긴 실제 좌표는 다르다. ``` com.networknt:json-schema-validator:3.0.2 ← 일치 tools.jackson.core:jackson-core:3.1.5 ← 3.0.2 아님 tools.jackson.core:jackson-databind:3.1.5 ← 3.0.2 아님 tools.jackson:jackson-bom:3.1.5 ``` Jackson 3 BOM이 3.1.5로 올라가면서 두 좌표가 어긋났고, 태스크는 그것을 "필수 모듈 누락"으로 보고 빌드를 깬다. **판정: P2.** 금지 조건 쪽(YAML 계열·Jackson 2 `core`/`databind` 부재)은 여전히 옳게 동작하지만, 필수 조건 쪽이 버전 드리프트로 고장 나 있어 **게이트 전체가 통과할 수 없다.** 결과는 이 저장소가 다른 곳에서 반복해 경계한 바로 그 상태다 — 붙어 있으나 초록일 수 없는 게이트는 사람들이 건너뛰는 법을 배우게 만든다. 수정은 필수 좌표에서 버전을 떼고 `group:name`만 확인하거나(닫힘 조건은 "무엇이 없는가"이지 "어느 패치인가"가 아니다), 잠금 파일에서 버전을 읽어 비교하는 것이다. ## 4. P3 — README의 `jackson-databind` 부재 주장이 현재 상태와 어긋난다 README:36이 손수 짠 JSON 직렬화의 근거를 적는다. > "이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope 직렬화는 의존성 없는 손수 짠 JSON 이다." 잠금 파일에는 `tools.jackson.core:jackson-databind:3.1.5`가 `compileClasspath`와 `runtimeClasspath` 양쪽에 있고, `build.gradle`의 검증 태스크는 그 모듈이 **있어야 한다**고 요구한다(§3). Jackson 2의 `com.fasterxml.jackson.core:jackson-databind`는 실제로 금지돼 있으므로 서술이 그 네임스페이스를 뜻했다면 맞지만, 문장은 네임스페이스를 한정하지 않는다. **판정: P3.** 코드 결함은 아니다 — `OutboxEnvelopeJson`의 손수 짠 직렬화는 그 자체로 문제가 없다. 기록하는 이유는 그 선택의 **근거로 적힌 사실이 더 이상 성립하지 않는다**는 점이고, fork가 그 문장을 읽고 "databind가 없다"를 전제로 다른 결정을 내릴 수 있기 때문이다. ## 5. P3/기록 — 컴파일된 서술자 계열이 production 소비자를 갖지 않는다 이 leaf의 main은 두 반쪽으로 나뉜다. | 반쪽 | 파일 | LOC | production 소비자 | |---|---|---|---| | broker/publisher | `core` 5 · `outbox` 4 · `kafka` 5 · 루트 2 | 883 | app-bootstrap 5개 파일이 import | | 컴파일된 계약 | `envelope` 5 · `contract` 3 · `destination` 4 · `config` 2 | **3,363 (79%)** | **0** | `contract`·`destination`·`envelope`·`config` 네 패키지를 참조하는 파일은 leaf 밖에 **하나도 없다**(`177-...` §8.4c, 매치 0). app-bootstrap이 import하는 것은 `core`·`outbox`·`kafka`·`MessagingConfig`뿐이다. 그리고 `CompiledMessagingDescriptor`는 leaf의 `main` 안에서도 참조가 0이다 — 유일한 소비자가 `DestinationBindingCompilerTest`다. 이것을 결함으로 올리지 않는 이유가 있다. `build.gradle`의 qualification lane 둘(`messagingCompiledContractsQualificationTest`·`messagingJsonSchemaV1QualificationTest`)이 이 절반을 **증거 산출 목적**으로 실행하고, JUnit XML을 루트의 `build/test-results/messaging-evidence/{compiled,json-schema}`로 내며, 루트 `build.gradle`의 `messagingVerificationSkeletons`가 `build/messaging-evidence/contracts-schema/manifest.json`을 요구한다. 즉 이 절반은 **애플리케이션에 조립되기 위한 것이 아니라 저장소 수준 readiness 증거를 만들기 위한 것**으로 보인다. 기록하는 이유는 그 사실이 **어디에도 적혀 있지 않다**는 점이다. README는 이 leaf를 "메시징(broker publish + outbox) 아웃바운드 어댑터 모듈"로 소개하고 broker 선택·비활성 sentinel·`OutboxEnvelopeJson`만 설명한다 — 전체 main LOC의 79%를 차지하는 계약·목적지·봉투·스키마 절반에 대해 **한 줄도 없다.** fork가 README만 읽으면 이 leaf가 무엇을 담고 있는지 알 수 없다. ## 6. Negative-space probes — sub-scope 01 - **8.1 활성화 등록**: `spring.factories`의 import filter와 `AutoConfiguration.imports`의 루트 자동설정이 실제로 등록됨을 파일 내용으로 확인. - **8.2 도달성**: 컴파일된 서술자 계열 5종의 leaf-main·app-bootstrap 참조 수 계수 — `CompiledMessagingDescriptor` 0/0(§5). - **8.3 스위치 ↔ 선택자**: `@ConditionalOnProperty` 전수(`app.messaging.enabled` 1곳, `app.messaging.broker=kafka` 1곳)와 그 관계 확인(§2). - **8.4 실행 probe**: `verifyJsonSchemaRuntimeGraph`를 실행해 실패를 확정하고 잠금 파일과 대조(§3). ## 7. Sub-scope 01 findings backlog | 우선순위 | finding | reachability | |---|---|---| | **P2** | `check`에 붙은 `verifyJsonSchemaRuntimeGraph`가 필수 좌표의 패치 버전을 하드코딩해 Jackson 3 BOM 3.1.5 아래에서 **항상 실패**한다 — 닫힌 런타임 보증이 실제로는 검증되지 않는다 | 이 모듈의 모든 `check` | | **P3** | README:36의 "`jackson-databind`를 classpath에 두지 않는다"가 잠금 파일(`tools.jackson.core:jackson-databind:3.1.5`, compile+runtime)과 어긋난다 | 문서 | | P3/기록 | 계약·목적지·봉투·스키마 절반(main LOC의 79%)이 production 소비자 0이고, README가 그 절반의 존재와 목적을 전혀 설명하지 않는다 | 이 leaf를 읽는 fork | ## 8. Sub-scope 01 완료 조건 - denominator 14 / 14 FULL_READ (`177-...` OWNED FILES) - §8.1~§8.4 probe 수행, 실행 probe 1건으로 게이트 실패 확정 - 소스 미변경 --- ## 9. Sub-scope 02 범위와 denominator > 내부 상태: COMPLETE — **21 / 21 FULL_READ** > 범위: `envelope/**` main 5 (1,573 LOC) + 전용 test 3 (1,140 LOC) + 리소스 13 (핀 고정 메타스키마 10 + test 벡터 3) > 역할: 닫힌 Draft 2020-12 스키마 레지스트리와 결정적 봉투 직렬화 manifest와 probe: `evidence/raw/178-outbound-messaging-envelope-probes.txt`. ## 10. 레지스트리가 "닫혀 있다"는 것의 의미 `LocalJsonSchemaRegistry`의 한 줄 요약이 계약이다 — "Immutable, startup-compiled Draft 2020-12 registry backed **only by explicitly supplied bytes**. Every reference is checked before NetworkNT compilation. After construction this type exposes **no loader, URL, file or classpath fetch operation**." 닫힘이 네 겹으로 표현된다. 1. **어휘 allowlist** — `KNOWN_VOCABULARIES` 8종(core·applicator·unevaluated·validation·meta-data·format-annotation·format-assertion·content) 밖의 `$vocabulary` 항목은 거부된다. 2. **키워드 부분집합** — `$anchor`·`$dynamicRef`·`$dynamicAnchor`·`$recursiveRef`·`$recursiveAnchor` 다섯이 `UNSUPPORTED_CLOSED_SUBSET_KEYWORDS`로 **문서 어디에서든** 거부된다(test `rejectsDynamicRecursiveAndAnchorKeywordsEverywhereInTheClosedSubset`). 3. **참조 사전 검사** — `validateAllReferences`가 NetworkNT 컴파일 **전에** 모든 `$ref`를 확인하고, 원격 참조와 설정된 깊이를 넘는 참조 그래프를 거부한다. 4. **핀 고정된 메타스키마 권위** — 9개 Draft 2020-12 메타 문서를 리소스로 동봉하고 `authority.sha256` 매니페스트로 해시를 고정하며, 도메인 분리 상수(`ca-skeleton.messaging.draft-2020-12-authority.v1`)를 섞는다. 매니페스트는 UTF-8 디코딩을 `REPORT` 모드로 읽어 잘못된 바이트를 조용히 대체하지 않는다. **실행 probe로 매니페스트를 검증했다** — 동봉된 9개 파일의 SHA-256이 `authority.sha256`의 아홉 줄과 **전부 일치한다**(`178-...` §8.3). 즉 핀이 실제로 현재 파일을 가리킨다. `$id`는 정확한 URN 스킴만 허용하고(`acceptsOnlyExactUrnSchemeForRootIdentifiersAndAbsoluteReferences`), 중첩 `$id`는 상대·절대 어느 쪽도 허용하지 않으며 **값 타입과 무관하게 키 자체를** 거부한다(`rejectsNestedSchemaIdentifierKeysRegardlessOfValueType`). ## 11. 봉투 작성이 파서를 거치지 않는다 `DeterministicEnvelopeWriter`는 페이로드를 **선언된 shape을 따라 스냅샷**한 뒤 그 정확한 바이트를 봉투에 끼워 넣는다 — "those exact trusted bytes are then embedded in the envelope **without any raw JSON parser or generator API**." `embedExactPayload`가 `,"payload":` 리터럴로 이어 붙이는 방식이다. 입력 검증이 촘촘하다 — draft의 페이로드가 **정확히 등록된 final record 클래스**여야 하고(`exactPayloadClassIsRequiredAndNoAssignableTypeSearchOccurs`), contractId와 payloadVersion이 컴파일된 계약과 같아야 하며, 레코드 성분 수·문자열 UTF-8 길이·배열/객체 크기·깊이가 모두 `EnvelopeAdmissionLimits`로 유계다. 그리고 **쓰는 도중에** 출력 크기를 본다(`boundsJsonOutputDuringWritesInsteadOfOnlyInspectingTheCompletedBuffer`). 가변 페이로드 처리도 명시적이다 — `snapshotsStatefulMutablePayloadAccessorsOnceAndEmbedsThoseExactBytes`. 접근자를 한 번만 부르고 그 바이트를 고정하므로, httpclient의 `ObjectBody` 문제(같은 키로 다른 바이트)가 여기서는 구조적으로 불가능하다. ## 12. 적대적 코퍼스가 이 leaf의 test 밀도를 설명한다 test 3파일 1,140줄이 main 1,573줄을 덮고, 이름이 하나씩 구체적인 공격 형태다. - 파서 경계: 짝 없는 서로게이트, 비유한 수, 깊이, 숫자 범위 — **검증 전에** 거부 - 스키마 입력: 중복 키, 잘못된 UTF-8, 뒤따르는 쓰레기, 예산 초과 정규식 - 수 처리: `rejectsExtremePositiveDecimalScaleBeforePlainStringAllocation` — 큰 scale의 `BigDecimal`을 평문 문자열로 만들기 **전에** 거부(메모리 폭발 방지) - 컬렉션: `checksListSizeBeforeIterationAndFailsClosedOnMutationOrConcurrency` - 해시: `exactEnvelopeHashHasDomainSeparatedGoldenVectorAndDefensiveShaValue` — 도메인 분리와 골든 벡터 - 권위: `startupAuthorityDoesNotDependOnARegularNetworkNtCodeSourceJar` — 핀 검증이 라이브러리 자신의 jar에 기대지 않음 ## 13. Negative-space probes — sub-scope 02 - **8.1 닫힘의 실제 강제**: 어휘 allowlist·키워드 부분집합·참조 사전 검사의 코드 지점을 각각 확인. - **8.2 핀 무결성**: `authority.sha256`의 9줄과 동봉 파일의 실제 SHA-256을 **실행으로 대조 — 전부 일치**(§10). - **8.3 파서 우회**: 봉투 작성이 원시 JSON 파서/생성기 API를 쓰지 않고 스냅샷 바이트를 끼워 넣는 경로 확인(§11). - **8.4 적대적 커버리지**: test 29개 메서드의 이름을 공격 형태별로 분류(§12). ## 14. Sub-scope 02 findings backlog | 우선순위 | finding | reachability | |---|---|---| | — | **없음.** 어휘·키워드·참조·메타스키마 권위 네 겹이 모두 강제되고, 핀 해시가 실제 파일과 일치하며, 봉투 작성이 파서를 거치지 않고, 적대적 코퍼스가 파서·수·컬렉션·해시 경계를 이름으로 고정한다 | — | ## 15. Sub-scope 02 완료 조건 - denominator 21 / 21 FULL_READ (`178-...` OWNED FILES) - §8.1~§8.4 probe 수행, 실행 probe 1건으로 핀 매니페스트 무결성 확정 - 소스 미변경 --- ## 16. Sub-scope 03 범위와 denominator > 내부 상태: COMPLETE — **13 / 13 FULL_READ** > 범위: `contract/**` 3 + `destination/**` 4 (main 7, 1,351 LOC) + 전용 test 6 > 역할: 통합 이벤트 계약을 컴파일해 닫고, 목적지 바인딩과 파티션 키를 결정적으로 유도한다 manifest와 probe: `evidence/raw/179-outbound-messaging-contract-destination-probes.txt`. ## 17. 계약이 컴파일되어 닫힌다 `ContractCatalogCompiler`가 **정확한 record 타입 토큰**으로부터 불변 카탈로그를 만들고, test 이름이 무엇을 거부하는지 전부 적는다 — 중복 stable/schema/payload 신원, 음수 버전, 잘못된 payload kind, null·공백·중복·반사 불일치 성분 순서, 서술자 누락, **payload 버전 사이의 logical destination 드리프트**. 특히 두 test가 이 계층의 성격을 보여 준다. - `recursivelyFreezesOnlyTheClosedDeclaredGenericPayloadGraph` / `rejectsOpenRawWildcardMapJsonTreeInterfaceAndGenericRecordGraphs` — 열린 타입(raw·wildcard·`Map`·JSON 트리·인터페이스·제네릭 record 그래프)을 페이로드로 받지 않는다. 봉투 작성기가 shape을 따라 스냅샷할 수 있으려면 그래프가 닫혀 있어야 한다(§11). - `snapshotsEveryContributionAccessorExactlyOnceIncludingAStatefulSchemaHash` / `statefulDescriptorCannotBypassCrossVersionLogicalDestinationDrift` — 기여 접근자를 **정확히 한 번만** 호출한다. 가변 서술자가 검사와 저장 사이에 값을 바꿔 규칙을 우회하는 경로를 닫는다. `compiledContractUsesOnlyAStaticPublicCompositionBridgeWithoutReflectionLeak` — 컴파일된 계약이 반사를 밖으로 새게 하지 않는다. ## 18. 도메인 분리 + 길이 프레이밍이 일곱 곳에서 일관된다 이 leaf의 모든 다이제스트가 같은 형태다 — 버전이 붙은 도메인 상수, `\0` 구분, 각 필드의 태그와 값을 **4바이트 길이로 프레이밍**. | 상수 | 위치 | |---|---| | `ca-skeleton.messaging.contract-catalog-digest.v1` | `ContractCatalogDigest:16` | | `ca-skeleton.messaging.destination-settings-digest.v1` | `DestinationBindingCompiler:95` | | `ca-skeleton.messaging.schema-set-digest.v1` | `DestinationBindingCompiler:121` | | `ca-skeleton.messaging.partition-key.v1` | `PartitionKeyV1:22` | | `ca-skeleton.messaging.envelope.v1` | `EnvelopeHashV1:13` | | `ca-skeleton.messaging.schema-set.v1` | `JsonSchemaIntegrationEventEncoder:25` | | `ca-skeleton.messaging.draft-2020-12-authority.v1` | `LocalJsonSchemaRegistry:64` | 카탈로그 다이제스트는 **입력 순서와 무관**하다(contractId + payloadVersion으로 정렬 후 소화) — test `digestIsDeterministicForEmptyAndInputOrderIndependentForNonEmptyCatalogs`. 그리고 `digestChangesForSchemaHashDescriptorAndCanonicalComponentOrderSemantics`가 무엇이 바뀌면 다이제스트가 바뀌어야 하는지를 고정한다. `PartitionKeyV1`은 **교차 언어 벡터 진입점**을 명시적으로 제공하고("Callers retain ownership of their canonical component grammar; this method **never substitutes a missing tenant scope**"), 골든 벡터 test 둘이 있다 — 소문자 hex와 정확한 ASCII 바이트, 그리고 `nonAsciiAggregateIdGoldenVectorUsesUtf8ByteLengthNotCharacterCount`(길이 프레이밍이 문자 수가 아니라 UTF-8 바이트 수임). `DestinationBindingCompiler`는 코드 최대치와 배포 최대치의 **교집합**을 취하고, 배포 쪽이 낮으면 그것이 이기되 양수여야 한다(`deploymentMaximumBelowCodeMaximumWinsAndMustRemainPositive`). ## 19. Sub-scope 03 findings backlog | 우선순위 | finding | reachability | |---|---|---| | — | **없음.** 계약 그래프가 닫혀 있고, 기여 접근자가 한 번만 호출되며, 일곱 다이제스트가 도메인 분리와 길이 프레이밍을 일관되게 쓰고 골든 벡터로 고정된다 | — | --- ## 20. Sub-scope 04 범위와 denominator > 내부 상태: COMPLETE — **21 / 21 FULL_READ** > 범위: `core` 5 + `kafka` 5 + `outbox` 4 + `realtime` 2 (main 16, 632 LOC) + 전용 test 5 > 역할: 실제로 조립되는 절반 — broker 추상화, fail-open/fail-closed 두 발행 경로, Kafka seam, 실시간 fan-out manifest: `evidence/raw/176-outbound-messaging-module-inventory.txt`의 OWNED FILES 절. ## 21. 두 발행 경로의 실패 정책이 정반대이고 그 이유가 적혀 있다 | 포트 | 정책 | 근거 | |---|---|---| | `MessagePublisher` → `OutboundMessagePublisher` | **fail-open** | "a broker outage must never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path)" | | `OutboxMessagePublishPort` → `OutboxMessagePublishAdapter` | **fail-closed** | 실패가 그대로 전파되어 relay가 FAILED/DEAD 전이를 몰 수 있게 한다 | `OutboundMessagePublisher.publish`에 이 저장소에서 반복해 본 종류의 수정 이력이 있다. > "The send and the observation are separate steps because they used to share a try block: **a logger that threw after a successful send was caught by the same catch and reported as a publish failure.** The broker had accepted the message; the only thing that failed was the record of it, and the two must not be confusable." 그리고 관측 자체가 결과를 바꾸지 못한다 — `observeQuietly`가 진단 예외를 흡수하며 "Diagnostics are non-authoritative. **An appender that is out of disk must not change what the caller believes about the broker.**" 비활성 sentinel 둘은 조용한 no-op이 아니라 `AdapterDisabledException`을 던지고, 서로 다른 클래스로 분리된 이유가 bean 조회 모호성이다(§2). ## 22. `BrokerAddress` — 정규식을 파서로 바꾼 기록 javadoc이 이전 정규식이 받아들이던 것 넷을 열거한다. > "It ran against the *trimmed* value but the **untrimmed original was what got stored**, so `" kafka:9092"` passed validation and was then handed to the client with its leading space. `\d{1,5}` accepts `0` and `99999`, neither of which is a port. And `[^:\s]+` cannot express a bracketed IPv6 literal at all, so `[::1]:9092` — the only correct way to write an IPv6 endpoint — was rejected while `::1:9092` was accepted and is ambiguous." 지금은 손수 짠 파서가 대괄호 IPv6를 정확히 다루고(닫는 대괄호 뒤에 `:port`가 없으면 거부, 빈 host 거부), 포트를 1..65535로 강제하며, 정규화된 형태로 저장한다. ## 23. Confirmed — 이스케이프 없이 삽입되는 outbox 페이로드는 상류에서 강제된다 (후보 → 결함 아님) `OutboxEnvelopeJson.toJson`은 `event.payload()`를 **이스케이프 없이 그대로** 봉투에 넣는다 — "MUST already be a valid serialised JSON value; it is inserted verbatim (no escaping)". 강제되지 않으면 JSON 주입 지점이다. 강제된다. `application-core`의 `OutboxEvent` 정규 생성자가 `OutboxPayloadPolicy.requireValidPayload(payload)`를 호출하고, 그 자리 주석이 위험을 그대로 적는다 — "the envelope serialiser inserts this verbatim and unescaped, so an invalid or oversized payload becomes **a permanently unparseable message that the relay retries forever**." **결함 아님.** ## 24. `realtime` 두 파일의 자기 한정 `MessagingDurableFanoutAdapter`는 옆의 ephemeral fan-out과 달리 **fail-closed**이고, 파티션 키가 채널이 아니라 **수신자**의 것이며, "Nothing here deduplicates. Delivery is at-least-once by construction and the receiver holds" — 중복 제거 책임이 수신자에게 있음을 명시한다. `RealtimeFanoutEnvelopeJson`의 리더는 **알 수 없는 필드를 허용하고 없는 필드를 거부**한다 — "During a rolling deploy both" 버전이 동시에 쓰므로, 새 필드를 추가한 쪽이 옛 쪽의 항목을 깨지 않게 하는 방향이다(httpclient의 `RegistrationCodec`과 같은 논리). ## 25. Negative-space probes — sub-scope 03·04 - **8.1 다이제스트 일관성**: 도메인 분리 상수 7종과 길이 프레이밍 방식을 전수 대조(§18). - **8.2 조건부 형제**: 같은 broker 위에 놓인 두 발행 경로의 실패 정책이 정반대이고 각각 근거를 가짐(§21). `realtime`의 durable/ephemeral 쌍도 같은 형태(§24). - **8.3 이스케이프 없는 삽입**: `OutboxEnvelopeJson`의 verbatim 삽입을 상류 `OutboxEvent` 생성자의 강제로 추적해 오탐 판정(§23). - **8.4 파서 대 정규식**: `BrokerAddress`가 정규식이 받아들이던 네 가지 비주소를 각각 거부하는지 확인(§22). ## 26. Sub-scope 03·04 findings backlog | 우선순위 | finding | reachability | |---|---|---| | P3/기록 | `OutboxEnvelopeJson`의 클래스 javadoc이 README:36과 같은 주장("no Jackson — the module deliberately keeps `jackson-databind` off its classpath")을 반복하며, 잠금 파일의 `tools.jackson.core:jackson-databind:3.1.5`와 어긋난다 — §4의 같은 drift가 코드 주석에도 있다 | 문서 | ## 27. Sub-scope 03·04 완료 조건 - denominator 13 / 13 및 21 / 21 FULL_READ - §8.1~§8.4 probe 수행, 조건부 형제 비교 2건 - 후보 finding 1건(이스케이프 없는 페이로드 삽입)을 상류 강제로 추적해 결함 아님으로 판정(§23) - 소스 미변경 --- ## 28. 모듈 ledger 정합 | # | 범위 | main | test | 기타 | 합 | FULL_READ | probe | |---|---|---|---|---|---|---|---| | 1 | governance + 루트 + `autoconfigure` + `config` | 6 | 2 | 6 | 14 | 14 | `176`, `177` | | 2 | `envelope/**` + 메타 리소스 | 5 | 3 | 13 | 21 | 21 | `178` | | 3 | `contract/**` + `destination/**` + qualification | 7 | 6 | – | 13 | 13 | `179` | | 4 | `core` + `kafka` + `outbox` + `realtime` | 16 | 5 | – | 21 | 21 | `176` | | | **TOTAL** | **34** | **16** | **19** | **69** | **69** | **4 / 4** | coverage ledger: `FULL_READ` **69** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / 미분류 **0**. ## 29. 모듈 findings | # | 우선순위 | finding | 위치 | |---|---|---|---| | 1 | **P2** | `check`에 붙은 `verifyJsonSchemaRuntimeGraph`가 필수 좌표의 패치 버전(`3.0.2`)을 하드코딩해 잠긴 Jackson 3 BOM(`3.1.5`) 아래에서 **항상 실패**한다 — 닫힌 JSON 런타임 보증이 실제로 검증되지 않는다 | §3 | | 2 | **P3** | README:36과 `OutboxEnvelopeJson`의 클래스 javadoc이 "`jackson-databind`를 classpath에 두지 않는다"고 적지만 잠금 파일에 `tools.jackson.core:jackson-databind:3.1.5`가 compile+runtime으로 있다 | §4, §26 | | 3 | P3/기록 | 계약·목적지·봉투·스키마 절반(main LOC의 **79%**)이 production 소비자 0이고 README가 그 존재와 목적(증거 산출)을 전혀 설명하지 않는다 | §5 | **결함 아님으로 판정한 후보 1건** — `OutboxEnvelopeJson`의 이스케이프 없는 페이로드 삽입을 `OutboxEvent` 생성자의 `OutboxPayloadPolicy.requireValidPayload`로 추적(§23). ## 30. 이 모듈에서 반복해서 나타난 패턴 - **스위치와 선택자의 분리.** "That is a selector doing a switch's job" — `app.messaging.enabled`가 여부를, `app.messaging.broker`가 무엇을 결정한다. 그리고 Boot의 broker 자동설정이 라이브러리 존재만으로 들어오는 경로를 import filter로 막는다. - **도메인 분리 + 길이 프레이밍이 예외 없이 일곱 곳.** 모든 다이제스트가 버전 붙은 도메인 상수와 4바이트 길이 프레이밍을 쓰고, 골든 벡터로 고정된다. - **닫힘을 네 겹으로 표현.** 어휘 allowlist, 키워드 부분집합, 참조 사전 검사, 핀 고정 메타스키마 — 그리고 그 핀이 실제 파일과 일치함을 실행으로 확인했다. - **관측이 결과를 바꾸지 못한다.** `observeQuietly`와 send/observe 분리 — httpclient의 `NoThrowObservationSink`와 같은 규칙이 다른 모듈에서 독립적으로 나타난다. - **그리고 이 모듈의 P2도 같은 계열이다** — 규율을 강제하려고 만든 게이트가 버전 드리프트로 통과할 수 없게 됐다. httpclient의 §51(test가 잡았는데 고쳐지지 않음)과 같은 방향이다: **검증 장치 자체가 빨간 채로 남아 있다.** ## 31. 검증 `evidence/raw/180-outbound-messaging-suite-verification.txt`. ``` $ ./gradlew :adapter:outbound:messaging:test → BUILD SUCCESSFUL classes=17 tests=92 failures=0 errors=0 skipped=0 $ ./gradlew :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph → FAILED (EXIT=1) > Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2 $ git status --short → 0 ``` `:test`는 92건 전원 통과하고 skip이 0이다. `:check`는 §3의 게이트에서 실패한다 — **이 모듈 소스의 결함이 아니라 게이트 자신의 버전 하드코딩** 때문이다. qualification lane 둘(`messagingCompiledContractsQualificationTest`·`messagingJsonSchemaV1QualificationTest`)은 루트의 `:prepareMessagingContractEvidence`에 의존하는 저장소 수준 증거 파이프라인의 일부이므로 이 분석에서 실행하지 않았다. ## 32. 모듈 완료 조건 - denominator 69 / 69 FULL_READ, `STRUCTURAL_ONLY` 0, `EXCLUDED` 0, 미분류 0 (§28) - 4개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence `176`~`180` 5건 생성 - 실행 probe 2건 — 핀 매니페스트 무결성 확인(통과), `verifyJsonSchemaRuntimeGraph` 실패 확정 - 후보 finding 1건을 상류 강제로 추적해 결함 아님으로 판정 - 소스 미변경 ## Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **21개** (main 19 · test 1 · 기타 1). ``` src/adapter/outbound/messaging/build.gradle src/config/architecture/modules.json (adapter-outbound-messaging 항목) main: src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingOffAutoConfigurationImportFilter.java src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java src/main/java/dev/caskeleton/adapter/outbound/messaging/core/DisabledMessagePublisher.java src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessagePublisher.java src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddress.java src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/MessagingDurableFanoutAdapter.java src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/RealtimeFanoutEnvelopeJson.java test: src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java 기타: src/build.gradle 해석되지 않은 인용 (6종) — 외부 타입·문서상 약칭 등: evidence/raw/176-outbound-messaging-module-inventory.txt evidence/raw/177-outbound-messaging-activation-probes.txt build/messaging-evidence/contracts-schema/manifest.json evidence/raw/178-outbound-messaging-envelope-probes.txt evidence/raw/179-outbound-messaging-contract-destination-probes.txt evidence/raw/180-outbound-messaging-suite-verification.txt ```