# messaging-core-api 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-core-api` > SSOT owner: `messaging-core-api` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) > **성격.** 정책 문서가 아니라 읽기 기록이다. 이 leaf가 무엇을 선언했고, 그 선언 중 무엇이 실제로 소비되며, 무엇이 소비되지 않는지를 source anchor와 함께 적는다. cycle 1의 family 문서(`analysis/19-messaging-platform.md`)는 25개 leaf를 하나의 문서로 다뤘고 새 계약에서 secondary evidence로 강등됐다. 이 문서가 `messaging-core-api`의 canonical SSOT다. --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-core-api` - canonical state `analysisFile`: `analysis/messaging/messaging-core-api.md` - source path: `src/messaging/messaging-core-api` - leaf-owned subdocuments: 없음 - related family/integration documents: `analysis/19-messaging-platform.md` (secondary) - registry `allowed_dependencies`: `[]` — 이 저장소에서 의존성이 하나도 없는 두 leaf 중 하나(다른 하나는 `grpc-core-api`) - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 85 | | production LOC | 3,948 | | 패키지 | 7 | | test 파일 | 8 | | test 메서드(실행 확인) | 79 | | build/config 파일 | `build.gradle` 1, `gradle.lockfile` 1 | | migration | 0 | | 외부 의존성 | **0** | 패키지 7개와 그 안의 타입 수: | 패키지 | 타입 | 성격 | |---|---:|---| | `api` (root) | 12 | 봉투와 그 안의 값 객체 | | `api.header` | 5 | 헤더 이름·값·맵·예약 네임스페이스 | | `api.destination` | 7 | 논리 목적지와 capability | | `api.publish` | 17 | 발행 요청·결과·증거 | | `api.delivery` | 13 | 수신·핸들러 결과 | | `api.settlement` | 5 | 수동 정산 | | `api.error` | 26 | 실패 분류와 예외 계층 | | 합계 | **85** | | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**/api/*.java` (root 12) | 12 | `FULL_READ` | 전 파일 본문 확인 | | `src/main/java/**/api/header/*.java` | 5 | `FULL_READ` | 전 파일 본문 확인 | | `src/main/java/**/api/destination/*.java` | 7 | `FULL_READ` | 전 파일 본문 확인 | | `src/main/java/**/api/publish/*.java` | 17 | `FULL_READ` | 전 파일 본문 확인 | | `src/main/java/**/api/delivery/*.java` | 13 | `FULL_READ` | 전 파일 본문 확인 | | `src/main/java/**/api/settlement/*.java` | 5 | `FULL_READ` | 전 파일 본문 확인 | | `api/error/FailureCategory·FailureDescriptor·MessagingException` | 3 | `FULL_READ` | 전 파일 본문 확인 | | `api/error/Message*Exception` 나머지 | 23 | `STRUCTURAL_ONLY` | 전부 동일 형태 — 3개 생성자, 고정 `CATEGORY` 상수, `retryable` 리터럴. 시그니처·카테고리·retryable 값을 전수 대조했고 그 외 본문이 없다 | | `src/test/java/**` | 8 | `FULL_READ` | 전 파일 본문 확인 | | `build.gradle` | 1 | `FULL_READ` | 4줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일; 선언 의존성 0을 build.gradle에서 이미 확인 | | `build/**` | — | `EXCLUDED` | 빌드 산출물. source가 아니다 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 이 leaf는 **브로커 중립 공개 계약**을 소유한다. 여기에는 구현이 거의 없다 — 85개 타입 중 인터페이스 11개, enum 12개, record 46개, 유틸리티 final class 5개, 예외 26개이고, 실행 가능한 로직은 `UuidV7.next()`, `WireSafeText.require`, `MessageHeaders.validateAndCopy`, 그리고 record 생성자의 검증뿐이다. **무엇이 아닌가**가 이 leaf에서는 무엇인가만큼 중요하고, 코드가 그것을 직접 말한다. `build.gradle` 전문: ```groovy apply plugin: 'java-library' dependencies { } ``` `src/main/java` 전체에서 `java.*`와 자기 패키지 밖 import는 **0개**다(`evidence/raw/269` §F). Spring도, Kafka·AMQP 클라이언트도, Reactor도 없다. 이것은 우연이 아니라 원래 계획이 명시한 제약이고(`docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:13` — "`messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message`, Reactor 의존성을 넣지 않는다"), 현재 소스에서 재측정해도 참이다. 경계는 세 방향으로 그어져 있다. **브로커 쪽으로.** `MessageDestination`은 논리 이름·카탈로그 타입·payload 클래스만 갖고 topic/exchange/queue/subject를 갖지 않는다(`destination/MessageDestination.java:9-11`). `DestinationName`의 패턴 `[a-z0-9][a-z0-9.-]{0,159}`은 `:`과 `/`와 공백을 배제해서 `topic://orders` 같은 물리 주소를 논리 이름으로 밀어 넣는 것을 생성자에서 막는다(`destination/DestinationName.java:16`). 주석이 이유를 적는다 — "otherwise the physical mapping owned by the destination profile could be bypassed from application code." **프로그래밍 모델 쪽으로.** 핵심 계약은 `CompletionStage`다. blocking facade(`BlockingMessagePublisher`)는 인터페이스만 여기 두고 구현을 다른 모듈로 밀어냈으며, Reactor facade는 아예 없다(`publish/MessagePublisher.java:10-11`). **애플리케이션 쪽으로.** 이 경계는 이 leaf가 아니라 ArchUnit이 긋는다. `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`(`CleanArchitectureTest.java:229-240`)은 `..application..` 패키지가 `dev.caskeleton.messaging..`에 의존하는 것을 금지한다. 이유가 규칙 본문에 적혀 있다: > the application owns its publish port and outbox model; a bridge adapter translates, and the two outbox status models mean opposite things under the same names 이 규칙은 §12의 reachability 결과를 읽을 때 반드시 같이 봐야 한다. 이 leaf의 공개 타입 중 다수가 `..application..`에서 참조 0인 것은 **금지되어 있기 때문**이지 잊혀서가 아니다. --- ## 2. 의존성과 런타임 배선 ### 2.1 source 의존성 들어오는 것: 없음. registry `allowed_dependencies: []`이고 `build.gradle`에 선언이 없다. 나가는 것(이 leaf를 의존하는 messaging leaf, registry 기준): `messaging-schema-api`, `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-observability`, `messaging-security`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-reliability-api`, `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-admin-api`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit` — messaging family의 나머지 **24개 전부**. ### 2.2 런타임 배선 `runtime_memberships: ["app-bootstrap"]`이고, 그 편입은 직접 선언이 아니라 **전이(transitive)**로 일어난다. `src/app-bootstrap/build.gradle:87`이 선언하는 것은 하나다: ```groovy implementation project(':messaging:messaging-spring-boot-starter') ``` starter의 `allowed_dependencies`가 17개 leaf를 끌고 오고 그 closure에 `messaging-core-api`가 있다. 즉 **배포 아티팩트가 이 leaf를 싣는다.** 실행 여부는 별개이고 master switch `app.messaging.enabled`(기본 `false`)가 결정한다(`src/messaging/CLAUDE.md:56-57`). 이 leaf 자체는 bean을 하나도 만들지 않는다. Spring stereotype·`@Bean`·`@Conditional`·`@Profile` 주석이 leaf 전체에 0개다(`evidence/raw/269` §F, `git grep` exit=1). 따라서 §12.2의 conditional sibling 비교는 이 leaf에 **적용 대상이 없다** — 비교할 sibling bean이 존재하지 않는다. --- ## 3. 패키지/컴포넌트 지도 ### 3.1 `api` — 봉투와 값 객체 (12) `MessageEnvelope`가 중심이고 나머지 11개가 그 필드 타입이다. ``` MessageEnvelope ├── MessageId UUIDv7만 허용 ├── MessageType 카탈로그 이름, 240 UTF-8 bytes ├── SchemaVersion 1 이상 ├── producedAt Instant ├── occurredAt Optional ├── ProducerId 서비스 이름, 120 bytes ├── CorrelationId 워크플로 상관값, 160 bytes ├── CausationId → MessageId ├── ContentType media type, 160자 ├── partitionKey Optional, 1024 bytes ├── orderingKey Optional, 1024 bytes ├── TenantContext [a-z0-9][a-z0-9._-]{0,63} ├── TraceContext W3C traceparent/tracestate/baggage ├── MessageHeaders ≤64개, ≤32,768 bytes └── payload T, non-null ``` 부속: `UuidV7`(생성기), `WireSafeText`(검증 유틸). 봉투는 불변이고 네 가지 파생 메서드가 있다 — `withPayload`, `withContentType`, `withTenant`, `withHeaders`. 넷 다 `messageId`를 복사한다. `withPayload`의 javadoc이 그 이유를 적는다: "Encoding, decoding, Claim Check offloading, and DLQ forwarding all need this, and every one of them must keep `messageId()` intact — which is exactly what this method guarantees by construction"(`MessageEnvelope.java:80-82`). ### 3.2 `api.header` — 헤더 (5) `HeaderName`, `HeaderValue`, `MessageHeaders`, `ReservedHeaders`, `CanonicalEnvelopeHeaders`. `ReservedHeaders`는 23개 이름 상수와 `msg.` **prefix 전체**를 소유한다. `CanonicalEnvelopeHeaders`는 그 예약 네임스페이스를 둘로 쪼갠다 — 봉투 필드가 이미 갖고 있는 15개(`ENVELOPE_FIELDS`)와, 봉투에 대응 필드가 없어서 헤더로만 이동할 수 있는 나머지 8개(`REDRIVE_ID`, `REDRIVE_COUNT`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`, `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`). ### 3.3 `api.destination` — 목적지 (7) `MessageDestination`, `DestinationName`, `DestinationKind`(7), `MessagingCapabilities`(boolean 12), `DestinationCapabilities`, `ConfirmationRequirement`(3), `CapabilityRegistry`. ### 3.4 `api.publish` — 발행 (17) 퍼블리셔 4종(`MessagePublisher`, `BlockingMessagePublisher`, `BatchMessagePublisher`, `DelayedMessagePublisher`), 요청 3종, 결과 5종, 증거 3종, enum 3종(`PublishCompletion`, `ConfirmationLevel`, `RoutingOutcome`, `TransmissionEvidence` — 4종), `BrokerPosition`. ### 3.5 `api.delivery` — 수신 (13) `MessageDelivery`, `DeliveryMetadata`, `DeliveryContext`, `MessageHandler`, `BatchMessageDelivery`, `BatchDeliveryMetadata`, `BatchMessageHandler`, `HandleResult`(sealed, 4 변형), `PauseResumeController`, enum 4종. ### 3.6 `api.settlement` — 수동 정산 (5) `ManualMessageHandler`, `SettlementController`, `SettlementResult`, `SettlementEvidence`, `SettlementCompletion`. ### 3.7 `api.error` — 실패 (26) `FailureCategory`(10), `FailureDescriptor`, `MessagingException`(abstract) + 구체 예외 23종. --- ## 4. 계약·불변식·상태 모델 이 leaf의 실질은 여기 있다. **표현할 수 없는 상태를 생성자에서 거절하는 것**이 설계의 축이다. ### 4.1 발행 결과: 3상태와 12개 금지 조합 `PublishCompletion`은 boolean이 아니라 3상태다. | 값 | 의미 | 호출자가 할 수 있는 것 | |---|---|---| | `CONFIRMED` | 요구 수준으로 브로커가 수락 | 완료 | | `REJECTED` | 확실히 저장되지 않음 | 이 시도를 버려도 안전 | | `AMBIGUOUS` | 브로커가 갖고 있을 수도 있음 | **같은 `messageId`로만** 재발행 | enum javadoc이 왜 셋인지 적는다: "Collapsing 'the broker refused this' and 'we never learned what the broker did' into one failure is what produces duplicate orders"(`publish/PublishCompletion.java:6-8`). `PublishResult` 생성자(`publish/PublishResult.java:39-101`)가 거절하는 조합 12가지: | # | 거절 조건 | 이유(코드/주석 기준) | |---:|---|---| | 1 | `attempts < 1` | 첫 시도가 1 | | 2 | `elapsed < 0` | — | | 3 | `CONFIRMED` + `!brokerAccepted` | 확인은 브로커 수락을 전제 | | 4 | `CONFIRMED` + `confirmationLevel == NONE` | 확인 수준 없는 확인은 확인이 아님 | | 5 | `CONFIRMED` + `UNROUTABLE` | 라우팅 실패를 성공으로 읽히게 함 | | 6 | `AMBIGUOUS` + `confirmationLevel != NONE` | 모호한데 확인을 주장 | | 7 | `AMBIGUOUS` + `brokerAccepted` | 같은 이유 | | 8 | `AMBIGUOUS` + `NOT_TRANSMITTED` | 나가지 않은 것은 모호가 아니라 거절 | | 9 | `!CONFIRMED` + `failure.isEmpty()` | 실패 서술 없는 실패 | | 10 | `CONFIRMED` + `failure.isPresent()` | 성공에 실패 서술 | | 11 | `REJECTED` + `brokerAccepted` | **"한 주문이 둘이 되는 조합"** | | 12 | `CONFIRMED` + `UNKNOWN` routing | 확인해 준 응답이 라우팅도 말한다 | | 13 | `AMBIGUOUS` + `ROUTED` | 라우팅을 보고한 브로커는 답한 것 | | 14 | `position.isPresent()` + `NOT_TRANSMITTED` | 나가지 않은 메시지의 좌표는 남의 것 | 11번과 14번에는 코드 주석이 직접 달려 있다. ```java if (completion == PublishCompletion.REJECTED && evidence.brokerAccepted()) { // A broker that acknowledged the message did not reject it. Left representable, this is the // combination that turns a delivered message into one the caller re-publishes as if it had // never been sent. throw new IllegalArgumentException("rejected publish cannot claim broker acceptance"); } ``` record가 public이고 모든 adapter가 이것을 만들기 때문에 호출부를 믿지 않고 여기서 검증한다는 것도 javadoc에 적혀 있다(`PublishResult.java:18-20`). ### 4.2 증거는 결론보다 먼저 기록된다 `PublishEvidence`(`publish/PublishEvidence.java`)는 `queuedLocally`, `transmission`, `brokerAccepted`, `confirmationLevel` 넷을 갖고, javadoc이 순서를 못 박는다 — "Evidence is recorded before a completion is chosen, not derived from it. That ordering is what lets an operator answer 'could the broker be holding this message?' from a stored result." `TransmissionEvidence`가 3상태(`NOT_TRANSMITTED` / `MAY_HAVE_BEEN_TRANSMITTED` / `TRANSMITTED`)인 것이 그 순서를 가능하게 한다. ### 4.3 정산: 같은 3상태 규율 `SettlementResult`(`settlement/SettlementResult.java:23-36`)도 같은 형태다. - `SETTLED`인데 `!brokerConfirmed` → 거절 - `SETTLED`인데 `redeliveryPossible` → 거절 - `!SETTLED`인데 `failure.isEmpty()` → 거절 `SettlementEvidence`는 `brokerConfirmed && !transmitted`를 거절한다. javadoc: "Treating an unconfirmed acknowledgement as settled is the classic route to a message that looks processed in logs and is processed again minutes later." ### 4.4 없는 것으로 말하는 계약 세 enum이 **일부러 비어 있는 자리**를 갖는다. | enum | 없는 값 | 코드가 적은 이유 | |---|---|---| | `DeliveryGuarantee` | `EXACTLY_ONCE` | "No broker delivers exactly-once across an external side effect... Naming a guarantee the platform cannot honour would push that responsibility out of sight, so the enum stops where the evidence stops." | | `OrderingScope` | `GLOBAL` | "Ordering is a property of a partition, a key mapping, or a single consumer — never of a whole destination." | | `PublishOptions` | 자유형 hint map | "One existed for a native surface that does not read it... an escape hatch around destination policy that never opened." | 이 셋은 테스트로 붙들려 있다 — `CoreValueTypesTest.guaranteeEnumsDoNotAdvertiseUnsupportedSemantics`가 `values()`에 `EXACTLY_ONCE`와 `GLOBAL`이 없음을 단언한다(`CoreValueTypesTest.java:25-29`). 이름이 다시 추가되면 테스트가 깨진다. ### 4.5 wire 안전성: 한 곳에 모은 규칙 `WireSafeText`(`WireSafeText.java`)가 두 가지를 한다. ```java public static void requireNoControls(String value, String what) { for (int index = 0; index < value.length(); index++) { char character = value.charAt(index); if (character < 0x20 || character == 0x7F) { throw ... } } } ``` - **바이트로 센다.** javadoc: "A `char` count bounds nothing on a wire: a 240-character string is up to 960 UTF-8 bytes." - **제어문자를 정제하지 않고 거절한다.** "Silently stripping a CR turns a caller's two-line value into a one-line value that no longer means what they wrote, and the caller never learns." - **탭도 거절한다.** HTTP 필드 값에서는 합법이지만 "a header carried over a line-folding binding and the same header carried over a length-prefixed one disagree about whether a tab ends the value." 호출자: `CorrelationId`(160), `MessageType`(240), `ProducerId`(120), `HeaderValue`(4096), `MessageEnvelope`의 partitionKey/orderingKey(1024), `TraceContext.baggage`. `HeaderName`은 `WireSafeText`를 쓰지 않고 자체 정규식 `[a-zA-Z0-9!#$%&'*+._|~-]+`(HTTP token)을 쓴다. 더 엄격하다 — 공백·콜론·비ASCII를 전부 배제한다. 그리고 trim하지 않고 **선행/후행 공백을 거절**한다. 주석이 이유를 적는다: ```java if (!value.equals(value.strip())) { // Trimming would mean `Authorization ` and `Authorization` are the same name to the // denylist and different names on the wire, which is precisely how the check was bypassed. ``` ### 4.6 자격증명 헤더 차단: 정확 일치 → 세그먼트 매칭 `MessageHeaders.carriesACredential`(`header/MessageHeaders.java:142-160`)은 두 단계다. 1. `SECRET_NAMES` 9개 정확 일치(`authorization`, `cookie`, `access_token`, …) 2. `SECRET_SEGMENTS` 10개를 `[._\-]+`로 쪼갠 **세그먼트** 단위로 검사, 그리고 **인접 세그먼트를 붙여서** 한 번 더 검사 ```java // Adjacent segments are also tested joined, because the same word is written both ways: // `api_key` is one segment to a reader and two to a splitter, and `x-api-key` is two of // three. Joining only neighbouring pairs is what keeps `routing-key` accepted. ``` 두 방향 다 테스트가 있다. `x-api-key`·`auth-token`·`db_password`·`request.signature`·`Cookie`는 거절되고(`WireBoundaryRejectionTest.java:166-175`), `tokenizer-version`·`secretariat-id`는 통과한다(`:177-188`). 부분문자열 매칭이었으면 후자가 오탐이 된다. 거절 메시지는 **이름만** 담고 값은 절대 담지 않는다. 주석: "an error message is written to a log that is exactly as readable as the broker storage this check exists to keep the value out of." ### 4.7 예약 네임스페이스: 이름 목록 → prefix 소유 `ReservedHeaders.isReserved`(`header/ReservedHeaders.java:135-141`)는 23개 이름 집합 **또는** `msg.` prefix로 판정한다. ```java // The check used to be exact membership of NAMES, so `msg.anything` that this // release has not defined was an ordinary application header — until a later release defined it, // at which point every application already writing it silently started overwriting envelope // metadata. Owning the prefix means a new platform header is a compatible change. ``` 테스트가 이 성질을 직접 붙든다 — `ReservedHeaders.isReserved("msg.not-defined-in-this-release")`가 `true`이고, 애플리케이션이 `msg.not-defined-yet`을 쓰면 거절되며, platform factory는 여전히 쓸 수 있다(`WireBoundaryRejectionTest.java:190-207`). ### 4.8 `MessageHeaders`의 두 factory | factory | 예약 이름 | 자격증명 이름 | 호출자 | |---|---|---|---| | `application(Map)` | 거절 | 거절 | 업무 코드 | | `platform(Map)` | **허용** | 거절 | wire에서 봉투를 복원하는 adapter | 자격증명은 양쪽 다 거절이다. javadoc: "a credential that reaches a header ends up in broker storage, DLQ dumps, and operator tooling, and no downstream redaction can undo that." ### 4.9 `MessageId`: 타입 이름과 실제 검증의 정렬 ```java if (value.version() != VERSION_7) { throw new IllegalArgumentException( "a message identity is UUIDv7 (time-ordered); this is version " + value.version()); } if (value.variant() != 2) { throw new IllegalArgumentException("a message identity must use the RFC 4122 variant"); } ``` 주석이 왜 이 검증이 생겼는지 적는다: "The type says UUIDv7 and the constructor accepted any UUID, including v4 and the nil UUID. Version 7 is what makes the identity time-ordered, which is what the outbox index and every 'oldest first' claim depend on; a v4 stored in the same column silently defeats both." 테스트가 그 문장을 그대로 단언한다 — `new MessageId(UUID.randomUUID())`는 거절되고 이유 문자열에 `UUIDv7`이 포함된다(`WireSafeValueObjectTest.java:73-80`, `as("a v4 in the same column defeats every 'oldest first' claim the outbox makes")`). > **주의.** 이것은 **이 leaf의** `MessageId`에만 해당한다. 저장소의 다른 UUIDv7 구현들은 별개이고 §12.3에서 다룬다. ### 4.10 `UuidV7`: 밀리초 내 단조성 `UuidV7.advance`(`UuidV7.java:54-61`)는 48비트 타임스탬프와 12비트 카운터를 하나의 `AtomicLong`에 packing하고 `updateAndGet`으로 CAS 루프를 돈다. ```java private static long advance(long previous) { long now = System.currentTimeMillis(); long previousTimestamp = previous >>> COUNTER_BITS; if (now > previousTimestamp) { return now << COUNTER_BITS; } return previous + 1; } ``` RFC 9562의 `rand_a` 12비트를 난수가 아니라 **밀리초 내 단조 카운터**로 쓴다. 시계가 뒤로 가도 `previous + 1`이므로 중복이나 역행이 나오지 않고 "미래에서 빌려올" 뿐이다. 카운터가 넘치면 타임스탬프 필드로 자연히 carry된다. 이 성질은 `CoreValueTypesTest.newMessageIdIsVersionSevenAndTimeOrdered`가 두 연속 호출의 `compareTo`가 음수임을 단언해서 붙든다. 다만 **단일 스레드 2회 호출**이므로 경합 하 단조성은 이 테스트가 증명하지 않는다(§16 참조). ### 4.11 `TraceContext`: 표준을 실제로 검사한다 세 값이 전부 `Optional`이고 non-null 검사만 있던 시절의 기록이 javadoc에 남아 있다 — "which made this record a general-purpose string carrier wearing the name of a standard." 현재 검사: | 필드 | 규칙 | |---|---| | `traceparent` | `[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}` 정확 일치, `ff` 버전 거절, all-zero trace id 거절, all-zero span id 거절 | | `tracestate` | ≤512 bytes, ≤32 list member, 각 member가 `key=value` 또는 `tenant@vendor=value` 문법, 빈 member는 허용(전방호환) | | `baggage` | ≤8,192 bytes, ≤64 member, 제어문자 없음, 각 member `key=value` | | 조합 | `tracestate`가 있는데 `traceparent`가 없으면 거절 | 대문자 hex를 접는 대신 **거절**하는 이유도 적혀 있다: "the standard defines the field as lowercase, and a receiver comparing trace IDs as strings — which collectors do — would treat the two cases as two different traces." `tracestate` 단독 거절 이유: "vendor state belonging to no trace. Propagating it hands the next hop a key it will attribute to whatever trace that hop starts." 7개 무효 traceparent가 파라미터 테스트로 전부 커버된다(`WireBoundaryRejectionTest.java:71-90`). ### 4.12 실패 분류와 기본 재시도 정책 `FailureCategory` 10개, `FailureDescriptor.defaultRetryable`(`error/FailureDescriptor.java:67-79`)이 그 중 3개만 재시도 가능으로 본다. | retryable = true | retryable = false | |---|---| | `TRANSIENT_INFRASTRUCTURE` | `PERMANENT_BUSINESS`, `POISON_MESSAGE`, `DESERIALIZATION`, `AUTHENTICATION`, `AUTHORIZATION`, **`AMBIGUOUS`**, `CONFIGURATION` | | `THROTTLED` | | | `PROCESSING_TRANSIENT` | | `AMBIGUOUS`가 false인 것은 모순이 아니라 설계다. 모호한 발행은 **자동** 재시도 대상이 아니고, 호출자가 같은 `messageId`로 재발행할지를 결정한다(`MessagePublishAmbiguousException` javadoc). `FailureDescriptor`는 DLQ까지 이동하므로 payload·스택트레이스·자격증명·실제 메시지 키를 담지 않고, `sanitizedMessage`는 512자에서 **잘린다**(거절이 아니라 절단). javadoc: "Stack traces belong in secure log storage; a DLQ is read by more people than the log is." ### 4.13 `HandleResult`: sealed 4변형 `Success` / `Retry(FailureDescriptor)` / `DeadLetter(FailureDescriptor)` / `Reject(FailureDescriptor)`. 어떤 변형도 브로커 ack 핸들을 갖지 않는다. javadoc: "The handler states an intent; the platform performs the settlement." `ConsumerContractTest.handleResultPermitsExactlyTheFourDeclaredOutcomes`가 `getPermittedSubclasses()`로 이 집합을 고정한다. ### 4.14 배치는 트랜잭션이 아니다 `BatchPublishResult`는 항목별 결과를 제출 인덱스와 함께 보존하고 배치 수준 boolean으로 접지 않는다. `BatchPublishOptions`에는 **retry 설정이 없다**. javadoc: "retrying the batch would resubmit entries that already confirmed." `BatchDeliveryMetadata.isSafeForOrderedDestination()`은 `orderingUnit.isPresent()`다 — 두 파티션에서 끌어온 배치는 순서 보장 목적지에 넘길 수 없다. --- ## 5. 주요 실행 경로 이 leaf에는 실행 경로가 거의 없다. 실제로 코드가 도는 지점은 넷이다. 1. **봉투 생성** — `new MessageEnvelope<>(...)` → 14개 non-null 검사 + partitionKey/orderingKey wire 검사 2. **헤더 생성** — `MessageHeaders.application/platform(Map)` → 개수(≤64) → 이름별 예약/자격증명/중복 검사 → 총 바이트(≤32,768) 3. **식별자 생성** — `MessageId.newId()` → `UuidV7.next()` → `AtomicLong.updateAndGet(advance)` 4. **결과 조립** — `new PublishResult(...)` / `new SettlementResult(...)` → 조합 검증 나머지는 전부 인터페이스 선언이고, 구현은 `messaging-runtime-core`·`messaging-kafka`·`messaging-rabbit` 등 다른 leaf가 소유한다. --- ## 6. 실패 경로와 복구/번역 ### 6.1 계층 `MessagingException`(abstract) → 23개 구체 예외. 기반 타입이 `FailureDescriptor`를 갖고 `category()`·`retryable()`를 위임한다. javadoc이 목적을 적는다 — "a caller catching the base type can still classify and route the failure without matching on exception classes." ### 6.2 23개 예외의 카테고리·재시도 전수표 | 예외 | category | retryable | leaf 밖 참조 | |---|---|:---:|:---:| | `MessageAuthenticationException` | `AUTHENTICATION` | false | **0** | | `MessageAuthorizationException` | `AUTHORIZATION` | false | 16 | | `MessageBackpressureException` | `TRANSIENT_INFRASTRUCTURE` | true | 4 | | `MessageBrokerUnavailableException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** | | `MessageConsumerException` | `PROCESSING_TRANSIENT` | true | **0** | | `MessageDeadLetterException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** | | `MessageHandlerTimeoutException` | `PROCESSING_TRANSIENT` | true | **0** | | `MessageHeaderRejectedException` | `PERMANENT_BUSINESS` | false | **0** | | `MessagePublishAmbiguousException` | `AMBIGUOUS` | false | **0** | | `MessagePublishRejectedException` | `PERMANENT_BUSINESS` | false | **0** | | `MessagePublishTimeoutException` | `AMBIGUOUS` | false | 2 | | `MessageRedriveException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** | | `MessageRetryExhaustedException` | `PERMANENT_BUSINESS` | false | **0** | | `MessageRoutingException` | `PERMANENT_BUSINESS` | false | **0** | | `MessageSchemaIncompatibleException` | `DESERIALIZATION` | false | 4 | | `MessageSerializationException` | `DESERIALIZATION` | false | 8 | | `MessageSettlementException` | `TRANSIENT_INFRASTRUCTURE` | true | 1 | | `MessageSettlementUnknownException` | `AMBIGUOUS` | false | **0** | | `MessageTooLargeException` | `PERMANENT_BUSINESS` | false | 17 | | `MessageTopologyException` | `CONFIGURATION` | false | 2 | | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 16 | | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | false | 13 | | `MessagingConfigurationException` | `CONFIGURATION` | false | 59 | **23개 중 12개가 leaf 밖에서 한 번도 참조되지 않는다**(`evidence/raw/269` §B, 12개 전부 `git grep` exit=1). §12.1에서 다룬다. ### 6.3 조용한 성능 저하를 막는 설계 `MessagingCapabilityUnavailableException` javadoc: "Downgrading replication evidence to a bare ack, or ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered." `MessageBackpressureException` javadoc: "Blocking the caller until a slot frees turns producer-side saturation into thread exhaustion in the calling application, which is a far worse failure than a fast rejection." 그리고 "Nothing was transmitted when this is thrown, so the message has no ambiguity." --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 개념이 이 leaf에는 두 가지 형태로만 등장하고 둘 다 **선언**이다. - `MessagingCapabilities.brokerTransaction` — 브로커가 트랜잭션 스코프를 제공하는가 - `ProcessingGuarantee.BROKER_TRANSACTIONAL` — "Atomicity holds only inside the transaction scope the broker itself defines" - `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL` — "An Inbox row and the side effect commit inside the same database transaction" DB 트랜잭션은 이 leaf가 만지지 않는다. 동시성 지점은 **하나**다: `UuidV7.STATE`(`AtomicLong`). `updateAndGet`이 CAS 루프이므로 다중 스레드에서도 각 호출이 서로 다른 packed state를 얻는다. `RANDOM`(`SecureRandom`)은 thread-safe다. `MessageHeaders`는 생성 시 `LinkedHashMap`에 복사하고 `Collections.unmodifiableMap`으로 감싸 반환하므로 공유 안전하다. 다만 `find(String)`이 `values.entrySet().stream()` 선형 탐색이다 — 최대 64개이므로 실용상 문제는 아니지만 hot path에서 반복 호출되면 O(n)이다. 수명주기 개념은 `DeliveryContext.shutdownRequested`뿐이고, javadoc이 목적을 적는다 — "during a graceful drain the platform stops creating new retry attempts, and a long-running handler that can wind down early shortens the drain instead of being cancelled at the deadline." **이 필드는 production에서 도달 불가능하다**(§12.1). --- ## 8. 설정·기능 플래그·환경 차이 이 leaf에는 설정이 **없다**. properties·yaml·환경변수·시스템 프로퍼티를 읽는 코드가 0이다. 모든 값은 컴파일 타임 상수다. 경계값 전수: | 상수 | 값 | 위치 | |---|---:|---| | `ContentType.MAX_LENGTH` | 160자 | `ContentType.java:12` | | `CorrelationId.MAX_BYTES` | 160 | `CorrelationId.java:16` | | `MessageType.MAX_BYTES` | 240 | `MessageType.java:13` | | `ProducerId.MAX_BYTES` | 120 | `ProducerId.java:13` | | `MessageEnvelope.MAX_KEY_BYTES` | 1,024 | `MessageEnvelope.java:75` | | `HeaderName.MAX_BYTES` | 128 | `HeaderName.java:16` | | `HeaderValue.MAX_BYTES` | 4,096 | `HeaderValue.java:18` | | `MessageHeaders.MAX_COUNT` | 64 | `MessageHeaders.java:22` | | `MessageHeaders.MAX_TOTAL_BYTES` | 32,768 | `MessageHeaders.java:23` | | `TenantContext` 패턴 | `[a-z0-9][a-z0-9._-]{0,63}` | `TenantContext.java:16` | | `DestinationName` 패턴 | `[a-z0-9][a-z0-9.-]{0,159}` | `DestinationName.java:16` | | `TraceContext.MAX_TRACESTATE_BYTES` | 512 | `TraceContext.java:53` | | `TraceContext.MAX_TRACESTATE_MEMBERS` | 32 | `TraceContext.java:51` | | `TraceContext.MAX_BAGGAGE_BYTES` | 8,192 | `TraceContext.java:56` | | `TraceContext.MAX_BAGGAGE_MEMBERS` | 64 | `TraceContext.java:58` | | `FailureDescriptor.MAX_MESSAGE_LENGTH` | 512자(절단) | `FailureDescriptor.java:26` | | `FailureDescriptor.MAX_CODE_LENGTH` | 120자(거절) | `FailureDescriptor.java:27` | | `PublishOptions.DEFAULT_TIMEOUT` | 5초 | `PublishOptions.java:25` | | `BatchPublishOptions.DEFAULT_TIMEOUT` | 30초 | `BatchPublishOptions.java:21` | | `BatchPublishOptions.DEFAULT_MAX_BATCH_SIZE` | 500 | `BatchPublishOptions.java:22` | `PublishOptions.defaults()`가 요구하는 확인 수준은 `REPLICATION_OR_PERSISTENCE_ACK`다 — 기본값이 가장 강한 보장이고, 약하게 쓰려면 명시해야 한다. 단위가 섞인 곳이 하나 있다. `ContentType`은 **문자** 160, 다른 문자열 값 객체는 **바이트**다. `ContentType`은 미디어 타입 정규식이 ASCII만 허용하므로 실질 차이가 없지만, 이 leaf에서 유일하게 `WireSafeText`를 쓰지 않는 문자열 값이다. --- ## 9. 퍼시스턴스/외부 시스템 세부 없다. 이 leaf는 DB·브로커·파일시스템·네트워크를 만지지 않는다. `SecureRandom`(엔트로피)과 `System.currentTimeMillis()`(시계)가 유일한 외부 접촉이고 둘 다 `UuidV7` 안에 있다. --- ## 10. 테스트 레인과 실제 증명 범위 레인은 하나다: `./gradlew :messaging:messaging-core-api:test`. 실행 결과 **BUILD SUCCESSFUL**, 79 tests, 0 skipped, 0 failures (`--rerun-tasks`, revision `21234e38`). | 테스트 클래스 | 수 | 무엇을 실제로 증명하는가 | 무엇을 증명하지 않는가 | |---|---:|---|---| | `CoreValueTypesTest` | 7 | 값 객체 거절 조건, `MessageId` v7/variant 2, 연속 2회 시간순, `EXACTLY_ONCE`/`GLOBAL` 부재 | 경합 하 `UuidV7` 단조성 | | `MessageEnvelopeTest` | 11 | 예약/비밀 헤더 거절(대소문자 무관), platform factory의 예약 쓰기 허용, 개수·바이트·이름·값 상한, `withPayload`의 identity 보존 | 실제 브로커가 이 값을 받아들이는지 | | `WireSafeValueObjectTest` | 7 | 헤더 이름 CRLF·NUL·콜론·후행공백 거절, 메시지 타입 개행 거절, 바이트 경계, v4 거절 | — | | `WireBoundaryRejectionTest` | 27 | 제어문자 6종 파라미터화, 바이트 경계, traceparent 무효 7종, tracestate/baggage 경계, 자격증명 이름 5종 거절 + 오탐 2종 통과, `msg.` prefix 소유 | 실제 collector/브로커 동작 | | `ConsumerContractTest` | 8 | `HandleResult` 4변형 고정, attempt 1 규칙, redelivered 모순 거절, `SETTLED` 불변식, `DeliveryContext.isExpired` 경계 | production이 `DeliveryContext`를 만드는지 | | `DestinationCapabilityTest` | 5 | 논리 이름에 브로커 주소 불가, 대문자 거절, `MessagingCapabilities.none()`, `DestinationKind` 7종 고정 | capability 선언이 실제 브로커와 맞는지 | | `PublishResultTest` | 13 | §4.1의 금지 조합 중 9가지를 직접 단언 | 실제 adapter가 이 조합을 만들지 않는지 | | `ModuleSmokeTest` | 1 | 패키지 이름 | 사실상 아무것도 | **이 레인이 증명하는 것의 성격.** 전부 `new`로 값을 만들고 예외를 기대하는 순수 단위 테스트다. 브로커도, Spring 컨텍스트도, 네트워크도 없다. 그래서 "계약이 자기 자신과 모순되지 않는다"는 증명되고, "adapter가 이 계약을 지킨다"는 증명되지 않는다. 후자는 `messaging-kafka`·`messaging-rabbit`의 contract harness가 소유하고 이 leaf 밖이다. `ConsumerContractTest.deliveryContextReportsHandlerDeadlineExpiry`가 특히 그렇다 — 경계 동작은 정확히 검증되지만, §12.1이 보이듯 production 코드는 `DeliveryContext`를 만들지 않으므로 그 검증이 실행 경로를 보호하고 있지는 않다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 위치 | 이 leaf에 대해 실제로 무엇을 막는가 | 실패 지점 | |---|---|---|---| | registry fail-closed | `src/config/architecture/modules.json` + `ca.architecture-registry.settings.gradle` | 등록되지 않은 leaf는 settings에 포함되지 않음 | Gradle configuration | | `verifyCleanArchitectureDependencies` | `src/build.gradle` | 실제 project 의존 edge를 `allowed_dependencies: []`와 대조 — 이 leaf에 의존성을 하나라도 추가하면 실패 | Gradle task | | `verifyRuntimeModuleMembership` | `src/build.gradle` | 코드만 추가해서 런타임에 들어가는 것을 막음. registry를 먼저 고쳐야 함 | Gradle task | | `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` | `CleanArchitectureTest.java:229` | `..application..`이 `dev.caskeleton.messaging..`을 참조하는 것을 금지 | ArchUnit | | checkstyle / spotbugs | convention plugin | `build/reports/{checkstyle,spotbugs}` 생성 확인 | Gradle | **이 leaf에 직접 걸리는 messaging 전용 ArchUnit 규칙은 없다.** `MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS`(`CleanArchitectureTest.java:2068`)는 `..adapter.outbound.messaging..`을 대상으로 하고 이 leaf(`dev.caskeleton.messaging.api`)가 아니다. `src/build.gradle:65-110`의 `messagingVerificationSkeletons`(9개 `verifyMessaging*` task)는 전부 `app-bootstrap/build/messaging-evidence/**/manifest.json`을 요구하는 fail-closed 자격 게이트이고, 이 leaf의 산출물을 요구하지 않는다. --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/269-messaging-core-api-reachability.txt`, `evidence/raw/270-messaging-runtime-membership-doc-drift.txt`. 검색 명령(전부 revision `21234e38`에서 실행): ```bash git grep -n -w '' -- src ':!src/messaging/messaging-core-api' ``` `git grep`은 무매치에서 exit 1을 반환하므로, 아래의 "0"은 전부 exit 1로 확인한 값이다. ### 12.1 Public surface reachability 85개 타입 중 leaf 밖 참조가 0인 것은 **21개**다. 성격이 다른 세 묶음으로 나뉜다. **(a) 내부 헬퍼 — 문제 없음 (1)** `WireSafeText`. 이 leaf의 값 객체들이 내부적으로만 부른다. public인 것은 패키지가 나뉘어 있어서다. **(b) 소비자 없는 예외 어휘 (12)** `MessageAuthenticationException`, `MessageBrokerUnavailableException`, `MessageConsumerException`, `MessageDeadLetterException`, `MessageHandlerTimeoutException`, `MessageHeaderRejectedException`, `MessagePublishAmbiguousException`, `MessagePublishRejectedException`, `MessageRedriveException`, `MessageRetryExhaustedException`, `MessageRoutingException`, `MessageSettlementUnknownException`. 기반 타입 `MessagingException`은 살아 있다 — leaf 밖 3곳이 쓴다: - `DefaultMessagingAdminService.java:223` — `instanceof`로 분류 - `ClaimCheckIntegrityException.java:20` — `extends` - `PublishResults.java:37` — `instanceof`로 sanitized descriptor 추출 즉 **계층은 쓰이고 잎은 쓰이지 않는다.** 특히 `MessagePublishAmbiguousException`은 이 설계 전체의 중심 개념(`AMBIGUOUS`)에 이름을 준 타입인데 아무도 던지지 않는다. adapter들은 예외 대신 `PublishResult`를 반환하는 경로를 쓰고(§6.2에서 `MessagingConfigurationException` 59회, `MessageTooLargeException` 17회처럼 실제로 쓰이는 것들은 대부분 **설정/검증** 계열이다), 발행·정산의 실패는 결과 record로 흐른다. **(c) 소비자 없는 consumer-side 계약 (8)** | 타입 | 선언된 역할 | leaf 밖 참조 | |---|---|:---:| | `MessageHandler` | "The M1 typed handler implemented by ordinary business code" | 0 | | `BatchMessageHandler` | "The M2 batch consume entry point" | 0 | | `BatchMessageDelivery` | 배치 핸들러에 넘겨지는 배치 | 0 | | `ManualMessageHandler` | "The M2 handler that settles its own deliveries" | 0 | | `PauseResumeController` | "The M2 consumer flow-control entry point" | 0 | | `ProcessingGuarantee` | 중복 처리 무력화 방식 | 0 | | `DelayedMessagePublisher` | "The M2 scheduled-delivery entry point" | 0 | | `CapabilityRegistry` | 목적지별 capability 해석 | 0 | 이 중 `MessageHandler`가 가장 무겁다. **선언된 핸들러 계약과 실제로 배선된 핸들러 계약이 다르다.** `messaging-core-api`가 선언하는 것: ```java // delivery/MessageHandler.java:14-22 public interface MessageHandler { CompletionStage handle(MessageDelivery delivery); } ``` `MessageDelivery`는 `MessageEnvelope` + `DeliveryMetadata` + `DeliveryContext`를 묶는다. 핸들러 결과를 정산으로 바꾸는 **유일한** 지점(`messaging-runtime-core.DefaultDeliveryProcessor`)이 실제로 받는 것: ```java // DefaultDeliveryProcessor.java:40, 47 private final Function, HandleResult> handler; ``` 세 가지가 다르다. 1. **동기다.** `CompletionStage`가 아니라 `Function`이므로 핸들러가 비동기일 수 없다. 2. **`MessageDelivery`가 없다.** 봉투만 받는다. 따라서 `DeliveryMetadata.deliveryAttempt`(몇 번째 시도인가)와 `redelivered`가 핸들러에 도달하지 않는다. 3. **`DeliveryContext`가 없다.** `handlerDeadline`·`isExpired(now)`·`shutdownRequested`가 도달하지 않는다. 세 번째는 독립적으로도 확인된다. `DeliveryContext`의 leaf 밖 참조 4건은 **전부 테스트 파일**이다 — `KafkaContractHarness.java:7,216`과 `DeadLetterOrchestratorTest.java:12,226`. production 소스에서 `DeliveryContext`를 만드는 코드는 저장소에 없다. `DeliveryContext`의 javadoc이 설명하는 graceful drain 협력("a long-running handler that can wind down early shortens the drain")은 현재 배선으로는 일어날 수 없다. 한편 `MessageDelivery`와 `DeliveryMetadata`는 production에서 **쓰인다** — 다만 핸들러에 넘기기 위해서가 아니라 DLQ·retry 경로에서 쓰인다: - `MessageDelivery`: `KafkaDeadLetterPublisher:44`, `KafkaRetryExecutor:72`, `KafkaRetryTopicPublisher:59`, `RabbitDeadLetterPublisher:67`, `DeadLetterOrchestrator:69`, `TransactionalInboxHandler:49` - `DeliveryMetadata`: `KafkaDeliveryMapper:105`, `RabbitDeliveryMapper:107`, `policy/RetryContext:23`, `transport-spi/TransportDelivery:21` 그리고 핸들러 계약은 저장소에 **셋**이 있다: | 인터페이스 | 소유 leaf | 시그니처 | 구현체 | |---|---|---|---| | `MessageHandler` | `messaging-core-api` | `CompletionStage handle(MessageDelivery)` | **없음** | | `IdempotentMessageHandler` | `messaging-reliability-api` | `CompletionStage handleOnce(String, MessageDelivery, TransactionalMessageAction)` | `TransactionalInboxHandler` | | (익명) `Function, HandleResult>` | `messaging-runtime-core` | 동기, 봉투만 | 생성자 인자 | **(d) 배치 경로: 만들어진 metadata를 받을 곳이 없다** `BatchDeliveryMetadata`는 leaf 밖 참조가 **있다**(0이 아니다). 두 registrar가 만든다: - `KafkaBatchConsumerRegistrar.java:104` — `metadataFor(partition, slice, now)` - `RabbitBatchConsumerRegistrar.java:139` — `release(now)` 그리고 둘 다 자기 브로커 전용 record에 담는다(`PartitionBatch`, `AmqpBatch`). 두 record의 javadoc이 같은 문장을 쓴다: ``` * @param metadata the batch-wide metadata handed to the handler ``` 그런데 `new BatchMessageDelivery`는 저장소 전체에서 **0건**이고(`git grep` exit=1), `BatchMessageHandler`를 구현하거나 참조하는 코드도 0건이다. 즉 두 registrar는 배치 metadata를 정확히 계산해서(Kafka는 파티션 단위라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`) 브로커별 record에 넣고, **javadoc이 말하는 handler로의 전달은 존재하지 않는다.** **(e) 한계** `git grep` 기반 정적 검색이므로 다음을 덮지 못한다: 리플렉션 조회, `ServiceLoader`, 애노테이션 프로세서 생성 코드, 문자열로 조립한 클래스 이름, 이 저장소 밖의 소비자. 다만 이 leaf에는 애노테이션이 0개이고 `META-INF/services`도 없으며(`find` 결과 resources 디렉터리 자체가 없다 — Gradle이 `processResources NO-SOURCE`를 보고한다), 이 저장소는 라이브러리 배포 저장소가 아니라 템플릿이므로 "저장소 밖 소비자"가 유일하게 남는 가능성이다. §17에서 그 갈래를 다룬다. ### 12.2 Conditional sibling comparison **적용 대상 없음.** 이 leaf에는 Spring stereotype·`@Bean`·`@Conditional`·`@Profile`이 0개이고(`git grep` exit=1), bean을 하나도 만들지 않는다. 비교할 sibling이 존재하지 않는다. 이 leaf의 활성화 비대칭은 다른 축에서 일어난다 — registry `runtime_memberships`. §12.4 참조. ### 12.3 Duplicate mechanism sweep **(a) UUIDv7 생성기** 저장소에 UUIDv7을 다루는 production 구현이 여럿이다. | 위치 | 성격 | |---|---| | `messaging-core-api/.../api/UuidV7.java` | 이 leaf. `AtomicLong` packing, 밀리초 내 단조 카운터 | | `adapter/outbound/notification/.../dispatch/UuidV7Generator.java` | notification 플랫폼 전용 | | `adapter/outbound/persistence-jpa/src/testkit/.../id/UuidV7Generator.java` | testkit source set | | `adapter/outbound/persistence-mongo/.../mapping/DomainDocumentId.java` | Mongo 문서 id | | `application-core/.../notification/platform/api/NotificationId.java` 외 | 애플리케이션 식별자 | | `sample-portfolio/.../identifier/Uuid*Factory.java` (3종) | 샘플 | `messaging-core-api.UuidV7`의 leaf 밖 참조는 **1건**이다(`messaging-testkit`의 JMH 벤치마크). 즉 messaging 밖에서는 아무도 이 구현을 쓰지 않고 각자 만들었다. 이것이 자동으로 결함은 아니다 — 모듈 경계가 의존을 금지하는 구조(`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`)에서는 중복이 **의도된 비용**일 수 있다. 다만 §4.10의 밀리초 내 단조성 같은 성질이 구현마다 같은지는 이 leaf가 답할 수 없고, family 밖이므로 cross-scope가 소유한다. **(b) wire-safe 텍스트 검증** `WireSafeText`의 leaf 밖 참조는 **0**이다. 그런데 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개 있다 — `web/conditional/EntityTag`, `web/http/WebUriPolicy`, `cache-redis/.../codec/RedisEnvelope`, `fileserver/FileserverControlRecordCodec`, `mongo/changestream/MongoChangeEventIdentity`, `application-core/cache/CacheRefreshOwnerToken`, `application-core/objectstorage/model/ObjectMediaType`, `grpc-core-api/core/GrpcIdentifiers`, `shared-contract/ratelimit/EdgeRateLimitSubject` 등. `WireSafeText`의 javadoc은 그 존재 이유를 "Each copy of this check that lived in its own record was one more place for the rule to drift"라고 적는데, 그 통합은 **이 leaf 안에서만** 일어났다. 저장소 수준에서는 여전히 각자 검사한다. 다시 말해 규칙은 옳게 진술됐고 적용 범위가 leaf 경계에서 멈춘다. **(c) 헤더 네임스페이스** `msg.` 리터럴을 이 leaf 밖에서 쓰는 production 코드는 **1곳**뿐이다 — `messaging-observability/.../MessagingRedactor.java:24`가 `"msg.id"`를 문자열 리터럴로 갖는다. 나머지 매치는 Kafka 테스트다. 상수(`ReservedHeaders.MESSAGE_ID`)가 있는데 리터럴을 쓴 것이므로, 상수가 바뀌면 redactor가 조용히 어긋난다. 작지만 실재하는 drift 표면이다. ### 12.4 Documentation / measured-count drift **확인된 drift 1건.** 원시 증거 `evidence/raw/270-messaging-runtime-membership-doc-drift.txt`. `docs/messaging/support-matrix.md:23-24`가 이렇게 말한다: > 또한 registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다. 이는 **build-only / incubating** — 어느 composition root에도 편입되지 않았다는 뜻이며… 현재 revision에서 registry를 다시 세면: ``` messaging leaves : 25 runtime_memberships empty : 7 runtime_memberships wired : 18 ``` `messaging-core-api` 자신이 wired 18개에 포함된다. 즉 이 문장은 **이 leaf에 대해 직접 틀렸다.** 같은 문단의 마지막 문장은 "자세한 규칙은 `src/messaging/CLAUDE.md`가 소유한다"고 가리키는데, 그 파일은 이미 정정을 기록해 두었다(`src/messaging/CLAUDE.md:46-59`): > **이 절은 한동안 사실이 아닌 채로 남아 있었다.** "registry의 모든 messaging leaf는 `runtime_memberships`가 비어 있고 따라서 build-only"라고 쓰여 있었는데, 다섯 어댑터 remediation이 `messaging-spring-boot-starter`를 `app-bootstrap` 의존성으로 넣으면서 그 closure 전체가 런타임 classpath에 올라갔다. 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다. 그래서 이것은 단순한 오래된 문서가 아니다. **같은 저장소의 두 문서가 같은 revision에서 서로 모순되고, 틀린 쪽이 옳은 쪽을 권위로 지목하고 있다.** 그리고 틀린 쪽이 운영자가 읽는 지원 매트릭스다. `CLAUDE.md`가 도달한 결론("세는 순간 다시 drift한다")이 정확히 support-matrix에는 적용되지 않았다. 영향 방향이 중요하다 — 문서는 실제보다 **약하게** 진술한다. "아무것도 배선되지 않았다"고 읽은 운영자는 배포 아티팩트가 이 leaf들을 싣고 있고 `app.messaging.enabled` 하나로 켜진다는 사실을 모른다. 과대 진술보다는 낫지만, 사고 시 조사 범위를 좁히는 방향의 오류다. **나머지 문서 주장은 재측정에서 일치했다.** - `docs/superpowers/plans/…:13` "`messaging-core-api`에는 Spring/broker/Reactor 의존성을 넣지 않는다" → 참(import 0개, `build.gradle` 빈 dependencies) - `docs/messaging/experimental-policy.md:44` "`messaging-core-api`의 타입을 바꾸지 않는다" → 정책 문장이며 이번 revision에서 위반 근거를 찾지 못함 **측정하지 않은 것.** `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md`의 경로(`modules/messaging/…`)와 패키지(`io.backend.skeleton.messaging.api`)는 현재 소스(`src/messaging/…`, `dev.caskeleton.messaging.api`)와 다르다. 다만 이것은 계획 문서이고 실행 후 이름이 바뀐 것으로 보이므로 "drift"로 분류하지 않고 §13의 역사로 기록한다. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 이 leaf를 건드린 커밋은 4개다. ``` a24ece9c feat: web, websocket 어댑터 추가 구현 01372634 refactor: 각 어댑터터별 리펙토링 진행 2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가 d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가 ``` 최초 커밋 메시지는 **24개 leaf**라고 적었고 현재 registry의 messaging leaf는 **25개**다. 이후 커밋에서 하나가 늘었다는 뜻이며, 커밋 메시지는 그 시점의 사실이므로 drift로 분류하지 않는다. **코드 주석이 보존한 실패 이력**이 이 leaf의 가장 밀도 높은 사료다. 아래는 전부 "예전에는 이랬고 그래서 무엇이 깨졌다"를 현재 코드가 직접 적어 둔 것이다. | 위치 | 이전 상태 | 그것이 만든 실패 | |---|---|---| | `WireSafeText` 클래스 javadoc | 각 값 객체가 Java `char`로만 길이 검사 | 240자 = 최대 960바이트. 바이트를 세는 브로커가 발행 시점에 거절 | | `HeaderName.TOKEN` 주석 | "not blank, at most 128 bytes" | CR/LF/NUL/콜론이 통과 → 헤더 인젝션, 이름 절단, 레코드 분할 | | `HeaderName` 공백 검사 주석 | `strip()`으로 trim | `Authorization `이 denylist에는 같은 이름, wire에는 다른 이름 → 우회 | | `HeaderValue` javadoc | 길이 상한만 | 값 안의 CRLF가 line-oriented 바인딩에서 헤더를 끝내고 새 헤더 시작 | | `CorrelationId` javadoc | 160 **문자** 상한 | 640바이트 값이 흐름 중간에 거절됨 — 다른 identity로 재전송할 수 없는 메시지에서 | | `MessageEnvelope` 생성자 주석 | partitionKey/orderingKey 무제한 | orderingKey는 여러 바인딩이 wire에 싣는다 → 헤더와 같은 인젝션 표면 | | `MessageId` 생성자 주석 | 아무 UUID나 허용 | v4가 같은 컬럼에 들어가 outbox의 "oldest first"를 무력화 | | `TraceContext` javadoc | non-null 검사만 | 파싱 불가 `traceparent`를 collector가 **드롭** → 조사 중인 바로 그 hop에서 trace 소실 | | `ReservedHeaders.PLATFORM_PREFIX` 주석 | `NAMES` 정확 일치 | 다음 릴리스가 `msg.x`를 정의하는 순간 기존 애플리케이션이 봉투 메타데이터를 덮어씀 | | `ReservedHeaders.TENANT` javadoc | 헤더 이름 자체가 없었음 | 소비된 메시지가 전부 빈 tenant로 재구성됨 — 하위 authorization/파티셔닝이 읽는 필드 | | `MessageHeaders.SECRET_SEGMENTS` 주석 | 정확 이름 매칭만 | `x-api-key`·`auth-token`·`db_password`가 전부 통과 | | `PublishOptions` javadoc | 자유형 hint map 존재 | 읽는 쪽이 없어서 런타임 거절만 유발하는 escape hatch | | `CanonicalEnvelopeHeaders` javadoc | 예약 네임스페이스를 통째로 "위조 가능한 내용"으로 취급 | `msg.retry-attempt`까지 드롭 → attempt 카운터가 1로 재시작, retry 예산이 아무것도 제한하지 못함 | | `DefaultDeliveryProcessor` javadoc (다른 leaf, 이 계약 관련) | `HandleResult`를 정산에 연결하는 곳이 없었음 | 각 브로커 adapter가 retry/dead-letter의 뜻을 각자 결정 | 이 목록 자체가 이 leaf의 성격을 말한다 — **13개 이상의 wire 경계 결함을 한 번에 정리한 흔적**이고, 대부분이 "검사가 없었다"가 아니라 "검사가 잘못된 단위(문자 vs 바이트, 정확일치 vs 세그먼트, 이름목록 vs prefix)로 되어 있었다"이다. --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-269 | command | `evidence/raw/269-messaging-core-api-reachability.txt` | 21개 타입의 leaf 밖 참조 0(exit=1), `DeliveryContext`의 test-only 성격, 세 핸들러 계약, `new BatchMessageDelivery` 0건, leaf의 무의존성 | `git grep` 정적 검색. 리플렉션·서비스로더·저장소 밖 소비자 미포함 | | EVD-270 | command | `evidence/raw/270-messaging-runtime-membership-doc-drift.txt` | support-matrix.md:23의 주장과 registry 재측정(25/7/18), `CLAUDE.md`의 정정 기록, starter 조립 edge | 한 시점 registry snapshot | | EVD-271 | command | `:messaging:messaging-core-api:test --rerun-tasks` | BUILD SUCCESSFUL, 79 tests / 0 skipped / 0 failures | 순수 단위 테스트 레인. 브로커·Spring 없음 | `evidence/raw/`에는 primary output만 둔다. 위 해석은 전부 이 문서가 소유한다. --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적(코드 주석·javadoc·테스트 이름·설계 문서가 직접 말함)** - `EXACTLY_ONCE`·`GLOBAL` 부재 — `DeliveryGuarantee`/`OrderingScope` javadoc + `CoreValueTypesTest` - 3상태 발행 결과 — `PublishCompletion` javadoc - 증거가 결론보다 먼저 — `PublishEvidence` javadoc - 정제 대신 거절 — `WireSafeText` javadoc - `msg.` prefix 소유 — `ReservedHeaders.PLATFORM_PREFIX` 주석 - 세그먼트 매칭 + 인접 결합 — `MessageHeaders.carriesACredential` 주석 - 예약 네임스페이스 2분할 — `CanonicalEnvelopeHeaders` javadoc - `messageId` 보존이 `withPayload`의 목적 — `MessageEnvelope.withPayload` javadoc - `rand_a`를 카운터로 — `UuidV7` javadoc - 애플리케이션이 이 플랫폼을 참조하지 않는 이유 — `CleanArchitectureTest:229` `.because(...)` - `runtime_memberships`의 현재 의미 — `src/messaging/CLAUDE.md:46-70` **추론(이 문서의 판단이며 코드가 직접 말하지 않음)** - `MessageHandler`가 미사용인 것은 `DefaultDeliveryProcessor`가 다른 시그니처를 택했기 때문이다 → **추론**. 두 사실(선언 존재, 다른 시그니처 사용)은 관측이고, 인과는 추론이다. 커밋 메시지나 ADR에서 이 선택의 근거를 찾지 못했다. - 12개 예외가 미사용인 것은 adapter들이 예외 대신 결과 record 경로를 택했기 때문이다 → **추론**. `MessagingConfigurationException`(59회)처럼 실제 쓰이는 것들이 설정/검증 계열에 몰려 있다는 관측에서 나온 설명이다. - 저장소 밖 소비자가 있을 가능성 → **가설**. 확인 수단이 이 저장소 안에 없다. **관측했으나 원인을 모름** - `MessagingRedactor.java:24`가 상수 대신 `"msg.id"` 리터럴을 쓰는 이유 - `ContentType`만 바이트가 아니라 문자로 상한을 두는 이유 --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - production 85파일 전부의 계약·불변식·경계값 (§4, §8) - 79개 테스트가 실제로 통과하고 무엇을 단언하는지 (§10) - 21개 타입의 leaf 밖 참조 0 — 재현 가능한 명령과 exit code로 (§12.1) - 선언된 핸들러 계약과 배선된 핸들러 계약의 불일치 (§12.1) - 배치 metadata를 만드는 두 지점과, 그것을 받을 `BatchMessageDelivery`가 0건이라는 사실 (§12.1) - `support-matrix.md:23`의 주장이 현재 registry와 어긋난다는 것 (§12.4) - 이 leaf가 외부 의존성 0이라는 것 (§1, §12.2) - 코드 주석이 보존한 13건 이상의 이전 결함 이력 (§13) **확인하지 못한 것** - **경합 하 `UuidV7` 단조성.** `updateAndGet`의 CAS 성질에서 추론되지만 다중 스레드 테스트가 없다. `CoreValueTypesTest`는 단일 스레드 2회 호출만 본다. - **저장소 밖 소비자.** 이 템플릿을 가져다 쓰는 파생 프로젝트가 `MessageHandler`·`CapabilityRegistry` 등을 구현하는지 확인할 방법이 이 저장소 안에 없다. §12.1(c)와 §17의 판단이 이 미지수에 걸려 있다. - **실제 브로커가 이 경계값을 받아들이는지.** 128바이트 헤더 이름, 32,768바이트 헤더 총량, 1,024바이트 ordering key가 Kafka·RabbitMQ에서 실제로 통과하는지는 이 leaf의 레인이 증명하지 않는다. `messaging-kafka`/`messaging-rabbit`의 컨테이너 레인이 소유하고, 그 레인들은 이번 분석에서 실행하지 않았다. - **`MessagingRedactor`의 리터럴이 실제로 어긋난 적이 있는지.** 현재는 `ReservedHeaders.MESSAGE_ID`와 값이 같다. --- ## 17. 손볼 것 ### P2 — 선언된 핸들러 계약이 배선된 것과 다르다 - **사실.** `MessageHandler`(`delivery/MessageHandler.java:14`)의 저장소 전체 참조가 0이다. 핸들러 결과를 정산으로 바꾸는 유일한 지점 `DefaultDeliveryProcessor`는 `Function, HandleResult>`를 받는다. - **근거.** `evidence/raw/269` §A, §D. - **왜 문제인가.** `MessageDelivery`가 빠지면서 `deliveryAttempt`·`redelivered`·`handlerDeadline`·`shutdownRequested`가 핸들러에 도달할 수 없다. `DeliveryContext`의 javadoc이 설명하는 graceful drain 협력은 현재 배선으로는 성립하지 않는다. 그리고 새 소비자를 붙이는 사람은 공개 API에서 `MessageHandler`를 먼저 보게 되는데, 그것을 구현해도 아무 데도 꽂히지 않는다. - **확인 방법.** `git grep -n -w MessageHandler -- src ':!src/messaging/messaging-core-api'` → exit 1. `DefaultDeliveryProcessor.java:40,47` 확인. - **후보.** (a) `DefaultDeliveryProcessor`가 `MessageHandler`를 받도록 시그니처를 맞춘다 — `MessageDelivery`를 조립해야 하므로 `DeliveryContext` 생성 책임을 runtime에 준다. (b) `MessageHandler`·`DeliveryContext`를 이 leaf에서 제거하고 실제 계약만 남긴다. (c) 파생 프로젝트가 구현하는 확장점이라면 그 사실을 javadoc과 `support-matrix.md`에 명시한다. - **다음 단계.** 세 선택지는 "저장소 밖 소비자가 있는가"라는 미지수에 걸린다(§16). 그 답을 먼저 정해야 한다 → **OPEN QUESTION 후보.** 답이 정해지면 CASE 승격 가능. ### P2 — 배치 metadata를 만들고 넘길 곳이 없다 - **사실.** `KafkaBatchConsumerRegistrar:104`와 `RabbitBatchConsumerRegistrar:139`가 `BatchDeliveryMetadata`를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다. `new BatchMessageDelivery`는 저장소 전체에서 0건이고 `BatchMessageHandler` 참조도 0건이다. - **근거.** `evidence/raw/269` §E (`git grep 'new BatchMessageDelivery'` exit=1). - **왜 문제인가.** 두 registrar는 브로커별로 다른 정확한 계산을 한다 — Kafka는 파티션 단위 커밋이라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`. 이 판단이 계산되어 어디에도 전달되지 않는다. javadoc은 존재하지 않는 수신자를 가리킨다. - **확인 방법.** `git grep -n 'new BatchMessageDelivery' -- 'src/**/*.java'` → exit 1. - **후보.** 배치 경로를 완성하거나(handler 인터페이스를 registrar에 연결), 미완성임을 javadoc과 `support-matrix.md`에 표시하거나, `BatchMessageHandler`/`BatchMessageDelivery`를 제거한다. - **다음 단계.** **CASE 후보.** 재현이 정적 검색으로 끝나고 결론이 경계 안에서 닫힌다. ### P2 — 운영자용 지원 매트릭스가 런타임 편입을 반대로 적는다 - **사실.** `docs/messaging/support-matrix.md:23`이 "registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다 … 어느 composition root에도 편입되지 않았다"고 적는다. 현재 registry는 25개 중 **18개**가 `["app-bootstrap"]`이고 `messaging-core-api`가 그 안에 있다. - **근거.** `evidence/raw/270`. - **왜 문제인가.** 같은 문단이 권위로 지목하는 `src/messaging/CLAUDE.md:46-59`는 이미 정정을 기록했고 "정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다"는 결론까지 적었다. 그 결론이 support-matrix에는 적용되지 않았다. 배포 아티팩트가 실제로 이 leaf들을 싣고 `app.messaging.enabled` 하나로 켜진다는 사실을 운영자가 문서에서 알 수 없다. - **확인 방법.** `evidence/raw/270`의 python 블록 재실행. - **후보.** support-matrix의 해당 문장을 삭제하고 `CLAUDE.md`로 위임하거나(문장이 이미 그렇게 하고 있다), registry에서 파생하는 생성 문서로 바꾼다. - **다음 단계.** **CASE 후보 + REFERENCE 후보**("숫자는 세지 말고 소유자에게 위임하거나 게이트로 붙든다"). 두 문서가 같은 revision에서 모순되고 틀린 쪽이 옳은 쪽을 가리킨다는 형태 자체가 재사용 가능한 기준이다. ### P3 — 12개 예외가 선언만 되어 있다 - **사실.** 23개 구체 예외 중 12개가 leaf 밖 참조 0이다(§6.2 표). - **근거.** `evidence/raw/269` §B. - **왜 문제인가.** 지금 당장 깨지는 것은 없다. 다만 `MessagePublishAmbiguousException`처럼 설계의 중심 개념에 이름을 준 타입이 던져지지 않으면, 그 개념이 실제로 어떤 경로로 표현되는지(결과 record)를 읽는 사람이 스스로 알아내야 한다. 그리고 `src/messaging/CLAUDE.md:44` — "새 public 타입은 그 모듈의 계약이다. 삭제·시그니처 변경은 breaking change로 취급한다" — 때문에 나중에 정리하는 비용이 계속 커진다. - **확인 방법.** `evidence/raw/269` §B 재실행. - **후보.** adapter들이 결과 record 대신 예외를 던져야 하는 지점을 정하거나, 미사용 예외를 제거하거나, "이것은 파생 프로젝트용 어휘"임을 명시한다. - **다음 단계.** P2 첫 항목과 같은 미지수(저장소 밖 소비자)를 공유한다 → 그 OPEN QUESTION에 **MERGED** 후보. ### P3 — `MessagingRedactor`가 상수 대신 문자열 리터럴을 쓴다 - **사실.** `messaging-observability/.../MessagingRedactor.java:24`가 `"msg.id"`를 리터럴로 갖는다. `ReservedHeaders.MESSAGE_ID` 상수가 있다. - **근거.** `git grep '"msg\.'` — production 매치는 이 한 곳뿐. - **왜 문제인가.** 상수가 바뀌면 redaction이 조용히 대상을 잃는다. 컴파일러가 잡지 않는다. - **확인 방법.** `git grep -n '"msg\.' -- 'src/**/*.java' | grep -v messaging-core-api` - **후보.** 리터럴을 `ReservedHeaders.MESSAGE_ID`로 교체. - **다음 단계.** `messaging-observability` leaf SSOT가 소유한다. 여기서는 교차 참조만 남긴다. ### P3 — `WireSafeText`의 규칙이 leaf 경계에서 멈춘다 - **사실.** `WireSafeText`의 leaf 밖 참조 0. 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개. - **근거.** §12.3(b). - **왜 문제인가.** javadoc이 "Each copy of this check ... was one more place for the rule to drift"라고 적었고 그 통합을 leaf 안에서만 했다. 저장소 수준에서는 같은 drift가 그대로 남아 있다. - **확인 방법.** `git grep -l -E 'requireNoControls|control character|0x7F' -- 'src/**/*.java'` - **후보.** 규칙을 공유 위치(`shared-contract`)로 올리거나, leaf 경계를 이유로 중복을 명시적으로 수용한다고 적는다. - **다음 단계.** 저장소 전역 판단이므로 **cross-scope 소유.** 여기서는 관측만 기록한다. ### 확인된 설계(문제 아님) - 외부 의존성 0 — 계획 문서의 제약이 현재 소스에서 성립 - `EXACTLY_ONCE`/`GLOBAL` 부재가 테스트로 고정됨 - `PublishResult`의 14개 금지 조합 중 9개가 테스트로 커버됨 - 자격증명 세그먼트 매칭의 양방향(거절/오탐 회피) 테스트 존재 - `msg.` prefix 소유가 테스트로 고정됨 - W3C traceparent 무효 7종이 파라미터 테스트로 커버됨 --- ## Source anchors | id | kind | path / command | revision | what it proves | limitations | |---|---|---|---|---|---| | MCA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | leaf id, `allowed_dependencies: []`, `runtime_memberships: ["app-bootstrap"]` | 선언이며 런타임 실행 자체는 아님 | | MCA-002 | build | `src/messaging/messaging-core-api/build.gradle` | same | 선언 의존성 0 | convention plugin의 test 의존성은 별개 | | MCA-003 | code | `src/main/java/**/api/*.java` (12) | same | 봉투와 값 객체 불변식, 바이트 경계, UUIDv7 검증 | — | | MCA-004 | code | `src/main/java/**/api/header/*.java` (5) | same | 헤더 문법, 예약 prefix 소유, 자격증명 세그먼트 매칭, 두 factory 분리 | 실제 브로커 수용 여부는 미포함 | | MCA-005 | code | `src/main/java/**/api/publish/*.java` (17) | same | 3상태 완료, 14개 금지 조합, 증거 우선 순서 | adapter가 이를 지키는지는 별개 | | MCA-006 | code | `src/main/java/**/api/delivery/*.java` (13) | same | `HandleResult` sealed 4변형, attempt 1 규칙, 선언된 핸들러 계약 | 배선 여부는 §12가 답함 | | MCA-007 | code | `src/main/java/**/api/settlement/*.java` (5) | same | 정산 3상태와 불변식 | — | | MCA-008 | code | `src/main/java/**/api/error/*.java` (26) | same | 10개 카테고리, 기본 retryable 정책, 23개 예외의 카테고리 전수 | — | | MCA-009 | code | `src/main/java/**/api/destination/*.java` (7) | same | 논리 목적지, 12개 capability boolean | capability 선언이 실제 브로커와 맞는지는 별개 | | MCA-010 | test | `src/test/java/**` (8 클래스 / 79 테스트) | same | §10 표의 단언 | 순수 단위. 브로커·Spring 없음 | | MCA-011 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `..application..` → `dev.caskeleton.messaging..` 금지와 그 이유 | 정적 분석. 헬퍼/AOP 우회는 별도 | | MCA-012 | assembly | `src/app-bootstrap/build.gradle:87` | same | starter를 통한 전이 편입 경로 | 실행 활성화는 `app.messaging.enabled`가 결정 | | MCA-013 | module policy | `src/messaging/CLAUDE.md:44, 46-70` | same | public 타입 = 계약, runtime membership의 현재 의미와 정정 기록 | 정책 문서 | | MCA-014 | doc | `docs/messaging/support-matrix.md:18-27` | same | 운영자용 등급표와 런타임 편입 주장 | 23행이 registry와 어긋남(§12.4) | | MCA-015 | design doc | `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:7,13` | same | 무의존성 제약의 원래 근거 | 계획 문서. 경로/패키지는 이후 변경됨 | | MCA-016 | cross-leaf code | `messaging-runtime-core/.../DefaultDeliveryProcessor.java:22-99` | same | 핸들러 결과 → 정산의 유일한 지점과 그 시그니처 | 해당 leaf SSOT가 소유 | | MCA-017 | cross-leaf code | `messaging-kafka/.../KafkaBatchConsumerRegistrar.java:102-124`, `messaging-rabbit/.../RabbitBatchConsumerRegistrar.java:133-160` | same | 배치 metadata 생성 지점과 "handed to the handler" javadoc | 해당 leaf SSOT가 소유 | | MCA-018 | cross-leaf code | `messaging-reliability-api/.../IdempotentMessageHandler.java:21-32` | same | 세 번째 핸들러 계약의 존재 | 해당 leaf SSOT가 소유 | | EVD-269 | command | `evidence/raw/269-messaging-core-api-reachability.txt` | same | §12.1·§12.2 전부, exit code 포함 | 정적 `git grep`. 리플렉션/서비스로더/저장소 밖 미포함 | | EVD-270 | command | `evidence/raw/270-messaging-runtime-membership-doc-drift.txt` | same | §12.4의 drift, registry 재측정 25/7/18 | 한 시점 snapshot | | EVD-271 | command | `./gradlew :messaging:messaging-core-api:test --rerun-tasks` | same | BUILD SUCCESSFUL, 79 / 0 skipped / 0 failures | 순수 단위 레인 |