{ "schema_version": "1.0", "document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md", "document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d", "line_count": 47035, "line_number_space": "canonical-source-with-managed-blocks-collapsed", "anchor": { "kind": "line", "value": 33802, "line": 33802 }, "current_section": { "heading": { "line": 33802, "level": 3, "text": "messaging-reliability-api 완전 해부" }, "start_line": 33802, "end_line": 34597, "text": "### messaging-reliability-api 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-reliability-api`\n> SSOT owner: `messaging-reliability-api`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-reliability-api`\n- canonical state `analysisFile`: §A19-MESSAGING-RELIABILITY-API\n- source path: `src/messaging/messaging-reliability-api`\n- registry `allowed_dependencies`: `[\"messaging-core-api\"]`\n- registry `runtime_memberships`: `[\"app-bootstrap\"]`\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 13 |\n| production LOC | 817 |\n| 패키지 | 1 (`dev.caskeleton.messaging.reliability`) |\n| **test 파일** | **0 — `src/test` 디렉터리가 없다** |\n| 외부(비프로젝트) 의존성 | **0** |\n\n13개 타입:\n\n| 축 | 타입 | leaf 밖 참조 |\n|---|---|---:|\n| **Outbox** | `OutboxRepository` · `OutboxRecord` · `OutboxCanonicalMetadata` · `OutboxStatus` · `OutboxLease` · `OutboxTransitionResult` | 7 · 13 · 8 · 7 · 6 · 6 |\n| **Inbox** | `InboxRepository` · `InboxRecord` · `InboxResult` · `IdempotentMessageHandler` · `TransactionalMessageAction` | 6 · **0** · 2 · 1 · 1 |\n| **기타** | `ClaimCheckReference` · `ReliableMessagePublisher` | 6 · **0** |\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |\n| `src/test/**` | 0 | — | **존재하지 않음**(§10) |\n| `build.gradle` | 1 | `FULL_READ` | 5줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n이 leaf는 **effectively-once 처리의 계약**을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 `isExpired`/`expiredAt` 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다.\n\n세 개의 독립적인 메커니즘을 담는다.\n\n**Outbox** — dual-write 문제의 답.\n\n```java\n// ReliableMessagePublisher.java:14-15\n *
This is the answer to the dual-write problem. Writing to the database and publishing to the\n * broker in the same method cannot be made atomic; writing both to the database can.\n```\n\n**Inbox** — 소비 측 중복 제거.\n\n```java\n// InboxRepository.java:9-13\n *
{@link #reserve} must run inside the same database transaction as the handler's side effect.\n * That is the entire mechanism: the uniqueness constraint on the inbox row and the business write\n * commit together, so a redelivered message either finds the row already present and skips, or\n * writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to\n * close.\n```\n\n**Claim Check** — 브로커 밖 payload 참조.\n\n그리고 셋의 관계를 `OutboxRecord`가 명시한다.\n\n```java\n// OutboxRecord.java:21-24\n *
What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will\n * retry it, and the same message may reach the broker twice. Effectively-once processing comes from\n * this row carrying a stable {@code messageId} and the consumer having an Inbox — not from the\n * outbox alone.\n```\n\n**Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 \"보장을 과대 진술하지 않는다\"의 예다.\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api) 하나.\n\n나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`.\n\n**구현 leaf가 셋 있고 전부 배선된다.**\n\n| 포트 | 구현 | 조립 |\n|---|---|---|\n| `OutboxRepository` | `messaging-outbox-jdbc-postgresql/JdbcOutboxRepository` | starter `MessagingReliabilityAutoConfiguration` |\n| `InboxRepository` | `messaging-inbox-jdbc-postgresql/JdbcInboxRepository` | 같음 |\n| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql/TransactionalInboxHandler` | `transactionalInboxHandler` bean |\n| `ReliableMessagePublisher` | **없음** | — |\n\n`ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.\n\n이 leaf 자체는 Spring 주석을 갖지 않는다.\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\nOutbox\n ReliableMessagePublisher.addToOutbox(dest, envelope) ← 구현 0\n ↓ (쓰기)\n OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers\n ├─ OutboxCanonicalMetadata (provenance 10필드)\n └─ status / attempts / leaseExpiresAt / lastFailureCode\n ↓ (릴레이)\n OutboxRepository ─┬─ append\n ├─ [구세대] leaseBatch → List The port used to take a {@code MessageId} for every terminal transition, so a write said which\n * row to change and nothing about which claim it belonged to. A relay that stalled past its lease\n * could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already\n * written, and the row became claimable again — one message, published twice, by a system whose\n * whole purpose is to publish it once.\n *\n * The token is the part that makes staleness detectable. It increases on every claim, so a\n * superseded relay holds a number the row no longer has and its update matches zero rows.\n```\n\n`token < 1`을 거절하는 이유도 적혀 있다 — `\"a claim's token starts at 1; 0 is the value of a row nobody has claimed\"`.\n\n`expiredAt(now)`가 `!now.isBefore(expiresAt)`다.\n\n##### 4.2 `OutboxTransitionResult` — void가 삼킨 것\n\n```java\n// :5-9\n * The transitions returned {@code void}, so an update that matched zero rows was\n * indistinguishable from one that matched one. That is precisely the stale-lease case: the relay\n * believes it recorded the outcome, the row still says something else, and nothing anywhere counts\n * the disagreement.\n```\n\n두 값이고 `STALE_LEASE`의 javadoc이 운영 의미까지 적는다.\n\n```java\n * Another relay claimed it after the lease expired. Not an error to throw — the message is\n * being handled by somebody else — but never a success either: it is the signal that this\n * worker's publish attempt may have produced a duplicate, and it belongs on a metric.\n```\n\n**\"belongs on a metric\"** — 그 메트릭이 존재하는지는 outbox leaf가 답한다.\n\n##### 4.3 `OutboxStatus` — 여섯 상태와 두 개의 구분\n\n`PENDING` → `IN_FLIGHT` → `PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`.\n\n**두 쌍의 구분이 각각 이유를 갖는다.**\n\n`AMBIGUOUS` vs `FAILED`:\n\n```java\n// :6-9\n * {@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose\n * publish timed out may already be on the broker; retrying it is correct, but only under the same\n * logical message id, and an operator looking at the table needs to be able to tell those rows\n * apart from ones that definitely never landed.\n```\n\n`EXHAUSTED` vs `FAILED`:\n\n```java\n// :31-34\n * Distinct from {@link #FAILED}, which means the broker refused the message: this one means\n * nobody ever got an answer. Collapsing the two loses the difference between \"this message is\n * invalid\" and \"the broker was unreachable for an hour\", and those need different operator\n * actions — the first a fix, the second a redrive.\n```\n\n`OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — \"The first needs a fix, the second a redrive.\"\n\n**`FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.** `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`의 `.because(...)`가 그것을 ArchUnit 규칙의 근거로 든다 — \"its `OutboxStatus.FAILED` means the opposite of the legacy `OutboxEventStatus.FAILED`, so the two models cannot be mixed by name without inverting retryable and terminal.\" 즉 **이 enum의 의미가 저장소 규칙 하나의 존재 이유다.**\n\n##### 4.4 `InboxResult` — 두 개가 아니라 세 개\n\n```java\n// :6-9\n * Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}\n * into a single \"duplicate\" would settle a message whose effect is still only half-written by\n * another instance: if that instance then rolls back, the effect is lost and the broker will never\n * redeliver, because this instance already acknowledged it.\n```\n\n`safeToSettle` 플래그가 상수에 붙어 있다.\n\n| 값 | safeToSettle | 뜻 |\n|---|:---:|---|\n| `APPLIED` | true | 이 트랜잭션에서 효과 실행 |\n| `ALREADY_APPLIED` | true | 커밋된 예약 존재 — 이미 실행됨 |\n| `CLAIMED_ELSEWHERE` | **false** | 다른 인스턴스가 **미커밋** 예약 보유 |\n\n세 번째의 javadoc이 결론을 적는다 — \"Do *not* settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect.\"\n\n**세 값 모두 필요한 이유가 명확하고, `isSafeToSettle()`이 그 판단을 하나로 모은다.**\n\n##### 4.5 `InboxRepository` — 키가 (message, consumer)다\n\n```java\n// InboxRecord.java:9-12\n * Keyed by message id and consumer id, because two independent consumers of the same\n * event must each process it once — deduplicating on the message alone would let the first consumer\n * suppress the second.\n```\n\n`IdempotentMessageHandler`의 javadoc이 같은 이유를 API 형태로 반복한다 — `consumerName`이 파라미터인 이유.\n\n`purgeProcessedBefore`의 javadoc이 보존 기간 규칙을 적는다.\n\n```java\n * Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery\n * arrives after its inbox row was pruned and is processed a second time.\n```\n\n**이 규칙을 강제하는 코드가 없다.** 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, `messaging-policy`의 프로파일 검증기에도 없다. §17.\n\n##### 4.6 `TransactionalMessageAction` — 트랜잭션 경계의 소유권\n\n```java\n// :8-16\n * Sharing one transaction is the entire mechanism. If the effect committed separately from the\n * \"I have handled this message\" marker, a crash between the two would either replay the effect or\n * suppress a message that was never handled — and which of those you get would depend on the order\n * the two commits happened to be written in.\n *\n * Implementations must not settle the message, publish, or start their own transaction. The\n * runtime owns the transaction boundary precisely so that the action cannot accidentally commit\n * half of it.\n```\n\n세 금지(\"settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라\")가 **문서로만 표현된다.** 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.\n\n##### 4.7 `OutboxCanonicalMetadata` — 컬럼이어야 하는 이유\n\n이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.\n\n```java\n// :14-28\n * They used to live nowhere. A row held identity, type, version, content type, payload and an\n * arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either\n * invented when the envelope was rebuilt — {@code Optional.empty()} for every one of them — or\n * smuggled through the header map under reserved names the platform was supposed to own.\n *\n * Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant\n * without decoding the payload, so the operational question \"which tenant is backed up\" has no\n * answer; and a message that crossed the outbox arrived at its consumer with a different tenant,\n * trace and correlation than the one that was published, which makes the publish path — direct,\n * polling or CDC — part of the message's meaning.\n *\n * Columns rather than a blob, because the point is that the database can answer questions about\n * them. A versioned envelope encoding would round-trip just as faithfully and would still leave the\n * relay unable to select rows for one tenant.\n```\n\n**세 번째 문단이 고려된 대안을 명시적으로 기각한다** — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.\n\n불변식 하나: `schemaUri.isPresent() && schemaSubject.isEmpty()`를 거절한다 — \"a reader would have a URI and no way to know what it is a schema for\".\n\n`traceContext`만 `Optional`이 아니고 `TraceContext.none()`이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것(\"the reader's behaviour is the same: carry what is there and invent nothing\").\n\n##### 4.8 `OutboxRecord` — 두 반쪽의 소유자가 다르다\n\n```java\n// :26-29\n * {@link OutboxCanonicalMetadata} is a separate component rather than more fields here because\n * the two halves answer to different owners. Identity, payload, status, attempts and lease are the\n * relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to\n * survive the round trip through the database unchanged.\n```\n\n`payload`가 양방향 방어 복사(`payload.clone()` 생성 시와 접근 시), `headers`가 `Map.copyOf` — `messaging-schema-api`의 `EncodedMessage`(그쪽 §4.3)와 같은 패턴이다.\n\n`withStatus`가 `messageId`를 파라미터로 받지 않는다 — \"The message id is never a parameter, so no state transition can change it.\" 타입이 불변식을 강제하는 예다.\n\n`equals`/`hashCode`가 **다섯 필드 중 넷만** 본다 — `messageId`, `status`, `attempts`, `payload`. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 **그 이유가 어디에도 적혀 있지 않다.** §17.\n\n`toString`이 payload를 담지 않는다.\n\n##### 4.9 `ClaimCheckReference` — digest가 선택이 아니다\n\n```java\n// :10-16\n * The digest is part of the reference, not an optional extra. A claim check splits a message\n * into two systems with independent retention and replication, so a consumer that fetches the\n * payload has to be able to prove it got the bytes the producer stored — otherwise a truncated or\n * replaced object is indistinguishable from a valid one.\n *\n * The expiry is carried for the same reason: a claim check whose payload has been reaped is a\n * dead message, and detecting that at fetch time is better than a mysterious not-found.\n```\n\n`sha256`이 `[a-f0-9]{64}` 정확 일치다 — 대문자 hex를 거절한다. `messaging-core-api`의 `TraceContext`가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.\n\n`expiresAt`이 `Optional`이 아니다 — 모든 claim check가 만료를 갖는다.\n\n---\n\n#### 5. 주요 실행 경로\n\n**Outbox 쓰기:** 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)` → `OutboxRepository.append(record)` — **진입점 구현이 없다**(§12.1)\n\n**Outbox 릴레이:** `claimBatch(owner, size, lease, now, maxAttempts)` → `List The return type is {@code void}, and that is the contract. There is no publish outcome to\n * report yet: the row is written inside the caller's transaction, so if the transaction rolls back\n * the message never existed, and if it commits the relay will publish it later. Handing back a\n * {@code PublishResult} here would be a lie about work that has not happened.\n```\n\n동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1).\n\n모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.\n\n수명주기 참여 없음.\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n설정 없음. 상수도 없다 — `ClaimCheckReference.SHA256` 정규식 하나가 private이다.\n\n`OutboxRepository`의 두 `purge*` 메서드가 `limit` 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.\n\n```java\n// :143-147\n * The unbounded version deletes everything before the cutoff in one statement. On a table that\n * has been accumulating published rows since the last sweep that is a single long transaction\n * holding locks and generating WAL in proportion to the backlog, which shows up as the relay and\n * the business writes stalling behind retention. The cleanup jobs describe themselves as bounded\n * by batch size; this is the parameter that makes that true.\n```\n\n`InboxRepository`도 같은 쌍을 갖는다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n없다 — 포트만 정의한다. 다만 **포트가 저장소 기술을 전제한다.**\n\n- `InboxRepository.reserve`의 메커니즘이 \"the uniqueness constraint on the inbox row\"다 — 유니크 제약이 있는 저장소를 전제\n- `OutboxRepository.claimBatch`의 의미가 \"a record claimed by one relay is invisible to the others\"다 — 행 잠금 또는 그에 준하는 것을 전제\n- `OutboxTransitionResult.STALE_LEASE`가 \"its update matches zero rows\"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제\n\n세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(`*-jdbc-postgresql`)이 실제 선택을 드러낸다.\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n**이 leaf에는 테스트가 없다.** `src/test` 디렉터리 자체가 존재하지 않는다 — `src` 아래에 `main`만 있다.\n\n13개 타입 중 record 생성자 검증이 있는 것이 여섯(`ClaimCheckReference`, `InboxRecord`, `OutboxCanonicalMetadata`, `OutboxLease`, `OutboxRecord`, `OutboxTransitionResult`는 enum), 술어가 있는 것이 셋(`isExpired`, `expiredAt`, `isSafeToSettle`)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.\n\n**검증은 전부 구현 leaf에서 일어난다.**\n\n| 검증 위치 | 무엇을 |\n|---|---|\n| `messaging-outbox-jdbc-postgresql` 테스트 4개 | `OutboxRepository` 구현, 릴레이 |\n| `messaging-inbox-jdbc-postgresql` 테스트 4개 | `InboxRepository` 구현, 멱등 핸들러 |\n| `messaging-claim-check` 테스트 3개 | claim check |\n| starter `MessagingOutboxRelayLifecycleTest` | 릴레이 수명주기 |\n\n그 결과 이 leaf의 **계약 불변식**(예: `OutboxCanonicalMetadata`의 `schemaUri` 없이 `schemaSubject` 금지, `OutboxLease`의 `token >= 1`, `InboxResult.isSafeToSettle`의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.\n\n**그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.**\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\"]` |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n| vendor `api` 규칙 | 벤더 의존성 0 |\n| **`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`** | `..application..`이 이 leaf를 포함한 `dev.caskeleton.messaging..`을 참조하는 것을 금지. **규칙의 근거가 이 leaf의 `OutboxStatus.FAILED` 의미다** |\n| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |\n| ArchUnit 전용 규칙 | 없음 |\n\n네 번째가 특이하다 — ArchUnit 규칙 하나가 **이 leaf의 enum 상수 의미**를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/289-reliability-api-two-generations.txt`.\n\n##### 12.1 Public surface reachability\n\n| 타입 | leaf 밖 파일 | 판정 |\n|---|---:|---|\n| `OutboxRecord` | 13 | 활발 |\n| `OutboxCanonicalMetadata` | 8 | 활발 |\n| `OutboxRepository` | 7 | 구현 1 + 릴레이 + 테스트 |\n| `OutboxStatus` | 7 | 활발 |\n| `OutboxLease` | 6 | 활발 |\n| `OutboxTransitionResult` | 6 | 활발 |\n| `InboxRepository` | 6 | 구현 1 + 테스트 |\n| `ClaimCheckReference` | 6 | 활발 |\n| `InboxResult` | 2 | |\n| `IdempotentMessageHandler` | 1 | `TransactionalInboxHandler` |\n| `TransactionalMessageAction` | 1 | 같음 |\n| **`InboxRecord`** | **0** | |\n| **`ReliableMessagePublisher`** | **0** | |\n\n**(a) Outbox 쓰기 진입점에 구현이 없다**\n\n`ReliableMessagePublisher`는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.\n\n`OutboxRepository.append`는 존재하지만 그것은 저장소 포트다 — javadoc이 \"must be callable inside the caller's business transaction\"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 `ReliableMessagePublisher`가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.\n\n**그리고 애플리케이션은 이 leaf를 참조할 수 없다** — `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`이 금지한다. 즉 `ReliableMessagePublisher`를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. `messaging-spring-cloud-stream-bridge`가 후보 이름이지만 그 leaf는 `runtime_memberships: []`다.\n\n**(b) `InboxRecord`가 쓰이지 않는다**\n\n`InboxRepository`의 어느 메서드도 `InboxRecord`를 주고받지 않는다 — `reserve`는 `boolean`, `isProcessed`는 `boolean`, `purge*`는 `int`다. record는 \"One row of the consumer inbox\"를 서술하지만 그 행을 반환하는 API가 없다.\n\n같은 leaf의 `OutboxRecord`는 정반대다 — `leaseBatch`/`find`가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.\n\n**(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다**\n\n`OutboxRepository`는 같은 다섯 전이에 대해 **두 세대**를 갖는다.\n\n| 전이 | 구세대 (MessageId) | 신세대 (OutboxLease) |\n|---|---|---|\n| 배치 획득 | `leaseBatch(size, lease, now)` → `List The token is what a terminal write is checked against. {@link #leaseBatch} returns records\n * without one, so its callers cannot prove a write belongs to their claim; it remains for\n * inspection paths and is deprecated for the relay's use.\n```\n\n`@Deprecated` 애노테이션이 **이 leaf 전체에 하나도 없다**(`git grep '@Deprecated' -- src/messaging/messaging-reliability-api` exit 1).\n\n결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.\n\n**(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다**\n\n> 이 항목은 `messaging-inbox-jdbc-postgresql` 분석 중에 확인됐다. 이 문서의 초판은 §17의 \"확인된 설계\"에 \"purge에 `limit` 파라미터를 둔 것\"을 넣었는데, 그것은 파라미터의 **존재**만 본 판정이었다. 호출 여부를 재측정해 정정한다.\n\n`InboxRepository.purgeProcessedBefore(Instant, int)`와 `OutboxRepository.purgePublishedBefore(Instant, int)`가 선언돼 있고 두 JDBC 구현이 `LIMIT`(inbox는 `FOR UPDATE SKIP LOCKED`까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 하나도 없다**. 두 cleanup job이 무제한 오버로드를 부른다 — `InboxCleanupJob:56`, `OutboxCleanupJob:50`.\n\n`OutboxRepository:140-151`의 javadoc이 그 상황을 예고한다.\n\n> The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true.**\n\n그 파라미터를 아무도 넘기지 않는다. 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유하고, 이 문서는 **포트가 두 오버로드를 나란히 노출했다는 것**을 기여한다 — (a)의 두 세대 전이와 같은 형태다.\n\n##### 12.2 Conditional sibling comparison\n\n이 leaf에 bean은 없다. **구현 leaf 셋의 sibling 비교가 유의미하다.**\n\n| 포트 | 구현 leaf | membership | starter bean |\n|---|---|---|---|\n| `OutboxRepository` | `messaging-outbox-jdbc-postgresql` | `[\"app-bootstrap\"]` | `MessagingReliabilityAutoConfiguration` |\n| `InboxRepository` | `messaging-inbox-jdbc-postgresql` | `[\"app-bootstrap\"]` | 같음 |\n| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql` | 같음 | `transactionalInboxHandler` bean |\n| `ReliableMessagePublisher` | **없음** | — | — |\n\n네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.\n\n##### 12.3 Duplicate mechanism sweep\n\n**(a) 같은 전이의 두 세대** — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.\n\n**(b) outbox 개념이 저장소에 둘 있다**\n\n| | 이 leaf | `application-core` |\n|---|---|---|\n| 상태 enum | `OutboxStatus` | `OutboxEventStatus` |\n| `FAILED`의 뜻 | 브로커가 확정적으로 거절 — **재시도 안 함** | (반대 의미, ArchUnit javadoc이 명시) |\n| 행 타입 | `OutboxRecord` | `NewOutboxEvent` 등 |\n| 사용처 | messaging family | application + persistence-jpa |\n\n**의도된 분리다.** ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 `.because(...)`가 이유를 적는다 — \"the two outbox status models mean opposite things under the same names\". 중복 경쟁이 아니라 **명시적으로 격리된 두 모델**이다.\n\n다만 그 결과 `ReliableMessagePublisher`가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.\n\n**(c) 이름 충돌 주의**\n\n`markPublished`·`markFailed`·`releaseLease`라는 메서드 이름이 저장소의 **완전히 다른 인터페이스** 여러 곳에 있다 — `persistence-jpa`의 `OutboxStoreAdapter`·`JpaCleanupQueue`·`JpaUploadSessionStore`, `cache-redis`의 `RedisIdempotencyStoreAdapter`, `notification`의 `JpaProviderEventLedger`. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 `src/messaging/**`로 범위를 좁혀 얻은 것이다.\n\n##### 12.4 Documentation / measured-count drift\n\n| 문서 주장 | 재측정 | 결과 |\n|---|---|---|\n| `OutboxRepository:43`: `leaseBatch`가 \"deprecated for the relay's use\" | `@Deprecated` 0건, 컨테이너 테스트가 사용 | **미강제** |\n| `OutboxRecord` javadoc: outbox만으로는 중복 제거 안 됨 | `InboxRepository`가 별도 존재 | **일치** |\n| `InboxRepository.purge*` javadoc: 보존이 브로커 재전달 창보다 길어야 함 | 그 비교를 하는 코드 없음 | **미강제** |\n| `TransactionalMessageAction` javadoc: 구현이 settle/publish/트랜잭션 시작 금지 | 타입이 강제하지 않음 | **미강제** |\n| `ReliableMessagePublisher` javadoc: dual-write의 답 | 구현 0 | **미실현** |\n| `OutboxTransitionResult.STALE_LEASE` javadoc: \"it belongs on a metric\" | 이 leaf에 메트릭 없음. outbox leaf가 답함 | **미확인** |\n| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |\n\n---\n\n#### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n\n이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다.\n\n| 위치 | 이전 상태 | 그것이 만든 실패 |\n|---|---|---|\n| `OutboxLease` javadoc | 모든 terminal 전이가 `MessageId`만 받음 | lease를 넘긴 릴레이가 다른 릴레이의 `PUBLISHED` 위에 `AMBIGUOUS`를 기록 → 행이 다시 claim 가능해짐 → **한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서** |\n| `OutboxTransitionResult` javadoc | 전이가 `void` 반환 | 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 **그 불일치를 아무도 세지 않음** |\n| `OutboxCanonicalMetadata` javadoc | provenance가 어디에도 없음 | 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 `Optional.empty()`가 되거나 헤더 맵에 예약 이름으로 밀반입 → **outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착**, 즉 발행 경로가 메시지의 의미의 일부가 됨 |\n| `OutboxRepository.purgePublishedBefore` javadoc | 무제한 삭제 | 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → **릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤** |\n\n첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.\n\n세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — **\"which makes the publish path — direct, polling or CDC — part of the message's meaning.\"** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.\n\n---\n\n#### 14. 런타임·터미널 Evidence\n\n| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n|---|---|---|---|---|\n| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 | 정적 검색. `messaging-inbox-jdbc-postgresql`이 판정 소유 |\n| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | `src/test` 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, `OutboxRepository`의 두 세대 시그니처 전수, `@Deprecated` 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 | 정적 검색. 이 leaf에 실행할 테스트 레인이 없음 |\n\n**이 leaf에는 test lane evidence가 없다** — `src/test`가 존재하지 않으므로 `:messaging:messaging-reliability-api:test`는 실행할 소스가 없다.\n\n---\n\n#### 15. 명시적 설계 이유와 추론을 구분한 정리\n\n**명시적**\n\n- outbox만으로 중복이 제거되지 않는 이유 — `OutboxRecord` javadoc\n- fencing token이 필요한 이유와 이전 이중 발행 — `OutboxLease` javadoc\n- 전이가 결과를 반환해야 하는 이유 — `OutboxTransitionResult` javadoc\n- `AMBIGUOUS`가 실패의 한 종류가 아닌 이유, `EXHAUSTED`가 `FAILED`와 다른 이유 — `OutboxStatus` javadoc\n- provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — `OutboxCanonicalMetadata` javadoc\n- 두 반쪽의 소유자가 다른 이유 — `OutboxRecord` javadoc\n- inbox 키가 (message, consumer)인 이유 — `InboxRecord`·`IdempotentMessageHandler` javadoc\n- `InboxResult`가 셋인 이유 — 그 javadoc\n- 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — `InboxRepository`·`TransactionalMessageAction` javadoc\n- `addToOutbox`가 `void`인 이유 — `ReliableMessagePublisher` javadoc\n- claim check digest와 만료가 필수인 이유 — `ClaimCheckReference` javadoc\n- purge에 `limit`이 필요한 이유 — `OutboxRepository` javadoc\n- inbox 보존이 재전달 창보다 길어야 하는 이유 — `InboxRepository` javadoc\n\n**추론**\n\n- `ReliableMessagePublisher` 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → **추론**. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.\n- `OutboxRecord.equals`가 다섯 필드만 보는 이유 → **미상**.\n- `sha256`이 소문자만 받는 이유 → **미상**(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).\n- 구세대를 남긴 이유 → **부분 명시**(\"remains for inspection paths\"). 제거 시점은 미상.\n\n---\n\n#### 16. 확인한 것 / 확인하지 못한 것\n\n**확인한 것**\n\n- 13개 타입 817줄 전문의 계약과 불변식\n- 이 leaf에 테스트가 하나도 없다는 것(`src/test` 부재)\n- `ReliableMessagePublisher`와 `InboxRecord`의 참조 0\n- `OutboxRepository`가 같은 다섯 전이의 두 세대를 갖고 `@Deprecated`가 하나도 없다는 것\n- production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것\n- 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태\n- `OutboxStatus.FAILED`의 의미가 저장소 ArchUnit 규칙의 근거라는 것\n\n**확인하지 못한 것**\n\n- **fencing token SQL이 실제 PostgreSQL에서 정확한지.** 그것을 검증할 레인이 다른 세대를 쓴다. `messaging-outbox-jdbc-postgresql` leaf가 이 판정을 소유한다.\n- `STALE_LEASE`가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.\n- inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.\n- `ReliableMessagePublisher`를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.\n- `OutboxRecord.equals`의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.\n\n---\n\n#### 17. 손볼 것\n\n##### P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 `@Deprecated`가 없다\n\n- **사실.** `OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다. javadoc이 전자를 \"deprecated for the relay's use\"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다.\n- **근거.** `evidence/raw/289` §E·§F.\n- **왜 문제인가.** 전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.\n- **확인 방법.** `git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api` → 없음. `evidence/raw/289` §E.\n- **후보.** (a) 구세대 다섯에 `@Deprecated`를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(`OutboxInspection`)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.\n- **다음 단계.** **CASE 후보 + REFERENCE 후보.** \"prose deprecation은 컴파일러가 읽지 않는다\"가 재사용 가능한 기준이다.\n\n##### P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다\n\n- **사실.** `OutboxRelay`는 `claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT`는 `leaseBatch`/`MessageId` 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository`와 `RecordingRepository` — SQL이 없는 fake다.\n- **근거.** `evidence/raw/289` §G.\n- **왜 문제인가.** fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 \"its update matches zero rows\"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**\n- **확인 방법.** `evidence/raw/289` §G 재실행. `OutboxPostgresIT`에서 `claimBatch` 검색 → 없음.\n- **후보.** 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.\n- **다음 단계.** **판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. **CASE 후보**(그 leaf).\n\n##### P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다\n\n- **사실.** `ReliableMessagePublisher`가 구현 0, 참조 0이다. javadoc은 \"This is the answer to the dual-write problem\"이라고 한다.\n- **근거.** `evidence/raw/289` §B·§C·§D.\n- **왜 문제인가.** `OutboxRepository.append`가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, `ReliableMessagePublisher`는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 **애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로** 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 \"릴레이가 읽는 쪽\"만 배선돼 있고 \"애플리케이션이 쓰는 쪽\"이 비어 있다.\n- **확인 방법.** `git grep -n -E 'implements .*ReliableMessagePublisher' -- src` → 없음.\n- **후보.** (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 \"파생 프로젝트가 구현하는 확장점\"임을 명시한다.\n- **다음 단계.** **OPEN QUESTION 후보.** 판정이 \"두 outbox 모델 중 어느 쪽이 정본인가\"에 걸리고, 그 질문은 `application-core`와 cross-scope가 함께 답한다.\n\n##### P3 — 이 leaf에 테스트가 없다\n\n- **사실.** `src/test` 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.\n- **근거.** `evidence/raw/289` §A.\n- **왜 문제인가.** 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: `OutboxCanonicalMetadata`가 `schemaUri` 있고 `schemaSubject` 없는 조합을 거절하는 것, `OutboxLease`가 `token < 1`을 거절하는 것, `InboxResult.isSafeToSettle`의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(`messaging-core-api` 79개, `messaging-policy` 42개 등).\n- **확인 방법.** `ls src/messaging/messaging-reliability-api/src` → `main`만.\n- **후보.** record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.\n- **다음 단계.** **REFERENCE 후보**(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).\n\n##### P3 — inbox 보존 규칙이 문서로만 있다\n\n- **사실.** `InboxRepository.purgeProcessedBefore` javadoc이 \"Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time\"라고 한다. 그 비교를 하는 코드가 이 leaf에도 `messaging-policy`의 프로파일 검증기에도 없다.\n- **근거.** 해당 javadoc. `DestinationProfileValidator` 16규칙 전수(재전달 창 관련 없음).\n- **왜 문제인가.** 위반의 결과가 **부작용의 이중 실행**이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.\n- **확인 방법.** `git grep -n -i 'redelivery window\\|retention' -- 'src/messaging/**/*.java'`\n- **후보.** 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 `messaging-policy`나 starter에 추가한다.\n- **다음 단계.** **CASE 후보 + REFERENCE 후보**(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).\n\n##### P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다\n\n- **사실.** `OutboxRepository.append`가 호출자 트랜잭션 안, `InboxRepository.reserve`가 부작용과 같은 트랜잭션, `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.\n- **근거.** 세 javadoc.\n- **왜 문제인가.** `ReliableMessagePublisher`는 `void` 반환으로 계약의 일부를 타입에 담았다(\"Handing back a `PublishResult` here would be a lie\"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — `InboxRepository.reserve`를 별도 트랜잭션에서 부르면 \"exactly the gap the Inbox exists to close\"가 다시 열린다.\n- **확인 방법.** 세 javadoc과 구현의 `@Transactional` 배치 대조 — 구현 leaf가 소유한다.\n- **후보.** 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 `append`/`reserve` 호출부의 트랜잭션 컨텍스트를 검사한다.\n- **다음 단계.** **REFERENCE 후보**(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).\n\n##### P3 — `OutboxRecord.equals`가 다섯 필드만 비교하고 이유가 없다\n\n- **사실.** `equals`/`hashCode`가 `messageId`·`status`·`attempts`·`payload` 넷만 본다. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 무시한다.\n- **근거.** `OutboxRecord.java:114-126`.\n- **왜 문제인가.** record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(`messaging-schema-api`의 `EncodedMessage`도 같다). 그러나 `EncodedMessage`는 **모든 필드**를 비교하고 이쪽은 아니다. 같은 `messageId`·`status`·`attempts`·`payload`를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.\n- **확인 방법.** 두 record의 `equals` 대조.\n- **후보.** 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.\n- **다음 단계.** **REFERENCE 후보**(record의 `equals`를 좁히면 이유를 적는다).\n\n##### P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다\n\n- **사실.** `InboxRepository`와 `OutboxRepository`가 각각 `purge*Before(Instant)`와 `purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.\n- **근거.** `evidence/raw/294-bounded-purge-never-called.txt`.\n- **왜 문제인가.** §12.1(a)의 두 세대 전이와 같은 형태다 — **한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.** 두 경우 모두 포트의 형태가 오용을 가능하게 했다.\n- **확인 방법.** `git grep -n -E 'purge(Processed|Published)Before\\s*\\([^)]*,' -- 'src/**/*.java'`\n- **다음 단계.** 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 **같은 CASE로 묶을 후보**다.\n\n##### 확인된 설계(문제 아님)\n\n- outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것\n- fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계\n- `AMBIGUOUS`/`FAILED`/`EXHAUSTED` 세 상태의 구분과 각각의 운영 행동 차이\n- `InboxResult`가 셋이고 `isSafeToSettle()`이 그 판단을 모으는 것\n- inbox 키가 (message, consumer)인 것\n- provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것\n- `withStatus`가 `messageId`를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것\n- `addToOutbox`의 `void` 반환이 계약인 것\n- claim check의 digest와 만료가 필수인 것\n- 두 outbox 모델을 ArchUnit으로 격리한 것\n\n---\n\n#### Source anchors\n\n| id | kind | path | revision | what it proves | limitations |\n|---|---|---|---|---|---|\n| MRA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `[\"app-bootstrap\"]` | 선언 |\n| MRA-002 | build | `messaging-reliability-api/build.gradle` | same | 벤더 의존성 0 | — |\n| MRA-003 | code | `.../reliability/OutboxRepository.java` 전문 | same | 두 세대 17메서드, purge limit 이유 | `@Deprecated` 없음 |\n| MRA-004 | code | `.../reliability/OutboxLease.java` | same | fencing token과 이중 발행 이력 | — |\n| MRA-005 | code | `.../reliability/OutboxTransitionResult.java` | same | void 반환이 삼킨 것 | — |\n| MRA-006 | code | `.../reliability/OutboxStatus.java` | same | 여섯 상태와 두 구분의 이유 | — |\n| MRA-007 | code | `.../reliability/OutboxCanonicalMetadata.java` | same | provenance 결함 이력, 기각된 대안 | — |\n| MRA-008 | code | `.../reliability/OutboxRecord.java` | same | 두 반쪽 분리, 방어 복사, 좁은 equals | equals 이유 없음(§17) |\n| MRA-009 | code | `.../reliability/{InboxRepository,InboxRecord,InboxResult}.java` | same | 트랜잭션 계약, (message,consumer) 키, 세 판정 | `InboxRecord` 참조 0 |\n| MRA-010 | code | `.../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java` | same | 멱등 핸들러 계약과 세 금지 | 금지 미강제 |\n| MRA-011 | code | `.../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java` | same | dual-write 답, digest 필수 | publisher 구현 0 |\n| MRA-012 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205` | same | production이 신세대만 사용 | 해당 leaf SSOT가 소유 |\n| MRA-013 | cross-leaf test | `messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200` | same | 컨테이너 테스트가 구세대만 사용 | 해당 leaf SSOT가 소유 |\n| MRA-014 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `OutboxStatus.FAILED` 의미가 규칙의 근거 | 정적 분석 |\n| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | same | §12.1 전부, `src/test` 부재 | 정적 검색. 이 leaf에 테스트 레인 없음 |\n\n---\n"
},
"previous_section": {
"heading": {
"line": 33798,
"level": 2,
"text": "A19-MESSAGING-RELIABILITY-API. messaging-reliability-api"
},
"start_line": 33798,
"end_line": 33801,
"text": "## A19-MESSAGING-RELIABILITY-API. messaging-reliability-api\n\n> 분석 중에는 `messaging/MESSAGING-RELIABILITY-API.md` 파일이었다. 793줄.\n"
},
"next_section": {
"heading": {
"line": 34598,
"level": 2,
"text": "A19-MESSAGING-RUNTIME-CORE. messaging-runtime-core"
},
"start_line": 34598,
"end_line": 35411,
"text": "## A19-MESSAGING-RUNTIME-CORE. messaging-runtime-core\n\n> 분석 중에는 `messaging/MESSAGING-RUNTIME-CORE.md` 파일이었다. 807줄.\n\n### messaging-runtime-core 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-runtime-core`\n> SSOT owner: `messaging-runtime-core`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-runtime-core`\n- canonical state `analysisFile`: §A19-MESSAGING-RUNTIME-CORE\n- source path: `src/messaging/messaging-runtime-core`\n- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\", \"messaging-policy\", \"messaging-transport-spi\", \"messaging-security\", \"messaging-observability\"]` — messaging family에서 두 번째로 많은 의존\n- registry `runtime_memberships`: `[\"app-bootstrap\"]`\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | **6** |\n| production LOC | 787 |\n| 패키지 | 1 (`dev.caskeleton.messaging.runtime`) |\n| test 파일 | 4 (테스트 3 + fixture 1) |\n| test 메서드(실행 확인) | 21 |\n| 외부(비프로젝트) 의존성 | **0** |\n\n여섯 클래스:\n\n| 클래스 | LOC | 역할 | 출하 조립 |\n|---|---:|---|---|\n| `DefaultMessagePublisher` | 366 | **유일한 발행 경로** | o (`:446`) |\n| `DefaultDeliveryProcessor` | 155 | 핸들러 결과 → 정산 | **x** |\n| `RegisteredMessageCodecs` | 89 | content type → codec | o (`:363`) |\n| `DestinationProfileRegistry` | 62 | 논리 이름 → 프로파일 | o (`:377`) |\n| `TransportMessagingRuntime` | 67 | transport를 세대로 포장 | o (`:476`) |\n| `DeclaredDestinationAccess` | 48 | 기본 접근 정책 | o |\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |\n| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |\n| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n**이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 \"X was an interface with no implementation\" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다.\n\n```groovy\n// The central publish and delivery orchestration.\n//\n// MessagePublisher was an interface with no implementation anywhere in the new platform: the\n// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade\n// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases\n// and observation existed as beans that no publish path ever called. A starter that filled the gap\n// with an application-supplied fake would pass a context test while running none of them.\n```\n\n이 진단의 마지막 문장이 핵심이다 — **컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.\n\nsix 파일이 메운 구멍:\n\n| 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 |\n|---|---|---|\n| `MessagePublisher` (core-api) | 어디에도 없음 | `DefaultMessagePublisher` |\n| `MessageCodecRegistry` (schema-api) | 어디에도 없음 | `RegisteredMessageCodecs` |\n| `MessagingRuntime` (transport-spi) | 어디에도 없음 | `TransportMessagingRuntime` |\n| (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | `DestinationProfileRegistry` |\n| `DestinationAccessPolicy` 기본값 (security) | `denyAll()`뿐 | `DeclaredDestinationAccess` |\n| `HandleResult` → 정산 (core-api) | 어댑터가 각자 결정 | `DefaultDeliveryProcessor` |\n\n여섯 중 다섯은 배선됐고 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1).\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: 여섯 project 의존, 전부 `api`. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다.\n\n나가는 것: `messaging-spring-boot-starter`만.\n\n**배선 지점 다섯**(전부 `MessagingCoreAutoConfiguration`):\n\n| 라인 | 무엇 |\n|---:|---|\n| 363 | `RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))` |\n| 377 | `DestinationProfileRegistry.of(destinations.all())` |\n| 446 | `new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)` |\n| 476 | `new TransportMessagingRuntime(selected.brokerName(), 1L, selected)` — `InitializingBean` 안 |\n| — | `DeclaredDestinationAccess.of(...)`로 접근 정책 bean |\n\n446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다.\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\n발행 (조립됨)\n DefaultMessagePublisher\n ├── DestinationProfileRegistry 논리 이름 → DestinationProfile\n ├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles)\n ├── MessageCodecRegistry ← RegisteredMessageCodecs\n ├── MessagingAdmissionController (policy)\n ├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime\n ├── MessagingTransport (transport-spi) → Kafka/Rabbit/…\n └── MessagingObservation ← NO_OBSERVATION (§12.1)\n\n소비 (조립 안 됨)\n DefaultDeliveryProcessor\n ├── Function The order below is fixed, not composed from a map of interceptors. Each stage's position is a\n * decision:\n *\n * Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as\n * the publish operation's deadline, so a slow destination lookup or a large encode spends the\n * same budget the broker wait does; timing only the transport call would let the total exceed the\n * deadline by however long preparation took.\n```\n\n`remainingBudget`이 `timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — \"Sending anyway would start a message the caller has already stopped waiting for.\"\n\n##### 4.3 마감을 복사본에 건다\n\n```java\n// :143-154\n * The bound is applied to a copy so that expiry never completes the transport's own stage: the\n * adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime\n * lease are released when the copy completes, which is deliberate — holding them until a stalled\n * broker answers is how a rotation waits forever on a generation nobody is using.\nprivate static CompletableFuture Everything acquired is released exactly once, on every path — success, failure, exception and\n * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one\n * per failure until it stops accepting anything.\n```\n\n두 경로가 있다.\n\n```java\n.handle((result, failure) -> {\n // One release per acquisition, whatever happened.\n held.close();\n admission.complete(destination.name().value());\n ...\n});\n```\n\n```java\n} catch (RuntimeException beforeTheSend) {\n if (lease != null) { lease.close(); }\n admission.complete(destination.name().value());\n return rejected(\"PUBLISH_RUNTIME_UNAVAILABLE\", ...);\n}\n```\n\n`handle`은 `whenComplete`와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. `lease.close()`는 `MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3).\n\n**한 가지 비대칭.** 6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다. `MessageTooLargeException`·`MessageBackpressureException`은 `MessagingException`이므로 호출자가 `FailureDescriptor`를 얻을 수 있지만, 반환 타입이 `CompletionStage The transports accept {@code request.options()} and read nothing from it, so an option this\n * destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for\n * broker-side deduplication got a publish with no deduplication and no error, and then skipped\n * the idempotency it would otherwise have written — which is exactly the case {@code\n * PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.\n```\n\n`messaging-core-api`의 `PublishDeduplication` javadoc(\"Requesting this on a broker without the `deduplicatedPublish` capability is a startup failure, not a silent no-op\")이 여기서 실제 검사가 된다. 다만 **startup이 아니라 publish 시점**이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.\n\n그리고 \"The transports accept `request.options()` and read nothing from it\"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.\n\n##### 4.6 `encode` — 폴백이 기본 codec이다\n\n```java\nprivate Nothing resolved a logical destination to a profile before this: the brokers took an\n * already-resolved {@code DestinationProfile} and the publisher that would have produced one did\n * not exist. A registry rather than a lookup with a fallback, because a destination nobody declared\n * has no physical name, no ordering guarantee and no payload bound — publishing to it would mean\n * inventing all three at the call site.\n```\n\n`require`가 미등록 목적지에 `MessagingConfigurationException(\"DESTINATION_NOT_REGISTERED\")`을 던지고 메시지가 세 가지 부재를 나열한다. `empty()` factory도 있다 — \"every publish is refused until a destination is declared\".\n\n##### 4.8 `RegisteredMessageCodecs` — 기본 codec은 명시 선택\n\n```java\n// :18-27\n * The default codec is a deliberate choice rather than \"the first one registered\". Selecting one\n * by iteration order means the encoding a message is written with depends on how the map was\n * populated, which is a wire-format decision made by accident. The registry takes it explicitly and\n * refuses to be constructed without it.\n *\n * The raw-bytes codec is never eligible as the default — that is the contract's own rule, and\n * the reason is that raw bytes silently disable schema validation for every destination that forgot\n * to declare an encoding.\n```\n\n두 가지를 생성자에서 거절한다.\n\n```java\nif (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }\n...\nMessageCodec existing = into.putIfAbsent(codec.contentType(), codec);\nif (existing != null && existing != codec) {\n // Two codecs for one content type is not a preference to resolve at runtime: whichever wins\n // decides how bytes on the wire are read by a consumer that was compiled against the other.\n throw new IllegalArgumentException(\"two codecs claim content type \" + ...);\n}\n```\n\n**클래스가 아니라 content type으로 raw-bytes를 거절**하는 것이 `messaging-schema-api`의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.\n\n##### 4.9 `TransportMessagingRuntime` — 얇은 포장\n\n`MessagingRuntime` 구현으로 `brokerName`·`generation`·`transport` 셋을 들고 `close()`가 CAS로 멱등이다.\n\n```java\n// close():61-62\n// Idempotent: the registry closes a drained generation, and a context shutdown may close it\n// again. Closing a transport twice is not an error worth propagating into shutdown.\n```\n\n`DefaultMessagingRuntimeRegistry`(transport-spi)도 자체 `closed` CAS를 갖는다 — **두 층이 각각 멱등**이다. 중복 방어이지만 `transport-spi`의 `Generation.forceClose()`가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.\n\n**generation이 항상 `1L`이다.** starter의 유일한 설치 지점(`:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 \"increasing with each replacement\"라고 하고, `TransportMessagingRuntime` javadoc은 \"the credential generation a rotation increments\"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.\n\n##### 4.10 `DeclaredDestinationAccess` — 기본값의 세 번째 선택지\n\n```java\n// :13-32\n * {@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}\n * factory. Neither is a usable default on its own:\n *\n * So the default is neither: a deployment may publish to the destinations it declared.\n * … a message to a destination nobody declared is not an access-control edge case, it is a typo or\n * a module reaching past its own contract.\n *\n * Consume and administer stay empty. A publisher's default has no business granting either, and\n * a deployment that needs them replaces this bean — which is the point of it being a bean.\n```\n\n**publish만 허용하고 consume·administer는 빈 집합**이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.\n\n##### 4.11 `DefaultDeliveryProcessor` — 두 규칙 (미조립)\n\n```java\n// :27-36\n * A driver message can carry a routing key, a payload fragment or a connection string, and a\n * {@code FailureDescriptor} is designed to be logged and exported.\nreturn cause.getClass().getSimpleName();\n```\n\n`messaging-core-api`의 `FailureDescriptor` javadoc(\"no payload, no stack trace, no credential\")과 같은 관심사다.\n\n`isDeadline`과 `sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.\n\n`DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다.\n\n---\n\n#### 7. 트랜잭션·동시성·수명주기\n\n트랜잭션 없음.\n\n| 지점 | 도구 | 보호 |\n|---|---|---|\n| `OneShotSettlement.settled` | `AtomicBoolean` CAS | 정확히 한 번 정산 |\n| `TransportMessagingRuntime.closed` | `AtomicBoolean` CAS | 정확히 한 번 transport close |\n| `RegisteredMessageCodecs.byContentType` | `Map.copyOf` | 불변 |\n| `DestinationProfileRegistry.profiles` | `Map.copyOf` | 불변 |\n| `withDeadline`의 `.copy()` | `CompletableFuture` | 어댑터 stage와 이쪽 경로 분리 |\n\n`DefaultMessagePublisher` 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. `lease`만 메서드 지역 변수이고 `handle` 람다가 `held`라는 effectively-final 복사본으로 캡처한다.\n\n수명주기 참여는 `TransportMessagingRuntime.close()`뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n설정 없음. 이 leaf의 모든 값은 생성자 인자다.\n\n**주입 가능한 두 지점**이 테스트 가능성을 만든다.\n\n| 인자 | 기본 | 목적 |\n|---|---|---|\n| `LongSupplier nanoTime` | `System::nanoTime` | 경과 시간을 sleep 없이 테스트 |\n| `MessagingObservation observation` | `NO_OBSERVATION` | 관측 주입 |\n\n두 번째의 기본값이 §12.1의 발견 지점이다.\n\n`TransportMessagingRuntime`의 `generation`은 생성자 인자이고 유일한 호출자가 `1L`을 넘긴다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n없다. 브로커 접촉은 `MessagingTransport` 인터페이스 뒤에 있다.\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n레인: `./gradlew :messaging:messaging-runtime-core:test`. **BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures**.\n\n| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n|---|---:|---|---|\n| `DefaultMessagePublisherTest` | 10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. **출하 조립이 관측을 넘기는지** |\n| `DefaultDeliveryProcessorTest` | 7 | `HandleResult` 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 | **production에서 호출되는지**(§12.1) |\n| `RegisteredMessageCodecsTest` | 4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — |\n\n`DefaultMessagePublisherTest:271`이 익명 `MessagingObservation`을 만들어 관측 호출을 확인한다. 즉 **테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다.** 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.\n\n`RecordingTransport`(`:426`)가 `MessagingTransport`를 구현해 전송을 대체한다. 그래서 이 레인은 \"발행 오케스트레이션이 옳다\"를 증명하고 \"어댑터가 계약을 지킨다\"는 증명하지 않는다.\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| `verifyCleanArchitectureDependencies` | 여섯 project 의존 |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n| vendor `api` 규칙 | 벤더 의존성 0 |\n| ArchUnit | 전용 규칙 없음 |\n\n`MessagingStarterOffContractTest`(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — \"DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …\". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/283-runtime-core-observation-noop.txt`.\n\n##### 12.1 Public surface reachability\n\n| 타입 | leaf 밖 파일 | 출하 조립 |\n|---|---:|---|\n| `DefaultMessagePublisher` | 2 | **o** — `MessagingCoreAutoConfiguration:446` |\n| `TransportMessagingRuntime` | 1 | **o** — `:476` |\n| `RegisteredMessageCodecs` | 1 | **o** — `:363` |\n| `DestinationProfileRegistry` | 1 | **o** — `:377` |\n| `DeclaredDestinationAccess` | 1 | **o** |\n| `DefaultDeliveryProcessor` | **0** | **x** — `src/main` 생성 0, `src/test` 1 |\n\n**(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다**\n\n`DefaultDeliveryProcessor`는 leaf 밖 참조가 0이고 `src/main`에서 생성되지 않는다. 이것이 §A19-MESSAGING-POLICY §12.1이 관측한 \"소비 경로 전체 미조립\"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.\n\n이 클래스의 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.\" 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.\n\n**(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다**\n\n네 조각이 있다.\n\n| 조각 | 상태 |\n|---|---|\n| `MessagingObservation` 인터페이스 (observability) | 존재 |\n| `MessagingMetrics implements MessagingObservation` | 존재 |\n| `DefaultMessagePublisher.observe(...)` 호출부 | 존재, 모든 발행 결과를 기록 |\n| 8인자 생성자 (관측 주입) | 존재 |\n| **출하 조립** | **6인자 생성자 → `NO_OBSERVATION`** |\n| **`MessagingMetrics` bean** | **없음** |\n\n```java\n// MessagingCoreAutoConfiguration.java:446-447\nreturn new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(\n destinations, access, codecs, admission, runtimes, transport);\n```\n\n그리고 `MessagingMetrics`는 저장소 전체에서 **자기 테스트에서만** 생성된다(`MessagingMetricCardinalityTest`, `MessagingSecretLeakTest`).\n\nstarter는 `MessagingMetrics`의 **두 협력자를 bean으로 만든다** — `MessagingRedactor`(:253)와 `CardinalityGuard`(:264). `MessagingMetrics`의 생성자는 `(registry, CardinalityGuard, MessagingRedactor)`를 받는다(테스트가 그렇게 호출한다). 즉 **재료 둘은 배선됐고 그것을 조립하는 bean이 없다.**\n\n이 클래스의 javadoc이 그 상황을 예언한다.\n\n```java\n// DefaultMessagePublisher.java:74-78\n * {@code MessagingObservation} existed as a bean and no publish path called it, so the\n * platform's own metrics described nothing. It is a constructor argument rather than an optional\n * decorator because an unobserved publish path is how \"the dashboards were empty during the\n * incident\" happens.\n```\n\n**이전 상태:** bean은 있고 호출하는 경로가 없었다.\n**현재 상태:** 호출하는 경로는 있고 bean이 없다.\n\n두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 **반대편으로 옮겼다.** 그리고 \"constructor argument rather than an optional decorator\"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.\n\n**(c) 배선된 것은 확실히 배선됐다**\n\n발행 경로 다섯이 전부 `src/main`에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.\n\n**한계.** 정적 검색이다. `ObjectProvider` 지연 조회는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 `MessagingObservation` bean을 제공하면 `@ConditionalOnMissingBean(MessagePublisher.class)` 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.\n\n##### 12.2 Conditional sibling comparison\n\n이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.\n\n`MessagingCoreAutoConfiguration`이 이 leaf의 타입을 만드는 지점 다섯의 조건:\n\n| 대상 | 조건 |\n|---|---|\n| `RegisteredMessageCodecs` | `@ConditionalOnMissingBean(MessageCodecRegistry.class)` |\n| `DestinationProfileRegistry` | `@ConditionalOnMissingBean` |\n| `DefaultMessagePublisher` | `@ConditionalOnMissingBean(MessagePublisher.class)` |\n| `TransportMessagingRuntime` | 조건 없음 — `InitializingBean` 안, `transport.getIfAvailable()` null 검사 |\n| `DeclaredDestinationAccess` | `@ConditionalOnMissingBean` |\n\n**네 번째만 조건 대신 런타임 null 검사를 쓴다.** 그 이유가 주석에 있다.\n\n```java\n// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport\n// when a broker is selected, and it refuses startup by name when one is not. This\n// configuration is also loadable on its own — an adopter composing the policy primitives\n// without a transport — and demanding one here would refuse that.\n```\n\n즉 \"transport 없이도 로드 가능해야 한다\"가 명시적 요구이고, 그 요구가 `@ConditionalOnBean` 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(`MessagingProviderSelection`)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.\n\n##### 12.3 Duplicate mechanism sweep\n\n**(a) DLQ 순서 불변식이 두 곳에 구현돼 있다**\n\n| | `messaging-policy` `DeadLetterOrchestrator` | 이 leaf `DefaultDeliveryProcessor` |\n|---|---|---|\n| 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack |\n| 미확인 시 | 정산하지 않음(`sourceSettled=false`) | **requeue** |\n| 헤더 | 예약 헤더 6개 부착 | 없음 |\n| 발행 주체 | `MessagePublisher` | `DeadLetterPublisher` 함수형 인터페이스 |\n\n**미확인 시 동작이 다르다.** policy 쪽은 \"정산하지 않는다\"(브로커가 알아서 재전달), 이쪽은 \"명시적으로 requeue한다\". 둘 다 메시지를 잃지 않지만 `requeue(delay)`는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.\n\n둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. §A19-MESSAGING-POLICY §12.3(b)가 같은 사건을 반대편에서 기록한다.\n\n**(b) 재시도 지연이 두 출처**\n\n`DefaultDeliveryProcessor`의 `retryDelay`는 **생성자 인자 하나**다. 시도 횟수를 세지 않고 백오프도 없다. `messaging-policy`의 `BackoffCalculator`(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.\n\n**(c) 멱등 종료가 두 층**\n\n`TransportMessagingRuntime.close()`와 `DefaultMessagingRuntimeRegistry.Generation.forceClose()`(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 **의도된 중복**이다 — 이쪽 주석이 \"the registry closes a drained generation, and a context shutdown may close it again\"이라고 두 경로를 명시한다. 결함 아님.\n\n**(d) content type 폴백**\n\n`encode`가 `codecs.find(contentType).orElseGet(codecs::defaultCodec)`으로 폴백한다. `RegisteredMessageCodecs.find`는 미등록이면 `Optional.empty()`를 주고, `defaultCodec()`은 JSON이다. 즉 **선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점**이고, 그 갈라짐이 조용하다. §17.\n\n##### 12.4 Documentation / measured-count drift\n\n| 문서 주장 | 재측정 | 결과 |\n|---|---|---|\n| build.gradle 주석: `MessagePublisher`에 구현이 없었다 | 현재 이 leaf가 구현하고 `:446`에서 조립 | **해소됨** |\n| `TransportMessagingRuntime` javadoc: registry가 비어 있어 모든 발행이 실패했다 | 현재 `:476`이 설치 | **해소됨** |\n| `DefaultMessagePublisher` javadoc: 관측 bean이 있고 호출 경로가 없었다 | 현재 호출 경로가 있고 bean이 없다 | **반전됨**(§12.1b) |\n| `DefaultDeliveryProcessor` javadoc: 어댑터가 각자 결정했다 | 한 곳에 모았으나 조립되지 않음 | **부분 해소** |\n| `MessagingRuntime.generation()` javadoc: \"increasing with each replacement\" | 유일한 설치가 리터럴 `1L` | **미실현** |\n| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |\n\n세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.\n\n---\n\n#### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n\n이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`).\n\n| 위치 | 이전 상태 | 그것이 만든 실패 |\n|---|---|---|\n| `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 |\n| `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |\n| `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |\n| `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |\n| `DefaultDeliveryProcessor` 핸들러 예외 주석 | Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 |\n| `requireSupportedOptions` javadoc | transport가 `options`를 읽지 않음 | 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 |\n| `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |\n\n`build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — \"A starter that filled the gap with an application-supplied fake would pass a context test while running none of them.\"\n\n---\n\n#### 14. 런타임·터미널 Evidence\n\n| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n|---|---|---|---|---|\n| EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | 여섯 타입 참조 수, 발행 경로 조립 지점, `DefaultDeliveryProcessor` src/main=0, 관측 4조각과 끊긴 한 지점, `MessagingMetrics`가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 | 정적 검색. 파생 프로젝트의 대체 조립 미포함 |\n| EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(`RecordingTransport`) |\n\n---\n\n#### 15. 명시적 설계 이유와 추론을 구분한 정리\n\n**명시적**\n\n- 이 leaf가 존재하는 이유와 이전 결함 — `build.gradle` 주석\n- 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — `DefaultMessagePublisher` javadoc\n- 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석\n- 전송 전 실패가 `REJECTED`인 이유 — 인라인 주석\n- 예산을 호출 시점부터 세는 이유 — `remainingBudget` javadoc\n- 마감을 복사본에 거는 이유와 그 대가 — `withDeadline` javadoc\n- 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석\n- 지원하지 않는 옵션을 거절하는 이유 — `requireSupportedOptions` javadoc\n- 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — `RegisteredMessageCodecs` javadoc\n- 폴백 없는 목적지 조회 이유 — `DestinationProfileRegistry` javadoc\n- 기본 접근 정책이 deny도 allow도 아닌 이유 — `DeclaredDestinationAccess` javadoc\n- 핸들러 예외가 retry인 이유 — 인라인 주석\n- DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석\n- transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — `InitializingBean` 안 주석\n- 관측을 생성자 인자로 둔 이유 — `observation` 필드 javadoc\n\n**추론**\n\n- 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → **추론이 아니라 미상.** 어디에도 근거가 없고, 8인자 생성자와 `MessagingMetrics`가 둘 다 존재한다는 점이 미완을 시사한다.\n- `generation`이 항상 1인 것은 회전 코드가 없기 때문이다 → **추론**. 회전 코드 부재는 관측이다.\n- `DefaultDeliveryProcessor` 미조립이 미완인지 확장점인지 → **미상**.\n\n---\n\n#### 16. 확인한 것 / 확인하지 못한 것\n\n**확인한 것**\n\n- 6개 클래스 787줄 전문의 계약과 순서 결정\n- 21개 테스트가 통과하고 무엇을 단언하는지\n- 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지\n- `DefaultDeliveryProcessor`가 `src/main`에서 생성되지 않는다는 것\n- 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것\n- `MessagingMetrics`가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것\n- `generation`이 유일한 설치 지점에서 리터럴 `1L`이라는 것\n\n**확인하지 못한 것**\n\n- **6인자 생성자 선택이 의도인지.** 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.\n- `MessagingProviderSelection`이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.\n- 어댑터들이 `request.options()`를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다.\n- 실제 브로커에서 `withDeadline`의 `.copy()` 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다.\n- 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.\n\n---\n\n#### 17. 손볼 것\n\n##### P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다\n\n- **사실.** `DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 \"constructor argument rather than an optional decorator\"로 받는다. `MessagingMetrics`가 `MessagingObservation`을 구현한다. 그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 `MessagingMetrics`의 협력자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만든다.\n- **근거.** `evidence/raw/283` §D.\n- **왜 문제인가.** 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — \"an unobserved publish path is how 'the dashboards were empty during the incident' happens\". 그리고 같은 javadoc이 **이전 결함**을 \"bean은 있고 호출 경로가 없었다\"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. **고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.** \"decorator가 아니라 생성자 인자\"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.\n- **확인 방법.** `evidence/raw/283` §D 재실행. 또는 `:446`의 인자 수와 `:138-146` 생성자 시그니처 대조.\n- **후보.** (a) `MessagingMetrics` bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 `support-matrix.md`에 표시한다.\n- **다음 단계.** **CASE 후보.** 재현이 정적이고, \"장치는 있고 회로가 닫히지 않았다\"의 변형 중 **회로가 반대편에서 끊긴** 사례라 독립적으로 가치가 있다. 그리고 \"생성자 기본값이 있는 필수 협력자는 필수가 아니다\"가 **REFERENCE 후보**다.\n\n##### P2 — 소비 오케스트레이터가 조립되지 않는다\n\n- **사실.** `DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다.\n- **근거.** `evidence/raw/283` §A·§C.\n- **왜 문제인가.** 이 클래스가 고친 문제(\"각 어댑터가 retry/dead-letter의 뜻을 각자 결정\")가 배선 없이는 그대로 남는다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.\n- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\\.)?DefaultDeliveryProcessor\\s*\\(' -- src`\n- **다음 단계.** §A19-MESSAGING-POLICY §17의 \"출하 컨텍스트가 발행은 하고 소비는 하지 못한다\"와 **동일 사건**이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.\n\n##### P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다\n\n- **사실.** `encode`가 `codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다.\n- **근거.** `DefaultMessagePublisher.java:97-102`, `RegisteredMessageCodecs.find`, `MessagingCoreAutoConfiguration:363`(varargs 비어 있음).\n- **왜 문제인가.** 실패하지 않고 **다른 포맷으로 성공**한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. `DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.\n- **확인 방법.** 등록되지 않은 content type의 봉투를 발행해 `EncodedMessage.contentType()`을 확인.\n- **후보.** 미등록 content type을 `MessagingConfigurationException`으로 거절하거나, `profile.schema().codec()`과 대조한다.\n- **다음 단계.** **CASE 후보.** 조용한 성공이라는 형태가 `messaging-core-api`의 \"조용한 성능 저하 금지\" 설계와 정면으로 어긋난다.\n\n##### P3 — 같은 실패 코드가 두 completion에 쓰인다\n\n- **사실.** `PUBLISH_DEADLINE_EXCEEDED`가 전송 전이면 `REJECTED`(`:16-21`), 전송 후면 `AMBIGUOUS`(`:42-47`)로 붙는다.\n- **근거.** 두 위치.\n- **왜 문제인가.** 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 `messageId`로만 재발행. `FailureDescriptor.code`가 \"stable, machine-readable code\"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다.\n- **확인 방법.** `git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core`\n- **후보.** 전송 전을 `PUBLISH_DEADLINE_BEFORE_SEND`처럼 분리한다.\n- **다음 단계.** **REFERENCE 후보**(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).\n\n##### P3 — admission 실패만 예외로 전파된다\n\n- **사실.** 8단계 중 admission(`:24`)만 `try` 블록 밖이고, `MessageTooLargeException`·`MessageBackpressureException`이 그대로 던져진다. 나머지 실패는 전부 `CompletionStage This is the answer to the dual-write problem. Writing to the database and publishing to the"
},
{
"line": 33863,
"text": " * broker in the same method cannot be made atomic; writing both to the database can."
},
{
"line": 33864,
"text": "```"
},
{
"line": 33865,
"text": ""
},
{
"line": 33866,
"text": "**Inbox** — 소비 측 중복 제거."
},
{
"line": 33867,
"text": ""
},
{
"line": 33868,
"text": "```java"
},
{
"line": 33869,
"text": "// InboxRepository.java:9-13"
},
{
"line": 33870,
"text": " * {@link #reserve} must run inside the same database transaction as the handler's side effect."
},
{
"line": 33871,
"text": " * That is the entire mechanism: the uniqueness constraint on the inbox row and the business write"
},
{
"line": 33872,
"text": " * commit together, so a redelivered message either finds the row already present and skips, or"
},
{
"line": 33873,
"text": " * writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to"
},
{
"line": 33874,
"text": " * close."
},
{
"line": 33875,
"text": "```"
},
{
"line": 33876,
"text": ""
},
{
"line": 33877,
"text": "**Claim Check** — 브로커 밖 payload 참조."
},
{
"line": 33878,
"text": ""
},
{
"line": 33879,
"text": "그리고 셋의 관계를 `OutboxRecord`가 명시한다."
},
{
"line": 33880,
"text": ""
},
{
"line": 33881,
"text": "```java"
},
{
"line": 33882,
"text": "// OutboxRecord.java:21-24"
},
{
"line": 33883,
"text": " * What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will"
},
{
"line": 33884,
"text": " * retry it, and the same message may reach the broker twice. Effectively-once processing comes from"
},
{
"line": 33885,
"text": " * this row carrying a stable {@code messageId} and the consumer having an Inbox — not from the"
},
{
"line": 33886,
"text": " * outbox alone."
},
{
"line": 33887,
"text": "```"
},
{
"line": 33888,
"text": ""
},
{
"line": 33889,
"text": "**Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 \"보장을 과대 진술하지 않는다\"의 예다."
},
{
"line": 33890,
"text": ""
},
{
"line": 33891,
"text": "---"
},
{
"line": 33892,
"text": ""
},
{
"line": 33893,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 33894,
"text": ""
},
{
"line": 33895,
"text": "들어오는 것: `messaging-core-api`(api) 하나."
},
{
"line": 33896,
"text": ""
},
{
"line": 33897,
"text": "나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`."
},
{
"line": 33898,
"text": ""
},
{
"line": 33899,
"text": "**구현 leaf가 셋 있고 전부 배선된다.**"
},
{
"line": 33900,
"text": ""
},
{
"line": 33901,
"text": "| 포트 | 구현 | 조립 |"
},
{
"line": 33902,
"text": "|---|---|---|"
},
{
"line": 33903,
"text": "| `OutboxRepository` | `messaging-outbox-jdbc-postgresql/JdbcOutboxRepository` | starter `MessagingReliabilityAutoConfiguration` |"
},
{
"line": 33904,
"text": "| `InboxRepository` | `messaging-inbox-jdbc-postgresql/JdbcInboxRepository` | 같음 |"
},
{
"line": 33905,
"text": "| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql/TransactionalInboxHandler` | `transactionalInboxHandler` bean |"
},
{
"line": 33906,
"text": "| `ReliableMessagePublisher` | **없음** | — |"
},
{
"line": 33907,
"text": ""
},
{
"line": 33908,
"text": "`ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다."
},
{
"line": 33909,
"text": ""
},
{
"line": 33910,
"text": "이 leaf 자체는 Spring 주석을 갖지 않는다."
},
{
"line": 33911,
"text": ""
},
{
"line": 33912,
"text": "---"
},
{
"line": 33913,
"text": ""
},
{
"line": 33914,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 33915,
"text": ""
},
{
"line": 33916,
"text": "```"
},
{
"line": 33917,
"text": "Outbox"
},
{
"line": 33918,
"text": " ReliableMessagePublisher.addToOutbox(dest, envelope) ← 구현 0"
},
{
"line": 33919,
"text": " ↓ (쓰기)"
},
{
"line": 33920,
"text": " OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers"
},
{
"line": 33921,
"text": " ├─ OutboxCanonicalMetadata (provenance 10필드)"
},
{
"line": 33922,
"text": " └─ status / attempts / leaseExpiresAt / lastFailureCode"
},
{
"line": 33923,
"text": " ↓ (릴레이)"
},
{
"line": 33924,
"text": " OutboxRepository ─┬─ append"
},
{
"line": 33925,
"text": " ├─ [구세대] leaseBatch → List The port used to take a {@code MessageId} for every terminal transition, so a write said which"
},
{
"line": 33953,
"text": " * row to change and nothing about which claim it belonged to. A relay that stalled past its lease"
},
{
"line": 33954,
"text": " * could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already"
},
{
"line": 33955,
"text": " * written, and the row became claimable again — one message, published twice, by a system whose"
},
{
"line": 33956,
"text": " * whole purpose is to publish it once."
},
{
"line": 33957,
"text": " *"
},
{
"line": 33958,
"text": " * The token is the part that makes staleness detectable. It increases on every claim, so a"
},
{
"line": 33959,
"text": " * superseded relay holds a number the row no longer has and its update matches zero rows."
},
{
"line": 33960,
"text": "```"
},
{
"line": 33961,
"text": ""
},
{
"line": 33962,
"text": "`token < 1`을 거절하는 이유도 적혀 있다 — `\"a claim's token starts at 1; 0 is the value of a row nobody has claimed\"`."
},
{
"line": 33963,
"text": ""
},
{
"line": 33964,
"text": "`expiredAt(now)`가 `!now.isBefore(expiresAt)`다."
},
{
"line": 33965,
"text": ""
},
{
"line": 33966,
"text": "##### 4.2 `OutboxTransitionResult` — void가 삼킨 것"
},
{
"line": 33967,
"text": ""
},
{
"line": 33968,
"text": "```java"
},
{
"line": 33969,
"text": "// :5-9"
},
{
"line": 33970,
"text": " * The transitions returned {@code void}, so an update that matched zero rows was"
},
{
"line": 33971,
"text": " * indistinguishable from one that matched one. That is precisely the stale-lease case: the relay"
},
{
"line": 33972,
"text": " * believes it recorded the outcome, the row still says something else, and nothing anywhere counts"
},
{
"line": 33973,
"text": " * the disagreement."
},
{
"line": 33974,
"text": "```"
},
{
"line": 33975,
"text": ""
},
{
"line": 33976,
"text": "두 값이고 `STALE_LEASE`의 javadoc이 운영 의미까지 적는다."
},
{
"line": 33977,
"text": ""
},
{
"line": 33978,
"text": "```java"
},
{
"line": 33979,
"text": " * Another relay claimed it after the lease expired. Not an error to throw — the message is"
},
{
"line": 33980,
"text": " * being handled by somebody else — but never a success either: it is the signal that this"
},
{
"line": 33981,
"text": " * worker's publish attempt may have produced a duplicate, and it belongs on a metric."
},
{
"line": 33982,
"text": "```"
},
{
"line": 33983,
"text": ""
},
{
"line": 33984,
"text": "**\"belongs on a metric\"** — 그 메트릭이 존재하는지는 outbox leaf가 답한다."
},
{
"line": 33985,
"text": ""
},
{
"line": 33986,
"text": "##### 4.3 `OutboxStatus` — 여섯 상태와 두 개의 구분"
},
{
"line": 33987,
"text": ""
},
{
"line": 33988,
"text": "`PENDING` → `IN_FLIGHT` → `PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`."
},
{
"line": 33989,
"text": ""
},
{
"line": 33990,
"text": "**두 쌍의 구분이 각각 이유를 갖는다.**"
},
{
"line": 33991,
"text": ""
},
{
"line": 33992,
"text": "`AMBIGUOUS` vs `FAILED`:"
},
{
"line": 33993,
"text": ""
},
{
"line": 33994,
"text": "```java"
},
{
"line": 33995,
"text": "// :6-9"
},
{
"line": 33996,
"text": " * {@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose"
},
{
"line": 33997,
"text": " * publish timed out may already be on the broker; retrying it is correct, but only under the same"
},
{
"line": 33998,
"text": " * logical message id, and an operator looking at the table needs to be able to tell those rows"
},
{
"line": 33999,
"text": " * apart from ones that definitely never landed."
},
{
"line": 34000,
"text": "```"
},
{
"line": 34001,
"text": ""
},
{
"line": 34002,
"text": "`EXHAUSTED` vs `FAILED`:"
},
{
"line": 34003,
"text": ""
},
{
"line": 34004,
"text": "```java"
},
{
"line": 34005,
"text": "// :31-34"
},
{
"line": 34006,
"text": " * Distinct from {@link #FAILED}, which means the broker refused the message: this one means"
},
{
"line": 34007,
"text": " * nobody ever got an answer. Collapsing the two loses the difference between \"this message is"
},
{
"line": 34008,
"text": " * invalid\" and \"the broker was unreachable for an hour\", and those need different operator"
},
{
"line": 34009,
"text": " * actions — the first a fix, the second a redrive."
},
{
"line": 34010,
"text": "```"
},
{
"line": 34011,
"text": ""
},
{
"line": 34012,
"text": "`OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — \"The first needs a fix, the second a redrive.\""
},
{
"line": 34013,
"text": ""
},
{
"line": 34014,
"text": "**`FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.** `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`의 `.because(...)`가 그것을 ArchUnit 규칙의 근거로 든다 — \"its `OutboxStatus.FAILED` means the opposite of the legacy `OutboxEventStatus.FAILED`, so the two models cannot be mixed by name without inverting retryable and terminal.\" 즉 **이 enum의 의미가 저장소 규칙 하나의 존재 이유다.**"
},
{
"line": 34015,
"text": ""
},
{
"line": 34016,
"text": "##### 4.4 `InboxResult` — 두 개가 아니라 세 개"
},
{
"line": 34017,
"text": ""
},
{
"line": 34018,
"text": "```java"
},
{
"line": 34019,
"text": "// :6-9"
},
{
"line": 34020,
"text": " * Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}"
},
{
"line": 34021,
"text": " * into a single \"duplicate\" would settle a message whose effect is still only half-written by"
},
{
"line": 34022,
"text": " * another instance: if that instance then rolls back, the effect is lost and the broker will never"
},
{
"line": 34023,
"text": " * redeliver, because this instance already acknowledged it."
},
{
"line": 34024,
"text": "```"
},
{
"line": 34025,
"text": ""
},
{
"line": 34026,
"text": "`safeToSettle` 플래그가 상수에 붙어 있다."
},
{
"line": 34027,
"text": ""
},
{
"line": 34028,
"text": "| 값 | safeToSettle | 뜻 |"
},
{
"line": 34029,
"text": "|---|:---:|---|"
},
{
"line": 34030,
"text": "| `APPLIED` | true | 이 트랜잭션에서 효과 실행 |"
},
{
"line": 34031,
"text": "| `ALREADY_APPLIED` | true | 커밋된 예약 존재 — 이미 실행됨 |"
},
{
"line": 34032,
"text": "| `CLAIMED_ELSEWHERE` | **false** | 다른 인스턴스가 **미커밋** 예약 보유 |"
},
{
"line": 34033,
"text": ""
},
{
"line": 34034,
"text": "세 번째의 javadoc이 결론을 적는다 — \"Do *not* settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect.\""
},
{
"line": 34035,
"text": ""
},
{
"line": 34036,
"text": "**세 값 모두 필요한 이유가 명확하고, `isSafeToSettle()`이 그 판단을 하나로 모은다.**"
},
{
"line": 34037,
"text": ""
},
{
"line": 34038,
"text": "##### 4.5 `InboxRepository` — 키가 (message, consumer)다"
},
{
"line": 34039,
"text": ""
},
{
"line": 34040,
"text": "```java"
},
{
"line": 34041,
"text": "// InboxRecord.java:9-12"
},
{
"line": 34042,
"text": " * Keyed by message id and consumer id, because two independent consumers of the same"
},
{
"line": 34043,
"text": " * event must each process it once — deduplicating on the message alone would let the first consumer"
},
{
"line": 34044,
"text": " * suppress the second."
},
{
"line": 34045,
"text": "```"
},
{
"line": 34046,
"text": ""
},
{
"line": 34047,
"text": "`IdempotentMessageHandler`의 javadoc이 같은 이유를 API 형태로 반복한다 — `consumerName`이 파라미터인 이유."
},
{
"line": 34048,
"text": ""
},
{
"line": 34049,
"text": "`purgeProcessedBefore`의 javadoc이 보존 기간 규칙을 적는다."
},
{
"line": 34050,
"text": ""
},
{
"line": 34051,
"text": "```java"
},
{
"line": 34052,
"text": " * Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery"
},
{
"line": 34053,
"text": " * arrives after its inbox row was pruned and is processed a second time."
},
{
"line": 34054,
"text": "```"
},
{
"line": 34055,
"text": ""
},
{
"line": 34056,
"text": "**이 규칙을 강제하는 코드가 없다.** 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, `messaging-policy`의 프로파일 검증기에도 없다. §17."
},
{
"line": 34057,
"text": ""
},
{
"line": 34058,
"text": "##### 4.6 `TransactionalMessageAction` — 트랜잭션 경계의 소유권"
},
{
"line": 34059,
"text": ""
},
{
"line": 34060,
"text": "```java"
},
{
"line": 34061,
"text": "// :8-16"
},
{
"line": 34062,
"text": " * Sharing one transaction is the entire mechanism. If the effect committed separately from the"
},
{
"line": 34063,
"text": " * \"I have handled this message\" marker, a crash between the two would either replay the effect or"
},
{
"line": 34064,
"text": " * suppress a message that was never handled — and which of those you get would depend on the order"
},
{
"line": 34065,
"text": " * the two commits happened to be written in."
},
{
"line": 34066,
"text": " *"
},
{
"line": 34067,
"text": " * Implementations must not settle the message, publish, or start their own transaction. The"
},
{
"line": 34068,
"text": " * runtime owns the transaction boundary precisely so that the action cannot accidentally commit"
},
{
"line": 34069,
"text": " * half of it."
},
{
"line": 34070,
"text": "```"
},
{
"line": 34071,
"text": ""
},
{
"line": 34072,
"text": "세 금지(\"settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라\")가 **문서로만 표현된다.** 함수형 인터페이스이므로 타입이 강제할 수 없다. §17."
},
{
"line": 34073,
"text": ""
},
{
"line": 34074,
"text": "##### 4.7 `OutboxCanonicalMetadata` — 컬럼이어야 하는 이유"
},
{
"line": 34075,
"text": ""
},
{
"line": 34076,
"text": "이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다."
},
{
"line": 34077,
"text": ""
},
{
"line": 34078,
"text": "```java"
},
{
"line": 34079,
"text": "// :14-28"
},
{
"line": 34080,
"text": " * They used to live nowhere. A row held identity, type, version, content type, payload and an"
},
{
"line": 34081,
"text": " * arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either"
},
{
"line": 34082,
"text": " * invented when the envelope was rebuilt — {@code Optional.empty()} for every one of them — or"
},
{
"line": 34083,
"text": " * smuggled through the header map under reserved names the platform was supposed to own."
},
{
"line": 34084,
"text": " *"
},
{
"line": 34085,
"text": " * Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant"
},
{
"line": 34086,
"text": " * without decoding the payload, so the operational question \"which tenant is backed up\" has no"
},
{
"line": 34087,
"text": " * answer; and a message that crossed the outbox arrived at its consumer with a different tenant,"
},
{
"line": 34088,
"text": " * trace and correlation than the one that was published, which makes the publish path — direct,"
},
{
"line": 34089,
"text": " * polling or CDC — part of the message's meaning."
},
{
"line": 34090,
"text": " *"
},
{
"line": 34091,
"text": " * Columns rather than a blob, because the point is that the database can answer questions about"
},
{
"line": 34092,
"text": " * them. A versioned envelope encoding would round-trip just as faithfully and would still leave the"
},
{
"line": 34093,
"text": " * relay unable to select rows for one tenant."
},
{
"line": 34094,
"text": "```"
},
{
"line": 34095,
"text": ""
},
{
"line": 34096,
"text": "**세 번째 문단이 고려된 대안을 명시적으로 기각한다** — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다."
},
{
"line": 34097,
"text": ""
},
{
"line": 34098,
"text": "불변식 하나: `schemaUri.isPresent() && schemaSubject.isEmpty()`를 거절한다 — \"a reader would have a URI and no way to know what it is a schema for\"."
},
{
"line": 34099,
"text": ""
},
{
"line": 34100,
"text": "`traceContext`만 `Optional`이 아니고 `TraceContext.none()`이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것(\"the reader's behaviour is the same: carry what is there and invent nothing\")."
},
{
"line": 34101,
"text": ""
},
{
"line": 34102,
"text": "##### 4.8 `OutboxRecord` — 두 반쪽의 소유자가 다르다"
},
{
"line": 34103,
"text": ""
},
{
"line": 34104,
"text": "```java"
},
{
"line": 34105,
"text": "// :26-29"
},
{
"line": 34106,
"text": " * {@link OutboxCanonicalMetadata} is a separate component rather than more fields here because"
},
{
"line": 34107,
"text": " * the two halves answer to different owners. Identity, payload, status, attempts and lease are the"
},
{
"line": 34108,
"text": " * relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to"
},
{
"line": 34109,
"text": " * survive the round trip through the database unchanged."
},
{
"line": 34110,
"text": "```"
},
{
"line": 34111,
"text": ""
},
{
"line": 34112,
"text": "`payload`가 양방향 방어 복사(`payload.clone()` 생성 시와 접근 시), `headers`가 `Map.copyOf` — `messaging-schema-api`의 `EncodedMessage`(그쪽 §4.3)와 같은 패턴이다."
},
{
"line": 34113,
"text": ""
},
{
"line": 34114,
"text": "`withStatus`가 `messageId`를 파라미터로 받지 않는다 — \"The message id is never a parameter, so no state transition can change it.\" 타입이 불변식을 강제하는 예다."
},
{
"line": 34115,
"text": ""
},
{
"line": 34116,
"text": "`equals`/`hashCode`가 **다섯 필드 중 넷만** 본다 — `messageId`, `status`, `attempts`, `payload`. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 **그 이유가 어디에도 적혀 있지 않다.** §17."
},
{
"line": 34117,
"text": ""
},
{
"line": 34118,
"text": "`toString`이 payload를 담지 않는다."
},
{
"line": 34119,
"text": ""
},
{
"line": 34120,
"text": "##### 4.9 `ClaimCheckReference` — digest가 선택이 아니다"
},
{
"line": 34121,
"text": ""
},
{
"line": 34122,
"text": "```java"
},
{
"line": 34123,
"text": "// :10-16"
},
{
"line": 34124,
"text": " * The digest is part of the reference, not an optional extra. A claim check splits a message"
},
{
"line": 34125,
"text": " * into two systems with independent retention and replication, so a consumer that fetches the"
},
{
"line": 34126,
"text": " * payload has to be able to prove it got the bytes the producer stored — otherwise a truncated or"
},
{
"line": 34127,
"text": " * replaced object is indistinguishable from a valid one."
},
{
"line": 34128,
"text": " *"
},
{
"line": 34129,
"text": " * The expiry is carried for the same reason: a claim check whose payload has been reaped is a"
},
{
"line": 34130,
"text": " * dead message, and detecting that at fetch time is better than a mysterious not-found."
},
{
"line": 34131,
"text": "```"
},
{
"line": 34132,
"text": ""
},
{
"line": 34133,
"text": "`sha256`이 `[a-f0-9]{64}` 정확 일치다 — 대문자 hex를 거절한다. `messaging-core-api`의 `TraceContext`가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다."
},
{
"line": 34134,
"text": ""
},
{
"line": 34135,
"text": "`expiresAt`이 `Optional`이 아니다 — 모든 claim check가 만료를 갖는다."
},
{
"line": 34136,
"text": ""
},
{
"line": 34137,
"text": "---"
},
{
"line": 34138,
"text": ""
},
{
"line": 34139,
"text": "#### 5. 주요 실행 경로"
},
{
"line": 34140,
"text": ""
},
{
"line": 34141,
"text": "**Outbox 쓰기:** 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)` → `OutboxRepository.append(record)` — **진입점 구현이 없다**(§12.1)"
},
{
"line": 34142,
"text": ""
},
{
"line": 34143,
"text": "**Outbox 릴레이:** `claimBatch(owner, size, lease, now, maxAttempts)` → `List The return type is {@code void}, and that is the contract. There is no publish outcome to"
},
{
"line": 34184,
"text": " * report yet: the row is written inside the caller's transaction, so if the transaction rolls back"
},
{
"line": 34185,
"text": " * the message never existed, and if it commits the relay will publish it later. Handing back a"
},
{
"line": 34186,
"text": " * {@code PublishResult} here would be a lie about work that has not happened."
},
{
"line": 34187,
"text": "```"
},
{
"line": 34188,
"text": ""
},
{
"line": 34189,
"text": "동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1)."
},
{
"line": 34190,
"text": ""
},
{
"line": 34191,
"text": "모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다."
},
{
"line": 34192,
"text": ""
},
{
"line": 34193,
"text": "수명주기 참여 없음."
},
{
"line": 34194,
"text": ""
},
{
"line": 34195,
"text": "---"
},
{
"line": 34196,
"text": ""
},
{
"line": 34197,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 34198,
"text": ""
},
{
"line": 34199,
"text": "설정 없음. 상수도 없다 — `ClaimCheckReference.SHA256` 정규식 하나가 private이다."
},
{
"line": 34200,
"text": ""
},
{
"line": 34201,
"text": "`OutboxRepository`의 두 `purge*` 메서드가 `limit` 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다."
},
{
"line": 34202,
"text": ""
},
{
"line": 34203,
"text": "```java"
},
{
"line": 34204,
"text": "// :143-147"
},
{
"line": 34205,
"text": " * The unbounded version deletes everything before the cutoff in one statement. On a table that"
},
{
"line": 34206,
"text": " * has been accumulating published rows since the last sweep that is a single long transaction"
},
{
"line": 34207,
"text": " * holding locks and generating WAL in proportion to the backlog, which shows up as the relay and"
},
{
"line": 34208,
"text": " * the business writes stalling behind retention. The cleanup jobs describe themselves as bounded"
},
{
"line": 34209,
"text": " * by batch size; this is the parameter that makes that true."
},
{
"line": 34210,
"text": "```"
},
{
"line": 34211,
"text": ""
},
{
"line": 34212,
"text": "`InboxRepository`도 같은 쌍을 갖는다."
},
{
"line": 34213,
"text": ""
},
{
"line": 34214,
"text": "---"
},
{
"line": 34215,
"text": ""
},
{
"line": 34216,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 34217,
"text": ""
},
{
"line": 34218,
"text": "없다 — 포트만 정의한다. 다만 **포트가 저장소 기술을 전제한다.**"
},
{
"line": 34219,
"text": ""
},
{
"line": 34220,
"text": "- `InboxRepository.reserve`의 메커니즘이 \"the uniqueness constraint on the inbox row\"다 — 유니크 제약이 있는 저장소를 전제"
},
{
"line": 34221,
"text": "- `OutboxRepository.claimBatch`의 의미가 \"a record claimed by one relay is invisible to the others\"다 — 행 잠금 또는 그에 준하는 것을 전제"
},
{
"line": 34222,
"text": "- `OutboxTransitionResult.STALE_LEASE`가 \"its update matches zero rows\"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제"
},
{
"line": 34223,
"text": ""
},
{
"line": 34224,
"text": "세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(`*-jdbc-postgresql`)이 실제 선택을 드러낸다."
},
{
"line": 34225,
"text": ""
},
{
"line": 34226,
"text": "---"
},
{
"line": 34227,
"text": ""
},
{
"line": 34228,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 34229,
"text": ""
},
{
"line": 34230,
"text": "**이 leaf에는 테스트가 없다.** `src/test` 디렉터리 자체가 존재하지 않는다 — `src` 아래에 `main`만 있다."
},
{
"line": 34231,
"text": ""
},
{
"line": 34232,
"text": "13개 타입 중 record 생성자 검증이 있는 것이 여섯(`ClaimCheckReference`, `InboxRecord`, `OutboxCanonicalMetadata`, `OutboxLease`, `OutboxRecord`, `OutboxTransitionResult`는 enum), 술어가 있는 것이 셋(`isExpired`, `expiredAt`, `isSafeToSettle`)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다."
},
{
"line": 34233,
"text": ""
},
{
"line": 34234,
"text": "**검증은 전부 구현 leaf에서 일어난다.**"
},
{
"line": 34235,
"text": ""
},
{
"line": 34236,
"text": "| 검증 위치 | 무엇을 |"
},
{
"line": 34237,
"text": "|---|---|"
},
{
"line": 34238,
"text": "| `messaging-outbox-jdbc-postgresql` 테스트 4개 | `OutboxRepository` 구현, 릴레이 |"
},
{
"line": 34239,
"text": "| `messaging-inbox-jdbc-postgresql` 테스트 4개 | `InboxRepository` 구현, 멱등 핸들러 |"
},
{
"line": 34240,
"text": "| `messaging-claim-check` 테스트 3개 | claim check |"
},
{
"line": 34241,
"text": "| starter `MessagingOutboxRelayLifecycleTest` | 릴레이 수명주기 |"
},
{
"line": 34242,
"text": ""
},
{
"line": 34243,
"text": "그 결과 이 leaf의 **계약 불변식**(예: `OutboxCanonicalMetadata`의 `schemaUri` 없이 `schemaSubject` 금지, `OutboxLease`의 `token >= 1`, `InboxResult.isSafeToSettle`의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다."
},
{
"line": 34244,
"text": ""
},
{
"line": 34245,
"text": "**그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.**"
},
{
"line": 34246,
"text": ""
},
{
"line": 34247,
"text": "---"
},
{
"line": 34248,
"text": ""
},
{
"line": 34249,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 34250,
"text": ""
},
{
"line": 34251,
"text": "| 게이트 | 이 leaf에 대해 |"
},
{
"line": 34252,
"text": "|---|---|"
},
{
"line": 34253,
"text": "| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\"]` |"
},
{
"line": 34254,
"text": "| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |"
},
{
"line": 34255,
"text": "| vendor `api` 규칙 | 벤더 의존성 0 |"
},
{
"line": 34256,
"text": "| **`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`** | `..application..`이 이 leaf를 포함한 `dev.caskeleton.messaging..`을 참조하는 것을 금지. **규칙의 근거가 이 leaf의 `OutboxStatus.FAILED` 의미다** |"
},
{
"line": 34257,
"text": "| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |"
},
{
"line": 34258,
"text": "| ArchUnit 전용 규칙 | 없음 |"
},
{
"line": 34259,
"text": ""
},
{
"line": 34260,
"text": "네 번째가 특이하다 — ArchUnit 규칙 하나가 **이 leaf의 enum 상수 의미**를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다."
},
{
"line": 34261,
"text": ""
},
{
"line": 34262,
"text": "---"
},
{
"line": 34263,
"text": ""
},
{
"line": 34264,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 34265,
"text": ""
},
{
"line": 34266,
"text": "원시 증거: `evidence/raw/289-reliability-api-two-generations.txt`."
},
{
"line": 34267,
"text": ""
},
{
"line": 34268,
"text": "##### 12.1 Public surface reachability"
},
{
"line": 34269,
"text": ""
},
{
"line": 34270,
"text": "| 타입 | leaf 밖 파일 | 판정 |"
},
{
"line": 34271,
"text": "|---|---:|---|"
},
{
"line": 34272,
"text": "| `OutboxRecord` | 13 | 활발 |"
},
{
"line": 34273,
"text": "| `OutboxCanonicalMetadata` | 8 | 활발 |"
},
{
"line": 34274,
"text": "| `OutboxRepository` | 7 | 구현 1 + 릴레이 + 테스트 |"
},
{
"line": 34275,
"text": "| `OutboxStatus` | 7 | 활발 |"
},
{
"line": 34276,
"text": "| `OutboxLease` | 6 | 활발 |"
},
{
"line": 34277,
"text": "| `OutboxTransitionResult` | 6 | 활발 |"
},
{
"line": 34278,
"text": "| `InboxRepository` | 6 | 구현 1 + 테스트 |"
},
{
"line": 34279,
"text": "| `ClaimCheckReference` | 6 | 활발 |"
},
{
"line": 34280,
"text": "| `InboxResult` | 2 | |"
},
{
"line": 34281,
"text": "| `IdempotentMessageHandler` | 1 | `TransactionalInboxHandler` |"
},
{
"line": 34282,
"text": "| `TransactionalMessageAction` | 1 | 같음 |"
},
{
"line": 34283,
"text": "| **`InboxRecord`** | **0** | |"
},
{
"line": 34284,
"text": "| **`ReliableMessagePublisher`** | **0** | |"
},
{
"line": 34285,
"text": ""
},
{
"line": 34286,
"text": "**(a) Outbox 쓰기 진입점에 구현이 없다**"
},
{
"line": 34287,
"text": ""
},
{
"line": 34288,
"text": "`ReliableMessagePublisher`는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다."
},
{
"line": 34289,
"text": ""
},
{
"line": 34290,
"text": "`OutboxRepository.append`는 존재하지만 그것은 저장소 포트다 — javadoc이 \"must be callable inside the caller's business transaction\"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 `ReliableMessagePublisher`가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다."
},
{
"line": 34291,
"text": ""
},
{
"line": 34292,
"text": "**그리고 애플리케이션은 이 leaf를 참조할 수 없다** — `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`이 금지한다. 즉 `ReliableMessagePublisher`를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. `messaging-spring-cloud-stream-bridge`가 후보 이름이지만 그 leaf는 `runtime_memberships: []`다."
},
{
"line": 34293,
"text": ""
},
{
"line": 34294,
"text": "**(b) `InboxRecord`가 쓰이지 않는다**"
},
{
"line": 34295,
"text": ""
},
{
"line": 34296,
"text": "`InboxRepository`의 어느 메서드도 `InboxRecord`를 주고받지 않는다 — `reserve`는 `boolean`, `isProcessed`는 `boolean`, `purge*`는 `int`다. record는 \"One row of the consumer inbox\"를 서술하지만 그 행을 반환하는 API가 없다."
},
{
"line": 34297,
"text": ""
},
{
"line": 34298,
"text": "같은 leaf의 `OutboxRecord`는 정반대다 — `leaseBatch`/`find`가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다."
},
{
"line": 34299,
"text": ""
},
{
"line": 34300,
"text": "**(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다**"
},
{
"line": 34301,
"text": ""
},
{
"line": 34302,
"text": "`OutboxRepository`는 같은 다섯 전이에 대해 **두 세대**를 갖는다."
},
{
"line": 34303,
"text": ""
},
{
"line": 34304,
"text": "| 전이 | 구세대 (MessageId) | 신세대 (OutboxLease) |"
},
{
"line": 34305,
"text": "|---|---|---|"
},
{
"line": 34306,
"text": "| 배치 획득 | `leaseBatch(size, lease, now)` → `List The token is what a terminal write is checked against. {@link #leaseBatch} returns records"
},
{
"line": 34343,
"text": " * without one, so its callers cannot prove a write belongs to their claim; it remains for"
},
{
"line": 34344,
"text": " * inspection paths and is deprecated for the relay's use."
},
{
"line": 34345,
"text": "```"
},
{
"line": 34346,
"text": ""
},
{
"line": 34347,
"text": "`@Deprecated` 애노테이션이 **이 leaf 전체에 하나도 없다**(`git grep '@Deprecated' -- src/messaging/messaging-reliability-api` exit 1)."
},
{
"line": 34348,
"text": ""
},
{
"line": 34349,
"text": "결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다."
},
{
"line": 34350,
"text": ""
},
{
"line": 34351,
"text": "**(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다**"
},
{
"line": 34352,
"text": ""
},
{
"line": 34353,
"text": "> 이 항목은 `messaging-inbox-jdbc-postgresql` 분석 중에 확인됐다. 이 문서의 초판은 §17의 \"확인된 설계\"에 \"purge에 `limit` 파라미터를 둔 것\"을 넣었는데, 그것은 파라미터의 **존재**만 본 판정이었다. 호출 여부를 재측정해 정정한다."
},
{
"line": 34354,
"text": ""
},
{
"line": 34355,
"text": "`InboxRepository.purgeProcessedBefore(Instant, int)`와 `OutboxRepository.purgePublishedBefore(Instant, int)`가 선언돼 있고 두 JDBC 구현이 `LIMIT`(inbox는 `FOR UPDATE SKIP LOCKED`까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 하나도 없다**. 두 cleanup job이 무제한 오버로드를 부른다 — `InboxCleanupJob:56`, `OutboxCleanupJob:50`."
},
{
"line": 34356,
"text": ""
},
{
"line": 34357,
"text": "`OutboxRepository:140-151`의 javadoc이 그 상황을 예고한다."
},
{
"line": 34358,
"text": ""
},
{
"line": 34359,
"text": "> The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true.**"
},
{
"line": 34360,
"text": ""
},
{
"line": 34361,
"text": "그 파라미터를 아무도 넘기지 않는다. 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유하고, 이 문서는 **포트가 두 오버로드를 나란히 노출했다는 것**을 기여한다 — (a)의 두 세대 전이와 같은 형태다."
},
{
"line": 34362,
"text": ""
},
{
"line": 34363,
"text": "##### 12.2 Conditional sibling comparison"
},
{
"line": 34364,
"text": ""
},
{
"line": 34365,
"text": "이 leaf에 bean은 없다. **구현 leaf 셋의 sibling 비교가 유의미하다.**"
},
{
"line": 34366,
"text": ""
},
{
"line": 34367,
"text": "| 포트 | 구현 leaf | membership | starter bean |"
},
{
"line": 34368,
"text": "|---|---|---|---|"
},
{
"line": 34369,
"text": "| `OutboxRepository` | `messaging-outbox-jdbc-postgresql` | `[\"app-bootstrap\"]` | `MessagingReliabilityAutoConfiguration` |"
},
{
"line": 34370,
"text": "| `InboxRepository` | `messaging-inbox-jdbc-postgresql` | `[\"app-bootstrap\"]` | 같음 |"
},
{
"line": 34371,
"text": "| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql` | 같음 | `transactionalInboxHandler` bean |"
},
{
"line": 34372,
"text": "| `ReliableMessagePublisher` | **없음** | — | — |"
},
{
"line": 34373,
"text": ""
},
{
"line": 34374,
"text": "네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다."
},
{
"line": 34375,
"text": ""
},
{
"line": 34376,
"text": "##### 12.3 Duplicate mechanism sweep"
},
{
"line": 34377,
"text": ""
},
{
"line": 34378,
"text": "**(a) 같은 전이의 두 세대** — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다."
},
{
"line": 34379,
"text": ""
},
{
"line": 34380,
"text": "**(b) outbox 개념이 저장소에 둘 있다**"
},
{
"line": 34381,
"text": ""
},
{
"line": 34382,
"text": "| | 이 leaf | `application-core` |"
},
{
"line": 34383,
"text": "|---|---|---|"
},
{
"line": 34384,
"text": "| 상태 enum | `OutboxStatus` | `OutboxEventStatus` |"
},
{
"line": 34385,
"text": "| `FAILED`의 뜻 | 브로커가 확정적으로 거절 — **재시도 안 함** | (반대 의미, ArchUnit javadoc이 명시) |"
},
{
"line": 34386,
"text": "| 행 타입 | `OutboxRecord` | `NewOutboxEvent` 등 |"
},
{
"line": 34387,
"text": "| 사용처 | messaging family | application + persistence-jpa |"
},
{
"line": 34388,
"text": ""
},
{
"line": 34389,
"text": "**의도된 분리다.** ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 `.because(...)`가 이유를 적는다 — \"the two outbox status models mean opposite things under the same names\". 중복 경쟁이 아니라 **명시적으로 격리된 두 모델**이다."
},
{
"line": 34390,
"text": ""
},
{
"line": 34391,
"text": "다만 그 결과 `ReliableMessagePublisher`가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다."
},
{
"line": 34392,
"text": ""
},
{
"line": 34393,
"text": "**(c) 이름 충돌 주의**"
},
{
"line": 34394,
"text": ""
},
{
"line": 34395,
"text": "`markPublished`·`markFailed`·`releaseLease`라는 메서드 이름이 저장소의 **완전히 다른 인터페이스** 여러 곳에 있다 — `persistence-jpa`의 `OutboxStoreAdapter`·`JpaCleanupQueue`·`JpaUploadSessionStore`, `cache-redis`의 `RedisIdempotencyStoreAdapter`, `notification`의 `JpaProviderEventLedger`. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 `src/messaging/**`로 범위를 좁혀 얻은 것이다."
},
{
"line": 34396,
"text": ""
},
{
"line": 34397,
"text": "##### 12.4 Documentation / measured-count drift"
},
{
"line": 34398,
"text": ""
},
{
"line": 34399,
"text": "| 문서 주장 | 재측정 | 결과 |"
},
{
"line": 34400,
"text": "|---|---|---|"
},
{
"line": 34401,
"text": "| `OutboxRepository:43`: `leaseBatch`가 \"deprecated for the relay's use\" | `@Deprecated` 0건, 컨테이너 테스트가 사용 | **미강제** |"
},
{
"line": 34402,
"text": "| `OutboxRecord` javadoc: outbox만으로는 중복 제거 안 됨 | `InboxRepository`가 별도 존재 | **일치** |"
},
{
"line": 34403,
"text": "| `InboxRepository.purge*` javadoc: 보존이 브로커 재전달 창보다 길어야 함 | 그 비교를 하는 코드 없음 | **미강제** |"
},
{
"line": 34404,
"text": "| `TransactionalMessageAction` javadoc: 구현이 settle/publish/트랜잭션 시작 금지 | 타입이 강제하지 않음 | **미강제** |"
},
{
"line": 34405,
"text": "| `ReliableMessagePublisher` javadoc: dual-write의 답 | 구현 0 | **미실현** |"
},
{
"line": 34406,
"text": "| `OutboxTransitionResult.STALE_LEASE` javadoc: \"it belongs on a metric\" | 이 leaf에 메트릭 없음. outbox leaf가 답함 | **미확인** |"
},
{
"line": 34407,
"text": "| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |"
},
{
"line": 34408,
"text": ""
},
{
"line": 34409,
"text": "---"
},
{
"line": 34410,
"text": ""
},
{
"line": 34411,
"text": "#### 13. Git/설계 문서에서 확인한 변화와 실패 기록"
},
{
"line": 34412,
"text": ""
},
{
"line": 34413,
"text": "이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다."
},
{
"line": 34414,
"text": ""
},
{
"line": 34415,
"text": "| 위치 | 이전 상태 | 그것이 만든 실패 |"
},
{
"line": 34416,
"text": "|---|---|---|"
},
{
"line": 34417,
"text": "| `OutboxLease` javadoc | 모든 terminal 전이가 `MessageId`만 받음 | lease를 넘긴 릴레이가 다른 릴레이의 `PUBLISHED` 위에 `AMBIGUOUS`를 기록 → 행이 다시 claim 가능해짐 → **한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서** |"
},
{
"line": 34418,
"text": "| `OutboxTransitionResult` javadoc | 전이가 `void` 반환 | 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 **그 불일치를 아무도 세지 않음** |"
},
{
"line": 34419,
"text": "| `OutboxCanonicalMetadata` javadoc | provenance가 어디에도 없음 | 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 `Optional.empty()`가 되거나 헤더 맵에 예약 이름으로 밀반입 → **outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착**, 즉 발행 경로가 메시지의 의미의 일부가 됨 |"
},
{
"line": 34420,
"text": "| `OutboxRepository.purgePublishedBefore` javadoc | 무제한 삭제 | 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → **릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤** |"
},
{
"line": 34421,
"text": ""
},
{
"line": 34422,
"text": "첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다."
},
{
"line": 34423,
"text": ""
},
{
"line": 34424,
"text": "세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — **\"which makes the publish path — direct, polling or CDC — part of the message's meaning.\"** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다."
},
{
"line": 34425,
"text": ""
},
{
"line": 34426,
"text": "---"
},
{
"line": 34427,
"text": ""
},
{
"line": 34428,
"text": "#### 14. 런타임·터미널 Evidence"
},
{
"line": 34429,
"text": ""
},
{
"line": 34430,
"text": "| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |"
},
{
"line": 34431,
"text": "|---|---|---|---|---|"
},
{
"line": 34432,
"text": "| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 | 정적 검색. `messaging-inbox-jdbc-postgresql`이 판정 소유 |"
},
{
"line": 34433,
"text": "| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | `src/test` 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, `OutboxRepository`의 두 세대 시그니처 전수, `@Deprecated` 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 | 정적 검색. 이 leaf에 실행할 테스트 레인이 없음 |"
},
{
"line": 34434,
"text": ""
},
{
"line": 34435,
"text": "**이 leaf에는 test lane evidence가 없다** — `src/test`가 존재하지 않으므로 `:messaging:messaging-reliability-api:test`는 실행할 소스가 없다."
},
{
"line": 34436,
"text": ""
},
{
"line": 34437,
"text": "---"
},
{
"line": 34438,
"text": ""
},
{
"line": 34439,
"text": "#### 15. 명시적 설계 이유와 추론을 구분한 정리"
},
{
"line": 34440,
"text": ""
},
{
"line": 34441,
"text": "**명시적**"
},
{
"line": 34442,
"text": ""
},
{
"line": 34443,
"text": "- outbox만으로 중복이 제거되지 않는 이유 — `OutboxRecord` javadoc"
},
{
"line": 34444,
"text": "- fencing token이 필요한 이유와 이전 이중 발행 — `OutboxLease` javadoc"
},
{
"line": 34445,
"text": "- 전이가 결과를 반환해야 하는 이유 — `OutboxTransitionResult` javadoc"
},
{
"line": 34446,
"text": "- `AMBIGUOUS`가 실패의 한 종류가 아닌 이유, `EXHAUSTED`가 `FAILED`와 다른 이유 — `OutboxStatus` javadoc"
},
{
"line": 34447,
"text": "- provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — `OutboxCanonicalMetadata` javadoc"
},
{
"line": 34448,
"text": "- 두 반쪽의 소유자가 다른 이유 — `OutboxRecord` javadoc"
},
{
"line": 34449,
"text": "- inbox 키가 (message, consumer)인 이유 — `InboxRecord`·`IdempotentMessageHandler` javadoc"
},
{
"line": 34450,
"text": "- `InboxResult`가 셋인 이유 — 그 javadoc"
},
{
"line": 34451,
"text": "- 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — `InboxRepository`·`TransactionalMessageAction` javadoc"
},
{
"line": 34452,
"text": "- `addToOutbox`가 `void`인 이유 — `ReliableMessagePublisher` javadoc"
},
{
"line": 34453,
"text": "- claim check digest와 만료가 필수인 이유 — `ClaimCheckReference` javadoc"
},
{
"line": 34454,
"text": "- purge에 `limit`이 필요한 이유 — `OutboxRepository` javadoc"
},
{
"line": 34455,
"text": "- inbox 보존이 재전달 창보다 길어야 하는 이유 — `InboxRepository` javadoc"
},
{
"line": 34456,
"text": ""
},
{
"line": 34457,
"text": "**추론**"
},
{
"line": 34458,
"text": ""
},
{
"line": 34459,
"text": "- `ReliableMessagePublisher` 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → **추론**. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다."
},
{
"line": 34460,
"text": "- `OutboxRecord.equals`가 다섯 필드만 보는 이유 → **미상**."
},
{
"line": 34461,
"text": "- `sha256`이 소문자만 받는 이유 → **미상**(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음)."
},
{
"line": 34462,
"text": "- 구세대를 남긴 이유 → **부분 명시**(\"remains for inspection paths\"). 제거 시점은 미상."
},
{
"line": 34463,
"text": ""
},
{
"line": 34464,
"text": "---"
},
{
"line": 34465,
"text": ""
},
{
"line": 34466,
"text": "#### 16. 확인한 것 / 확인하지 못한 것"
},
{
"line": 34467,
"text": ""
},
{
"line": 34468,
"text": "**확인한 것**"
},
{
"line": 34469,
"text": ""
},
{
"line": 34470,
"text": "- 13개 타입 817줄 전문의 계약과 불변식"
},
{
"line": 34471,
"text": "- 이 leaf에 테스트가 하나도 없다는 것(`src/test` 부재)"
},
{
"line": 34472,
"text": "- `ReliableMessagePublisher`와 `InboxRecord`의 참조 0"
},
{
"line": 34473,
"text": "- `OutboxRepository`가 같은 다섯 전이의 두 세대를 갖고 `@Deprecated`가 하나도 없다는 것"
},
{
"line": 34474,
"text": "- production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것"
},
{
"line": 34475,
"text": "- 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태"
},
{
"line": 34476,
"text": "- `OutboxStatus.FAILED`의 의미가 저장소 ArchUnit 규칙의 근거라는 것"
},
{
"line": 34477,
"text": ""
},
{
"line": 34478,
"text": "**확인하지 못한 것**"
},
{
"line": 34479,
"text": ""
},
{
"line": 34480,
"text": "- **fencing token SQL이 실제 PostgreSQL에서 정확한지.** 그것을 검증할 레인이 다른 세대를 쓴다. `messaging-outbox-jdbc-postgresql` leaf가 이 판정을 소유한다."
},
{
"line": 34481,
"text": "- `STALE_LEASE`가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다."
},
{
"line": 34482,
"text": "- inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다."
},
{
"line": 34483,
"text": "- `ReliableMessagePublisher`를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지."
},
{
"line": 34484,
"text": "- `OutboxRecord.equals`의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다."
},
{
"line": 34485,
"text": ""
},
{
"line": 34486,
"text": "---"
},
{
"line": 34487,
"text": ""
},
{
"line": 34488,
"text": "#### 17. 손볼 것"
},
{
"line": 34489,
"text": ""
},
{
"line": 34490,
"text": "##### P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 `@Deprecated`가 없다"
},
{
"line": 34491,
"text": ""
},
{
"line": 34492,
"text": "- **사실.** `OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다. javadoc이 전자를 \"deprecated for the relay's use\"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다."
},
{
"line": 34493,
"text": "- **근거.** `evidence/raw/289` §E·§F."
},
{
"line": 34494,
"text": "- **왜 문제인가.** 전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다."
},
{
"line": 34495,
"text": "- **확인 방법.** `git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api` → 없음. `evidence/raw/289` §E."
},
{
"line": 34496,
"text": "- **후보.** (a) 구세대 다섯에 `@Deprecated`를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(`OutboxInspection`)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다."
},
{
"line": 34497,
"text": "- **다음 단계.** **CASE 후보 + REFERENCE 후보.** \"prose deprecation은 컴파일러가 읽지 않는다\"가 재사용 가능한 기준이다."
},
{
"line": 34498,
"text": ""
},
{
"line": 34499,
"text": "##### P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다"
},
{
"line": 34500,
"text": ""
},
{
"line": 34501,
"text": "- **사실.** `OutboxRelay`는 `claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT`는 `leaseBatch`/`MessageId` 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository`와 `RecordingRepository` — SQL이 없는 fake다."
},
{
"line": 34502,
"text": "- **근거.** `evidence/raw/289` §G."
},
{
"line": 34503,
"text": "- **왜 문제인가.** fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 \"its update matches zero rows\"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**"
},
{
"line": 34504,
"text": "- **확인 방법.** `evidence/raw/289` §G 재실행. `OutboxPostgresIT`에서 `claimBatch` 검색 → 없음."
},
{
"line": 34505,
"text": "- **후보.** 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다."
},
{
"line": 34506,
"text": "- **다음 단계.** **판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. **CASE 후보**(그 leaf)."
},
{
"line": 34507,
"text": ""
},
{
"line": 34508,
"text": "##### P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다"
},
{
"line": 34509,
"text": ""
},
{
"line": 34510,
"text": "- **사실.** `ReliableMessagePublisher`가 구현 0, 참조 0이다. javadoc은 \"This is the answer to the dual-write problem\"이라고 한다."
},
{
"line": 34511,
"text": "- **근거.** `evidence/raw/289` §B·§C·§D."
},
{
"line": 34512,
"text": "- **왜 문제인가.** `OutboxRepository.append`가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, `ReliableMessagePublisher`는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 **애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로** 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 \"릴레이가 읽는 쪽\"만 배선돼 있고 \"애플리케이션이 쓰는 쪽\"이 비어 있다."
},
{
"line": 34513,
"text": "- **확인 방법.** `git grep -n -E 'implements .*ReliableMessagePublisher' -- src` → 없음."
},
{
"line": 34514,
"text": "- **후보.** (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 \"파생 프로젝트가 구현하는 확장점\"임을 명시한다."
},
{
"line": 34515,
"text": "- **다음 단계.** **OPEN QUESTION 후보.** 판정이 \"두 outbox 모델 중 어느 쪽이 정본인가\"에 걸리고, 그 질문은 `application-core`와 cross-scope가 함께 답한다."
},
{
"line": 34516,
"text": ""
},
{
"line": 34517,
"text": "##### P3 — 이 leaf에 테스트가 없다"
},
{
"line": 34518,
"text": ""
},
{
"line": 34519,
"text": "- **사실.** `src/test` 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다."
},
{
"line": 34520,
"text": "- **근거.** `evidence/raw/289` §A."
},
{
"line": 34521,
"text": "- **왜 문제인가.** 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: `OutboxCanonicalMetadata`가 `schemaUri` 있고 `schemaSubject` 없는 조합을 거절하는 것, `OutboxLease`가 `token < 1`을 거절하는 것, `InboxResult.isSafeToSettle`의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(`messaging-core-api` 79개, `messaging-policy` 42개 등)."
},
{
"line": 34522,
"text": "- **확인 방법.** `ls src/messaging/messaging-reliability-api/src` → `main`만."
},
{
"line": 34523,
"text": "- **후보.** record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다."
},
{
"line": 34524,
"text": "- **다음 단계.** **REFERENCE 후보**(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다)."
},
{
"line": 34525,
"text": ""
},
{
"line": 34526,
"text": "##### P3 — inbox 보존 규칙이 문서로만 있다"
},
{
"line": 34527,
"text": ""
},
{
"line": 34528,
"text": "- **사실.** `InboxRepository.purgeProcessedBefore` javadoc이 \"Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time\"라고 한다. 그 비교를 하는 코드가 이 leaf에도 `messaging-policy`의 프로파일 검증기에도 없다."
},
{
"line": 34529,
"text": "- **근거.** 해당 javadoc. `DestinationProfileValidator` 16규칙 전수(재전달 창 관련 없음)."
},
{
"line": 34530,
"text": "- **왜 문제인가.** 위반의 결과가 **부작용의 이중 실행**이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다."
},
{
"line": 34531,
"text": "- **확인 방법.** `git grep -n -i 'redelivery window\\|retention' -- 'src/messaging/**/*.java'`"
},
{
"line": 34532,
"text": "- **후보.** 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 `messaging-policy`나 starter에 추가한다."
},
{
"line": 34533,
"text": "- **다음 단계.** **CASE 후보 + REFERENCE 후보**(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다)."
},
{
"line": 34534,
"text": ""
},
{
"line": 34535,
"text": "##### P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다"
},
{
"line": 34536,
"text": ""
},
{
"line": 34537,
"text": "- **사실.** `OutboxRepository.append`가 호출자 트랜잭션 안, `InboxRepository.reserve`가 부작용과 같은 트랜잭션, `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다."
},
{
"line": 34538,
"text": "- **근거.** 세 javadoc."
},
{
"line": 34539,
"text": "- **왜 문제인가.** `ReliableMessagePublisher`는 `void` 반환으로 계약의 일부를 타입에 담았다(\"Handing back a `PublishResult` here would be a lie\"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — `InboxRepository.reserve`를 별도 트랜잭션에서 부르면 \"exactly the gap the Inbox exists to close\"가 다시 열린다."
},
{
"line": 34540,
"text": "- **확인 방법.** 세 javadoc과 구현의 `@Transactional` 배치 대조 — 구현 leaf가 소유한다."
},
{
"line": 34541,
"text": "- **후보.** 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 `append`/`reserve` 호출부의 트랜잭션 컨텍스트를 검사한다."
},
{
"line": 34542,
"text": "- **다음 단계.** **REFERENCE 후보**(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다)."
},
{
"line": 34543,
"text": ""
},
{
"line": 34544,
"text": "##### P3 — `OutboxRecord.equals`가 다섯 필드만 비교하고 이유가 없다"
},
{
"line": 34545,
"text": ""
},
{
"line": 34546,
"text": "- **사실.** `equals`/`hashCode`가 `messageId`·`status`·`attempts`·`payload` 넷만 본다. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 무시한다."
},
{
"line": 34547,
"text": "- **근거.** `OutboxRecord.java:114-126`."
},
{
"line": 34548,
"text": "- **왜 문제인가.** record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(`messaging-schema-api`의 `EncodedMessage`도 같다). 그러나 `EncodedMessage`는 **모든 필드**를 비교하고 이쪽은 아니다. 같은 `messageId`·`status`·`attempts`·`payload`를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다."
},
{
"line": 34549,
"text": "- **확인 방법.** 두 record의 `equals` 대조."
},
{
"line": 34550,
"text": "- **후보.** 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다."
},
{
"line": 34551,
"text": "- **다음 단계.** **REFERENCE 후보**(record의 `equals`를 좁히면 이유를 적는다)."
},
{
"line": 34552,
"text": ""
},
{
"line": 34553,
"text": "##### P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다"
},
{
"line": 34554,
"text": ""
},
{
"line": 34555,
"text": "- **사실.** `InboxRepository`와 `OutboxRepository`가 각각 `purge*Before(Instant)`와 `purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다."
},
{
"line": 34556,
"text": "- **근거.** `evidence/raw/294-bounded-purge-never-called.txt`."
},
{
"line": 34557,
"text": "- **왜 문제인가.** §12.1(a)의 두 세대 전이와 같은 형태다 — **한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.** 두 경우 모두 포트의 형태가 오용을 가능하게 했다."
},
{
"line": 34558,
"text": "- **확인 방법.** `git grep -n -E 'purge(Processed|Published)Before\\s*\\([^)]*,' -- 'src/**/*.java'`"
},
{
"line": 34559,
"text": "- **다음 단계.** 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 **같은 CASE로 묶을 후보**다."
},
{
"line": 34560,
"text": ""
},
{
"line": 34561,
"text": "##### 확인된 설계(문제 아님)"
},
{
"line": 34562,
"text": ""
},
{
"line": 34563,
"text": "- outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것"
},
{
"line": 34564,
"text": "- fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계"
},
{
"line": 34565,
"text": "- `AMBIGUOUS`/`FAILED`/`EXHAUSTED` 세 상태의 구분과 각각의 운영 행동 차이"
},
{
"line": 34566,
"text": "- `InboxResult`가 셋이고 `isSafeToSettle()`이 그 판단을 모으는 것"
},
{
"line": 34567,
"text": "- inbox 키가 (message, consumer)인 것"
},
{
"line": 34568,
"text": "- provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것"
},
{
"line": 34569,
"text": "- `withStatus`가 `messageId`를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것"
},
{
"line": 34570,
"text": "- `addToOutbox`의 `void` 반환이 계약인 것"
},
{
"line": 34571,
"text": "- claim check의 digest와 만료가 필수인 것"
},
{
"line": 34572,
"text": "- 두 outbox 모델을 ArchUnit으로 격리한 것"
},
{
"line": 34573,
"text": ""
},
{
"line": 34574,
"text": "---"
},
{
"line": 34575,
"text": ""
},
{
"line": 34576,
"text": "#### Source anchors"
},
{
"line": 34577,
"text": ""
},
{
"line": 34578,
"text": "| id | kind | path | revision | what it proves | limitations |"
},
{
"line": 34579,
"text": "|---|---|---|---|---|---|"
},
{
"line": 34580,
"text": "| MRA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `[\"app-bootstrap\"]` | 선언 |"
},
{
"line": 34581,
"text": "| MRA-002 | build | `messaging-reliability-api/build.gradle` | same | 벤더 의존성 0 | — |"
},
{
"line": 34582,
"text": "| MRA-003 | code | `.../reliability/OutboxRepository.java` 전문 | same | 두 세대 17메서드, purge limit 이유 | `@Deprecated` 없음 |"
},
{
"line": 34583,
"text": "| MRA-004 | code | `.../reliability/OutboxLease.java` | same | fencing token과 이중 발행 이력 | — |"
},
{
"line": 34584,
"text": "| MRA-005 | code | `.../reliability/OutboxTransitionResult.java` | same | void 반환이 삼킨 것 | — |"
},
{
"line": 34585,
"text": "| MRA-006 | code | `.../reliability/OutboxStatus.java` | same | 여섯 상태와 두 구분의 이유 | — |"
},
{
"line": 34586,
"text": "| MRA-007 | code | `.../reliability/OutboxCanonicalMetadata.java` | same | provenance 결함 이력, 기각된 대안 | — |"
},
{
"line": 34587,
"text": "| MRA-008 | code | `.../reliability/OutboxRecord.java` | same | 두 반쪽 분리, 방어 복사, 좁은 equals | equals 이유 없음(§17) |"
},
{
"line": 34588,
"text": "| MRA-009 | code | `.../reliability/{InboxRepository,InboxRecord,InboxResult}.java` | same | 트랜잭션 계약, (message,consumer) 키, 세 판정 | `InboxRecord` 참조 0 |"
},
{
"line": 34589,
"text": "| MRA-010 | code | `.../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java` | same | 멱등 핸들러 계약과 세 금지 | 금지 미강제 |"
},
{
"line": 34590,
"text": "| MRA-011 | code | `.../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java` | same | dual-write 답, digest 필수 | publisher 구현 0 |"
},
{
"line": 34591,
"text": "| MRA-012 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205` | same | production이 신세대만 사용 | 해당 leaf SSOT가 소유 |"
},
{
"line": 34592,
"text": "| MRA-013 | cross-leaf test | `messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200` | same | 컨테이너 테스트가 구세대만 사용 | 해당 leaf SSOT가 소유 |"
},
{
"line": 34593,
"text": "| MRA-014 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `OutboxStatus.FAILED` 의미가 규칙의 근거 | 정적 분석 |"
},
{
"line": 34594,
"text": "| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | same | §12.1 전부, `src/test` 부재 | 정적 검색. 이 leaf에 테스트 레인 없음 |"
},
{
"line": 34595,
"text": ""
},
{
"line": 34596,
"text": "---"
},
{
"line": 34597,
"text": ""
},
{
"line": 34598,
"text": "## A19-MESSAGING-RUNTIME-CORE. messaging-runtime-core"
},
{
"line": 34599,
"text": ""
},
{
"line": 34600,
"text": "> 분석 중에는 `messaging/MESSAGING-RUNTIME-CORE.md` 파일이었다. 807줄."
},
{
"line": 34601,
"text": ""
},
{
"line": 34602,
"text": "### messaging-runtime-core 완전 해부"
},
{
"line": 34603,
"text": ""
},
{
"line": 34604,
"text": "> 상태: COMPLETE"
},
{
"line": 34605,
"text": "> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`"
},
{
"line": 34606,
"text": "> 분석 범위: `src/messaging/messaging-runtime-core`"
},
{
"line": 34607,
"text": "> SSOT owner: `messaging-runtime-core`"
},
{
"line": 34608,
"text": "> integration/family document: §A19 (secondary, INTEGRATION_ONLY)"
},
{
"line": 34609,
"text": ""
},
{
"line": 34610,
"text": "---"
},
{
"line": 34611,
"text": ""
},
{
"line": 34612,
"text": "#### 0. SSOT identity / 커버리지와 숫자 지도"
},
{
"line": 34613,
"text": ""
},
{
"line": 34614,
"text": "- registered leaf id: `messaging-runtime-core`"
},
{
"line": 34615,
"text": "- canonical state `analysisFile`: §A19-MESSAGING-RUNTIME-CORE"
},
{
"line": 34616,
"text": "- source path: `src/messaging/messaging-runtime-core`"
},
{
"line": 34617,
"text": "- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\", \"messaging-policy\", \"messaging-transport-spi\", \"messaging-security\", \"messaging-observability\"]` — messaging family에서 두 번째로 많은 의존"
},
{
"line": 34618,
"text": "- registry `runtime_memberships`: `[\"app-bootstrap\"]`"
},
{
"line": 34619,
"text": ""
},
{
"line": 34620,
"text": "##### 숫자"
},
{
"line": 34621,
"text": ""
},
{
"line": 34622,
"text": "| 항목 | 수 |"
},
{
"line": 34623,
"text": "|---|---:|"
},
{
"line": 34624,
"text": "| production Java 파일 | **6** |"
},
{
"line": 34625,
"text": "| production LOC | 787 |"
},
{
"line": 34626,
"text": "| 패키지 | 1 (`dev.caskeleton.messaging.runtime`) |"
},
{
"line": 34627,
"text": "| test 파일 | 4 (테스트 3 + fixture 1) |"
},
{
"line": 34628,
"text": "| test 메서드(실행 확인) | 21 |"
},
{
"line": 34629,
"text": "| 외부(비프로젝트) 의존성 | **0** |"
},
{
"line": 34630,
"text": ""
},
{
"line": 34631,
"text": "여섯 클래스:"
},
{
"line": 34632,
"text": ""
},
{
"line": 34633,
"text": "| 클래스 | LOC | 역할 | 출하 조립 |"
},
{
"line": 34634,
"text": "|---|---:|---|---|"
},
{
"line": 34635,
"text": "| `DefaultMessagePublisher` | 366 | **유일한 발행 경로** | o (`:446`) |"
},
{
"line": 34636,
"text": "| `DefaultDeliveryProcessor` | 155 | 핸들러 결과 → 정산 | **x** |"
},
{
"line": 34637,
"text": "| `RegisteredMessageCodecs` | 89 | content type → codec | o (`:363`) |"
},
{
"line": 34638,
"text": "| `DestinationProfileRegistry` | 62 | 논리 이름 → 프로파일 | o (`:377`) |"
},
{
"line": 34639,
"text": "| `TransportMessagingRuntime` | 67 | transport를 세대로 포장 | o (`:476`) |"
},
{
"line": 34640,
"text": "| `DeclaredDestinationAccess` | 48 | 기본 접근 정책 | o |"
},
{
"line": 34641,
"text": ""
},
{
"line": 34642,
"text": "##### Coverage ledger"
},
{
"line": 34643,
"text": ""
},
{
"line": 34644,
"text": "| scope/file group | count | disposition | reason |"
},
{
"line": 34645,
"text": "|---|---:|---|---|"
},
{
"line": 34646,
"text": "| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |"
},
{
"line": 34647,
"text": "| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |"
},
{
"line": 34648,
"text": "| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |"
},
{
"line": 34649,
"text": "| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |"
},
{
"line": 34650,
"text": "| `build/**` | — | `EXCLUDED` | 빌드 산출물 |"
},
{
"line": 34651,
"text": ""
},
{
"line": 34652,
"text": "`UNCLASSIFIED` 0."
},
{
"line": 34653,
"text": ""
},
{
"line": 34654,
"text": "---"
},
{
"line": 34655,
"text": ""
},
{
"line": 34656,
"text": "#### 1. 모듈의 정체와 경계"
},
{
"line": 34657,
"text": ""
},
{
"line": 34658,
"text": "**이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 \"X was an interface with no implementation\" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다."
},
{
"line": 34659,
"text": ""
},
{
"line": 34660,
"text": "```groovy"
},
{
"line": 34661,
"text": "// The central publish and delivery orchestration."
},
{
"line": 34662,
"text": "//"
},
{
"line": 34663,
"text": "// MessagePublisher was an interface with no implementation anywhere in the new platform: the"
},
{
"line": 34664,
"text": "// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade"
},
{
"line": 34665,
"text": "// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases"
},
{
"line": 34666,
"text": "// and observation existed as beans that no publish path ever called. A starter that filled the gap"
},
{
"line": 34667,
"text": "// with an application-supplied fake would pass a context test while running none of them."
},
{
"line": 34668,
"text": "```"
},
{
"line": 34669,
"text": ""
},
{
"line": 34670,
"text": "이 진단의 마지막 문장이 핵심이다 — **컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다."
},
{
"line": 34671,
"text": ""
},
{
"line": 34672,
"text": "six 파일이 메운 구멍:"
},
{
"line": 34673,
"text": ""
},
{
"line": 34674,
"text": "| 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 |"
},
{
"line": 34675,
"text": "|---|---|---|"
},
{
"line": 34676,
"text": "| `MessagePublisher` (core-api) | 어디에도 없음 | `DefaultMessagePublisher` |"
},
{
"line": 34677,
"text": "| `MessageCodecRegistry` (schema-api) | 어디에도 없음 | `RegisteredMessageCodecs` |"
},
{
"line": 34678,
"text": "| `MessagingRuntime` (transport-spi) | 어디에도 없음 | `TransportMessagingRuntime` |"
},
{
"line": 34679,
"text": "| (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | `DestinationProfileRegistry` |"
},
{
"line": 34680,
"text": "| `DestinationAccessPolicy` 기본값 (security) | `denyAll()`뿐 | `DeclaredDestinationAccess` |"
},
{
"line": 34681,
"text": "| `HandleResult` → 정산 (core-api) | 어댑터가 각자 결정 | `DefaultDeliveryProcessor` |"
},
{
"line": 34682,
"text": ""
},
{
"line": 34683,
"text": "여섯 중 다섯은 배선됐고 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1)."
},
{
"line": 34684,
"text": ""
},
{
"line": 34685,
"text": "---"
},
{
"line": 34686,
"text": ""
},
{
"line": 34687,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 34688,
"text": ""
},
{
"line": 34689,
"text": "들어오는 것: 여섯 project 의존, 전부 `api`. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다."
},
{
"line": 34690,
"text": ""
},
{
"line": 34691,
"text": "나가는 것: `messaging-spring-boot-starter`만."
},
{
"line": 34692,
"text": ""
},
{
"line": 34693,
"text": "**배선 지점 다섯**(전부 `MessagingCoreAutoConfiguration`):"
},
{
"line": 34694,
"text": ""
},
{
"line": 34695,
"text": "| 라인 | 무엇 |"
},
{
"line": 34696,
"text": "|---:|---|"
},
{
"line": 34697,
"text": "| 363 | `RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))` |"
},
{
"line": 34698,
"text": "| 377 | `DestinationProfileRegistry.of(destinations.all())` |"
},
{
"line": 34699,
"text": "| 446 | `new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)` |"
},
{
"line": 34700,
"text": "| 476 | `new TransportMessagingRuntime(selected.brokerName(), 1L, selected)` — `InitializingBean` 안 |"
},
{
"line": 34701,
"text": "| — | `DeclaredDestinationAccess.of(...)`로 접근 정책 bean |"
},
{
"line": 34702,
"text": ""
},
{
"line": 34703,
"text": "446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다."
},
{
"line": 34704,
"text": ""
},
{
"line": 34705,
"text": "---"
},
{
"line": 34706,
"text": ""
},
{
"line": 34707,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 34708,
"text": ""
},
{
"line": 34709,
"text": "```"
},
{
"line": 34710,
"text": "발행 (조립됨)"
},
{
"line": 34711,
"text": " DefaultMessagePublisher"
},
{
"line": 34712,
"text": " ├── DestinationProfileRegistry 논리 이름 → DestinationProfile"
},
{
"line": 34713,
"text": " ├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles)"
},
{
"line": 34714,
"text": " ├── MessageCodecRegistry ← RegisteredMessageCodecs"
},
{
"line": 34715,
"text": " ├── MessagingAdmissionController (policy)"
},
{
"line": 34716,
"text": " ├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime"
},
{
"line": 34717,
"text": " ├── MessagingTransport (transport-spi) → Kafka/Rabbit/…"
},
{
"line": 34718,
"text": " └── MessagingObservation ← NO_OBSERVATION (§12.1)"
},
{
"line": 34719,
"text": ""
},
{
"line": 34720,
"text": "소비 (조립 안 됨)"
},
{
"line": 34721,
"text": " DefaultDeliveryProcessor"
},
{
"line": 34722,
"text": " ├── Function The order below is fixed, not composed from a map of interceptors. Each stage's position is a"
},
{
"line": 34736,
"text": " * decision:"
},
{
"line": 34737,
"text": " *"
},
{
"line": 34738,
"text": " * Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as"
},
{
"line": 34784,
"text": " * the publish operation's deadline, so a slow destination lookup or a large encode spends the"
},
{
"line": 34785,
"text": " * same budget the broker wait does; timing only the transport call would let the total exceed the"
},
{
"line": 34786,
"text": " * deadline by however long preparation took."
},
{
"line": 34787,
"text": "```"
},
{
"line": 34788,
"text": ""
},
{
"line": 34789,
"text": "`remainingBudget`이 `timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — \"Sending anyway would start a message the caller has already stopped waiting for.\""
},
{
"line": 34790,
"text": ""
},
{
"line": 34791,
"text": "##### 4.3 마감을 복사본에 건다"
},
{
"line": 34792,
"text": ""
},
{
"line": 34793,
"text": "```java"
},
{
"line": 34794,
"text": "// :143-154"
},
{
"line": 34795,
"text": " * The bound is applied to a copy so that expiry never completes the transport's own stage: the"
},
{
"line": 34796,
"text": " * adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime"
},
{
"line": 34797,
"text": " * lease are released when the copy completes, which is deliberate — holding them until a stalled"
},
{
"line": 34798,
"text": " * broker answers is how a rotation waits forever on a generation nobody is using."
},
{
"line": 34799,
"text": "private static CompletableFuture Everything acquired is released exactly once, on every path — success, failure, exception and"
},
{
"line": 34815,
"text": " * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one"
},
{
"line": 34816,
"text": " * per failure until it stops accepting anything."
},
{
"line": 34817,
"text": "```"
},
{
"line": 34818,
"text": ""
},
{
"line": 34819,
"text": "두 경로가 있다."
},
{
"line": 34820,
"text": ""
},
{
"line": 34821,
"text": "```java"
},
{
"line": 34822,
"text": ".handle((result, failure) -> {"
},
{
"line": 34823,
"text": " // One release per acquisition, whatever happened."
},
{
"line": 34824,
"text": " held.close();"
},
{
"line": 34825,
"text": " admission.complete(destination.name().value());"
},
{
"line": 34826,
"text": " ..."
},
{
"line": 34827,
"text": "});"
},
{
"line": 34828,
"text": "```"
},
{
"line": 34829,
"text": ""
},
{
"line": 34830,
"text": "```java"
},
{
"line": 34831,
"text": "} catch (RuntimeException beforeTheSend) {"
},
{
"line": 34832,
"text": " if (lease != null) { lease.close(); }"
},
{
"line": 34833,
"text": " admission.complete(destination.name().value());"
},
{
"line": 34834,
"text": " return rejected(\"PUBLISH_RUNTIME_UNAVAILABLE\", ...);"
},
{
"line": 34835,
"text": "}"
},
{
"line": 34836,
"text": "```"
},
{
"line": 34837,
"text": ""
},
{
"line": 34838,
"text": "`handle`은 `whenComplete`와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. `lease.close()`는 `MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3)."
},
{
"line": 34839,
"text": ""
},
{
"line": 34840,
"text": "**한 가지 비대칭.** 6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다. `MessageTooLargeException`·`MessageBackpressureException`은 `MessagingException`이므로 호출자가 `FailureDescriptor`를 얻을 수 있지만, 반환 타입이 `CompletionStage The transports accept {@code request.options()} and read nothing from it, so an option this"
},
{
"line": 34847,
"text": " * destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for"
},
{
"line": 34848,
"text": " * broker-side deduplication got a publish with no deduplication and no error, and then skipped"
},
{
"line": 34849,
"text": " * the idempotency it would otherwise have written — which is exactly the case {@code"
},
{
"line": 34850,
"text": " * PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op."
},
{
"line": 34851,
"text": "```"
},
{
"line": 34852,
"text": ""
},
{
"line": 34853,
"text": "`messaging-core-api`의 `PublishDeduplication` javadoc(\"Requesting this on a broker without the `deduplicatedPublish` capability is a startup failure, not a silent no-op\")이 여기서 실제 검사가 된다. 다만 **startup이 아니라 publish 시점**이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17."
},
{
"line": 34854,
"text": ""
},
{
"line": 34855,
"text": "그리고 \"The transports accept `request.options()` and read nothing from it\"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다."
},
{
"line": 34856,
"text": ""
},
{
"line": 34857,
"text": "##### 4.6 `encode` — 폴백이 기본 codec이다"
},
{
"line": 34858,
"text": ""
},
{
"line": 34859,
"text": "```java"
},
{
"line": 34860,
"text": "private Nothing resolved a logical destination to a profile before this: the brokers took an"
},
{
"line": 34875,
"text": " * already-resolved {@code DestinationProfile} and the publisher that would have produced one did"
},
{
"line": 34876,
"text": " * not exist. A registry rather than a lookup with a fallback, because a destination nobody declared"
},
{
"line": 34877,
"text": " * has no physical name, no ordering guarantee and no payload bound — publishing to it would mean"
},
{
"line": 34878,
"text": " * inventing all three at the call site."
},
{
"line": 34879,
"text": "```"
},
{
"line": 34880,
"text": ""
},
{
"line": 34881,
"text": "`require`가 미등록 목적지에 `MessagingConfigurationException(\"DESTINATION_NOT_REGISTERED\")`을 던지고 메시지가 세 가지 부재를 나열한다. `empty()` factory도 있다 — \"every publish is refused until a destination is declared\"."
},
{
"line": 34882,
"text": ""
},
{
"line": 34883,
"text": "##### 4.8 `RegisteredMessageCodecs` — 기본 codec은 명시 선택"
},
{
"line": 34884,
"text": ""
},
{
"line": 34885,
"text": "```java"
},
{
"line": 34886,
"text": "// :18-27"
},
{
"line": 34887,
"text": " * The default codec is a deliberate choice rather than \"the first one registered\". Selecting one"
},
{
"line": 34888,
"text": " * by iteration order means the encoding a message is written with depends on how the map was"
},
{
"line": 34889,
"text": " * populated, which is a wire-format decision made by accident. The registry takes it explicitly and"
},
{
"line": 34890,
"text": " * refuses to be constructed without it."
},
{
"line": 34891,
"text": " *"
},
{
"line": 34892,
"text": " * The raw-bytes codec is never eligible as the default — that is the contract's own rule, and"
},
{
"line": 34893,
"text": " * the reason is that raw bytes silently disable schema validation for every destination that forgot"
},
{
"line": 34894,
"text": " * to declare an encoding."
},
{
"line": 34895,
"text": "```"
},
{
"line": 34896,
"text": ""
},
{
"line": 34897,
"text": "두 가지를 생성자에서 거절한다."
},
{
"line": 34898,
"text": ""
},
{
"line": 34899,
"text": "```java"
},
{
"line": 34900,
"text": "if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }"
},
{
"line": 34901,
"text": "..."
},
{
"line": 34902,
"text": "MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);"
},
{
"line": 34903,
"text": "if (existing != null && existing != codec) {"
},
{
"line": 34904,
"text": " // Two codecs for one content type is not a preference to resolve at runtime: whichever wins"
},
{
"line": 34905,
"text": " // decides how bytes on the wire are read by a consumer that was compiled against the other."
},
{
"line": 34906,
"text": " throw new IllegalArgumentException(\"two codecs claim content type \" + ...);"
},
{
"line": 34907,
"text": "}"
},
{
"line": 34908,
"text": "```"
},
{
"line": 34909,
"text": ""
},
{
"line": 34910,
"text": "**클래스가 아니라 content type으로 raw-bytes를 거절**하는 것이 `messaging-schema-api`의 규칙보다 넓다 — 그 leaf §12.2가 소유한다."
},
{
"line": 34911,
"text": ""
},
{
"line": 34912,
"text": "##### 4.9 `TransportMessagingRuntime` — 얇은 포장"
},
{
"line": 34913,
"text": ""
},
{
"line": 34914,
"text": "`MessagingRuntime` 구현으로 `brokerName`·`generation`·`transport` 셋을 들고 `close()`가 CAS로 멱등이다."
},
{
"line": 34915,
"text": ""
},
{
"line": 34916,
"text": "```java"
},
{
"line": 34917,
"text": "// close():61-62"
},
{
"line": 34918,
"text": "// Idempotent: the registry closes a drained generation, and a context shutdown may close it"
},
{
"line": 34919,
"text": "// again. Closing a transport twice is not an error worth propagating into shutdown."
},
{
"line": 34920,
"text": "```"
},
{
"line": 34921,
"text": ""
},
{
"line": 34922,
"text": "`DefaultMessagingRuntimeRegistry`(transport-spi)도 자체 `closed` CAS를 갖는다 — **두 층이 각각 멱등**이다. 중복 방어이지만 `transport-spi`의 `Generation.forceClose()`가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다."
},
{
"line": 34923,
"text": ""
},
{
"line": 34924,
"text": "**generation이 항상 `1L`이다.** starter의 유일한 설치 지점(`:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 \"increasing with each replacement\"라고 하고, `TransportMessagingRuntime` javadoc은 \"the credential generation a rotation increments\"라고 한다. 회전 코드가 없으므로 항상 1이다. §17."
},
{
"line": 34925,
"text": ""
},
{
"line": 34926,
"text": "##### 4.10 `DeclaredDestinationAccess` — 기본값의 세 번째 선택지"
},
{
"line": 34927,
"text": ""
},
{
"line": 34928,
"text": "```java"
},
{
"line": 34929,
"text": "// :13-32"
},
{
"line": 34930,
"text": " * {@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}"
},
{
"line": 34931,
"text": " * factory. Neither is a usable default on its own:"
},
{
"line": 34932,
"text": " *"
},
{
"line": 34933,
"text": " * So the default is neither: a deployment may publish to the destinations it declared."
},
{
"line": 34939,
"text": " * … a message to a destination nobody declared is not an access-control edge case, it is a typo or"
},
{
"line": 34940,
"text": " * a module reaching past its own contract."
},
{
"line": 34941,
"text": " *"
},
{
"line": 34942,
"text": " * Consume and administer stay empty. A publisher's default has no business granting either, and"
},
{
"line": 34943,
"text": " * a deployment that needs them replaces this bean — which is the point of it being a bean."
},
{
"line": 34944,
"text": "```"
},
{
"line": 34945,
"text": ""
},
{
"line": 34946,
"text": "**publish만 허용하고 consume·administer는 빈 집합**이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다."
},
{
"line": 34947,
"text": ""
},
{
"line": 34948,
"text": "##### 4.11 `DefaultDeliveryProcessor` — 두 규칙 (미조립)"
},
{
"line": 34949,
"text": ""
},
{
"line": 34950,
"text": "```java"
},
{
"line": 34951,
"text": "// :27-36"
},
{
"line": 34952,
"text": " * A driver message can carry a routing key, a payload fragment or a connection string, and a"
},
{
"line": 35018,
"text": " * {@code FailureDescriptor} is designed to be logged and exported."
},
{
"line": 35019,
"text": "return cause.getClass().getSimpleName();"
},
{
"line": 35020,
"text": "```"
},
{
"line": 35021,
"text": ""
},
{
"line": 35022,
"text": "`messaging-core-api`의 `FailureDescriptor` javadoc(\"no payload, no stack trace, no credential\")과 같은 관심사다."
},
{
"line": 35023,
"text": ""
},
{
"line": 35024,
"text": "`isDeadline`과 `sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다."
},
{
"line": 35025,
"text": ""
},
{
"line": 35026,
"text": "`DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다."
},
{
"line": 35027,
"text": ""
},
{
"line": 35028,
"text": "---"
},
{
"line": 35029,
"text": ""
},
{
"line": 35030,
"text": "#### 7. 트랜잭션·동시성·수명주기"
},
{
"line": 35031,
"text": ""
},
{
"line": 35032,
"text": "트랜잭션 없음."
},
{
"line": 35033,
"text": ""
},
{
"line": 35034,
"text": "| 지점 | 도구 | 보호 |"
},
{
"line": 35035,
"text": "|---|---|---|"
},
{
"line": 35036,
"text": "| `OneShotSettlement.settled` | `AtomicBoolean` CAS | 정확히 한 번 정산 |"
},
{
"line": 35037,
"text": "| `TransportMessagingRuntime.closed` | `AtomicBoolean` CAS | 정확히 한 번 transport close |"
},
{
"line": 35038,
"text": "| `RegisteredMessageCodecs.byContentType` | `Map.copyOf` | 불변 |"
},
{
"line": 35039,
"text": "| `DestinationProfileRegistry.profiles` | `Map.copyOf` | 불변 |"
},
{
"line": 35040,
"text": "| `withDeadline`의 `.copy()` | `CompletableFuture` | 어댑터 stage와 이쪽 경로 분리 |"
},
{
"line": 35041,
"text": ""
},
{
"line": 35042,
"text": "`DefaultMessagePublisher` 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. `lease`만 메서드 지역 변수이고 `handle` 람다가 `held`라는 effectively-final 복사본으로 캡처한다."
},
{
"line": 35043,
"text": ""
},
{
"line": 35044,
"text": "수명주기 참여는 `TransportMessagingRuntime.close()`뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다."
},
{
"line": 35045,
"text": ""
},
{
"line": 35046,
"text": "---"
},
{
"line": 35047,
"text": ""
},
{
"line": 35048,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 35049,
"text": ""
},
{
"line": 35050,
"text": "설정 없음. 이 leaf의 모든 값은 생성자 인자다."
},
{
"line": 35051,
"text": ""
},
{
"line": 35052,
"text": "**주입 가능한 두 지점**이 테스트 가능성을 만든다."
},
{
"line": 35053,
"text": ""
},
{
"line": 35054,
"text": "| 인자 | 기본 | 목적 |"
},
{
"line": 35055,
"text": "|---|---|---|"
},
{
"line": 35056,
"text": "| `LongSupplier nanoTime` | `System::nanoTime` | 경과 시간을 sleep 없이 테스트 |"
},
{
"line": 35057,
"text": "| `MessagingObservation observation` | `NO_OBSERVATION` | 관측 주입 |"
},
{
"line": 35058,
"text": ""
},
{
"line": 35059,
"text": "두 번째의 기본값이 §12.1의 발견 지점이다."
},
{
"line": 35060,
"text": ""
},
{
"line": 35061,
"text": "`TransportMessagingRuntime`의 `generation`은 생성자 인자이고 유일한 호출자가 `1L`을 넘긴다."
},
{
"line": 35062,
"text": ""
},
{
"line": 35063,
"text": "---"
},
{
"line": 35064,
"text": ""
},
{
"line": 35065,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 35066,
"text": ""
},
{
"line": 35067,
"text": "없다. 브로커 접촉은 `MessagingTransport` 인터페이스 뒤에 있다."
},
{
"line": 35068,
"text": ""
},
{
"line": 35069,
"text": "---"
},
{
"line": 35070,
"text": ""
},
{
"line": 35071,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 35072,
"text": ""
},
{
"line": 35073,
"text": "레인: `./gradlew :messaging:messaging-runtime-core:test`. **BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures**."
},
{
"line": 35074,
"text": ""
},
{
"line": 35075,
"text": "| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |"
},
{
"line": 35076,
"text": "|---|---:|---|---|"
},
{
"line": 35077,
"text": "| `DefaultMessagePublisherTest` | 10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. **출하 조립이 관측을 넘기는지** |"
},
{
"line": 35078,
"text": "| `DefaultDeliveryProcessorTest` | 7 | `HandleResult` 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 | **production에서 호출되는지**(§12.1) |"
},
{
"line": 35079,
"text": "| `RegisteredMessageCodecsTest` | 4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — |"
},
{
"line": 35080,
"text": ""
},
{
"line": 35081,
"text": "`DefaultMessagePublisherTest:271`이 익명 `MessagingObservation`을 만들어 관측 호출을 확인한다. 즉 **테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다.** 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다."
},
{
"line": 35082,
"text": ""
},
{
"line": 35083,
"text": "`RecordingTransport`(`:426`)가 `MessagingTransport`를 구현해 전송을 대체한다. 그래서 이 레인은 \"발행 오케스트레이션이 옳다\"를 증명하고 \"어댑터가 계약을 지킨다\"는 증명하지 않는다."
},
{
"line": 35084,
"text": ""
},
{
"line": 35085,
"text": "---"
},
{
"line": 35086,
"text": ""
},
{
"line": 35087,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 35088,
"text": ""
},
{
"line": 35089,
"text": "| 게이트 | 이 leaf에 대해 |"
},
{
"line": 35090,
"text": "|---|---|"
},
{
"line": 35091,
"text": "| `verifyCleanArchitectureDependencies` | 여섯 project 의존 |"
},
{
"line": 35092,
"text": "| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |"
},
{
"line": 35093,
"text": "| vendor `api` 규칙 | 벤더 의존성 0 |"
},
{
"line": 35094,
"text": "| ArchUnit | 전용 규칙 없음 |"
},
{
"line": 35095,
"text": ""
},
{
"line": 35096,
"text": "`MessagingStarterOffContractTest`(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — \"DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …\". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다."
},
{
"line": 35097,
"text": ""
},
{
"line": 35098,
"text": "---"
},
{
"line": 35099,
"text": ""
},
{
"line": 35100,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 35101,
"text": ""
},
{
"line": 35102,
"text": "원시 증거: `evidence/raw/283-runtime-core-observation-noop.txt`."
},
{
"line": 35103,
"text": ""
},
{
"line": 35104,
"text": "##### 12.1 Public surface reachability"
},
{
"line": 35105,
"text": ""
},
{
"line": 35106,
"text": "| 타입 | leaf 밖 파일 | 출하 조립 |"
},
{
"line": 35107,
"text": "|---|---:|---|"
},
{
"line": 35108,
"text": "| `DefaultMessagePublisher` | 2 | **o** — `MessagingCoreAutoConfiguration:446` |"
},
{
"line": 35109,
"text": "| `TransportMessagingRuntime` | 1 | **o** — `:476` |"
},
{
"line": 35110,
"text": "| `RegisteredMessageCodecs` | 1 | **o** — `:363` |"
},
{
"line": 35111,
"text": "| `DestinationProfileRegistry` | 1 | **o** — `:377` |"
},
{
"line": 35112,
"text": "| `DeclaredDestinationAccess` | 1 | **o** |"
},
{
"line": 35113,
"text": "| `DefaultDeliveryProcessor` | **0** | **x** — `src/main` 생성 0, `src/test` 1 |"
},
{
"line": 35114,
"text": ""
},
{
"line": 35115,
"text": "**(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다**"
},
{
"line": 35116,
"text": ""
},
{
"line": 35117,
"text": "`DefaultDeliveryProcessor`는 leaf 밖 참조가 0이고 `src/main`에서 생성되지 않는다. 이것이 §A19-MESSAGING-POLICY §12.1이 관측한 \"소비 경로 전체 미조립\"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다."
},
{
"line": 35118,
"text": ""
},
{
"line": 35119,
"text": "이 클래스의 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.\" 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다."
},
{
"line": 35120,
"text": ""
},
{
"line": 35121,
"text": "**(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다**"
},
{
"line": 35122,
"text": ""
},
{
"line": 35123,
"text": "네 조각이 있다."
},
{
"line": 35124,
"text": ""
},
{
"line": 35125,
"text": "| 조각 | 상태 |"
},
{
"line": 35126,
"text": "|---|---|"
},
{
"line": 35127,
"text": "| `MessagingObservation` 인터페이스 (observability) | 존재 |"
},
{
"line": 35128,
"text": "| `MessagingMetrics implements MessagingObservation` | 존재 |"
},
{
"line": 35129,
"text": "| `DefaultMessagePublisher.observe(...)` 호출부 | 존재, 모든 발행 결과를 기록 |"
},
{
"line": 35130,
"text": "| 8인자 생성자 (관측 주입) | 존재 |"
},
{
"line": 35131,
"text": "| **출하 조립** | **6인자 생성자 → `NO_OBSERVATION`** |"
},
{
"line": 35132,
"text": "| **`MessagingMetrics` bean** | **없음** |"
},
{
"line": 35133,
"text": ""
},
{
"line": 35134,
"text": "```java"
},
{
"line": 35135,
"text": "// MessagingCoreAutoConfiguration.java:446-447"
},
{
"line": 35136,
"text": "return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher("
},
{
"line": 35137,
"text": " destinations, access, codecs, admission, runtimes, transport);"
},
{
"line": 35138,
"text": "```"
},
{
"line": 35139,
"text": ""
},
{
"line": 35140,
"text": "그리고 `MessagingMetrics`는 저장소 전체에서 **자기 테스트에서만** 생성된다(`MessagingMetricCardinalityTest`, `MessagingSecretLeakTest`)."
},
{
"line": 35141,
"text": ""
},
{
"line": 35142,
"text": "starter는 `MessagingMetrics`의 **두 협력자를 bean으로 만든다** — `MessagingRedactor`(:253)와 `CardinalityGuard`(:264). `MessagingMetrics`의 생성자는 `(registry, CardinalityGuard, MessagingRedactor)`를 받는다(테스트가 그렇게 호출한다). 즉 **재료 둘은 배선됐고 그것을 조립하는 bean이 없다.**"
},
{
"line": 35143,
"text": ""
},
{
"line": 35144,
"text": "이 클래스의 javadoc이 그 상황을 예언한다."
},
{
"line": 35145,
"text": ""
},
{
"line": 35146,
"text": "```java"
},
{
"line": 35147,
"text": "// DefaultMessagePublisher.java:74-78"
},
{
"line": 35148,
"text": " * {@code MessagingObservation} existed as a bean and no publish path called it, so the"
},
{
"line": 35149,
"text": " * platform's own metrics described nothing. It is a constructor argument rather than an optional"
},
{
"line": 35150,
"text": " * decorator because an unobserved publish path is how \"the dashboards were empty during the"
},
{
"line": 35151,
"text": " * incident\" happens."
},
{
"line": 35152,
"text": "```"
},
{
"line": 35153,
"text": ""
},
{
"line": 35154,
"text": "**이전 상태:** bean은 있고 호출하는 경로가 없었다."
},
{
"line": 35155,
"text": "**현재 상태:** 호출하는 경로는 있고 bean이 없다."
},
{
"line": 35156,
"text": ""
},
{
"line": 35157,
"text": "두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 **반대편으로 옮겼다.** 그리고 \"constructor argument rather than an optional decorator\"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다."
},
{
"line": 35158,
"text": ""
},
{
"line": 35159,
"text": "**(c) 배선된 것은 확실히 배선됐다**"
},
{
"line": 35160,
"text": ""
},
{
"line": 35161,
"text": "발행 경로 다섯이 전부 `src/main`에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다."
},
{
"line": 35162,
"text": ""
},
{
"line": 35163,
"text": "**한계.** 정적 검색이다. `ObjectProvider` 지연 조회는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 `MessagingObservation` bean을 제공하면 `@ConditionalOnMissingBean(MessagePublisher.class)` 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다."
},
{
"line": 35164,
"text": ""
},
{
"line": 35165,
"text": "##### 12.2 Conditional sibling comparison"
},
{
"line": 35166,
"text": ""
},
{
"line": 35167,
"text": "이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다."
},
{
"line": 35168,
"text": ""
},
{
"line": 35169,
"text": "`MessagingCoreAutoConfiguration`이 이 leaf의 타입을 만드는 지점 다섯의 조건:"
},
{
"line": 35170,
"text": ""
},
{
"line": 35171,
"text": "| 대상 | 조건 |"
},
{
"line": 35172,
"text": "|---|---|"
},
{
"line": 35173,
"text": "| `RegisteredMessageCodecs` | `@ConditionalOnMissingBean(MessageCodecRegistry.class)` |"
},
{
"line": 35174,
"text": "| `DestinationProfileRegistry` | `@ConditionalOnMissingBean` |"
},
{
"line": 35175,
"text": "| `DefaultMessagePublisher` | `@ConditionalOnMissingBean(MessagePublisher.class)` |"
},
{
"line": 35176,
"text": "| `TransportMessagingRuntime` | 조건 없음 — `InitializingBean` 안, `transport.getIfAvailable()` null 검사 |"
},
{
"line": 35177,
"text": "| `DeclaredDestinationAccess` | `@ConditionalOnMissingBean` |"
},
{
"line": 35178,
"text": ""
},
{
"line": 35179,
"text": "**네 번째만 조건 대신 런타임 null 검사를 쓴다.** 그 이유가 주석에 있다."
},
{
"line": 35180,
"text": ""
},
{
"line": 35181,
"text": "```java"
},
{
"line": 35182,
"text": "// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport"
},
{
"line": 35183,
"text": "// when a broker is selected, and it refuses startup by name when one is not. This"
},
{
"line": 35184,
"text": "// configuration is also loadable on its own — an adopter composing the policy primitives"
},
{
"line": 35185,
"text": "// without a transport — and demanding one here would refuse that."
},
{
"line": 35186,
"text": "```"
},
{
"line": 35187,
"text": ""
},
{
"line": 35188,
"text": "즉 \"transport 없이도 로드 가능해야 한다\"가 명시적 요구이고, 그 요구가 `@ConditionalOnBean` 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(`MessagingProviderSelection`)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다."
},
{
"line": 35189,
"text": ""
},
{
"line": 35190,
"text": "##### 12.3 Duplicate mechanism sweep"
},
{
"line": 35191,
"text": ""
},
{
"line": 35192,
"text": "**(a) DLQ 순서 불변식이 두 곳에 구현돼 있다**"
},
{
"line": 35193,
"text": ""
},
{
"line": 35194,
"text": "| | `messaging-policy` `DeadLetterOrchestrator` | 이 leaf `DefaultDeliveryProcessor` |"
},
{
"line": 35195,
"text": "|---|---|---|"
},
{
"line": 35196,
"text": "| 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack |"
},
{
"line": 35197,
"text": "| 미확인 시 | 정산하지 않음(`sourceSettled=false`) | **requeue** |"
},
{
"line": 35198,
"text": "| 헤더 | 예약 헤더 6개 부착 | 없음 |"
},
{
"line": 35199,
"text": "| 발행 주체 | `MessagePublisher` | `DeadLetterPublisher` 함수형 인터페이스 |"
},
{
"line": 35200,
"text": ""
},
{
"line": 35201,
"text": "**미확인 시 동작이 다르다.** policy 쪽은 \"정산하지 않는다\"(브로커가 알아서 재전달), 이쪽은 \"명시적으로 requeue한다\". 둘 다 메시지를 잃지 않지만 `requeue(delay)`는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다."
},
{
"line": 35202,
"text": ""
},
{
"line": 35203,
"text": "둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. §A19-MESSAGING-POLICY §12.3(b)가 같은 사건을 반대편에서 기록한다."
},
{
"line": 35204,
"text": ""
},
{
"line": 35205,
"text": "**(b) 재시도 지연이 두 출처**"
},
{
"line": 35206,
"text": ""
},
{
"line": 35207,
"text": "`DefaultDeliveryProcessor`의 `retryDelay`는 **생성자 인자 하나**다. 시도 횟수를 세지 않고 백오프도 없다. `messaging-policy`의 `BackoffCalculator`(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다."
},
{
"line": 35208,
"text": ""
},
{
"line": 35209,
"text": "**(c) 멱등 종료가 두 층**"
},
{
"line": 35210,
"text": ""
},
{
"line": 35211,
"text": "`TransportMessagingRuntime.close()`와 `DefaultMessagingRuntimeRegistry.Generation.forceClose()`(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 **의도된 중복**이다 — 이쪽 주석이 \"the registry closes a drained generation, and a context shutdown may close it again\"이라고 두 경로를 명시한다. 결함 아님."
},
{
"line": 35212,
"text": ""
},
{
"line": 35213,
"text": "**(d) content type 폴백**"
},
{
"line": 35214,
"text": ""
},
{
"line": 35215,
"text": "`encode`가 `codecs.find(contentType).orElseGet(codecs::defaultCodec)`으로 폴백한다. `RegisteredMessageCodecs.find`는 미등록이면 `Optional.empty()`를 주고, `defaultCodec()`은 JSON이다. 즉 **선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점**이고, 그 갈라짐이 조용하다. §17."
},
{
"line": 35216,
"text": ""
},
{
"line": 35217,
"text": "##### 12.4 Documentation / measured-count drift"
},
{
"line": 35218,
"text": ""
},
{
"line": 35219,
"text": "| 문서 주장 | 재측정 | 결과 |"
},
{
"line": 35220,
"text": "|---|---|---|"
},
{
"line": 35221,
"text": "| build.gradle 주석: `MessagePublisher`에 구현이 없었다 | 현재 이 leaf가 구현하고 `:446`에서 조립 | **해소됨** |"
},
{
"line": 35222,
"text": "| `TransportMessagingRuntime` javadoc: registry가 비어 있어 모든 발행이 실패했다 | 현재 `:476`이 설치 | **해소됨** |"
},
{
"line": 35223,
"text": "| `DefaultMessagePublisher` javadoc: 관측 bean이 있고 호출 경로가 없었다 | 현재 호출 경로가 있고 bean이 없다 | **반전됨**(§12.1b) |"
},
{
"line": 35224,
"text": "| `DefaultDeliveryProcessor` javadoc: 어댑터가 각자 결정했다 | 한 곳에 모았으나 조립되지 않음 | **부분 해소** |"
},
{
"line": 35225,
"text": "| `MessagingRuntime.generation()` javadoc: \"increasing with each replacement\" | 유일한 설치가 리터럴 `1L` | **미실현** |"
},
{
"line": 35226,
"text": "| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |"
},
{
"line": 35227,
"text": ""
},
{
"line": 35228,
"text": "세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다."
},
{
"line": 35229,
"text": ""
},
{
"line": 35230,
"text": "---"
},
{
"line": 35231,
"text": ""
},
{
"line": 35232,
"text": "#### 13. Git/설계 문서에서 확인한 변화와 실패 기록"
},
{
"line": 35233,
"text": ""
},
{
"line": 35234,
"text": "이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`)."
},
{
"line": 35235,
"text": ""
},
{
"line": 35236,
"text": "| 위치 | 이전 상태 | 그것이 만든 실패 |"
},
{
"line": 35237,
"text": "|---|---|---|"
},
{
"line": 35238,
"text": "| `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 |"
},
{
"line": 35239,
"text": "| `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |"
},
{
"line": 35240,
"text": "| `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |"
},
{
"line": 35241,
"text": "| `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |"
},
{
"line": 35242,
"text": "| `DefaultDeliveryProcessor` 핸들러 예외 주석 | Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 |"
},
{
"line": 35243,
"text": "| `requireSupportedOptions` javadoc | transport가 `options`를 읽지 않음 | 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 |"
},
{
"line": 35244,
"text": "| `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |"
},
{
"line": 35245,
"text": ""
},
{
"line": 35246,
"text": "`build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — \"A starter that filled the gap with an application-supplied fake would pass a context test while running none of them.\""
},
{
"line": 35247,
"text": ""
},
{
"line": 35248,
"text": "---"
},
{
"line": 35249,
"text": ""
},
{
"line": 35250,
"text": "#### 14. 런타임·터미널 Evidence"
},
{
"line": 35251,
"text": ""
},
{
"line": 35252,
"text": "| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |"
},
{
"line": 35253,
"text": "|---|---|---|---|---|"
},
{
"line": 35254,
"text": "| EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | 여섯 타입 참조 수, 발행 경로 조립 지점, `DefaultDeliveryProcessor` src/main=0, 관측 4조각과 끊긴 한 지점, `MessagingMetrics`가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 | 정적 검색. 파생 프로젝트의 대체 조립 미포함 |"
},
{
"line": 35255,
"text": "| EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(`RecordingTransport`) |"
},
{
"line": 35256,
"text": ""
},
{
"line": 35257,
"text": "---"
},
{
"line": 35258,
"text": ""
},
{
"line": 35259,
"text": "#### 15. 명시적 설계 이유와 추론을 구분한 정리"
},
{
"line": 35260,
"text": ""
},
{
"line": 35261,
"text": "**명시적**"
},
{
"line": 35262,
"text": ""
},
{
"line": 35263,
"text": "- 이 leaf가 존재하는 이유와 이전 결함 — `build.gradle` 주석"
},
{
"line": 35264,
"text": "- 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — `DefaultMessagePublisher` javadoc"
},
{
"line": 35265,
"text": "- 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석"
},
{
"line": 35266,
"text": "- 전송 전 실패가 `REJECTED`인 이유 — 인라인 주석"
},
{
"line": 35267,
"text": "- 예산을 호출 시점부터 세는 이유 — `remainingBudget` javadoc"
},
{
"line": 35268,
"text": "- 마감을 복사본에 거는 이유와 그 대가 — `withDeadline` javadoc"
},
{
"line": 35269,
"text": "- 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석"
},
{
"line": 35270,
"text": "- 지원하지 않는 옵션을 거절하는 이유 — `requireSupportedOptions` javadoc"
},
{
"line": 35271,
"text": "- 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — `RegisteredMessageCodecs` javadoc"
},
{
"line": 35272,
"text": "- 폴백 없는 목적지 조회 이유 — `DestinationProfileRegistry` javadoc"
},
{
"line": 35273,
"text": "- 기본 접근 정책이 deny도 allow도 아닌 이유 — `DeclaredDestinationAccess` javadoc"
},
{
"line": 35274,
"text": "- 핸들러 예외가 retry인 이유 — 인라인 주석"
},
{
"line": 35275,
"text": "- DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석"
},
{
"line": 35276,
"text": "- transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — `InitializingBean` 안 주석"
},
{
"line": 35277,
"text": "- 관측을 생성자 인자로 둔 이유 — `observation` 필드 javadoc"
},
{
"line": 35278,
"text": ""
},
{
"line": 35279,
"text": "**추론**"
},
{
"line": 35280,
"text": ""
},
{
"line": 35281,
"text": "- 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → **추론이 아니라 미상.** 어디에도 근거가 없고, 8인자 생성자와 `MessagingMetrics`가 둘 다 존재한다는 점이 미완을 시사한다."
},
{
"line": 35282,
"text": "- `generation`이 항상 1인 것은 회전 코드가 없기 때문이다 → **추론**. 회전 코드 부재는 관측이다."
},
{
"line": 35283,
"text": "- `DefaultDeliveryProcessor` 미조립이 미완인지 확장점인지 → **미상**."
},
{
"line": 35284,
"text": ""
},
{
"line": 35285,
"text": "---"
},
{
"line": 35286,
"text": ""
},
{
"line": 35287,
"text": "#### 16. 확인한 것 / 확인하지 못한 것"
},
{
"line": 35288,
"text": ""
},
{
"line": 35289,
"text": "**확인한 것**"
},
{
"line": 35290,
"text": ""
},
{
"line": 35291,
"text": "- 6개 클래스 787줄 전문의 계약과 순서 결정"
},
{
"line": 35292,
"text": "- 21개 테스트가 통과하고 무엇을 단언하는지"
},
{
"line": 35293,
"text": "- 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지"
},
{
"line": 35294,
"text": "- `DefaultDeliveryProcessor`가 `src/main`에서 생성되지 않는다는 것"
},
{
"line": 35295,
"text": "- 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것"
},
{
"line": 35296,
"text": "- `MessagingMetrics`가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것"
},
{
"line": 35297,
"text": "- `generation`이 유일한 설치 지점에서 리터럴 `1L`이라는 것"
},
{
"line": 35298,
"text": ""
},
{
"line": 35299,
"text": "**확인하지 못한 것**"
},
{
"line": 35300,
"text": ""
},
{
"line": 35301,
"text": "- **6인자 생성자 선택이 의도인지.** 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다."
},
{
"line": 35302,
"text": "- `MessagingProviderSelection`이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다."
},
{
"line": 35303,
"text": "- 어댑터들이 `request.options()`를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다."
},
{
"line": 35304,
"text": "- 실제 브로커에서 `withDeadline`의 `.copy()` 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다."
},
{
"line": 35305,
"text": "- 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지."
},
{
"line": 35306,
"text": ""
},
{
"line": 35307,
"text": "---"
},
{
"line": 35308,
"text": ""
},
{
"line": 35309,
"text": "#### 17. 손볼 것"
},
{
"line": 35310,
"text": ""
},
{
"line": 35311,
"text": "##### P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다"
},
{
"line": 35312,
"text": ""
},
{
"line": 35313,
"text": "- **사실.** `DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 \"constructor argument rather than an optional decorator\"로 받는다. `MessagingMetrics`가 `MessagingObservation`을 구현한다. 그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 `MessagingMetrics`의 협력자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만든다."
},
{
"line": 35314,
"text": "- **근거.** `evidence/raw/283` §D."
},
{
"line": 35315,
"text": "- **왜 문제인가.** 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — \"an unobserved publish path is how 'the dashboards were empty during the incident' happens\". 그리고 같은 javadoc이 **이전 결함**을 \"bean은 있고 호출 경로가 없었다\"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. **고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.** \"decorator가 아니라 생성자 인자\"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다."
},
{
"line": 35316,
"text": "- **확인 방법.** `evidence/raw/283` §D 재실행. 또는 `:446`의 인자 수와 `:138-146` 생성자 시그니처 대조."
},
{
"line": 35317,
"text": "- **후보.** (a) `MessagingMetrics` bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 `support-matrix.md`에 표시한다."
},
{
"line": 35318,
"text": "- **다음 단계.** **CASE 후보.** 재현이 정적이고, \"장치는 있고 회로가 닫히지 않았다\"의 변형 중 **회로가 반대편에서 끊긴** 사례라 독립적으로 가치가 있다. 그리고 \"생성자 기본값이 있는 필수 협력자는 필수가 아니다\"가 **REFERENCE 후보**다."
},
{
"line": 35319,
"text": ""
},
{
"line": 35320,
"text": "##### P2 — 소비 오케스트레이터가 조립되지 않는다"
},
{
"line": 35321,
"text": ""
},
{
"line": 35322,
"text": "- **사실.** `DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다."
},
{
"line": 35323,
"text": "- **근거.** `evidence/raw/283` §A·§C."
},
{
"line": 35324,
"text": "- **왜 문제인가.** 이 클래스가 고친 문제(\"각 어댑터가 retry/dead-letter의 뜻을 각자 결정\")가 배선 없이는 그대로 남는다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다."
},
{
"line": 35325,
"text": "- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\\.)?DefaultDeliveryProcessor\\s*\\(' -- src`"
},
{
"line": 35326,
"text": "- **다음 단계.** §A19-MESSAGING-POLICY §17의 \"출하 컨텍스트가 발행은 하고 소비는 하지 못한다\"와 **동일 사건**이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다."
},
{
"line": 35327,
"text": ""
},
{
"line": 35328,
"text": "##### P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다"
},
{
"line": 35329,
"text": ""
},
{
"line": 35330,
"text": "- **사실.** `encode`가 `codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다."
},
{
"line": 35331,
"text": "- **근거.** `DefaultMessagePublisher.java:97-102`, `RegisteredMessageCodecs.find`, `MessagingCoreAutoConfiguration:363`(varargs 비어 있음)."
},
{
"line": 35332,
"text": "- **왜 문제인가.** 실패하지 않고 **다른 포맷으로 성공**한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. `DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다."
},
{
"line": 35333,
"text": "- **확인 방법.** 등록되지 않은 content type의 봉투를 발행해 `EncodedMessage.contentType()`을 확인."
},
{
"line": 35334,
"text": "- **후보.** 미등록 content type을 `MessagingConfigurationException`으로 거절하거나, `profile.schema().codec()`과 대조한다."
},
{
"line": 35335,
"text": "- **다음 단계.** **CASE 후보.** 조용한 성공이라는 형태가 `messaging-core-api`의 \"조용한 성능 저하 금지\" 설계와 정면으로 어긋난다."
},
{
"line": 35336,
"text": ""
},
{
"line": 35337,
"text": "##### P3 — 같은 실패 코드가 두 completion에 쓰인다"
},
{
"line": 35338,
"text": ""
},
{
"line": 35339,
"text": "- **사실.** `PUBLISH_DEADLINE_EXCEEDED`가 전송 전이면 `REJECTED`(`:16-21`), 전송 후면 `AMBIGUOUS`(`:42-47`)로 붙는다."
},
{
"line": 35340,
"text": "- **근거.** 두 위치."
},
{
"line": 35341,
"text": "- **왜 문제인가.** 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 `messageId`로만 재발행. `FailureDescriptor.code`가 \"stable, machine-readable code\"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다."
},
{
"line": 35342,
"text": "- **확인 방법.** `git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core`"
},
{
"line": 35343,
"text": "- **후보.** 전송 전을 `PUBLISH_DEADLINE_BEFORE_SEND`처럼 분리한다."
},
{
"line": 35344,
"text": "- **다음 단계.** **REFERENCE 후보**(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다)."
},
{
"line": 35345,
"text": ""
},
{
"line": 35346,
"text": "##### P3 — admission 실패만 예외로 전파된다"
},
{
"line": 35347,
"text": ""
},
{
"line": 35348,
"text": "- **사실.** 8단계 중 admission(`:24`)만 `try` 블록 밖이고, `MessageTooLargeException`·`MessageBackpressureException`이 그대로 던져진다. 나머지 실패는 전부 `CompletionStage This is the answer to the dual-write problem. Writing to the database and publishing to the\n33863 | * broker in the same method cannot be made atomic; writing both to the database can.\n33864 | ```\n33865 | \n33866 | **Inbox** — 소비 측 중복 제거.\n33867 | \n33868 | ```java\n33869 | // InboxRepository.java:9-13\n33870 | * {@link #reserve} must run inside the same database transaction as the handler's side effect.\n33871 | * That is the entire mechanism: the uniqueness constraint on the inbox row and the business write\n33872 | * commit together, so a redelivered message either finds the row already present and skips, or\n33873 | * writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to\n33874 | * close.\n33875 | ```\n33876 | \n33877 | **Claim Check** — 브로커 밖 payload 참조.\n33878 | \n33879 | 그리고 셋의 관계를 `OutboxRecord`가 명시한다.\n33880 | \n33881 | ```java\n33882 | // OutboxRecord.java:21-24\n33883 | * What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will\n33884 | * retry it, and the same message may reach the broker twice. Effectively-once processing comes from\n33885 | * this row carrying a stable {@code messageId} and the consumer having an Inbox — not from the\n33886 | * outbox alone.\n33887 | ```\n33888 | \n33889 | **Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 \"보장을 과대 진술하지 않는다\"의 예다.\n33890 | \n33891 | ---\n33892 | \n33893 | #### 2. 의존성과 런타임 배선\n33894 | \n33895 | 들어오는 것: `messaging-core-api`(api) 하나.\n33896 | \n33897 | 나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`.\n33898 | \n33899 | **구현 leaf가 셋 있고 전부 배선된다.**\n33900 | \n33901 | | 포트 | 구현 | 조립 |\n33902 | |---|---|---|\n33903 | | `OutboxRepository` | `messaging-outbox-jdbc-postgresql/JdbcOutboxRepository` | starter `MessagingReliabilityAutoConfiguration` |\n33904 | | `InboxRepository` | `messaging-inbox-jdbc-postgresql/JdbcInboxRepository` | 같음 |\n33905 | | `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql/TransactionalInboxHandler` | `transactionalInboxHandler` bean |\n33906 | | `ReliableMessagePublisher` | **없음** | — |\n33907 | \n33908 | `ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.\n33909 | \n33910 | 이 leaf 자체는 Spring 주석을 갖지 않는다.\n33911 | \n33912 | ---\n33913 | \n33914 | #### 3. 패키지/컴포넌트 지도\n33915 | \n33916 | ```\n33917 | Outbox\n33918 | ReliableMessagePublisher.addToOutbox(dest, envelope) ← 구현 0\n33919 | ↓ (쓰기)\n33920 | OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers\n33921 | ├─ OutboxCanonicalMetadata (provenance 10필드)\n33922 | └─ status / attempts / leaseExpiresAt / lastFailureCode\n33923 | ↓ (릴레이)\n33924 | OutboxRepository ─┬─ append\n33925 | ├─ [구세대] leaseBatch → List The port used to take a {@code MessageId} for every terminal transition, so a write said which\n33953 | * row to change and nothing about which claim it belonged to. A relay that stalled past its lease\n33954 | * could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already\n33955 | * written, and the row became claimable again — one message, published twice, by a system whose\n33956 | * whole purpose is to publish it once.\n33957 | *\n33958 | * The token is the part that makes staleness detectable. It increases on every claim, so a\n33959 | * superseded relay holds a number the row no longer has and its update matches zero rows.\n33960 | ```\n33961 | \n33962 | `token < 1`을 거절하는 이유도 적혀 있다 — `\"a claim's token starts at 1; 0 is the value of a row nobody has claimed\"`.\n33963 | \n33964 | `expiredAt(now)`가 `!now.isBefore(expiresAt)`다.\n33965 | \n33966 | ##### 4.2 `OutboxTransitionResult` — void가 삼킨 것\n33967 | \n33968 | ```java\n33969 | // :5-9\n33970 | * The transitions returned {@code void}, so an update that matched zero rows was\n33971 | * indistinguishable from one that matched one. That is precisely the stale-lease case: the relay\n33972 | * believes it recorded the outcome, the row still says something else, and nothing anywhere counts\n33973 | * the disagreement.\n33974 | ```\n33975 | \n33976 | 두 값이고 `STALE_LEASE`의 javadoc이 운영 의미까지 적는다.\n33977 | \n33978 | ```java\n33979 | * Another relay claimed it after the lease expired. Not an error to throw — the message is\n33980 | * being handled by somebody else — but never a success either: it is the signal that this\n33981 | * worker's publish attempt may have produced a duplicate, and it belongs on a metric.\n33982 | ```\n33983 | \n33984 | **\"belongs on a metric\"** — 그 메트릭이 존재하는지는 outbox leaf가 답한다.\n33985 | \n33986 | ##### 4.3 `OutboxStatus` — 여섯 상태와 두 개의 구분\n33987 | \n33988 | `PENDING` → `IN_FLIGHT` → `PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`.\n33989 | \n33990 | **두 쌍의 구분이 각각 이유를 갖는다.**\n33991 | \n33992 | `AMBIGUOUS` vs `FAILED`:\n33993 | \n33994 | ```java\n33995 | // :6-9\n33996 | * {@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose\n33997 | * publish timed out may already be on the broker; retrying it is correct, but only under the same\n33998 | * logical message id, and an operator looking at the table needs to be able to tell those rows\n33999 | * apart from ones that definitely never landed.\n34000 | ```\n34001 | \n34002 | `EXHAUSTED` vs `FAILED`:\n34003 | \n34004 | ```java\n34005 | // :31-34\n34006 | * Distinct from {@link #FAILED}, which means the broker refused the message: this one means\n34007 | * nobody ever got an answer. Collapsing the two loses the difference between \"this message is\n34008 | * invalid\" and \"the broker was unreachable for an hour\", and those need different operator\n34009 | * actions — the first a fix, the second a redrive.\n34010 | ```\n34011 | \n34012 | `OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — \"The first needs a fix, the second a redrive.\"\n34013 | \n34014 | **`FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.** `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`의 `.because(...)`가 그것을 ArchUnit 규칙의 근거로 든다 — \"its `OutboxStatus.FAILED` means the opposite of the legacy `OutboxEventStatus.FAILED`, so the two models cannot be mixed by name without inverting retryable and terminal.\" 즉 **이 enum의 의미가 저장소 규칙 하나의 존재 이유다.**\n34015 | \n34016 | ##### 4.4 `InboxResult` — 두 개가 아니라 세 개\n34017 | \n34018 | ```java\n34019 | // :6-9\n34020 | * Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}\n34021 | * into a single \"duplicate\" would settle a message whose effect is still only half-written by\n34022 | * another instance: if that instance then rolls back, the effect is lost and the broker will never\n34023 | * redeliver, because this instance already acknowledged it.\n34024 | ```\n34025 | \n34026 | `safeToSettle` 플래그가 상수에 붙어 있다.\n34027 | \n34028 | | 값 | safeToSettle | 뜻 |\n34029 | |---|:---:|---|\n34030 | | `APPLIED` | true | 이 트랜잭션에서 효과 실행 |\n34031 | | `ALREADY_APPLIED` | true | 커밋된 예약 존재 — 이미 실행됨 |\n34032 | | `CLAIMED_ELSEWHERE` | **false** | 다른 인스턴스가 **미커밋** 예약 보유 |\n34033 | \n34034 | 세 번째의 javadoc이 결론을 적는다 — \"Do *not* settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect.\"\n34035 | \n34036 | **세 값 모두 필요한 이유가 명확하고, `isSafeToSettle()`이 그 판단을 하나로 모은다.**\n34037 | \n34038 | ##### 4.5 `InboxRepository` — 키가 (message, consumer)다\n34039 | \n34040 | ```java\n34041 | // InboxRecord.java:9-12\n34042 | * Keyed by message id and consumer id, because two independent consumers of the same\n34043 | * event must each process it once — deduplicating on the message alone would let the first consumer\n34044 | * suppress the second.\n34045 | ```\n34046 | \n34047 | `IdempotentMessageHandler`의 javadoc이 같은 이유를 API 형태로 반복한다 — `consumerName`이 파라미터인 이유.\n34048 | \n34049 | `purgeProcessedBefore`의 javadoc이 보존 기간 규칙을 적는다.\n34050 | \n34051 | ```java\n34052 | * Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery\n34053 | * arrives after its inbox row was pruned and is processed a second time.\n34054 | ```\n34055 | \n34056 | **이 규칙을 강제하는 코드가 없다.** 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, `messaging-policy`의 프로파일 검증기에도 없다. §17.\n34057 | \n34058 | ##### 4.6 `TransactionalMessageAction` — 트랜잭션 경계의 소유권\n34059 | \n34060 | ```java\n34061 | // :8-16\n34062 | * Sharing one transaction is the entire mechanism. If the effect committed separately from the\n34063 | * \"I have handled this message\" marker, a crash between the two would either replay the effect or\n34064 | * suppress a message that was never handled — and which of those you get would depend on the order\n34065 | * the two commits happened to be written in.\n34066 | *\n34067 | * Implementations must not settle the message, publish, or start their own transaction. The\n34068 | * runtime owns the transaction boundary precisely so that the action cannot accidentally commit\n34069 | * half of it.\n34070 | ```\n34071 | \n34072 | 세 금지(\"settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라\")가 **문서로만 표현된다.** 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.\n34073 | \n34074 | ##### 4.7 `OutboxCanonicalMetadata` — 컬럼이어야 하는 이유\n34075 | \n34076 | 이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.\n34077 | \n34078 | ```java\n34079 | // :14-28\n34080 | * They used to live nowhere. A row held identity, type, version, content type, payload and an\n34081 | * arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either\n34082 | * invented when the envelope was rebuilt — {@code Optional.empty()} for every one of them — or\n34083 | * smuggled through the header map under reserved names the platform was supposed to own.\n34084 | *\n34085 | * Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant\n34086 | * without decoding the payload, so the operational question \"which tenant is backed up\" has no\n34087 | * answer; and a message that crossed the outbox arrived at its consumer with a different tenant,\n34088 | * trace and correlation than the one that was published, which makes the publish path — direct,\n34089 | * polling or CDC — part of the message's meaning.\n34090 | *\n34091 | * Columns rather than a blob, because the point is that the database can answer questions about\n34092 | * them. A versioned envelope encoding would round-trip just as faithfully and would still leave the\n34093 | * relay unable to select rows for one tenant.\n34094 | ```\n34095 | \n34096 | **세 번째 문단이 고려된 대안을 명시적으로 기각한다** — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.\n34097 | \n34098 | 불변식 하나: `schemaUri.isPresent() && schemaSubject.isEmpty()`를 거절한다 — \"a reader would have a URI and no way to know what it is a schema for\".\n34099 | \n34100 | `traceContext`만 `Optional`이 아니고 `TraceContext.none()`이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것(\"the reader's behaviour is the same: carry what is there and invent nothing\").\n34101 | \n34102 | ##### 4.8 `OutboxRecord` — 두 반쪽의 소유자가 다르다\n34103 | \n34104 | ```java\n34105 | // :26-29\n34106 | * {@link OutboxCanonicalMetadata} is a separate component rather than more fields here because\n34107 | * the two halves answer to different owners. Identity, payload, status, attempts and lease are the\n34108 | * relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to\n34109 | * survive the round trip through the database unchanged.\n34110 | ```\n34111 | \n34112 | `payload`가 양방향 방어 복사(`payload.clone()` 생성 시와 접근 시), `headers`가 `Map.copyOf` — `messaging-schema-api`의 `EncodedMessage`(그쪽 §4.3)와 같은 패턴이다.\n34113 | \n34114 | `withStatus`가 `messageId`를 파라미터로 받지 않는다 — \"The message id is never a parameter, so no state transition can change it.\" 타입이 불변식을 강제하는 예다.\n34115 | \n34116 | `equals`/`hashCode`가 **다섯 필드 중 넷만** 본다 — `messageId`, `status`, `attempts`, `payload`. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 **그 이유가 어디에도 적혀 있지 않다.** §17.\n34117 | \n34118 | `toString`이 payload를 담지 않는다.\n34119 | \n34120 | ##### 4.9 `ClaimCheckReference` — digest가 선택이 아니다\n34121 | \n34122 | ```java\n34123 | // :10-16\n34124 | * The digest is part of the reference, not an optional extra. A claim check splits a message\n34125 | * into two systems with independent retention and replication, so a consumer that fetches the\n34126 | * payload has to be able to prove it got the bytes the producer stored — otherwise a truncated or\n34127 | * replaced object is indistinguishable from a valid one.\n34128 | *\n34129 | * The expiry is carried for the same reason: a claim check whose payload has been reaped is a\n34130 | * dead message, and detecting that at fetch time is better than a mysterious not-found.\n34131 | ```\n34132 | \n34133 | `sha256`이 `[a-f0-9]{64}` 정확 일치다 — 대문자 hex를 거절한다. `messaging-core-api`의 `TraceContext`가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.\n34134 | \n34135 | `expiresAt`이 `Optional`이 아니다 — 모든 claim check가 만료를 갖는다.\n34136 | \n34137 | ---\n34138 | \n34139 | #### 5. 주요 실행 경로\n34140 | \n34141 | **Outbox 쓰기:** 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)` → `OutboxRepository.append(record)` — **진입점 구현이 없다**(§12.1)\n34142 | \n34143 | **Outbox 릴레이:** `claimBatch(owner, size, lease, now, maxAttempts)` → `List The return type is {@code void}, and that is the contract. There is no publish outcome to\n34184 | * report yet: the row is written inside the caller's transaction, so if the transaction rolls back\n34185 | * the message never existed, and if it commits the relay will publish it later. Handing back a\n34186 | * {@code PublishResult} here would be a lie about work that has not happened.\n34187 | ```\n34188 | \n34189 | 동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1).\n34190 | \n34191 | 모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.\n34192 | \n34193 | 수명주기 참여 없음.\n34194 | \n34195 | ---\n34196 | \n34197 | #### 8. 설정·기능 플래그·환경 차이\n34198 | \n34199 | 설정 없음. 상수도 없다 — `ClaimCheckReference.SHA256` 정규식 하나가 private이다.\n34200 | \n34201 | `OutboxRepository`의 두 `purge*` 메서드가 `limit` 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.\n34202 | \n34203 | ```java\n34204 | // :143-147\n34205 | * The unbounded version deletes everything before the cutoff in one statement. On a table that\n34206 | * has been accumulating published rows since the last sweep that is a single long transaction\n34207 | * holding locks and generating WAL in proportion to the backlog, which shows up as the relay and\n34208 | * the business writes stalling behind retention. The cleanup jobs describe themselves as bounded\n34209 | * by batch size; this is the parameter that makes that true.\n34210 | ```\n34211 | \n34212 | `InboxRepository`도 같은 쌍을 갖는다.\n34213 | \n34214 | ---\n34215 | \n34216 | #### 9. 퍼시스턴스/외부 시스템 세부\n34217 | \n34218 | 없다 — 포트만 정의한다. 다만 **포트가 저장소 기술을 전제한다.**\n34219 | \n34220 | - `InboxRepository.reserve`의 메커니즘이 \"the uniqueness constraint on the inbox row\"다 — 유니크 제약이 있는 저장소를 전제\n34221 | - `OutboxRepository.claimBatch`의 의미가 \"a record claimed by one relay is invisible to the others\"다 — 행 잠금 또는 그에 준하는 것을 전제\n34222 | - `OutboxTransitionResult.STALE_LEASE`가 \"its update matches zero rows\"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제\n34223 | \n34224 | 세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(`*-jdbc-postgresql`)이 실제 선택을 드러낸다.\n34225 | \n34226 | ---\n34227 | \n34228 | #### 10. 테스트 레인과 실제 증명 범위\n34229 | \n34230 | **이 leaf에는 테스트가 없다.** `src/test` 디렉터리 자체가 존재하지 않는다 — `src` 아래에 `main`만 있다.\n34231 | \n34232 | 13개 타입 중 record 생성자 검증이 있는 것이 여섯(`ClaimCheckReference`, `InboxRecord`, `OutboxCanonicalMetadata`, `OutboxLease`, `OutboxRecord`, `OutboxTransitionResult`는 enum), 술어가 있는 것이 셋(`isExpired`, `expiredAt`, `isSafeToSettle`)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.\n34233 | \n34234 | **검증은 전부 구현 leaf에서 일어난다.**\n34235 | \n34236 | | 검증 위치 | 무엇을 |\n34237 | |---|---|\n34238 | | `messaging-outbox-jdbc-postgresql` 테스트 4개 | `OutboxRepository` 구현, 릴레이 |\n34239 | | `messaging-inbox-jdbc-postgresql` 테스트 4개 | `InboxRepository` 구현, 멱등 핸들러 |\n34240 | | `messaging-claim-check` 테스트 3개 | claim check |\n34241 | | starter `MessagingOutboxRelayLifecycleTest` | 릴레이 수명주기 |\n34242 | \n34243 | 그 결과 이 leaf의 **계약 불변식**(예: `OutboxCanonicalMetadata`의 `schemaUri` 없이 `schemaSubject` 금지, `OutboxLease`의 `token >= 1`, `InboxResult.isSafeToSettle`의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.\n34244 | \n34245 | **그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.**\n34246 | \n34247 | ---\n34248 | \n34249 | #### 11. 빌드/ArchUnit/CI 강제 지점\n34250 | \n34251 | | 게이트 | 이 leaf에 대해 |\n34252 | |---|---|\n34253 | | `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\"]` |\n34254 | | `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n34255 | | vendor `api` 규칙 | 벤더 의존성 0 |\n34256 | | **`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`** | `..application..`이 이 leaf를 포함한 `dev.caskeleton.messaging..`을 참조하는 것을 금지. **규칙의 근거가 이 leaf의 `OutboxStatus.FAILED` 의미다** |\n34257 | | `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |\n34258 | | ArchUnit 전용 규칙 | 없음 |\n34259 | \n34260 | 네 번째가 특이하다 — ArchUnit 규칙 하나가 **이 leaf의 enum 상수 의미**를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.\n34261 | \n34262 | ---\n34263 | \n34264 | #### 12. 실제 사용 여부와 negative-space probes\n34265 | \n34266 | 원시 증거: `evidence/raw/289-reliability-api-two-generations.txt`.\n34267 | \n34268 | ##### 12.1 Public surface reachability\n34269 | \n34270 | | 타입 | leaf 밖 파일 | 판정 |\n34271 | |---|---:|---|\n34272 | | `OutboxRecord` | 13 | 활발 |\n34273 | | `OutboxCanonicalMetadata` | 8 | 활발 |\n34274 | | `OutboxRepository` | 7 | 구현 1 + 릴레이 + 테스트 |\n34275 | | `OutboxStatus` | 7 | 활발 |\n34276 | | `OutboxLease` | 6 | 활발 |\n34277 | | `OutboxTransitionResult` | 6 | 활발 |\n34278 | | `InboxRepository` | 6 | 구현 1 + 테스트 |\n34279 | | `ClaimCheckReference` | 6 | 활발 |\n34280 | | `InboxResult` | 2 | |\n34281 | | `IdempotentMessageHandler` | 1 | `TransactionalInboxHandler` |\n34282 | | `TransactionalMessageAction` | 1 | 같음 |\n34283 | | **`InboxRecord`** | **0** | |\n34284 | | **`ReliableMessagePublisher`** | **0** | |\n34285 | \n34286 | **(a) Outbox 쓰기 진입점에 구현이 없다**\n34287 | \n34288 | `ReliableMessagePublisher`는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.\n34289 | \n34290 | `OutboxRepository.append`는 존재하지만 그것은 저장소 포트다 — javadoc이 \"must be callable inside the caller's business transaction\"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 `ReliableMessagePublisher`가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.\n34291 | \n34292 | **그리고 애플리케이션은 이 leaf를 참조할 수 없다** — `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`이 금지한다. 즉 `ReliableMessagePublisher`를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. `messaging-spring-cloud-stream-bridge`가 후보 이름이지만 그 leaf는 `runtime_memberships: []`다.\n34293 | \n34294 | **(b) `InboxRecord`가 쓰이지 않는다**\n34295 | \n34296 | `InboxRepository`의 어느 메서드도 `InboxRecord`를 주고받지 않는다 — `reserve`는 `boolean`, `isProcessed`는 `boolean`, `purge*`는 `int`다. record는 \"One row of the consumer inbox\"를 서술하지만 그 행을 반환하는 API가 없다.\n34297 | \n34298 | 같은 leaf의 `OutboxRecord`는 정반대다 — `leaseBatch`/`find`가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.\n34299 | \n34300 | **(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다**\n34301 | \n34302 | `OutboxRepository`는 같은 다섯 전이에 대해 **두 세대**를 갖는다.\n34303 | \n34304 | | 전이 | 구세대 (MessageId) | 신세대 (OutboxLease) |\n34305 | |---|---|---|\n34306 | | 배치 획득 | `leaseBatch(size, lease, now)` → `List The token is what a terminal write is checked against. {@link #leaseBatch} returns records\n34343 | * without one, so its callers cannot prove a write belongs to their claim; it remains for\n34344 | * inspection paths and is deprecated for the relay's use.\n34345 | ```\n34346 | \n34347 | `@Deprecated` 애노테이션이 **이 leaf 전체에 하나도 없다**(`git grep '@Deprecated' -- src/messaging/messaging-reliability-api` exit 1).\n34348 | \n34349 | 결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.\n34350 | \n34351 | **(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다**\n34352 | \n34353 | > 이 항목은 `messaging-inbox-jdbc-postgresql` 분석 중에 확인됐다. 이 문서의 초판은 §17의 \"확인된 설계\"에 \"purge에 `limit` 파라미터를 둔 것\"을 넣었는데, 그것은 파라미터의 **존재**만 본 판정이었다. 호출 여부를 재측정해 정정한다.\n34354 | \n34355 | `InboxRepository.purgeProcessedBefore(Instant, int)`와 `OutboxRepository.purgePublishedBefore(Instant, int)`가 선언돼 있고 두 JDBC 구현이 `LIMIT`(inbox는 `FOR UPDATE SKIP LOCKED`까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 하나도 없다**. 두 cleanup job이 무제한 오버로드를 부른다 — `InboxCleanupJob:56`, `OutboxCleanupJob:50`.\n34356 | \n34357 | `OutboxRepository:140-151`의 javadoc이 그 상황을 예고한다.\n34358 | \n34359 | > The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true.**\n34360 | \n34361 | 그 파라미터를 아무도 넘기지 않는다. 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유하고, 이 문서는 **포트가 두 오버로드를 나란히 노출했다는 것**을 기여한다 — (a)의 두 세대 전이와 같은 형태다.\n34362 | \n34363 | ##### 12.2 Conditional sibling comparison\n34364 | \n34365 | 이 leaf에 bean은 없다. **구현 leaf 셋의 sibling 비교가 유의미하다.**\n34366 | \n34367 | | 포트 | 구현 leaf | membership | starter bean |\n34368 | |---|---|---|---|\n34369 | | `OutboxRepository` | `messaging-outbox-jdbc-postgresql` | `[\"app-bootstrap\"]` | `MessagingReliabilityAutoConfiguration` |\n34370 | | `InboxRepository` | `messaging-inbox-jdbc-postgresql` | `[\"app-bootstrap\"]` | 같음 |\n34371 | | `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql` | 같음 | `transactionalInboxHandler` bean |\n34372 | | `ReliableMessagePublisher` | **없음** | — | — |\n34373 | \n34374 | 네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.\n34375 | \n34376 | ##### 12.3 Duplicate mechanism sweep\n34377 | \n34378 | **(a) 같은 전이의 두 세대** — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.\n34379 | \n34380 | **(b) outbox 개념이 저장소에 둘 있다**\n34381 | \n34382 | | | 이 leaf | `application-core` |\n34383 | |---|---|---|\n34384 | | 상태 enum | `OutboxStatus` | `OutboxEventStatus` |\n34385 | | `FAILED`의 뜻 | 브로커가 확정적으로 거절 — **재시도 안 함** | (반대 의미, ArchUnit javadoc이 명시) |\n34386 | | 행 타입 | `OutboxRecord` | `NewOutboxEvent` 등 |\n34387 | | 사용처 | messaging family | application + persistence-jpa |\n34388 | \n34389 | **의도된 분리다.** ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 `.because(...)`가 이유를 적는다 — \"the two outbox status models mean opposite things under the same names\". 중복 경쟁이 아니라 **명시적으로 격리된 두 모델**이다.\n34390 | \n34391 | 다만 그 결과 `ReliableMessagePublisher`가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.\n34392 | \n34393 | **(c) 이름 충돌 주의**\n34394 | \n34395 | `markPublished`·`markFailed`·`releaseLease`라는 메서드 이름이 저장소의 **완전히 다른 인터페이스** 여러 곳에 있다 — `persistence-jpa`의 `OutboxStoreAdapter`·`JpaCleanupQueue`·`JpaUploadSessionStore`, `cache-redis`의 `RedisIdempotencyStoreAdapter`, `notification`의 `JpaProviderEventLedger`. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 `src/messaging/**`로 범위를 좁혀 얻은 것이다.\n34396 | \n34397 | ##### 12.4 Documentation / measured-count drift\n34398 | \n34399 | | 문서 주장 | 재측정 | 결과 |\n34400 | |---|---|---|\n34401 | | `OutboxRepository:43`: `leaseBatch`가 \"deprecated for the relay's use\" | `@Deprecated` 0건, 컨테이너 테스트가 사용 | **미강제** |\n34402 | | `OutboxRecord` javadoc: outbox만으로는 중복 제거 안 됨 | `InboxRepository`가 별도 존재 | **일치** |\n34403 | | `InboxRepository.purge*` javadoc: 보존이 브로커 재전달 창보다 길어야 함 | 그 비교를 하는 코드 없음 | **미강제** |\n34404 | | `TransactionalMessageAction` javadoc: 구현이 settle/publish/트랜잭션 시작 금지 | 타입이 강제하지 않음 | **미강제** |\n34405 | | `ReliableMessagePublisher` javadoc: dual-write의 답 | 구현 0 | **미실현** |\n34406 | | `OutboxTransitionResult.STALE_LEASE` javadoc: \"it belongs on a metric\" | 이 leaf에 메트릭 없음. outbox leaf가 답함 | **미확인** |\n34407 | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |\n34408 | \n34409 | ---\n34410 | \n34411 | #### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n34412 | \n34413 | 이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다.\n34414 | \n34415 | | 위치 | 이전 상태 | 그것이 만든 실패 |\n34416 | |---|---|---|\n34417 | | `OutboxLease` javadoc | 모든 terminal 전이가 `MessageId`만 받음 | lease를 넘긴 릴레이가 다른 릴레이의 `PUBLISHED` 위에 `AMBIGUOUS`를 기록 → 행이 다시 claim 가능해짐 → **한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서** |\n34418 | | `OutboxTransitionResult` javadoc | 전이가 `void` 반환 | 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 **그 불일치를 아무도 세지 않음** |\n34419 | | `OutboxCanonicalMetadata` javadoc | provenance가 어디에도 없음 | 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 `Optional.empty()`가 되거나 헤더 맵에 예약 이름으로 밀반입 → **outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착**, 즉 발행 경로가 메시지의 의미의 일부가 됨 |\n34420 | | `OutboxRepository.purgePublishedBefore` javadoc | 무제한 삭제 | 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → **릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤** |\n34421 | \n34422 | 첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.\n34423 | \n34424 | 세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — **\"which makes the publish path — direct, polling or CDC — part of the message's meaning.\"** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.\n34425 | \n34426 | ---\n34427 | \n34428 | #### 14. 런타임·터미널 Evidence\n34429 | \n34430 | | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n34431 | |---|---|---|---|---|\n34432 | | EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 | 정적 검색. `messaging-inbox-jdbc-postgresql`이 판정 소유 |\n34433 | | EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | `src/test` 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, `OutboxRepository`의 두 세대 시그니처 전수, `@Deprecated` 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 | 정적 검색. 이 leaf에 실행할 테스트 레인이 없음 |\n34434 | \n34435 | **이 leaf에는 test lane evidence가 없다** — `src/test`가 존재하지 않으므로 `:messaging:messaging-reliability-api:test`는 실행할 소스가 없다.\n34436 | \n34437 | ---\n34438 | \n34439 | #### 15. 명시적 설계 이유와 추론을 구분한 정리\n34440 | \n34441 | **명시적**\n34442 | \n34443 | - outbox만으로 중복이 제거되지 않는 이유 — `OutboxRecord` javadoc\n34444 | - fencing token이 필요한 이유와 이전 이중 발행 — `OutboxLease` javadoc\n34445 | - 전이가 결과를 반환해야 하는 이유 — `OutboxTransitionResult` javadoc\n34446 | - `AMBIGUOUS`가 실패의 한 종류가 아닌 이유, `EXHAUSTED`가 `FAILED`와 다른 이유 — `OutboxStatus` javadoc\n34447 | - provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — `OutboxCanonicalMetadata` javadoc\n34448 | - 두 반쪽의 소유자가 다른 이유 — `OutboxRecord` javadoc\n34449 | - inbox 키가 (message, consumer)인 이유 — `InboxRecord`·`IdempotentMessageHandler` javadoc\n34450 | - `InboxResult`가 셋인 이유 — 그 javadoc\n34451 | - 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — `InboxRepository`·`TransactionalMessageAction` javadoc\n34452 | - `addToOutbox`가 `void`인 이유 — `ReliableMessagePublisher` javadoc\n34453 | - claim check digest와 만료가 필수인 이유 — `ClaimCheckReference` javadoc\n34454 | - purge에 `limit`이 필요한 이유 — `OutboxRepository` javadoc\n34455 | - inbox 보존이 재전달 창보다 길어야 하는 이유 — `InboxRepository` javadoc\n34456 | \n34457 | **추론**\n34458 | \n34459 | - `ReliableMessagePublisher` 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → **추론**. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.\n34460 | - `OutboxRecord.equals`가 다섯 필드만 보는 이유 → **미상**.\n34461 | - `sha256`이 소문자만 받는 이유 → **미상**(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).\n34462 | - 구세대를 남긴 이유 → **부분 명시**(\"remains for inspection paths\"). 제거 시점은 미상.\n34463 | \n34464 | ---\n34465 | \n34466 | #### 16. 확인한 것 / 확인하지 못한 것\n34467 | \n34468 | **확인한 것**\n34469 | \n34470 | - 13개 타입 817줄 전문의 계약과 불변식\n34471 | - 이 leaf에 테스트가 하나도 없다는 것(`src/test` 부재)\n34472 | - `ReliableMessagePublisher`와 `InboxRecord`의 참조 0\n34473 | - `OutboxRepository`가 같은 다섯 전이의 두 세대를 갖고 `@Deprecated`가 하나도 없다는 것\n34474 | - production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것\n34475 | - 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태\n34476 | - `OutboxStatus.FAILED`의 의미가 저장소 ArchUnit 규칙의 근거라는 것\n34477 | \n34478 | **확인하지 못한 것**\n34479 | \n34480 | - **fencing token SQL이 실제 PostgreSQL에서 정확한지.** 그것을 검증할 레인이 다른 세대를 쓴다. `messaging-outbox-jdbc-postgresql` leaf가 이 판정을 소유한다.\n34481 | - `STALE_LEASE`가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.\n34482 | - inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.\n34483 | - `ReliableMessagePublisher`를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.\n34484 | - `OutboxRecord.equals`의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.\n34485 | \n34486 | ---\n34487 | \n34488 | #### 17. 손볼 것\n34489 | \n34490 | ##### P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 `@Deprecated`가 없다\n34491 | \n34492 | - **사실.** `OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다. javadoc이 전자를 \"deprecated for the relay's use\"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다.\n34493 | - **근거.** `evidence/raw/289` §E·§F.\n34494 | - **왜 문제인가.** 전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.\n34495 | - **확인 방법.** `git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api` → 없음. `evidence/raw/289` §E.\n34496 | - **후보.** (a) 구세대 다섯에 `@Deprecated`를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(`OutboxInspection`)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.\n34497 | - **다음 단계.** **CASE 후보 + REFERENCE 후보.** \"prose deprecation은 컴파일러가 읽지 않는다\"가 재사용 가능한 기준이다.\n34498 | \n34499 | ##### P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다\n34500 | \n34501 | - **사실.** `OutboxRelay`는 `claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT`는 `leaseBatch`/`MessageId` 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository`와 `RecordingRepository` — SQL이 없는 fake다.\n34502 | - **근거.** `evidence/raw/289` §G.\n34503 | - **왜 문제인가.** fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 \"its update matches zero rows\"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**\n34504 | - **확인 방법.** `evidence/raw/289` §G 재실행. `OutboxPostgresIT`에서 `claimBatch` 검색 → 없음.\n34505 | - **후보.** 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.\n34506 | - **다음 단계.** **판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. **CASE 후보**(그 leaf).\n34507 | \n34508 | ##### P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다\n34509 | \n34510 | - **사실.** `ReliableMessagePublisher`가 구현 0, 참조 0이다. javadoc은 \"This is the answer to the dual-write problem\"이라고 한다.\n34511 | - **근거.** `evidence/raw/289` §B·§C·§D.\n34512 | - **왜 문제인가.** `OutboxRepository.append`가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, `ReliableMessagePublisher`는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 **애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로** 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 \"릴레이가 읽는 쪽\"만 배선돼 있고 \"애플리케이션이 쓰는 쪽\"이 비어 있다.\n34513 | - **확인 방법.** `git grep -n -E 'implements .*ReliableMessagePublisher' -- src` → 없음.\n34514 | - **후보.** (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 \"파생 프로젝트가 구현하는 확장점\"임을 명시한다.\n34515 | - **다음 단계.** **OPEN QUESTION 후보.** 판정이 \"두 outbox 모델 중 어느 쪽이 정본인가\"에 걸리고, 그 질문은 `application-core`와 cross-scope가 함께 답한다.\n34516 | \n34517 | ##### P3 — 이 leaf에 테스트가 없다\n34518 | \n34519 | - **사실.** `src/test` 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.\n34520 | - **근거.** `evidence/raw/289` §A.\n34521 | - **왜 문제인가.** 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: `OutboxCanonicalMetadata`가 `schemaUri` 있고 `schemaSubject` 없는 조합을 거절하는 것, `OutboxLease`가 `token < 1`을 거절하는 것, `InboxResult.isSafeToSettle`의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(`messaging-core-api` 79개, `messaging-policy` 42개 등).\n34522 | - **확인 방법.** `ls src/messaging/messaging-reliability-api/src` → `main`만.\n34523 | - **후보.** record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.\n34524 | - **다음 단계.** **REFERENCE 후보**(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).\n34525 | \n34526 | ##### P3 — inbox 보존 규칙이 문서로만 있다\n34527 | \n34528 | - **사실.** `InboxRepository.purgeProcessedBefore` javadoc이 \"Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time\"라고 한다. 그 비교를 하는 코드가 이 leaf에도 `messaging-policy`의 프로파일 검증기에도 없다.\n34529 | - **근거.** 해당 javadoc. `DestinationProfileValidator` 16규칙 전수(재전달 창 관련 없음).\n34530 | - **왜 문제인가.** 위반의 결과가 **부작용의 이중 실행**이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.\n34531 | - **확인 방법.** `git grep -n -i 'redelivery window\\|retention' -- 'src/messaging/**/*.java'`\n34532 | - **후보.** 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 `messaging-policy`나 starter에 추가한다.\n34533 | - **다음 단계.** **CASE 후보 + REFERENCE 후보**(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).\n34534 | \n34535 | ##### P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다\n34536 | \n34537 | - **사실.** `OutboxRepository.append`가 호출자 트랜잭션 안, `InboxRepository.reserve`가 부작용과 같은 트랜잭션, `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.\n34538 | - **근거.** 세 javadoc.\n34539 | - **왜 문제인가.** `ReliableMessagePublisher`는 `void` 반환으로 계약의 일부를 타입에 담았다(\"Handing back a `PublishResult` here would be a lie\"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — `InboxRepository.reserve`를 별도 트랜잭션에서 부르면 \"exactly the gap the Inbox exists to close\"가 다시 열린다.\n34540 | - **확인 방법.** 세 javadoc과 구현의 `@Transactional` 배치 대조 — 구현 leaf가 소유한다.\n34541 | - **후보.** 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 `append`/`reserve` 호출부의 트랜잭션 컨텍스트를 검사한다.\n34542 | - **다음 단계.** **REFERENCE 후보**(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).\n34543 | \n34544 | ##### P3 — `OutboxRecord.equals`가 다섯 필드만 비교하고 이유가 없다\n34545 | \n34546 | - **사실.** `equals`/`hashCode`가 `messageId`·`status`·`attempts`·`payload` 넷만 본다. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 무시한다.\n34547 | - **근거.** `OutboxRecord.java:114-126`.\n34548 | - **왜 문제인가.** record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(`messaging-schema-api`의 `EncodedMessage`도 같다). 그러나 `EncodedMessage`는 **모든 필드**를 비교하고 이쪽은 아니다. 같은 `messageId`·`status`·`attempts`·`payload`를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.\n34549 | - **확인 방법.** 두 record의 `equals` 대조.\n34550 | - **후보.** 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.\n34551 | - **다음 단계.** **REFERENCE 후보**(record의 `equals`를 좁히면 이유를 적는다).\n34552 | \n34553 | ##### P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다\n34554 | \n34555 | - **사실.** `InboxRepository`와 `OutboxRepository`가 각각 `purge*Before(Instant)`와 `purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.\n34556 | - **근거.** `evidence/raw/294-bounded-purge-never-called.txt`.\n34557 | - **왜 문제인가.** §12.1(a)의 두 세대 전이와 같은 형태다 — **한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.** 두 경우 모두 포트의 형태가 오용을 가능하게 했다.\n34558 | - **확인 방법.** `git grep -n -E 'purge(Processed|Published)Before\\s*\\([^)]*,' -- 'src/**/*.java'`\n34559 | - **다음 단계.** 판정은 §A19-MESSAGING-INBOX-JDBC-POSTGRESQL §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 **같은 CASE로 묶을 후보**다.\n34560 | \n34561 | ##### 확인된 설계(문제 아님)\n34562 | \n34563 | - outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것\n34564 | - fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계\n34565 | - `AMBIGUOUS`/`FAILED`/`EXHAUSTED` 세 상태의 구분과 각각의 운영 행동 차이\n34566 | - `InboxResult`가 셋이고 `isSafeToSettle()`이 그 판단을 모으는 것\n34567 | - inbox 키가 (message, consumer)인 것\n34568 | - provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것\n34569 | - `withStatus`가 `messageId`를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것\n34570 | - `addToOutbox`의 `void` 반환이 계약인 것\n34571 | - claim check의 digest와 만료가 필수인 것\n34572 | - 두 outbox 모델을 ArchUnit으로 격리한 것\n34573 | \n34574 | ---\n34575 | \n34576 | #### Source anchors\n34577 | \n34578 | | id | kind | path | revision | what it proves | limitations |\n34579 | |---|---|---|---|---|---|\n34580 | | MRA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `[\"app-bootstrap\"]` | 선언 |\n34581 | | MRA-002 | build | `messaging-reliability-api/build.gradle` | same | 벤더 의존성 0 | — |\n34582 | | MRA-003 | code | `.../reliability/OutboxRepository.java` 전문 | same | 두 세대 17메서드, purge limit 이유 | `@Deprecated` 없음 |\n34583 | | MRA-004 | code | `.../reliability/OutboxLease.java` | same | fencing token과 이중 발행 이력 | — |\n34584 | | MRA-005 | code | `.../reliability/OutboxTransitionResult.java` | same | void 반환이 삼킨 것 | — |\n34585 | | MRA-006 | code | `.../reliability/OutboxStatus.java` | same | 여섯 상태와 두 구분의 이유 | — |\n34586 | | MRA-007 | code | `.../reliability/OutboxCanonicalMetadata.java` | same | provenance 결함 이력, 기각된 대안 | — |\n34587 | | MRA-008 | code | `.../reliability/OutboxRecord.java` | same | 두 반쪽 분리, 방어 복사, 좁은 equals | equals 이유 없음(§17) |\n34588 | | MRA-009 | code | `.../reliability/{InboxRepository,InboxRecord,InboxResult}.java` | same | 트랜잭션 계약, (message,consumer) 키, 세 판정 | `InboxRecord` 참조 0 |\n34589 | | MRA-010 | code | `.../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java` | same | 멱등 핸들러 계약과 세 금지 | 금지 미강제 |\n34590 | | MRA-011 | code | `.../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java` | same | dual-write 답, digest 필수 | publisher 구현 0 |\n34591 | | MRA-012 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205` | same | production이 신세대만 사용 | 해당 leaf SSOT가 소유 |\n34592 | | MRA-013 | cross-leaf test | `messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200` | same | 컨테이너 테스트가 구세대만 사용 | 해당 leaf SSOT가 소유 |\n34593 | | MRA-014 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `OutboxStatus.FAILED` 의미가 규칙의 근거 | 정적 분석 |\n34594 | | EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | same | §12.1 전부, `src/test` 부재 | 정적 검색. 이 leaf에 테스트 레인 없음 |\n34595 | \n34596 | ---\n34597 | \n34598 | ## A19-MESSAGING-RUNTIME-CORE. messaging-runtime-core\n34599 | \n34600 | > 분석 중에는 `messaging/MESSAGING-RUNTIME-CORE.md` 파일이었다. 807줄.\n34601 | \n34602 | ### messaging-runtime-core 완전 해부\n34603 | \n34604 | > 상태: COMPLETE\n34605 | > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n34606 | > 분석 범위: `src/messaging/messaging-runtime-core`\n34607 | > SSOT owner: `messaging-runtime-core`\n34608 | > integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n34609 | \n34610 | ---\n34611 | \n34612 | #### 0. SSOT identity / 커버리지와 숫자 지도\n34613 | \n34614 | - registered leaf id: `messaging-runtime-core`\n34615 | - canonical state `analysisFile`: §A19-MESSAGING-RUNTIME-CORE\n34616 | - source path: `src/messaging/messaging-runtime-core`\n34617 | - registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\", \"messaging-policy\", \"messaging-transport-spi\", \"messaging-security\", \"messaging-observability\"]` — messaging family에서 두 번째로 많은 의존\n34618 | - registry `runtime_memberships`: `[\"app-bootstrap\"]`\n34619 | \n34620 | ##### 숫자\n34621 | \n34622 | | 항목 | 수 |\n34623 | |---|---:|\n34624 | | production Java 파일 | **6** |\n34625 | | production LOC | 787 |\n34626 | | 패키지 | 1 (`dev.caskeleton.messaging.runtime`) |\n34627 | | test 파일 | 4 (테스트 3 + fixture 1) |\n34628 | | test 메서드(실행 확인) | 21 |\n34629 | | 외부(비프로젝트) 의존성 | **0** |\n34630 | \n34631 | 여섯 클래스:\n34632 | \n34633 | | 클래스 | LOC | 역할 | 출하 조립 |\n34634 | |---|---:|---|---|\n34635 | | `DefaultMessagePublisher` | 366 | **유일한 발행 경로** | o (`:446`) |\n34636 | | `DefaultDeliveryProcessor` | 155 | 핸들러 결과 → 정산 | **x** |\n34637 | | `RegisteredMessageCodecs` | 89 | content type → codec | o (`:363`) |\n34638 | | `DestinationProfileRegistry` | 62 | 논리 이름 → 프로파일 | o (`:377`) |\n34639 | | `TransportMessagingRuntime` | 67 | transport를 세대로 포장 | o (`:476`) |\n34640 | | `DeclaredDestinationAccess` | 48 | 기본 접근 정책 | o |\n34641 | \n34642 | ##### Coverage ledger\n34643 | \n34644 | | scope/file group | count | disposition | reason |\n34645 | |---|---:|---|---|\n34646 | | `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |\n34647 | | `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |\n34648 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |\n34649 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n34650 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n34651 | \n34652 | `UNCLASSIFIED` 0.\n34653 | \n34654 | ---\n34655 | \n34656 | #### 1. 모듈의 정체와 경계\n34657 | \n34658 | **이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 \"X was an interface with no implementation\" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다.\n34659 | \n34660 | ```groovy\n34661 | // The central publish and delivery orchestration.\n34662 | //\n34663 | // MessagePublisher was an interface with no implementation anywhere in the new platform: the\n34664 | // brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade\n34665 | // beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases\n34666 | // and observation existed as beans that no publish path ever called. A starter that filled the gap\n34667 | // with an application-supplied fake would pass a context test while running none of them.\n34668 | ```\n34669 | \n34670 | 이 진단의 마지막 문장이 핵심이다 — **컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.\n34671 | \n34672 | six 파일이 메운 구멍:\n34673 | \n34674 | | 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 |\n34675 | |---|---|---|\n34676 | | `MessagePublisher` (core-api) | 어디에도 없음 | `DefaultMessagePublisher` |\n34677 | | `MessageCodecRegistry` (schema-api) | 어디에도 없음 | `RegisteredMessageCodecs` |\n34678 | | `MessagingRuntime` (transport-spi) | 어디에도 없음 | `TransportMessagingRuntime` |\n34679 | | (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | `DestinationProfileRegistry` |\n34680 | | `DestinationAccessPolicy` 기본값 (security) | `denyAll()`뿐 | `DeclaredDestinationAccess` |\n34681 | | `HandleResult` → 정산 (core-api) | 어댑터가 각자 결정 | `DefaultDeliveryProcessor` |\n34682 | \n34683 | 여섯 중 다섯은 배선됐고 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1).\n34684 | \n34685 | ---\n34686 | \n34687 | #### 2. 의존성과 런타임 배선\n34688 | \n34689 | 들어오는 것: 여섯 project 의존, 전부 `api`. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다.\n34690 | \n34691 | 나가는 것: `messaging-spring-boot-starter`만.\n34692 | \n34693 | **배선 지점 다섯**(전부 `MessagingCoreAutoConfiguration`):\n34694 | \n34695 | | 라인 | 무엇 |\n34696 | |---:|---|\n34697 | | 363 | `RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))` |\n34698 | | 377 | `DestinationProfileRegistry.of(destinations.all())` |\n34699 | | 446 | `new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)` |\n34700 | | 476 | `new TransportMessagingRuntime(selected.brokerName(), 1L, selected)` — `InitializingBean` 안 |\n34701 | | — | `DeclaredDestinationAccess.of(...)`로 접근 정책 bean |\n34702 | \n34703 | 446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다.\n34704 | \n34705 | ---\n34706 | \n34707 | #### 3. 패키지/컴포넌트 지도\n34708 | \n34709 | ```\n34710 | 발행 (조립됨)\n34711 | DefaultMessagePublisher\n34712 | ├── DestinationProfileRegistry 논리 이름 → DestinationProfile\n34713 | ├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles)\n34714 | ├── MessageCodecRegistry ← RegisteredMessageCodecs\n34715 | ├── MessagingAdmissionController (policy)\n34716 | ├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime\n34717 | ├── MessagingTransport (transport-spi) → Kafka/Rabbit/…\n34718 | └── MessagingObservation ← NO_OBSERVATION (§12.1)\n34719 | \n34720 | 소비 (조립 안 됨)\n34721 | DefaultDeliveryProcessor\n34722 | ├── Function The order below is fixed, not composed from a map of interceptors. Each stage's position is a\n34736 | * decision:\n34737 | *\n34738 | * Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as\n34784 | * the publish operation's deadline, so a slow destination lookup or a large encode spends the\n34785 | * same budget the broker wait does; timing only the transport call would let the total exceed the\n34786 | * deadline by however long preparation took.\n34787 | ```\n34788 | \n34789 | `remainingBudget`이 `timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — \"Sending anyway would start a message the caller has already stopped waiting for.\"\n34790 | \n34791 | ##### 4.3 마감을 복사본에 건다\n34792 | \n34793 | ```java\n34794 | // :143-154\n34795 | * The bound is applied to a copy so that expiry never completes the transport's own stage: the\n34796 | * adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime\n34797 | * lease are released when the copy completes, which is deliberate — holding them until a stalled\n34798 | * broker answers is how a rotation waits forever on a generation nobody is using.\n34799 | private static CompletableFuture Everything acquired is released exactly once, on every path — success, failure, exception and\n34815 | * cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one\n34816 | * per failure until it stops accepting anything.\n34817 | ```\n34818 | \n34819 | 두 경로가 있다.\n34820 | \n34821 | ```java\n34822 | .handle((result, failure) -> {\n34823 | // One release per acquisition, whatever happened.\n34824 | held.close();\n34825 | admission.complete(destination.name().value());\n34826 | ...\n34827 | });\n34828 | ```\n34829 | \n34830 | ```java\n34831 | } catch (RuntimeException beforeTheSend) {\n34832 | if (lease != null) { lease.close(); }\n34833 | admission.complete(destination.name().value());\n34834 | return rejected(\"PUBLISH_RUNTIME_UNAVAILABLE\", ...);\n34835 | }\n34836 | ```\n34837 | \n34838 | `handle`은 `whenComplete`와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. `lease.close()`는 `MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3).\n34839 | \n34840 | **한 가지 비대칭.** 6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다. `MessageTooLargeException`·`MessageBackpressureException`은 `MessagingException`이므로 호출자가 `FailureDescriptor`를 얻을 수 있지만, 반환 타입이 `CompletionStage The transports accept {@code request.options()} and read nothing from it, so an option this\n34847 | * destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for\n34848 | * broker-side deduplication got a publish with no deduplication and no error, and then skipped\n34849 | * the idempotency it would otherwise have written — which is exactly the case {@code\n34850 | * PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.\n34851 | ```\n34852 | \n34853 | `messaging-core-api`의 `PublishDeduplication` javadoc(\"Requesting this on a broker without the `deduplicatedPublish` capability is a startup failure, not a silent no-op\")이 여기서 실제 검사가 된다. 다만 **startup이 아니라 publish 시점**이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.\n34854 | \n34855 | 그리고 \"The transports accept `request.options()` and read nothing from it\"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.\n34856 | \n34857 | ##### 4.6 `encode` — 폴백이 기본 codec이다\n34858 | \n34859 | ```java\n34860 | private Nothing resolved a logical destination to a profile before this: the brokers took an\n34875 | * already-resolved {@code DestinationProfile} and the publisher that would have produced one did\n34876 | * not exist. A registry rather than a lookup with a fallback, because a destination nobody declared\n34877 | * has no physical name, no ordering guarantee and no payload bound — publishing to it would mean\n34878 | * inventing all three at the call site.\n34879 | ```\n34880 | \n34881 | `require`가 미등록 목적지에 `MessagingConfigurationException(\"DESTINATION_NOT_REGISTERED\")`을 던지고 메시지가 세 가지 부재를 나열한다. `empty()` factory도 있다 — \"every publish is refused until a destination is declared\".\n34882 | \n34883 | ##### 4.8 `RegisteredMessageCodecs` — 기본 codec은 명시 선택\n34884 | \n34885 | ```java\n34886 | // :18-27\n34887 | * The default codec is a deliberate choice rather than \"the first one registered\". Selecting one\n34888 | * by iteration order means the encoding a message is written with depends on how the map was\n34889 | * populated, which is a wire-format decision made by accident. The registry takes it explicitly and\n34890 | * refuses to be constructed without it.\n34891 | *\n34892 | * The raw-bytes codec is never eligible as the default — that is the contract's own rule, and\n34893 | * the reason is that raw bytes silently disable schema validation for every destination that forgot\n34894 | * to declare an encoding.\n34895 | ```\n34896 | \n34897 | 두 가지를 생성자에서 거절한다.\n34898 | \n34899 | ```java\n34900 | if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }\n34901 | ...\n34902 | MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);\n34903 | if (existing != null && existing != codec) {\n34904 | // Two codecs for one content type is not a preference to resolve at runtime: whichever wins\n34905 | // decides how bytes on the wire are read by a consumer that was compiled against the other.\n34906 | throw new IllegalArgumentException(\"two codecs claim content type \" + ...);\n34907 | }\n34908 | ```\n34909 | \n34910 | **클래스가 아니라 content type으로 raw-bytes를 거절**하는 것이 `messaging-schema-api`의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.\n34911 | \n34912 | ##### 4.9 `TransportMessagingRuntime` — 얇은 포장\n34913 | \n34914 | `MessagingRuntime` 구현으로 `brokerName`·`generation`·`transport` 셋을 들고 `close()`가 CAS로 멱등이다.\n34915 | \n34916 | ```java\n34917 | // close():61-62\n34918 | // Idempotent: the registry closes a drained generation, and a context shutdown may close it\n34919 | // again. Closing a transport twice is not an error worth propagating into shutdown.\n34920 | ```\n34921 | \n34922 | `DefaultMessagingRuntimeRegistry`(transport-spi)도 자체 `closed` CAS를 갖는다 — **두 층이 각각 멱등**이다. 중복 방어이지만 `transport-spi`의 `Generation.forceClose()`가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.\n34923 | \n34924 | **generation이 항상 `1L`이다.** starter의 유일한 설치 지점(`:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 \"increasing with each replacement\"라고 하고, `TransportMessagingRuntime` javadoc은 \"the credential generation a rotation increments\"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.\n34925 | \n34926 | ##### 4.10 `DeclaredDestinationAccess` — 기본값의 세 번째 선택지\n34927 | \n34928 | ```java\n34929 | // :13-32\n34930 | * {@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}\n34931 | * factory. Neither is a usable default on its own:\n34932 | *\n34933 | * So the default is neither: a deployment may publish to the destinations it declared.\n34939 | * … a message to a destination nobody declared is not an access-control edge case, it is a typo or\n34940 | * a module reaching past its own contract.\n34941 | *\n34942 | * Consume and administer stay empty. A publisher's default has no business granting either, and\n34943 | * a deployment that needs them replaces this bean — which is the point of it being a bean.\n34944 | ```\n34945 | \n34946 | **publish만 허용하고 consume·administer는 빈 집합**이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.\n34947 | \n34948 | ##### 4.11 `DefaultDeliveryProcessor` — 두 규칙 (미조립)\n34949 | \n34950 | ```java\n34951 | // :27-36\n34952 | * A driver message can carry a routing key, a payload fragment or a connection string, and a\n35018 | * {@code FailureDescriptor} is designed to be logged and exported.\n35019 | return cause.getClass().getSimpleName();\n35020 | ```\n35021 | \n35022 | `messaging-core-api`의 `FailureDescriptor` javadoc(\"no payload, no stack trace, no credential\")과 같은 관심사다.\n35023 | \n35024 | `isDeadline`과 `sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.\n35025 | \n35026 | `DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다.\n35027 | \n35028 | ---\n35029 | \n35030 | #### 7. 트랜잭션·동시성·수명주기\n35031 | \n35032 | 트랜잭션 없음.\n35033 | \n35034 | | 지점 | 도구 | 보호 |\n35035 | |---|---|---|\n35036 | | `OneShotSettlement.settled` | `AtomicBoolean` CAS | 정확히 한 번 정산 |\n35037 | | `TransportMessagingRuntime.closed` | `AtomicBoolean` CAS | 정확히 한 번 transport close |\n35038 | | `RegisteredMessageCodecs.byContentType` | `Map.copyOf` | 불변 |\n35039 | | `DestinationProfileRegistry.profiles` | `Map.copyOf` | 불변 |\n35040 | | `withDeadline`의 `.copy()` | `CompletableFuture` | 어댑터 stage와 이쪽 경로 분리 |\n35041 | \n35042 | `DefaultMessagePublisher` 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. `lease`만 메서드 지역 변수이고 `handle` 람다가 `held`라는 effectively-final 복사본으로 캡처한다.\n35043 | \n35044 | 수명주기 참여는 `TransportMessagingRuntime.close()`뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.\n35045 | \n35046 | ---\n35047 | \n35048 | #### 8. 설정·기능 플래그·환경 차이\n35049 | \n35050 | 설정 없음. 이 leaf의 모든 값은 생성자 인자다.\n35051 | \n35052 | **주입 가능한 두 지점**이 테스트 가능성을 만든다.\n35053 | \n35054 | | 인자 | 기본 | 목적 |\n35055 | |---|---|---|\n35056 | | `LongSupplier nanoTime` | `System::nanoTime` | 경과 시간을 sleep 없이 테스트 |\n35057 | | `MessagingObservation observation` | `NO_OBSERVATION` | 관측 주입 |\n35058 | \n35059 | 두 번째의 기본값이 §12.1의 발견 지점이다.\n35060 | \n35061 | `TransportMessagingRuntime`의 `generation`은 생성자 인자이고 유일한 호출자가 `1L`을 넘긴다.\n35062 | \n35063 | ---\n35064 | \n35065 | #### 9. 퍼시스턴스/외부 시스템 세부\n35066 | \n35067 | 없다. 브로커 접촉은 `MessagingTransport` 인터페이스 뒤에 있다.\n35068 | \n35069 | ---\n35070 | \n35071 | #### 10. 테스트 레인과 실제 증명 범위\n35072 | \n35073 | 레인: `./gradlew :messaging:messaging-runtime-core:test`. **BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures**.\n35074 | \n35075 | | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n35076 | |---|---:|---|---|\n35077 | | `DefaultMessagePublisherTest` | 10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. **출하 조립이 관측을 넘기는지** |\n35078 | | `DefaultDeliveryProcessorTest` | 7 | `HandleResult` 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 | **production에서 호출되는지**(§12.1) |\n35079 | | `RegisteredMessageCodecsTest` | 4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — |\n35080 | \n35081 | `DefaultMessagePublisherTest:271`이 익명 `MessagingObservation`을 만들어 관측 호출을 확인한다. 즉 **테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다.** 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.\n35082 | \n35083 | `RecordingTransport`(`:426`)가 `MessagingTransport`를 구현해 전송을 대체한다. 그래서 이 레인은 \"발행 오케스트레이션이 옳다\"를 증명하고 \"어댑터가 계약을 지킨다\"는 증명하지 않는다.\n35084 | \n35085 | ---\n35086 | \n35087 | #### 11. 빌드/ArchUnit/CI 강제 지점\n35088 | \n35089 | | 게이트 | 이 leaf에 대해 |\n35090 | |---|---|\n35091 | | `verifyCleanArchitectureDependencies` | 여섯 project 의존 |\n35092 | | `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n35093 | | vendor `api` 규칙 | 벤더 의존성 0 |\n35094 | | ArchUnit | 전용 규칙 없음 |\n35095 | \n35096 | `MessagingStarterOffContractTest`(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — \"DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …\". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.\n35097 | \n35098 | ---\n35099 | \n35100 | #### 12. 실제 사용 여부와 negative-space probes\n35101 | \n35102 | 원시 증거: `evidence/raw/283-runtime-core-observation-noop.txt`.\n35103 | \n35104 | ##### 12.1 Public surface reachability\n35105 | \n35106 | | 타입 | leaf 밖 파일 | 출하 조립 |\n35107 | |---|---:|---|\n35108 | | `DefaultMessagePublisher` | 2 | **o** — `MessagingCoreAutoConfiguration:446` |\n35109 | | `TransportMessagingRuntime` | 1 | **o** — `:476` |\n35110 | | `RegisteredMessageCodecs` | 1 | **o** — `:363` |\n35111 | | `DestinationProfileRegistry` | 1 | **o** — `:377` |\n35112 | | `DeclaredDestinationAccess` | 1 | **o** |\n35113 | | `DefaultDeliveryProcessor` | **0** | **x** — `src/main` 생성 0, `src/test` 1 |\n35114 | \n35115 | **(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다**\n35116 | \n35117 | `DefaultDeliveryProcessor`는 leaf 밖 참조가 0이고 `src/main`에서 생성되지 않는다. 이것이 §A19-MESSAGING-POLICY §12.1이 관측한 \"소비 경로 전체 미조립\"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.\n35118 | \n35119 | 이 클래스의 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.\" 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.\n35120 | \n35121 | **(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다**\n35122 | \n35123 | 네 조각이 있다.\n35124 | \n35125 | | 조각 | 상태 |\n35126 | |---|---|\n35127 | | `MessagingObservation` 인터페이스 (observability) | 존재 |\n35128 | | `MessagingMetrics implements MessagingObservation` | 존재 |\n35129 | | `DefaultMessagePublisher.observe(...)` 호출부 | 존재, 모든 발행 결과를 기록 |\n35130 | | 8인자 생성자 (관측 주입) | 존재 |\n35131 | | **출하 조립** | **6인자 생성자 → `NO_OBSERVATION`** |\n35132 | | **`MessagingMetrics` bean** | **없음** |\n35133 | \n35134 | ```java\n35135 | // MessagingCoreAutoConfiguration.java:446-447\n35136 | return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(\n35137 | destinations, access, codecs, admission, runtimes, transport);\n35138 | ```\n35139 | \n35140 | 그리고 `MessagingMetrics`는 저장소 전체에서 **자기 테스트에서만** 생성된다(`MessagingMetricCardinalityTest`, `MessagingSecretLeakTest`).\n35141 | \n35142 | starter는 `MessagingMetrics`의 **두 협력자를 bean으로 만든다** — `MessagingRedactor`(:253)와 `CardinalityGuard`(:264). `MessagingMetrics`의 생성자는 `(registry, CardinalityGuard, MessagingRedactor)`를 받는다(테스트가 그렇게 호출한다). 즉 **재료 둘은 배선됐고 그것을 조립하는 bean이 없다.**\n35143 | \n35144 | 이 클래스의 javadoc이 그 상황을 예언한다.\n35145 | \n35146 | ```java\n35147 | // DefaultMessagePublisher.java:74-78\n35148 | * {@code MessagingObservation} existed as a bean and no publish path called it, so the\n35149 | * platform's own metrics described nothing. It is a constructor argument rather than an optional\n35150 | * decorator because an unobserved publish path is how \"the dashboards were empty during the\n35151 | * incident\" happens.\n35152 | ```\n35153 | \n35154 | **이전 상태:** bean은 있고 호출하는 경로가 없었다.\n35155 | **현재 상태:** 호출하는 경로는 있고 bean이 없다.\n35156 | \n35157 | 두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 **반대편으로 옮겼다.** 그리고 \"constructor argument rather than an optional decorator\"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.\n35158 | \n35159 | **(c) 배선된 것은 확실히 배선됐다**\n35160 | \n35161 | 발행 경로 다섯이 전부 `src/main`에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.\n35162 | \n35163 | **한계.** 정적 검색이다. `ObjectProvider` 지연 조회는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 `MessagingObservation` bean을 제공하면 `@ConditionalOnMissingBean(MessagePublisher.class)` 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.\n35164 | \n35165 | ##### 12.2 Conditional sibling comparison\n35166 | \n35167 | 이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.\n35168 | \n35169 | `MessagingCoreAutoConfiguration`이 이 leaf의 타입을 만드는 지점 다섯의 조건:\n35170 | \n35171 | | 대상 | 조건 |\n35172 | |---|---|\n35173 | | `RegisteredMessageCodecs` | `@ConditionalOnMissingBean(MessageCodecRegistry.class)` |\n35174 | | `DestinationProfileRegistry` | `@ConditionalOnMissingBean` |\n35175 | | `DefaultMessagePublisher` | `@ConditionalOnMissingBean(MessagePublisher.class)` |\n35176 | | `TransportMessagingRuntime` | 조건 없음 — `InitializingBean` 안, `transport.getIfAvailable()` null 검사 |\n35177 | | `DeclaredDestinationAccess` | `@ConditionalOnMissingBean` |\n35178 | \n35179 | **네 번째만 조건 대신 런타임 null 검사를 쓴다.** 그 이유가 주석에 있다.\n35180 | \n35181 | ```java\n35182 | // Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport\n35183 | // when a broker is selected, and it refuses startup by name when one is not. This\n35184 | // configuration is also loadable on its own — an adopter composing the policy primitives\n35185 | // without a transport — and demanding one here would refuse that.\n35186 | ```\n35187 | \n35188 | 즉 \"transport 없이도 로드 가능해야 한다\"가 명시적 요구이고, 그 요구가 `@ConditionalOnBean` 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(`MessagingProviderSelection`)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.\n35189 | \n35190 | ##### 12.3 Duplicate mechanism sweep\n35191 | \n35192 | **(a) DLQ 순서 불변식이 두 곳에 구현돼 있다**\n35193 | \n35194 | | | `messaging-policy` `DeadLetterOrchestrator` | 이 leaf `DefaultDeliveryProcessor` |\n35195 | |---|---|---|\n35196 | | 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack |\n35197 | | 미확인 시 | 정산하지 않음(`sourceSettled=false`) | **requeue** |\n35198 | | 헤더 | 예약 헤더 6개 부착 | 없음 |\n35199 | | 발행 주체 | `MessagePublisher` | `DeadLetterPublisher` 함수형 인터페이스 |\n35200 | \n35201 | **미확인 시 동작이 다르다.** policy 쪽은 \"정산하지 않는다\"(브로커가 알아서 재전달), 이쪽은 \"명시적으로 requeue한다\". 둘 다 메시지를 잃지 않지만 `requeue(delay)`는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.\n35202 | \n35203 | 둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. §A19-MESSAGING-POLICY §12.3(b)가 같은 사건을 반대편에서 기록한다.\n35204 | \n35205 | **(b) 재시도 지연이 두 출처**\n35206 | \n35207 | `DefaultDeliveryProcessor`의 `retryDelay`는 **생성자 인자 하나**다. 시도 횟수를 세지 않고 백오프도 없다. `messaging-policy`의 `BackoffCalculator`(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.\n35208 | \n35209 | **(c) 멱등 종료가 두 층**\n35210 | \n35211 | `TransportMessagingRuntime.close()`와 `DefaultMessagingRuntimeRegistry.Generation.forceClose()`(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 **의도된 중복**이다 — 이쪽 주석이 \"the registry closes a drained generation, and a context shutdown may close it again\"이라고 두 경로를 명시한다. 결함 아님.\n35212 | \n35213 | **(d) content type 폴백**\n35214 | \n35215 | `encode`가 `codecs.find(contentType).orElseGet(codecs::defaultCodec)`으로 폴백한다. `RegisteredMessageCodecs.find`는 미등록이면 `Optional.empty()`를 주고, `defaultCodec()`은 JSON이다. 즉 **선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점**이고, 그 갈라짐이 조용하다. §17.\n35216 | \n35217 | ##### 12.4 Documentation / measured-count drift\n35218 | \n35219 | | 문서 주장 | 재측정 | 결과 |\n35220 | |---|---|---|\n35221 | | build.gradle 주석: `MessagePublisher`에 구현이 없었다 | 현재 이 leaf가 구현하고 `:446`에서 조립 | **해소됨** |\n35222 | | `TransportMessagingRuntime` javadoc: registry가 비어 있어 모든 발행이 실패했다 | 현재 `:476`이 설치 | **해소됨** |\n35223 | | `DefaultMessagePublisher` javadoc: 관측 bean이 있고 호출 경로가 없었다 | 현재 호출 경로가 있고 bean이 없다 | **반전됨**(§12.1b) |\n35224 | | `DefaultDeliveryProcessor` javadoc: 어댑터가 각자 결정했다 | 한 곳에 모았으나 조립되지 않음 | **부분 해소** |\n35225 | | `MessagingRuntime.generation()` javadoc: \"increasing with each replacement\" | 유일한 설치가 리터럴 `1L` | **미실현** |\n35226 | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |\n35227 | \n35228 | 세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.\n35229 | \n35230 | ---\n35231 | \n35232 | #### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n35233 | \n35234 | 이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`).\n35235 | \n35236 | | 위치 | 이전 상태 | 그것이 만든 실패 |\n35237 | |---|---|---|\n35238 | | `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 |\n35239 | | `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |\n35240 | | `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |\n35241 | | `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |\n35242 | | `DefaultDeliveryProcessor` 핸들러 예외 주석 | Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 |\n35243 | | `requireSupportedOptions` javadoc | transport가 `options`를 읽지 않음 | 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 |\n35244 | | `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |\n35245 | \n35246 | `build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — \"A starter that filled the gap with an application-supplied fake would pass a context test while running none of them.\"\n35247 | \n35248 | ---\n35249 | \n35250 | #### 14. 런타임·터미널 Evidence\n35251 | \n35252 | | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n35253 | |---|---|---|---|---|\n35254 | | EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | 여섯 타입 참조 수, 발행 경로 조립 지점, `DefaultDeliveryProcessor` src/main=0, 관측 4조각과 끊긴 한 지점, `MessagingMetrics`가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 | 정적 검색. 파생 프로젝트의 대체 조립 미포함 |\n35255 | | EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(`RecordingTransport`) |\n35256 | \n35257 | ---\n35258 | \n35259 | #### 15. 명시적 설계 이유와 추론을 구분한 정리\n35260 | \n35261 | **명시적**\n35262 | \n35263 | - 이 leaf가 존재하는 이유와 이전 결함 — `build.gradle` 주석\n35264 | - 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — `DefaultMessagePublisher` javadoc\n35265 | - 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석\n35266 | - 전송 전 실패가 `REJECTED`인 이유 — 인라인 주석\n35267 | - 예산을 호출 시점부터 세는 이유 — `remainingBudget` javadoc\n35268 | - 마감을 복사본에 거는 이유와 그 대가 — `withDeadline` javadoc\n35269 | - 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석\n35270 | - 지원하지 않는 옵션을 거절하는 이유 — `requireSupportedOptions` javadoc\n35271 | - 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — `RegisteredMessageCodecs` javadoc\n35272 | - 폴백 없는 목적지 조회 이유 — `DestinationProfileRegistry` javadoc\n35273 | - 기본 접근 정책이 deny도 allow도 아닌 이유 — `DeclaredDestinationAccess` javadoc\n35274 | - 핸들러 예외가 retry인 이유 — 인라인 주석\n35275 | - DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석\n35276 | - transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — `InitializingBean` 안 주석\n35277 | - 관측을 생성자 인자로 둔 이유 — `observation` 필드 javadoc\n35278 | \n35279 | **추론**\n35280 | \n35281 | - 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → **추론이 아니라 미상.** 어디에도 근거가 없고, 8인자 생성자와 `MessagingMetrics`가 둘 다 존재한다는 점이 미완을 시사한다.\n35282 | - `generation`이 항상 1인 것은 회전 코드가 없기 때문이다 → **추론**. 회전 코드 부재는 관측이다.\n35283 | - `DefaultDeliveryProcessor` 미조립이 미완인지 확장점인지 → **미상**.\n35284 | \n35285 | ---\n35286 | \n35287 | #### 16. 확인한 것 / 확인하지 못한 것\n35288 | \n35289 | **확인한 것**\n35290 | \n35291 | - 6개 클래스 787줄 전문의 계약과 순서 결정\n35292 | - 21개 테스트가 통과하고 무엇을 단언하는지\n35293 | - 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지\n35294 | - `DefaultDeliveryProcessor`가 `src/main`에서 생성되지 않는다는 것\n35295 | - 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것\n35296 | - `MessagingMetrics`가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것\n35297 | - `generation`이 유일한 설치 지점에서 리터럴 `1L`이라는 것\n35298 | \n35299 | **확인하지 못한 것**\n35300 | \n35301 | - **6인자 생성자 선택이 의도인지.** 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.\n35302 | - `MessagingProviderSelection`이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.\n35303 | - 어댑터들이 `request.options()`를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다.\n35304 | - 실제 브로커에서 `withDeadline`의 `.copy()` 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다.\n35305 | - 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.\n35306 | \n35307 | ---\n35308 | \n35309 | #### 17. 손볼 것\n35310 | \n35311 | ##### P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다\n35312 | \n35313 | - **사실.** `DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 \"constructor argument rather than an optional decorator\"로 받는다. `MessagingMetrics`가 `MessagingObservation`을 구현한다. 그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 `MessagingMetrics`의 협력자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만든다.\n35314 | - **근거.** `evidence/raw/283` §D.\n35315 | - **왜 문제인가.** 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — \"an unobserved publish path is how 'the dashboards were empty during the incident' happens\". 그리고 같은 javadoc이 **이전 결함**을 \"bean은 있고 호출 경로가 없었다\"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. **고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.** \"decorator가 아니라 생성자 인자\"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.\n35316 | - **확인 방법.** `evidence/raw/283` §D 재실행. 또는 `:446`의 인자 수와 `:138-146` 생성자 시그니처 대조.\n35317 | - **후보.** (a) `MessagingMetrics` bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 `support-matrix.md`에 표시한다.\n35318 | - **다음 단계.** **CASE 후보.** 재현이 정적이고, \"장치는 있고 회로가 닫히지 않았다\"의 변형 중 **회로가 반대편에서 끊긴** 사례라 독립적으로 가치가 있다. 그리고 \"생성자 기본값이 있는 필수 협력자는 필수가 아니다\"가 **REFERENCE 후보**다.\n35319 | \n35320 | ##### P2 — 소비 오케스트레이터가 조립되지 않는다\n35321 | \n35322 | - **사실.** `DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다.\n35323 | - **근거.** `evidence/raw/283` §A·§C.\n35324 | - **왜 문제인가.** 이 클래스가 고친 문제(\"각 어댑터가 retry/dead-letter의 뜻을 각자 결정\")가 배선 없이는 그대로 남는다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.\n35325 | - **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\\.)?DefaultDeliveryProcessor\\s*\\(' -- src`\n35326 | - **다음 단계.** §A19-MESSAGING-POLICY §17의 \"출하 컨텍스트가 발행은 하고 소비는 하지 못한다\"와 **동일 사건**이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.\n35327 | \n35328 | ##### P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다\n35329 | \n35330 | - **사실.** `encode`가 `codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다.\n35331 | - **근거.** `DefaultMessagePublisher.java:97-102`, `RegisteredMessageCodecs.find`, `MessagingCoreAutoConfiguration:363`(varargs 비어 있음).\n35332 | - **왜 문제인가.** 실패하지 않고 **다른 포맷으로 성공**한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. `DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.\n35333 | - **확인 방법.** 등록되지 않은 content type의 봉투를 발행해 `EncodedMessage.contentType()`을 확인.\n35334 | - **후보.** 미등록 content type을 `MessagingConfigurationException`으로 거절하거나, `profile.schema().codec()`과 대조한다.\n35335 | - **다음 단계.** **CASE 후보.** 조용한 성공이라는 형태가 `messaging-core-api`의 \"조용한 성능 저하 금지\" 설계와 정면으로 어긋난다.\n35336 | \n35337 | ##### P3 — 같은 실패 코드가 두 completion에 쓰인다\n35338 | \n35339 | - **사실.** `PUBLISH_DEADLINE_EXCEEDED`가 전송 전이면 `REJECTED`(`:16-21`), 전송 후면 `AMBIGUOUS`(`:42-47`)로 붙는다.\n35340 | - **근거.** 두 위치.\n35341 | - **왜 문제인가.** 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 `messageId`로만 재발행. `FailureDescriptor.code`가 \"stable, machine-readable code\"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다.\n35342 | - **확인 방법.** `git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core`\n35343 | - **후보.** 전송 전을 `PUBLISH_DEADLINE_BEFORE_SEND`처럼 분리한다.\n35344 | - **다음 단계.** **REFERENCE 후보**(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).\n35345 | \n35346 | ##### P3 — admission 실패만 예외로 전파된다\n35347 | \n35348 | - **사실.** 8단계 중 admission(`:24`)만 `try` 블록 밖이고, `MessageTooLargeException`·`MessageBackpressureException`이 그대로 던져진다. 나머지 실패는 전부 `CompletionStage\n *
\n```\n\n실제 순서 여덟 단계:\n\n| # | 단계 | 실패 시 |\n|---:|---|---|\n| 1 | `destinations.require(name)` | `DESTINATION_NOT_REGISTERED` → `REJECTED` |\n| 2 | `requireSupportedOptions(profile, options)` | `PUBLISH_DEDUPLICATION_UNSUPPORTED` → `REJECTED` |\n| 3 | `access.mayPublish(name)` | `PUBLISH_FORBIDDEN` → `REJECTED` (**인코딩 전**) |\n| 4 | `encode(message)` | `PUBLISH_PREPARATION_FAILED` → `REJECTED` |\n| 5 | 남은 예산 확인 | `PUBLISH_DEADLINE_EXCEEDED` → `REJECTED` |\n| 6 | `admission.admit(name, bytes)` | 예외 전파(`MessageTooLargeException`/`MessageBackpressureException`) |\n| 7 | `runtimes.acquire(broker)` | `PUBLISH_RUNTIME_UNAVAILABLE` → `REJECTED` |\n| 8 | `transport.publish(...)` + 마감 | 타임아웃 → `AMBIGUOUS` / 그 외 예외 → `AMBIGUOUS` |\n\n**1–7은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 \"바이트가 프로세스를 떠났는가\"다.\n\n```java\n} catch (RuntimeException beforeTheWire) {\n // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send\n // the caller into reconciliation for a message no broker ever saw.\n return rejected(\"PUBLISH_PREPARATION_FAILED\", sanitized(beforeTheWire), startedAt);\n}\n```\n\n`messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult`는 `PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다.\n\n**3번이 4번보다 먼저인 이유**가 인라인 주석에 있다.\n\n```java\n// Before encoding: an unauthorized publish must not serialise the payload, because the\n// encoded bytes are what a claim-check or a log would then be holding.\n```\n\n##### 4.2 예산은 호출 시점부터 센다\n\n```java\n// :131-138\n * \n *
\n *\n * "
},
{
"line": 34739,
"text": " *
"
},
{
"line": 34745,
"text": "```"
},
{
"line": 34746,
"text": ""
},
{
"line": 34747,
"text": "실제 순서 여덟 단계:"
},
{
"line": 34748,
"text": ""
},
{
"line": 34749,
"text": "| # | 단계 | 실패 시 |"
},
{
"line": 34750,
"text": "|---:|---|---|"
},
{
"line": 34751,
"text": "| 1 | `destinations.require(name)` | `DESTINATION_NOT_REGISTERED` → `REJECTED` |"
},
{
"line": 34752,
"text": "| 2 | `requireSupportedOptions(profile, options)` | `PUBLISH_DEDUPLICATION_UNSUPPORTED` → `REJECTED` |"
},
{
"line": 34753,
"text": "| 3 | `access.mayPublish(name)` | `PUBLISH_FORBIDDEN` → `REJECTED` (**인코딩 전**) |"
},
{
"line": 34754,
"text": "| 4 | `encode(message)` | `PUBLISH_PREPARATION_FAILED` → `REJECTED` |"
},
{
"line": 34755,
"text": "| 5 | 남은 예산 확인 | `PUBLISH_DEADLINE_EXCEEDED` → `REJECTED` |"
},
{
"line": 34756,
"text": "| 6 | `admission.admit(name, bytes)` | 예외 전파(`MessageTooLargeException`/`MessageBackpressureException`) |"
},
{
"line": 34757,
"text": "| 7 | `runtimes.acquire(broker)` | `PUBLISH_RUNTIME_UNAVAILABLE` → `REJECTED` |"
},
{
"line": 34758,
"text": "| 8 | `transport.publish(...)` + 마감 | 타임아웃 → `AMBIGUOUS` / 그 외 예외 → `AMBIGUOUS` |"
},
{
"line": 34759,
"text": ""
},
{
"line": 34760,
"text": "**1–7은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 \"바이트가 프로세스를 떠났는가\"다."
},
{
"line": 34761,
"text": ""
},
{
"line": 34762,
"text": "```java"
},
{
"line": 34763,
"text": "} catch (RuntimeException beforeTheWire) {"
},
{
"line": 34764,
"text": " // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send"
},
{
"line": 34765,
"text": " // the caller into reconciliation for a message no broker ever saw."
},
{
"line": 34766,
"text": " return rejected(\"PUBLISH_PREPARATION_FAILED\", sanitized(beforeTheWire), startedAt);"
},
{
"line": 34767,
"text": "}"
},
{
"line": 34768,
"text": "```"
},
{
"line": 34769,
"text": ""
},
{
"line": 34770,
"text": "`messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult`는 `PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다."
},
{
"line": 34771,
"text": ""
},
{
"line": 34772,
"text": "**3번이 4번보다 먼저인 이유**가 인라인 주석에 있다."
},
{
"line": 34773,
"text": ""
},
{
"line": 34774,
"text": "```java"
},
{
"line": 34775,
"text": "// Before encoding: an unauthorized publish must not serialise the payload, because the"
},
{
"line": 34776,
"text": "// encoded bytes are what a claim-check or a log would then be holding."
},
{
"line": 34777,
"text": "```"
},
{
"line": 34778,
"text": ""
},
{
"line": 34779,
"text": "##### 4.2 예산은 호출 시점부터 센다"
},
{
"line": 34780,
"text": ""
},
{
"line": 34781,
"text": "```java"
},
{
"line": 34782,
"text": "// :131-138"
},
{
"line": 34783,
"text": " * "
},
{
"line": 34934,
"text": " *
"
},
{
"line": 34937,
"text": " *"
},
{
"line": 34938,
"text": " * \n34739 | *
\n34745 | ```\n34746 | \n34747 | 실제 순서 여덟 단계:\n34748 | \n34749 | | # | 단계 | 실패 시 |\n34750 | |---:|---|---|\n34751 | | 1 | `destinations.require(name)` | `DESTINATION_NOT_REGISTERED` → `REJECTED` |\n34752 | | 2 | `requireSupportedOptions(profile, options)` | `PUBLISH_DEDUPLICATION_UNSUPPORTED` → `REJECTED` |\n34753 | | 3 | `access.mayPublish(name)` | `PUBLISH_FORBIDDEN` → `REJECTED` (**인코딩 전**) |\n34754 | | 4 | `encode(message)` | `PUBLISH_PREPARATION_FAILED` → `REJECTED` |\n34755 | | 5 | 남은 예산 확인 | `PUBLISH_DEADLINE_EXCEEDED` → `REJECTED` |\n34756 | | 6 | `admission.admit(name, bytes)` | 예외 전파(`MessageTooLargeException`/`MessageBackpressureException`) |\n34757 | | 7 | `runtimes.acquire(broker)` | `PUBLISH_RUNTIME_UNAVAILABLE` → `REJECTED` |\n34758 | | 8 | `transport.publish(...)` + 마감 | 타임아웃 → `AMBIGUOUS` / 그 외 예외 → `AMBIGUOUS` |\n34759 | \n34760 | **1–7은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 \"바이트가 프로세스를 떠났는가\"다.\n34761 | \n34762 | ```java\n34763 | } catch (RuntimeException beforeTheWire) {\n34764 | // Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send\n34765 | // the caller into reconciliation for a message no broker ever saw.\n34766 | return rejected(\"PUBLISH_PREPARATION_FAILED\", sanitized(beforeTheWire), startedAt);\n34767 | }\n34768 | ```\n34769 | \n34770 | `messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult`는 `PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다.\n34771 | \n34772 | **3번이 4번보다 먼저인 이유**가 인라인 주석에 있다.\n34773 | \n34774 | ```java\n34775 | // Before encoding: an unauthorized publish must not serialise the payload, because the\n34776 | // encoded bytes are what a claim-check or a log would then be holding.\n34777 | ```\n34778 | \n34779 | ##### 4.2 예산은 호출 시점부터 센다\n34780 | \n34781 | ```java\n34782 | // :131-138\n34783 | * \n34934 | *
\n34937 | *\n34938 | *