Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-runtime-core.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

50 KiB
Raw Blame History

messaging-runtime-core 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-runtime-core SSOT owner: messaging-runtime-core integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-runtime-core
  • canonical state analysisFile: analysis/messaging/messaging-runtime-core.md
  • source path: src/messaging/messaging-runtime-core
  • registry allowed_dependencies: ["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi", "messaging-security", "messaging-observability"] — messaging family에서 두 번째로 많은 의존
  • registry runtime_memberships: ["app-bootstrap"]

숫자

항목
production Java 파일 6
production LOC 787
패키지 1 (dev.caskeleton.messaging.runtime)
test 파일 4 (테스트 3 + fixture 1)
test 메서드(실행 확인) 21
외부(비프로젝트) 의존성 0

여섯 클래스:

클래스 LOC 역할 출하 조립
DefaultMessagePublisher 366 유일한 발행 경로 o (:446)
DefaultDeliveryProcessor 155 핸들러 결과 → 정산 x
RegisteredMessageCodecs 89 content type → codec o (:363)
DestinationProfileRegistry 62 논리 이름 → 프로파일 o (:377)
TransportMessagingRuntime 67 transport를 세대로 포장 o (:476)
DeclaredDestinationAccess 48 기본 접근 정책 o

Coverage ledger

scope/file group count disposition reason
src/main/java/** (6) 6 FULL_READ 전 파일 본문 확인
src/test/java/** (4) 4 FULL_READ 전 파일 본문 및 단언 확인
build.gradle 1 FULL_READ 주석 포함 17줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다. 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. build.gradle이 그 사정을 파일 맨 위에 적는다.

// The central publish and delivery orchestration.
//
// MessagePublisher was an interface with no implementation anywhere in the new platform: the
// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade
// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases
// and observation existed as beans that no publish path ever called. A starter that filled the gap
// with an application-supplied fake would pass a context test while running none of them.

이 진단의 마지막 문장이 핵심이다 — 컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.

six 파일이 메운 구멍:

인터페이스(소유 leaf) 구현이 없었음 이 leaf가 채운 것
MessagePublisher (core-api) 어디에도 없음 DefaultMessagePublisher
MessageCodecRegistry (schema-api) 어디에도 없음 RegisteredMessageCodecs
MessagingRuntime (transport-spi) 어디에도 없음 TransportMessagingRuntime
(없음) 논리이름→프로파일 해석 아무도 하지 않음 DestinationProfileRegistry
DestinationAccessPolicy 기본값 (security) denyAll() DeclaredDestinationAccess
HandleResult → 정산 (core-api) 어댑터가 각자 결정 DefaultDeliveryProcessor

여섯 중 다섯은 배선됐고 마지막 하나(DefaultDeliveryProcessor)는 배선되지 않았다(§12.1).


2. 의존성과 런타임 배선

들어오는 것: 여섯 project 의존, 전부 api. DefaultMessagePublisher 한 클래스가 그중 다섯을 생성자로 받으므로 api가 맞다.

나가는 것: messaging-spring-boot-starter만.

배선 지점 다섯(전부 MessagingCoreAutoConfiguration):

라인 무엇
363 RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))
377 DestinationProfileRegistry.of(destinations.all())
446 new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)
476 new TransportMessagingRuntime(selected.brokerName(), 1L, selected)InitializingBean
DeclaredDestinationAccess.of(...)로 접근 정책 bean

446의 인자가 여섯 개라는 것이 §12.1의 관측 지점이다.


3. 패키지/컴포넌트 지도

발행 (조립됨)
  DefaultMessagePublisher
    ├── DestinationProfileRegistry   논리 이름 → DestinationProfile
    ├── DestinationAccessPolicy      ← DeclaredDestinationAccess.of(profiles)
    ├── MessageCodecRegistry         ← RegisteredMessageCodecs
    ├── MessagingAdmissionController (policy)
    ├── MessagingRuntimeRegistry     (transport-spi) → TransportMessagingRuntime
    ├── MessagingTransport           (transport-spi) → Kafka/Rabbit/…
    └── MessagingObservation         ← NO_OBSERVATION (§12.1)

소비 (조립 안 됨)
  DefaultDeliveryProcessor
    ├── Function<MessageEnvelope<EncodedMessage>, HandleResult>
    ├── DeadLetterPublisher (내부 함수형 인터페이스)
    └── OneShotSettlement → TransportSettlement

4. 계약·불변식·상태 모델

4.1 DefaultMessagePublisher — 순서가 계약이다

// :40-49
 * <p>The order below is fixed, not composed from a map of interceptors. Each stage's position is a
 * decision:
 *
 * <ul>
 *   <li>destination and access first, so an unauthorized publish never encodes a payload;
 *   <li>encoding before admission, because the admission bound is on bytes and the byte count is
 *       not known until the payload is encoded;
 *   <li>the runtime lease last before the send, so a rotation cannot swap the transport underneath
 *       a message that has already been counted against the in-flight limit.
 * </ul>

실제 순서 여덟 단계:

# 단계 실패 시
1 destinations.require(name) DESTINATION_NOT_REGISTEREDREJECTED
2 requireSupportedOptions(profile, options) PUBLISH_DEDUPLICATION_UNSUPPORTEDREJECTED
3 access.mayPublish(name) PUBLISH_FORBIDDENREJECTED (인코딩 전)
4 encode(message) PUBLISH_PREPARATION_FAILEDREJECTED
5 남은 예산 확인 PUBLISH_DEADLINE_EXCEEDEDREJECTED
6 admission.admit(name, bytes) 예외 전파(MessageTooLargeException/MessageBackpressureException)
7 runtimes.acquire(broker) PUBLISH_RUNTIME_UNAVAILABLEREJECTED
8 transport.publish(...) + 마감 타임아웃 → AMBIGUOUS / 그 외 예외 → AMBIGUOUS

17은 전부 REJECTED, 8만 AMBIGUOUS다. 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다.

} catch (RuntimeException beforeTheWire) {
  // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send
  // the caller into reconciliation for a message no broker ever saw.
  return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt);
}

messaging-core-api의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 rejected(...)가 만드는 PublishResultPublishEvidence.notTransmitted()를 쓰므로 PublishResult 생성자의 14가지 금지 조합 검증을 자연히 통과한다.

3번이 4번보다 먼저인 이유가 인라인 주석에 있다.

// Before encoding: an unauthorized publish must not serialise the payload, because the
// encoded bytes are what a claim-check or a log would then be holding.

4.2 예산은 호출 시점부터 센다

// :131-138
 * <p>Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as
 * the publish operation's deadline, so a slow destination lookup or a large encode spends the
 * same budget the broker wait does; timing only the transport call would let the total exceed the
 * deadline by however long preparation took.

remainingBudgettimeout - elapsedSince(startedAt)이고, 0 이하면 전송 전에 REJECTED로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for."

4.3 마감을 복사본에 건다

// :143-154
 * <p>The bound is applied to a copy so that expiry never completes the transport's own stage: the
 * adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime
 * lease are released when the copy completes, which is deliberate  holding them until a stalled
 * broker answers is how a rotation waits forever on a generation nobody is using.
private static CompletableFuture<TransportPublishResult> withDeadline(
    CompletionStage<TransportPublishResult> inFlight, Duration remaining) {
  return inFlight.toCompletableFuture().copy()
      .orTimeout(remaining.toMillis(), TimeUnit.MILLISECONDS);
}

.copy()가 핵심이다. orTimeout을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다.

그 대가도 명시돼 있다 — permit과 lease는 복사본이 완료될 때 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever").

4.4 획득한 것은 모든 경로에서 정확히 한 번 반납된다

// :51-53
 * <p>Everything acquired is released exactly once, on every path  success, failure, exception and
 * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one
 * per failure until it stops accepting anything.

두 경로가 있다.

.handle((result, failure) -> {
  // One release per acquisition, whatever happened.
  held.close();
  admission.complete(destination.name().value());
  ...
});
} catch (RuntimeException beforeTheSend) {
  if (lease != null) { lease.close(); }
  admission.complete(destination.name().value());
  return rejected("PUBLISH_RUNTIME_UNAVAILABLE", ...);
}

handlewhenComplete와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. lease.close()MessagingRuntimeLease 계약상 멱등이고(transport-spi §4.1), admission.complete도 미보유 목적지에 대해 무해하다(messaging-policy §4.3).

한 가지 비대칭. 6번(admit)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — try 블록 밖이다. 다른 모든 실패는 PublishResult로 정규화되는데 admission 실패만 예외다. MessageTooLargeException·MessageBackpressureExceptionMessagingException이므로 호출자가 FailureDescriptor를 얻을 수 있지만, 반환 타입이 CompletionStage<PublishResult>인 메서드가 동기적으로 throw한다. §17.

4.5 requireSupportedOptions — 조용한 no-op을 막는다

// :65-72
 * <p>The transports accept {@code request.options()} and read nothing from it, so an option this
 * destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for
 * broker-side deduplication got a publish with no deduplication and no error, and then skipped
 * the idempotency it would otherwise have written  which is exactly the case {@code
 * PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.

messaging-core-apiPublishDeduplication javadoc("Requesting this on a broker without the deduplicatedPublish capability is a startup failure, not a silent no-op")이 여기서 실제 검사가 된다. 다만 startup이 아니라 publish 시점이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.

그리고 "The transports accept request.options() and read nothing from it"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.

4.6 encode — 폴백이 기본 codec이다

private <T> MessageEnvelope<EncodedMessage> encode(MessageEnvelope<T> message) {
  MessageCodec codec = codecs.find(message.contentType()).orElseGet(codecs::defaultCodec);
  ...
}

봉투의 content type에 맞는 codec이 없으면 기본 codec으로 인코딩한다. content type을 무시하는 폴백이다 — 봉투가 application/avro를 선언해도 registry에 Avro codec이 없으면 JSON으로 인코딩되고, EncodedMessage의 content type은 codec이 정하므로(ContentType.JSON) 봉투 선언과 실제 인코딩이 갈라진다. 그리고 출하 registry에는 JSON 하나뿐이다(analysis/messaging/messaging-schema-json.md §2). §17.

RegisteredMessageCodecs.defaultCodec()이 raw bytes일 수 없다는 것은 그 클래스가 생성자에서 강제한다(§4.8).

4.7 DestinationProfileRegistry — 폴백 없는 조회

// :13-18
 * <p>Nothing resolved a logical destination to a profile before this: the brokers took an
 * already-resolved {@code DestinationProfile} and the publisher that would have produced one did
 * not exist. A registry rather than a lookup with a fallback, because a destination nobody declared
 * has no physical name, no ordering guarantee and no payload bound  publishing to it would mean
 * inventing all three at the call site.

require가 미등록 목적지에 MessagingConfigurationException("DESTINATION_NOT_REGISTERED")을 던지고 메시지가 세 가지 부재를 나열한다. empty() factory도 있다 — "every publish is refused until a destination is declared".

4.8 RegisteredMessageCodecs — 기본 codec은 명시 선택

// :18-27
 * <p>The default codec is a deliberate choice rather than "the first one registered". Selecting one
 * by iteration order means the encoding a message is written with depends on how the map was
 * populated, which is a wire-format decision made by accident. The registry takes it explicitly and
 * refuses to be constructed without it.
 *
 * <p>The raw-bytes codec is never eligible as the default  that is the contract's own rule, and
 * the reason is that raw bytes silently disable schema validation for every destination that forgot
 * to declare an encoding.

두 가지를 생성자에서 거절한다.

if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }
...
MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);
if (existing != null && existing != codec) {
  // Two codecs for one content type is not a preference to resolve at runtime: whichever wins
  // decides how bytes on the wire are read by a consumer that was compiled against the other.
  throw new IllegalArgumentException("two codecs claim content type " + ...);
}

클래스가 아니라 content type으로 raw-bytes를 거절하는 것이 messaging-schema-api의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.

4.9 TransportMessagingRuntime — 얇은 포장

MessagingRuntime 구현으로 brokerName·generation·transport 셋을 들고 close()가 CAS로 멱등이다.

// close():61-62
// Idempotent: the registry closes a drained generation, and a context shutdown may close it
// again. Closing a transport twice is not an error worth propagating into shutdown.

DefaultMessagingRuntimeRegistry(transport-spi)도 자체 closed CAS를 갖는다 — 두 층이 각각 멱등이다. 중복 방어이지만 transport-spiGeneration.forceClose()가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.

generation이 항상 1L이다. starter의 유일한 설치 지점(:476)이 리터럴 1L을 넘긴다. MessagingRuntime.generation() javadoc은 "increasing with each replacement"라고 하고, TransportMessagingRuntime javadoc은 "the credential generation a rotation increments"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.

4.10 DeclaredDestinationAccess — 기본값의 세 번째 선택지

// :13-32
 * <p>{@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}
 * factory. Neither is a usable default on its own:
 *
 * <ul>
 *   <li><b>Deny everything</b> and the platform assembles, starts, and refuses every publish 
 *   <li><b>Allow everything</b> and the check is decoration. 
 * </ul>
 *
 * <p>So the default is neither: <b>a deployment may publish to the destinations it declared.</b>
 *  a message to a destination nobody declared is not an access-control edge case, it is a typo or
 * a module reaching past its own contract.
 *
 * <p>Consume and administer stay empty. A publisher's default has no business granting either, and
 * a deployment that needs them replaces this bean  which is the point of it being a bean.

publish만 허용하고 consume·administer는 빈 집합이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.

4.11 DefaultDeliveryProcessor — 두 규칙 (미조립)

// :27-36
 *   <li><strong>One terminal call.</strong> A delivery is acknowledged, requeued or discarded once.
 *       A second call is a programming error  acknowledging after a requeue tells the broker the
 *       message is done while a copy is already in flight.
 *   <li><strong>Dead-letter before acknowledgement.</strong> The source is acknowledged only after
 *       the dead-letter publish is confirmed. 

OneShotSettlementAtomicBoolean CAS로 한 번을 강제하고, 두 번째 호출은 CompletableFuture.failedFuture(IllegalStateException)을 반환한다 — 예외를 던지지 않고 stage로 보고한다.

핸들러 예외 처리에 이전 결함이 기록돼 있다.

} catch (RuntimeException handlerFailed) {
  // A handler that threw is a retry, not a discard. Treating an exception as "this message is
  // undeliverable" is how a transient bug in one consumer silently drops a day of traffic —
  // and it is exactly what the Rabbit consumer did by folding handler exceptions into its
  // deserialization-failure path.
  return settlement.requeue(retryDelay);
}

result == null도 requeue다. 그런데 그것을 서술하는 missingResult() 정적 메서드가 있고 아무도 부르지 않는다HANDLER_RETURNED_NOTHING 코드가 만들어지지만 어떤 경로도 그 descriptor를 사용하지 않는다. §17.

DLQ 분기의 두 주석이 trade를 명시한다.

? settlement.acknowledge()   // Confirmed: the message exists somewhere else, so removing it here is safe.
: settlement.requeue(retryDelay);  // Not confirmed — rejected or ambiguous. Requeueing risks a
                                   // duplicate; acknowledging loses the message outright, and a
                                   // duplicate is the recoverable half of that choice.

messaging-policyDeadLetterOrchestrator가 같은 불변식을 다른 형태로 구현한다(§12.3).


5. 주요 실행 경로

발행(조립됨): §4.1의 8단계.

소비(미조립): TransportDeliveryhandler.apply(envelope)HandleResult 4분기 → OneShotSettlement로 정확히 한 번 정산.

세대 설치(조립됨): InitializingBeantransport.getIfAvailable() → null이면 조용히 반환(이유가 주석에 있음) → new TransportMessagingRuntime(brokerName, 1L, transport)runtimes.install(...).


6. 실패 경로와 복구/번역

DefaultMessagePublisher가 만드는 결과:

코드 completion category 언제
PUBLISH_FORBIDDEN REJECTED CONFIGURATION 접근 정책 거부
PUBLISH_PREPARATION_FAILED REJECTED CONFIGURATION 해석·인코딩 중 예외
PUBLISH_DEADLINE_EXCEEDED REJECTED CONFIGURATION 전송 전 예산 소진
PUBLISH_RUNTIME_UNAVAILABLE REJECTED CONFIGURATION lease 획득 실패
PUBLISH_DEADLINE_EXCEEDED AMBIGUOUS AMBIGUOUS 전송 후 마감
PUBLISH_OUTCOME_UNKNOWN AMBIGUOUS AMBIGUOUS 전송 후 그 외 실패

같은 코드 PUBLISH_DEADLINE_EXCEEDED두 completion에 쓰인다. 전송 전이면 REJECTED, 후면 AMBIGUOUS다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17.

sanitized(Throwable)가 메시지가 아니라 타입 이름만 남긴다.

// :175-180
 * <p>A driver message can carry a routing key, a payload fragment or a connection string, and a
 * {@code FailureDescriptor} is designed to be logged and exported.
return cause.getClass().getSimpleName();

messaging-core-apiFailureDescriptor javadoc("no payload, no stack trace, no credential")과 같은 관심사다.

isDeadlinesanitized 둘 다 CompletionException을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.

DefaultDeliveryProcessor는 예외를 던지지 않는다. 이중 정산만 failedFuture로 보고한다.


7. 트랜잭션·동시성·수명주기

트랜잭션 없음.

지점 도구 보호
OneShotSettlement.settled AtomicBoolean CAS 정확히 한 번 정산
TransportMessagingRuntime.closed AtomicBoolean CAS 정확히 한 번 transport close
RegisteredMessageCodecs.byContentType Map.copyOf 불변
DestinationProfileRegistry.profiles Map.copyOf 불변
withDeadline.copy() CompletableFuture 어댑터 stage와 이쪽 경로 분리

DefaultMessagePublisher 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. lease만 메서드 지역 변수이고 handle 람다가 held라는 effectively-final 복사본으로 캡처한다.

수명주기 참여는 TransportMessagingRuntime.close()뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.


8. 설정·기능 플래그·환경 차이

설정 없음. 이 leaf의 모든 값은 생성자 인자다.

주입 가능한 두 지점이 테스트 가능성을 만든다.

인자 기본 목적
LongSupplier nanoTime System::nanoTime 경과 시간을 sleep 없이 테스트
MessagingObservation observation NO_OBSERVATION 관측 주입

두 번째의 기본값이 §12.1의 발견 지점이다.

TransportMessagingRuntimegeneration은 생성자 인자이고 유일한 호출자가 1L을 넘긴다.


9. 퍼시스턴스/외부 시스템 세부

없다. 브로커 접촉은 MessagingTransport 인터페이스 뒤에 있다.


10. 테스트 레인과 실제 증명 범위

레인: ./gradlew :messaging:messaging-runtime-core:test. BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures.

클래스 실제로 증명하는 것 증명하지 않는 것
DefaultMessagePublisherTest 10 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 실제 브로커. 출하 조립이 관측을 넘기는지
DefaultDeliveryProcessorTest 7 HandleResult 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 production에서 호출되는지(§12.1)
RegisteredMessageCodecsTest 4 raw-bytes 기본 거절, content type 충돌 거절, 조회

DefaultMessagePublisherTest:271이 익명 MessagingObservation을 만들어 관측 호출을 확인한다. 즉 테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다. 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.

RecordingTransport(:426)가 MessagingTransport를 구현해 전송을 대체한다. 그래서 이 레인은 "발행 오케스트레이션이 옳다"를 증명하고 "어댑터가 계약을 지킨다"는 증명하지 않는다.


11. 빌드/ArchUnit/CI 강제 지점

게이트 이 leaf에 대해
verifyCleanArchitectureDependencies 여섯 project 의존
verifyRuntimeModuleMembership ["app-bootstrap"]
vendor api 규칙 벤더 의존성 0
ArchUnit 전용 규칙 없음

MessagingStarterOffContractTest(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — "DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.


12. 실제 사용 여부와 negative-space probes

원시 증거: evidence/raw/283-runtime-core-observation-noop.txt.

12.1 Public surface reachability

타입 leaf 밖 파일 출하 조립
DefaultMessagePublisher 2 oMessagingCoreAutoConfiguration:446
TransportMessagingRuntime 1 o:476
RegisteredMessageCodecs 1 o:363
DestinationProfileRegistry 1 o:377
DeclaredDestinationAccess 1 o
DefaultDeliveryProcessor 0 xsrc/main 생성 0, src/test 1

(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다

DefaultDeliveryProcessor는 leaf 밖 참조가 0이고 src/main에서 생성되지 않는다. 이것이 analysis/messaging/messaging-policy.md §12.1이 관측한 "소비 경로 전체 미조립"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.

이 클래스의 javadoc은 자기가 고친 문제를 서술한다 — "Each broker adapter decided for itself what a retry or a dead-letter meant, so 'the platform decides when and in what order the settlement happens' … described a decision nobody made in one place." 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.

(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다

네 조각이 있다.

조각 상태
MessagingObservation 인터페이스 (observability) 존재
MessagingMetrics implements MessagingObservation 존재
DefaultMessagePublisher.observe(...) 호출부 존재, 모든 발행 결과를 기록
8인자 생성자 (관측 주입) 존재
출하 조립 6인자 생성자 → NO_OBSERVATION
MessagingMetrics bean 없음
// MessagingCoreAutoConfiguration.java:446-447
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
    destinations, access, codecs, admission, runtimes, transport);

그리고 MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다(MessagingMetricCardinalityTest, MessagingSecretLeakTest).

starter는 MessagingMetrics두 협력자를 bean으로 만든다MessagingRedactor(:253)와 CardinalityGuard(:264). MessagingMetrics의 생성자는 (registry, CardinalityGuard, MessagingRedactor)를 받는다(테스트가 그렇게 호출한다). 즉 재료 둘은 배선됐고 그것을 조립하는 bean이 없다.

이 클래스의 javadoc이 그 상황을 예언한다.

// DefaultMessagePublisher.java:74-78
 * <p>{@code MessagingObservation} existed as a bean and no publish path called it, so the
 * platform's own metrics described nothing. It is a constructor argument rather than an optional
 * decorator because an unobserved publish path is how "the dashboards were empty during the
 * incident" happens.

이전 상태: bean은 있고 호출하는 경로가 없었다. 현재 상태: 호출하는 경로는 있고 bean이 없다.

두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 반대편으로 옮겼다. 그리고 "constructor argument rather than an optional decorator"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.

(c) 배선된 것은 확실히 배선됐다

발행 경로 다섯이 전부 src/main에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.

한계. 정적 검색이다. ObjectProvider 지연 조회는 MessageContractsMessagingTransport 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 MessagingObservation bean을 제공하면 @ConditionalOnMissingBean(MessagePublisher.class) 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.

12.2 Conditional sibling comparison

이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.

MessagingCoreAutoConfiguration이 이 leaf의 타입을 만드는 지점 다섯의 조건:

대상 조건
RegisteredMessageCodecs @ConditionalOnMissingBean(MessageCodecRegistry.class)
DestinationProfileRegistry @ConditionalOnMissingBean
DefaultMessagePublisher @ConditionalOnMissingBean(MessagePublisher.class)
TransportMessagingRuntime 조건 없음 — InitializingBean 안, transport.getIfAvailable() null 검사
DeclaredDestinationAccess @ConditionalOnMissingBean

네 번째만 조건 대신 런타임 null 검사를 쓴다. 그 이유가 주석에 있다.

// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport
// when a broker is selected, and it refuses startup by name when one is not. This
// configuration is also loadable on its own — an adopter composing the policy primitives
// without a transport — and demanding one here would refuse that.

즉 "transport 없이도 로드 가능해야 한다"가 명시적 요구이고, 그 요구가 @ConditionalOnBean 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(MessagingProviderSelection)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.

12.3 Duplicate mechanism sweep

(a) DLQ 순서 불변식이 두 곳에 구현돼 있다

messaging-policy DeadLetterOrchestrator 이 leaf DefaultDeliveryProcessor
불변식 확인 후에만 원본 정산 확인 후에만 ack
미확인 시 정산하지 않음(sourceSettled=false) requeue
헤더 예약 헤더 6개 부착 없음
발행 주체 MessagePublisher DeadLetterPublisher 함수형 인터페이스

미확인 시 동작이 다르다. policy 쪽은 "정산하지 않는다"(브로커가 알아서 재전달), 이쪽은 "명시적으로 requeue한다". 둘 다 메시지를 잃지 않지만 requeue(delay)는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.

둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. analysis/messaging/messaging-policy.md §12.3(b)가 같은 사건을 반대편에서 기록한다.

(b) 재시도 지연이 두 출처

DefaultDeliveryProcessorretryDelay생성자 인자 하나다. 시도 횟수를 세지 않고 백오프도 없다. messaging-policyBackoffCalculator(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.

(c) 멱등 종료가 두 층

TransportMessagingRuntime.close()DefaultMessagingRuntimeRegistry.Generation.forceClose()(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 의도된 중복이다 — 이쪽 주석이 "the registry closes a drained generation, and a context shutdown may close it again"이라고 두 경로를 명시한다. 결함 아님.

(d) content type 폴백

encodecodecs.find(contentType).orElseGet(codecs::defaultCodec)으로 폴백한다. RegisteredMessageCodecs.find는 미등록이면 Optional.empty()를 주고, defaultCodec()은 JSON이다. 즉 선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점이고, 그 갈라짐이 조용하다. §17.

12.4 Documentation / measured-count drift

문서 주장 재측정 결과
build.gradle 주석: MessagePublisher에 구현이 없었다 현재 이 leaf가 구현하고 :446에서 조립 해소됨
TransportMessagingRuntime javadoc: registry가 비어 있어 모든 발행이 실패했다 현재 :476이 설치 해소됨
DefaultMessagePublisher javadoc: 관측 bean이 있고 호출 경로가 없었다 현재 호출 경로가 있고 bean이 없다 반전됨(§12.1b)
DefaultDeliveryProcessor javadoc: 어댑터가 각자 결정했다 한 곳에 모았으나 조립되지 않음 부분 해소
MessagingRuntime.generation() javadoc: "increasing with each replacement" 유일한 설치가 리터럴 1L 미실현
support-matrix.md:23: 모든 messaging leaf가 unwired 이 leaf는 ["app-bootstrap"] 불일치(family drift)

세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.


13. Git/설계 문서에서 확인한 변화와 실패 기록

이 leaf는 통째로 하나의 수정이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(DeclaredDestinationAccess, TransportMessagingRuntime, MessagingCoreAutoConfiguration:461).

위치 이전 상태 그것이 만든 실패
build.gradle 주석 MessagePublisher 구현 없음 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음
TransportMessagingRuntime javadoc MessagingRuntime 구현 없음 registry가 빈 채로 만들어져 모든 발행이 PUBLISH_RUNTIME_UNAVAILABLE — 목적지 해석·접근 확인·인코딩을 전부 마친 뒤에
DestinationProfileRegistry javadoc 논리 이름→프로파일 해석 없음 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음
DefaultDeliveryProcessor javadoc HandleResult→정산 연결 없음 각 어댑터가 retry/dead-letter의 뜻을 각자 결정
DefaultDeliveryProcessor 핸들러 예외 주석 Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림
requireSupportedOptions javadoc transport가 options를 읽지 않음 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀
withDeadline javadoc transport가 마감을 무시 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임

build.gradle 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."


14. 런타임·터미널 Evidence

id 종류 파일 무엇을 보여주는가 한계
EVD-283 command evidence/raw/283-runtime-core-observation-noop.txt 여섯 타입 참조 수, 발행 경로 조립 지점, DefaultDeliveryProcessor src/main=0, 관측 4조각과 끊긴 한 지점, MessagingMetrics가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 정적 검색. 파생 프로젝트의 대체 조립 미포함
EVD-284 command ./gradlew :messaging:messaging-runtime-core:test --rerun-tasks BUILD SUCCESSFUL, 21 / 0 / 0 브로커 대체(RecordingTransport)

15. 명시적 설계 이유와 추론을 구분한 정리

명시적

  • 이 leaf가 존재하는 이유와 이전 결함 — build.gradle 주석
  • 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — DefaultMessagePublisher javadoc
  • 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석
  • 전송 전 실패가 REJECTED인 이유 — 인라인 주석
  • 예산을 호출 시점부터 세는 이유 — remainingBudget javadoc
  • 마감을 복사본에 거는 이유와 그 대가 — withDeadline javadoc
  • 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석
  • 지원하지 않는 옵션을 거절하는 이유 — requireSupportedOptions javadoc
  • 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — RegisteredMessageCodecs javadoc
  • 폴백 없는 목적지 조회 이유 — DestinationProfileRegistry javadoc
  • 기본 접근 정책이 deny도 allow도 아닌 이유 — DeclaredDestinationAccess javadoc
  • 핸들러 예외가 retry인 이유 — 인라인 주석
  • DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석
  • transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — InitializingBean 안 주석
  • 관측을 생성자 인자로 둔 이유 — observation 필드 javadoc

추론

  • 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → 추론이 아니라 미상. 어디에도 근거가 없고, 8인자 생성자와 MessagingMetrics가 둘 다 존재한다는 점이 미완을 시사한다.
  • generation이 항상 1인 것은 회전 코드가 없기 때문이다 → 추론. 회전 코드 부재는 관측이다.
  • DefaultDeliveryProcessor 미조립이 미완인지 확장점인지 → 미상.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • 6개 클래스 787줄 전문의 계약과 순서 결정
  • 21개 테스트가 통과하고 무엇을 단언하는지
  • 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지
  • DefaultDeliveryProcessorsrc/main에서 생성되지 않는다는 것
  • 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것
  • MessagingMetrics가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것
  • generation이 유일한 설치 지점에서 리터럴 1L이라는 것

확인하지 못한 것

  • 6인자 생성자 선택이 의도인지. 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.
  • MessagingProviderSelection이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.
  • 어댑터들이 request.options()를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다.
  • 실제 브로커에서 withDeadline.copy() 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다.
  • 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.

17. 손볼 것

P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다

  • 사실. DefaultMessagePublisher가 모든 발행 결과를 observation.recordPublish(...)로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. MessagingMetricsMessagingObservation을 구현한다. 그런데 출하 조립(MessagingCoreAutoConfiguration:446)은 6인자 생성자를 써서 NO_OBSERVATION을 넣고, MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 MessagingMetrics의 협력자 둘(MessagingRedactor:253, CardinalityGuard:264)을 bean으로 만든다.
  • 근거. evidence/raw/283 §D.
  • 왜 문제인가. 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens". 그리고 같은 javadoc이 이전 결함을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. 고침이 간극을 닫은 게 아니라 반대편으로 옮겼다. "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
  • 확인 방법. evidence/raw/283 §D 재실행. 또는 :446의 인자 수와 :138-146 생성자 시그니처 대조.
  • 후보. (a) MessagingMetrics bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 support-matrix.md에 표시한다.
  • 다음 단계. CASE 후보. 재현이 정적이고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 회로가 반대편에서 끊긴 사례라 독립적으로 가치가 있다. 그리고 "생성자 기본값이 있는 필수 협력자는 필수가 아니다"가 REFERENCE 후보다.

P2 — 소비 오케스트레이터가 조립되지 않는다

  • 사실. DefaultDeliveryProcessor는 leaf 밖 참조 0, src/main 생성 0, src/test 생성 1이다.
  • 근거. evidence/raw/283 §A·§C.
  • 왜 문제인가. 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다. 그리고 DeclaredDestinationAccess가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.
  • 확인 방법. git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?DefaultDeliveryProcessor\s*\(' -- src
  • 다음 단계. analysis/messaging/messaging-policy.md §17의 "출하 컨텍스트가 발행은 하고 소비는 하지 못한다"와 동일 사건이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.

P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다

  • 사실. encodecodecs.find(message.contentType()).orElseGet(codecs::defaultCodec)으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 application/avro를 선언해도 JSON으로 인코딩되고, EncodedMessage의 content type은 codec이 정하므로 application/json이 된다.
  • 근거. DefaultMessagePublisher.java:97-102, RegisteredMessageCodecs.find, MessagingCoreAutoConfiguration:363(varargs 비어 있음).
  • 왜 문제인가. 실패하지 않고 다른 포맷으로 성공한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. DestinationProfile.schema().codec()이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.
  • 확인 방법. 등록되지 않은 content type의 봉투를 발행해 EncodedMessage.contentType()을 확인.
  • 후보. 미등록 content type을 MessagingConfigurationException으로 거절하거나, profile.schema().codec()과 대조한다.
  • 다음 단계. CASE 후보. 조용한 성공이라는 형태가 messaging-core-api의 "조용한 성능 저하 금지" 설계와 정면으로 어긋난다.

P3 — 같은 실패 코드가 두 completion에 쓰인다

  • 사실. PUBLISH_DEADLINE_EXCEEDED가 전송 전이면 REJECTED(:16-21), 전송 후면 AMBIGUOUS(:42-47)로 붙는다.
  • 근거. 두 위치.
  • 왜 문제인가. 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 messageId로만 재발행. FailureDescriptor.code가 "stable, machine-readable code"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다.
  • 확인 방법. git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core
  • 후보. 전송 전을 PUBLISH_DEADLINE_BEFORE_SEND처럼 분리한다.
  • 다음 단계. REFERENCE 후보(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).

P3 — admission 실패만 예외로 전파된다

  • 사실. 8단계 중 admission(:24)만 try 블록 밖이고, MessageTooLargeException·MessageBackpressureException이 그대로 던져진다. 나머지 실패는 전부 CompletionStage<PublishResult>로 정규화된다.
  • 근거. DefaultMessagePublisher.java:198(admit 호출 위치)과 그 앞뒤 try 블록 범위.
  • 왜 문제인가. 반환 타입이 CompletionStage인 메서드가 동기적으로 throw한다. .publish(...).exceptionally(...)로만 처리하는 호출자는 이 두 예외를 놓친다. 두 예외 다 MessagingException이라 FailureDescriptor는 있지만 전달 방식이 다른 실패들과 다르다.
  • 확인 방법. 상한 초과 payload로 publish를 호출하고 반환 stage가 아니라 호출 자체가 던지는지 확인.
  • 후보. admission을 try 안으로 넣어 rejected(...)로 정규화하거나, javadoc에 동기 throw를 명시한다.
  • 다음 단계. REFERENCE 후보(CompletionStage를 반환하는 메서드는 동기적으로 던지지 않는다).

P3 — generation이 항상 1이다

  • 사실. 유일한 설치 지점(MessagingCoreAutoConfiguration:476)이 리터럴 1L을 넘긴다. MessagingRuntime.generation() javadoc은 "increasing with each replacement", TransportMessagingRuntime javadoc은 "the credential generation a rotation increments"라고 한다.
  • 근거. :476, 두 javadoc.
  • 왜 문제인가. 오늘 회전 코드가 없으므로 무해하다. 다만 DefaultMessagingRuntimeRegistry의 세대 드레인 로직(transport-spi §4.2)이 세대 구분을 전제하고, 진단에서 generation을 읽는 사람은 항상 1을 본다. 회전을 붙일 때 이 리터럴이 잊히면 두 세대가 같은 번호를 갖는다.
  • 확인 방법. git grep -n 'TransportMessagingRuntime(' -- src/main
  • 후보. 자격증명 회전 카운터에서 값을 가져오거나, 회전이 없음을 주석으로 남긴다.
  • 다음 단계. REFERENCE 후보(증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다).

P3 — missingResult()가 아무 데도 쓰이지 않는다

  • 사실. DefaultDeliveryProcessor.missingResult()(package-private static)가 HANDLER_RETURNED_NOTHING descriptor를 만든다. result == null 분기는 그것을 쓰지 않고 바로 settlement.requeue(retryDelay)를 부른다.
  • 근거. DefaultDeliveryProcessor.java:77-79, :146-154.
  • 왜 문제인가. 핸들러가 null을 반환한 경우와 HandleResult.Retry를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue가 된다. descriptor는 만들어졌으나 흐르지 않는다.
  • 확인 방법. git grep -n 'missingResult' -- src
  • 후보. null 분기에서 descriptor를 관측이나 로그로 흘리거나, 메서드를 제거한다.
  • 다음 단계. REFERENCE 후보(만들어 두고 흘리지 않는 진단값은 진단이 아니다).

확인된 설계(문제 아님)

  • 발행 8단계의 고정 순서와 각 위치의 명시된 근거
  • 전송 전/후 경계가 REJECTED/AMBIGUOUS를 가르는 것
  • 예산을 호출 시점부터 세는 것
  • 마감을 복사본에 걸어 어댑터의 stage를 완료시키지 않는 것과, permit/lease를 그 시점에 반납한다는 명시적 trade
  • 성공·실패·예외 모든 경로에서 lease와 permit을 정확히 한 번 반납하는 것
  • 지원하지 않는 발행 옵션을 조용히 무시하지 않고 거절하는 것
  • 기본 codec을 명시 인자로 받고 raw-bytes를 content type 기준으로 거절하는 것
  • 폴백 없는 목적지 조회
  • 기본 접근 정책이 "선언한 목적지에만 발행"인 것과 consume·administer를 비워 두는 것
  • 정확히 한 번 정산(CAS)과 핸들러 예외를 retry로 취급하는 것
  • 실패 서술에 예외 메시지가 아니라 타입 이름만 남기는 것

Source anchors

id kind path revision what it proves limitations
MRC-001 registry src/config/architecture/modules.json 21234e38 deps 6개, memberships ["app-bootstrap"] 선언
MRC-002 build messaging-runtime-core/build.gradle same 이 leaf가 존재하는 이유(MSG-INT-003 진단)
MRC-003 code .../runtime/DefaultMessagePublisher.java 전문 same §4.14.6, §6 브로커 대체 테스트만
MRC-004 code .../runtime/DefaultDeliveryProcessor.java 전문 same §4.11 두 규칙, 핸들러 예외 이력 조립되지 않음(§12.1a)
MRC-005 code .../runtime/RegisteredMessageCodecs.java same §4.8 기본 codec 규칙과 충돌 거절
MRC-006 code .../runtime/DestinationProfileRegistry.java same §4.7 폴백 없는 조회
MRC-007 code .../runtime/TransportMessagingRuntime.java same §4.9 멱등 종료, generation 인자 항상 1(§17)
MRC-008 code .../runtime/DeclaredDestinationAccess.java same §4.10 기본 접근 정책의 세 번째 선택지
MRC-009 test DefaultMessagePublisherTest (10) same 8단계와 실패 정규화, 관측 호출 8인자 생성자 사용
MRC-010 test DefaultDeliveryProcessorTest (7) same 4분기 정산, 이중 정산 거절 배선 미증명
MRC-011 test RegisteredMessageCodecsTest (4) same 기본 codec 규칙
MRC-012 assembly messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:363,377,446,461-478 same 다섯 조립 지점과 6인자 생성자 선택 해당 leaf SSOT가 소유
MRC-013 cross-leaf code messaging-observability/.../MessagingMetrics.java:29 same MessagingObservation의 유일한 구현 테스트에서만 생성
MRC-014 cross-leaf code messaging-policy/.../DeadLetterOrchestrator.java same 경쟁하는 DLQ 구현 해당 leaf SSOT가 소유
EVD-283 command evidence/raw/283-runtime-core-observation-noop.txt same §12.1 전부 정적 검색
EVD-284 command ./gradlew :messaging:messaging-runtime-core:test --rerun-tasks same 21 / 0 / 0 RecordingTransport 대체