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>
441 lines
34 KiB
Markdown
441 lines
34 KiB
Markdown
# 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
|
||
|
||
```
|