# adapter:outbound:messaging — 설계 결정 참조 메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 일반 publisher에서 재사용한다. outbox relay 실패는 이 모듈이 별도의 typed report adapter로 구조화한다. 허용/금지 의존 정책은 `src/config/architecture/modules.json`과 이 모듈의 [CLAUDE.md](CLAUDE.md)가 소유한다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다. ## 모듈 개요 application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 두는 **선택형** 연동 어댑터다. `@ConditionalOnProperty` 로 게이팅되고 기본 비활성이며, 비활성 바인딩은 `Disabled*` 구현으로 fail-fast 한다(Layer 3). 무거운 broker SDK 는 의도적으로 classpath 에 최소화하고, 실제 broker client(`KafkaSender`)는 포킹 프로젝트가 채우는 seam 이다. ## 두 포트를 하나의 활성 broker 에 조립 `MessagingConfig` 는 두 messaging 포트를 단일 활성 `MessageBroker` 위에 조립한다 — broker 추가는 새 broker 구현 파일 추가만으로 끝나고 이 config 는 바뀌지 않는다. ## broker 선택 검증 `app.messaging.broker` 가 설정됐는데 `MessageBroker` 빈이 없으면 startup 을 명시적 메시지로 실패시킨다(조용한 no-op 아님). settings 와 활성 빈의 `brokerId()` 불일치도 startup 실패다. ## 비활성 sentinel 두 개를 분리한 이유 `DisabledMessagePublisher` 와 `DisabledOutboxMessagePublisher` 는 별도 클래스다 — 한 클래스가 두 포트를 모두 구현하면 `getBean(MessagePublisher.class)` 가 모호해진다. ## OutboxEnvelopeJson — 손수 짠 JSON 이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope 직렬화는 의존성 없는 손수 짠 JSON 이다. ## MessagePublisher vs OutboxMessagePublishPort `MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께 로깅하고 삼켜(→ `:adapter:outbound:support` 의 `FailOpenDependencyLogger`) outbox/retry 로 위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort` 를 쓴다. `OutboxMessagePublishAdapter`는 envelope mapping + broker send만 수행하며 runtime 예외를 그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7). ## Legacy R0 characterization 현재 `KafkaSender.send(...)`와 `MessageBroker.send(...)`의 정상 void 반환은 호출이 예외 없이 끝났다는 뜻일 뿐 broker acknowledgement 증거가 아니다. relay는 이 정상 반환 뒤 legacy `PUBLISHED`를 기록한다. sender 예외는 fail-closed outbox 경로에서 전파되어 relay의 `FAILED/DEAD` 전이를 유발한다. broker 설정이 blank면 두 포트는 각각 `DisabledMessagePublisher`와 `DisabledOutboxMessagePublisher`에 바인딩된다. broker를 선택했지만 project-supplied sender가 없거나 선택 ID와 활성 `MessageBroker.brokerId()`가 다르면 startup이 실패한다. publisher 정상 반환 뒤 DB mark가 실패하면 row는 `IN_FLIGHT`에 남아 timeout reclaim 후 같은 event가 다시 publish될 수 있다. 현재 consumer/inbox가 없으므로 이 duplicate 가능 구간을 중복 안전 전달로 표현하지 않는다. 또한 legacy FIFO gate는 `occurred_at`의 strict less-than 비교만 사용하므로 동일 aggregate의 timestamp 동률 row는 서로를 gate하지 않는다. ## OutboxRelayFailureReport 구조화 ERROR `MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의 `Slf4jOutboxRelayFailureReportAdapter`를 등록한다. broker 설정이 blank면 안전한 `dependency_name=disabled`를 쓴다. 이 adapter는 confirmed FAILED/DEAD report 하나를 SLF4J 2 fluent ERROR 하나로 렌더링한다. 공통 field는 `error.code`, `error.category`, `dependency_name`, `dependency_type=messaging`, `outcome`, `event_id`, `event_type`, `aggregate_id`, `correlation_id`, `attempt_count`, `runbook_link`다. retry report만 `next_attempt_at`을 추가한다. payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로 붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted FAILED/DEAD outcome을 바꾸지 않는다. ## Closed local binding compiler R1 `contract`, `destination`, `config` package는 명시적으로 주입된 `IntegrationEventContractContribution` 목록만 사용해 contract catalog와 deployment binding을 로컬에서 컴파일한다. payload record의 exact `Class` token, 선언 순서, schema resource/hash를 검증하며 scan, `Class.forName`, assignable discovery, raw JSON/tree discovery를 하지 않는다. catalog/settings/schema digest는 정렬된 semantic identity와 length-prefixed UTF-8/raw SHA-256 bytes로 계산한다. physical topic과 bootstrap server는 `DestinationBindingSettings`와 `CompiledPublicationBinding`에만 존재한다. application contract에는 broker, topic, credential 설정이 유출되지 않는다. logical destination과 physical topic은 각각 deployment 안에서 unique다. compiled publication binding은 package-local compiler만 만들 수 있고 active descriptor는 public static compile 경로로만 생성한다. compiled integration-event contract도 catalog compiler만 package-local constructor로 만들 수 있다. partition key v1은 canonical non-null tenant scope, logical destination, aggregate type/id를 strict UTF-8 REPORT + domain-separated length-prefixed SHA-256으로 계산하고 lowercase hex US-ASCII bytes를 반환한다. malformed surrogate는 replacement 문자로 바꾸지 않고 거부한다. adapter 내부 compiler/card/binding 타입은 direct 또는 generic argument 형태의 public instance response surface로 노출하지 않는다. cross-package adapter composition이 정확한 compiled identity를 확인해야 할 때만 public static bridge를 사용하며, instance accessor는 package-private이고 외부에 필요한 상태는 boolean/String/ core value로만 제공한다. 이는 broker SDK뿐 아니라 adapter 자체의 조립 타입도 B7 반환 경계를 통해 application consumer로 새지 않게 한다. `MessagingCapabilityCardRegistry`는 first R2 tuple의 11개 ID와 각 role을 닫힌 Java vocabulary로 제공할 뿐이다. readiness registry의 `not-implemented` 상태를 승격하거나 R2/`ACTIVE_READY`를 주장하지 않는다. `DISABLED + empty catalog/bindings`는 client, thread, scheduler, network, filesystem resource가 정확히 0인 descriptor다. `ACTIVE`는 empty catalog, missing/duplicate/extra binding, unknown/mismatched card와 ordering/schema/security 완화를 resource 생성 전에 거부한다. ## Deterministic local JSON Schema encoder candidate `envelope` package는 exact final `IntegrationPayload` record만 받아 frozen v1 field order로 UTF-8 JSON을 쓴다. `Map`, raw JSON, `JsonNode`, polymorphic typing, custom serializer, assignable search는 입력 경로에 없다. contract compiler가 선언된 generic type graph를 scalar/enum, `Optional`/`List`/exact final nested record로만 재귀 고정하며 `Object`, interface, raw/wildcard, generic record와 runtime record discovery를 거부한다. writer는 이 compiled graph를 따라서 각 record accessor를 정확히 한 번 읽은 immutable snapshot을 만들고 payload를 정확히 한 번 encode한다. schema validation에 건넨 바로 그 payload bytes를 raw JSON API 없이 envelope suffix에 삽입한다. depth, string character/UTF-8 byte, array, object, number, payload/envelope byte limit는 writer와 parser 양쪽에서 적용한다. output byte limit는 generator가 쓰는 bounded stream에서 allocation 전에 강제하고, list는 size를 먼저 검사한 뒤 bounded iterator로 한 번만 snapshot한다. mutation/concurrent access와 극단적 `BigDecimal` scale도 큰 배열/String 생성 전에 fail-closed 한다. duplicate key, malformed UTF-8, trailing data, unpaired surrogate와 non-finite number는 replacement나 coercion 없이 거부한다. `LocalJsonSchemaRegistry`는 호출자가 명시적으로 제공한 exact bytes와 SHA-256만 startup에서 Draft 2020-12 meta-schema로 검사하고 precompile한다. root `$id`와 absolute `$ref`는 lowercase exact `urn` scheme만 허용하고 `$ref`는 동일 문서 fragment 또는 제공된 exact URN map으로 닫힌다. HTTP/HTTPS/file/classpath/resource/jar 및 unknown absolute scheme fetch와 YAML loader는 제공하지 않는다. public validation boundary도 UTF-8 bytes만 받는다. format assertion은 활성이다. 이 Task 6 closed subset은 scope 추적의 모호성을 제거하기 위해 root 외 nested `$id`를 value type과 무관하게 거부하고, `$dynamicRef`, `$dynamicAnchor`, deprecated `$recursiveRef`/`$recursiveAnchor`, `$anchor` 키워드를 어느 위치에서도 지원하지 않는다. Draft 2020-12 authority는 `draft/2020-12/schema` 1개와 `meta/*` 8개의 exact checked-in bytes 및 digest로 pin한다. registry startup은 9개 digest, 각 `$id`, NetworkNT runtime schema tree를 대조하고 하나라도 다르면 fail-closed 한다. Spring Boot executable/fat/nested JAR 배치를 깨뜨리는 `CodeSource` regular-file/JAR 가정은 하지 않는다. NetworkNT 3.0.2 artifact provenance는 strict Gradle dependency lock과 `verifyJsonSchemaRuntimeGraph`가 담당한다. 이 검증은 business schema registry나 runtime remote resolution 경로를 넓히지 않는다. 공통 build evidence manifest는 수동 구조 검사만으로 PASS하지 않는다. test/build 전용 `MessagingEvidenceManifestSchemaValidator`가 exact common schema와 생성된 manifest bytes를 같은 pinned NetworkNT/`LocalJsonSchemaRegistry`로 Draft 2020-12 검증하며, common schema 자체도 meta-schema 검증을 통과해야 한다. `verifyMessagingContracts`는 JSON-only qualification과 그 manifest schema validation을 명시적으로 선행해 CLI task 나열 순서와 무관하게 combined manifest가 `build/messaging-evidence/contracts-schema/manifest.json`의 최종 소유자가 된다. NetworkNT 3.0.2의 adopted regex 구현은 JDK regex 실행시간을 강제 중단시키는 별도 engine/timeout을 제공하지 않는다. 따라서 현재 후보는 regex 입력 길이와 전체 document 구조를 먼저 제한하고, pathological pattern의 작은 repository corpus를 회귀 테스트한다. 이는 JSON Schema Test Suite/Bowtie 전체 호환이나 hostile regex 시간 상한 증명이 아니며 evidence의 `regex-engine-timeout`, `consumer-compatibility-full-suite` unsupported claim으로 남는다. 테스트 runtime은 remote corpus를 내려받지 않는다. 이 Task의 acceptance는 **deterministic local wire contract candidate**다. encoder/catalog는 기존 `MessagingConfig`, runtime append, `KafkaSender` 또는 legacy broker selection에 연결하지 않았다. Kafka ACK, durable outbox R2, external topic attestation은 아직 구현되지 않았고 R0 runtime authority와 동작은 그대로다.