Files
document-haness/docs/clean-architecture-backend-template/analysis/12-adapter-outbound-messaging.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

441 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<id>`가 선택자다. `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
```