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>
34 KiB
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_READ69 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 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.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 2core/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 +
autoconfigure2 +config2 (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.brokerwas 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
원인은 태스크가 정확한 패치 버전까지 하드코딩한 데 있다.
[
'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 참조 수 계수 —
CompiledMessagingDescriptor0/0(§5). - 8.3 스위치 ↔ 선택자:
@ConditionalOnProperty전수(app.messaging.enabled1곳,app.messaging.broker=kafka1곳)와 그 관계 확인(§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."
닫힘이 네 겹으로 표현된다.
- 어휘 allowlist —
KNOWN_VOCABULARIES8종(core·applicator·unevaluated·validation·meta-data·format-annotation·format-assertion·content) 밖의$vocabulary항목은 거부된다. - 키워드 부분집합 —
$anchor·$dynamicRef·$dynamicAnchor·$recursiveRef·$recursiveAnchor다섯이UNSUPPORTED_CLOSED_SUBSET_KEYWORDS로 문서 어디에서든 거부된다(testrejectsDynamicRecursiveAndAnchorKeywordsEverywhereInTheClosedSubset). - 참조 사전 검사 —
validateAllReferences가 NetworkNT 컴파일 전에 모든$ref를 확인하고, 원격 참조와 설정된 깊이를 넘는 참조 그래프를 거부한다. - 핀 고정된 메타스키마 권위 — 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 범위:
core5 +kafka5 +outbox4 +realtime2 (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}accepts0and99999, 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:9092was 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_ONLY0,EXCLUDED0, 미분류 0 (§28) - 4개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence
176~1805건 생성 - 실행 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