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

34 KiB
Raw Blame History

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.

{ "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.configureEachtools.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.factoriesAutoConfigurationImportFilter로 실제 등록돼 있고(177-... §8.1), 다섯 개 Boot 자동설정 클래스 이름을 명시적으로 막는다.

DisabledMessagePublisherDisabledOutboxMessagePublisher가 별도 클래스인 이유도 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

원인은 태스크가 정확한 패치 버전까지 하드코딩한 데 있다.

[
        '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.5compileClasspathruntimeClasspath 양쪽에 있고, 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.gradlemessagingVerificationSkeletonsbuild/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. 어휘 allowlistKNOWN_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. 두 발행 경로의 실패 정책이 정반대이고 그 이유가 적혀 있다

포트 정책 근거
MessagePublisherOutboundMessagePublisher fail-open "a broker outage must never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path)"
OutboxMessagePublishPortOutboxMessagePublishAdapter 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.toJsonevent.payload()이스케이프 없이 그대로 봉투에 넣는다 — "MUST already be a valid serialised JSON value; it is inserted verbatim (no escaping)". 강제되지 않으면 JSON 주입 지점이다.

강제된다. application-coreOutboxEvent 정규 생성자가 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