# messaging-reliability-api 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-reliability-api` > SSOT owner: `messaging-reliability-api` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-reliability-api` - canonical state `analysisFile`: `analysis/messaging/messaging-reliability-api.md` - source path: `src/messaging/messaging-reliability-api` - registry `allowed_dependencies`: `["messaging-core-api"]` - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 13 | | production LOC | 817 | | 패키지 | 1 (`dev.caskeleton.messaging.reliability`) | | **test 파일** | **0 — `src/test` 디렉터리가 없다** | | 외부(비프로젝트) 의존성 | **0** | 13개 타입: | 축 | 타입 | leaf 밖 참조 | |---|---|---:| | **Outbox** | `OutboxRepository` · `OutboxRecord` · `OutboxCanonicalMetadata` · `OutboxStatus` · `OutboxLease` · `OutboxTransitionResult` | 7 · 13 · 8 · 7 · 6 · 6 | | **Inbox** | `InboxRepository` · `InboxRecord` · `InboxResult` · `IdempotentMessageHandler` · `TransactionalMessageAction` | 6 · **0** · 2 · 1 · 1 | | **기타** | `ClaimCheckReference` · `ReliableMessagePublisher` | 6 · **0** | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/**` | 0 | — | **존재하지 않음**(§10) | | `build.gradle` | 1 | `FULL_READ` | 5줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 이 leaf는 **effectively-once 처리의 계약**을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 `isExpired`/`expiredAt` 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다. 세 개의 독립적인 메커니즘을 담는다. **Outbox** — dual-write 문제의 답. ```java // ReliableMessagePublisher.java:14-15 *
This is the answer to the dual-write problem. Writing to the database and publishing to the * broker in the same method cannot be made atomic; writing both to the database can. ``` **Inbox** — 소비 측 중복 제거. ```java // InboxRepository.java:9-13 *
{@link #reserve} must run inside the same database transaction as the handler's side effect. * That is the entire mechanism: the uniqueness constraint on the inbox row and the business write * commit together, so a redelivered message either finds the row already present and skips, or * writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to * close. ``` **Claim Check** — 브로커 밖 payload 참조. 그리고 셋의 관계를 `OutboxRecord`가 명시한다. ```java // OutboxRecord.java:21-24 *
What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will
* retry it, and the same message may reach the broker twice. Effectively-once processing comes from
* this row carrying a stable {@code messageId} and the consumer having an Inbox — not from the
* outbox alone.
```
**Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 "보장을 과대 진술하지 않는다"의 예다.
---
## 2. 의존성과 런타임 배선
들어오는 것: `messaging-core-api`(api) 하나.
나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`.
**구현 leaf가 셋 있고 전부 배선된다.**
| 포트 | 구현 | 조립 |
|---|---|---|
| `OutboxRepository` | `messaging-outbox-jdbc-postgresql/JdbcOutboxRepository` | starter `MessagingReliabilityAutoConfiguration` |
| `InboxRepository` | `messaging-inbox-jdbc-postgresql/JdbcInboxRepository` | 같음 |
| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql/TransactionalInboxHandler` | `transactionalInboxHandler` bean |
| `ReliableMessagePublisher` | **없음** | — |
`ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.
이 leaf 자체는 Spring 주석을 갖지 않는다.
---
## 3. 패키지/컴포넌트 지도
```
Outbox
ReliableMessagePublisher.addToOutbox(dest, envelope) ← 구현 0
↓ (쓰기)
OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers
├─ OutboxCanonicalMetadata (provenance 10필드)
└─ status / attempts / leaseExpiresAt / lastFailureCode
↓ (릴레이)
OutboxRepository ─┬─ append
├─ [구세대] leaseBatch → List The port used to take a {@code MessageId} for every terminal transition, so a write said which
* row to change and nothing about which claim it belonged to. A relay that stalled past its lease
* could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already
* written, and the row became claimable again — one message, published twice, by a system whose
* whole purpose is to publish it once.
*
* The token is the part that makes staleness detectable. It increases on every claim, so a
* superseded relay holds a number the row no longer has and its update matches zero rows.
```
`token < 1`을 거절하는 이유도 적혀 있다 — `"a claim's token starts at 1; 0 is the value of a row nobody has claimed"`.
`expiredAt(now)`가 `!now.isBefore(expiresAt)`다.
### 4.2 `OutboxTransitionResult` — void가 삼킨 것
```java
// :5-9
* The transitions returned {@code void}, so an update that matched zero rows was
* indistinguishable from one that matched one. That is precisely the stale-lease case: the relay
* believes it recorded the outcome, the row still says something else, and nothing anywhere counts
* the disagreement.
```
두 값이고 `STALE_LEASE`의 javadoc이 운영 의미까지 적는다.
```java
* Another relay claimed it after the lease expired. Not an error to throw — the message is
* being handled by somebody else — but never a success either: it is the signal that this
* worker's publish attempt may have produced a duplicate, and it belongs on a metric.
```
**"belongs on a metric"** — 그 메트릭이 존재하는지는 outbox leaf가 답한다.
### 4.3 `OutboxStatus` — 여섯 상태와 두 개의 구분
`PENDING` → `IN_FLIGHT` → `PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`.
**두 쌍의 구분이 각각 이유를 갖는다.**
`AMBIGUOUS` vs `FAILED`:
```java
// :6-9
* {@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose
* publish timed out may already be on the broker; retrying it is correct, but only under the same
* logical message id, and an operator looking at the table needs to be able to tell those rows
* apart from ones that definitely never landed.
```
`EXHAUSTED` vs `FAILED`:
```java
// :31-34
* Distinct from {@link #FAILED}, which means the broker refused the message: this one means
* nobody ever got an answer. Collapsing the two loses the difference between "this message is
* invalid" and "the broker was unreachable for an hour", and those need different operator
* actions — the first a fix, the second a redrive.
```
`OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — "The first needs a fix, the second a redrive."
**`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의 의미가 저장소 규칙 하나의 존재 이유다.**
### 4.4 `InboxResult` — 두 개가 아니라 세 개
```java
// :6-9
* Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}
* into a single "duplicate" would settle a message whose effect is still only half-written by
* another instance: if that instance then rolls back, the effect is lost and the broker will never
* redeliver, because this instance already acknowledged it.
```
`safeToSettle` 플래그가 상수에 붙어 있다.
| 값 | safeToSettle | 뜻 |
|---|:---:|---|
| `APPLIED` | true | 이 트랜잭션에서 효과 실행 |
| `ALREADY_APPLIED` | true | 커밋된 예약 존재 — 이미 실행됨 |
| `CLAIMED_ELSEWHERE` | **false** | 다른 인스턴스가 **미커밋** 예약 보유 |
세 번째의 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."
**세 값 모두 필요한 이유가 명확하고, `isSafeToSettle()`이 그 판단을 하나로 모은다.**
### 4.5 `InboxRepository` — 키가 (message, consumer)다
```java
// InboxRecord.java:9-12
* Keyed by message id and consumer id, because two independent consumers of the same
* event must each process it once — deduplicating on the message alone would let the first consumer
* suppress the second.
```
`IdempotentMessageHandler`의 javadoc이 같은 이유를 API 형태로 반복한다 — `consumerName`이 파라미터인 이유.
`purgeProcessedBefore`의 javadoc이 보존 기간 규칙을 적는다.
```java
* 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`의 프로파일 검증기에도 없다. §17.
### 4.6 `TransactionalMessageAction` — 트랜잭션 경계의 소유권
```java
// :8-16
* Sharing one transaction is the entire mechanism. If the effect committed separately from the
* "I have handled this message" marker, a crash between the two would either replay the effect or
* suppress a message that was never handled — and which of those you get would depend on the order
* the two commits happened to be written in.
*
* Implementations must not settle the message, publish, or start their own transaction. The
* runtime owns the transaction boundary precisely so that the action cannot accidentally commit
* half of it.
```
세 금지("settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라")가 **문서로만 표현된다.** 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.
### 4.7 `OutboxCanonicalMetadata` — 컬럼이어야 하는 이유
이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.
```java
// :14-28
* They used to live nowhere. A row held identity, type, version, content type, payload and an
* arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either
* invented when the envelope was rebuilt — {@code Optional.empty()} for every one of them — or
* smuggled through the header map under reserved names the platform was supposed to own.
*
* Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant
* without decoding the payload, so the operational question "which tenant is backed up" has no
* answer; and a message that crossed the outbox arrived at its consumer with a different tenant,
* trace and correlation than the one that was published, which makes the publish path — direct,
* polling or CDC — part of the message's meaning.
*
* Columns rather than a blob, because the point is that the database can answer questions about
* them. A versioned envelope encoding would round-trip just as faithfully and would still leave the
* relay unable to select rows for one tenant.
```
**세 번째 문단이 고려된 대안을 명시적으로 기각한다** — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.
불변식 하나: `schemaUri.isPresent() && schemaSubject.isEmpty()`를 거절한다 — "a reader would have a URI and no way to know what it is a schema for".
`traceContext`만 `Optional`이 아니고 `TraceContext.none()`이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것("the reader's behaviour is the same: carry what is there and invent nothing").
### 4.8 `OutboxRecord` — 두 반쪽의 소유자가 다르다
```java
// :26-29
* {@link OutboxCanonicalMetadata} is a separate component rather than more fields here because
* the two halves answer to different owners. Identity, payload, status, attempts and lease are the
* relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to
* survive the round trip through the database unchanged.
```
`payload`가 양방향 방어 복사(`payload.clone()` 생성 시와 접근 시), `headers`가 `Map.copyOf` — `messaging-schema-api`의 `EncodedMessage`(그쪽 §4.3)와 같은 패턴이다.
`withStatus`가 `messageId`를 파라미터로 받지 않는다 — "The message id is never a parameter, so no state transition can change it." 타입이 불변식을 강제하는 예다.
`equals`/`hashCode`가 **다섯 필드 중 넷만** 본다 — `messageId`, `status`, `attempts`, `payload`. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 **그 이유가 어디에도 적혀 있지 않다.** §17.
`toString`이 payload를 담지 않는다.
### 4.9 `ClaimCheckReference` — digest가 선택이 아니다
```java
// :10-16
* The digest is part of the reference, not an optional extra. A claim check splits a message
* into two systems with independent retention and replication, so a consumer that fetches the
* payload has to be able to prove it got the bytes the producer stored — otherwise a truncated or
* replaced object is indistinguishable from a valid one.
*
* The expiry is carried for the same reason: a claim check whose payload has been reaped is a
* dead message, and detecting that at fetch time is better than a mysterious not-found.
```
`sha256`이 `[a-f0-9]{64}` 정확 일치다 — 대문자 hex를 거절한다. `messaging-core-api`의 `TraceContext`가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.
`expiresAt`이 `Optional`이 아니다 — 모든 claim check가 만료를 갖는다.
---
## 5. 주요 실행 경로
**Outbox 쓰기:** 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)` → `OutboxRepository.append(record)` — **진입점 구현이 없다**(§12.1)
**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
* report yet: the row is written inside the caller's transaction, so if the transaction rolls back
* the message never existed, and if it commits the relay will publish it later. Handing back a
* {@code PublishResult} here would be a lie about work that has not happened.
```
동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1).
모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.
수명주기 참여 없음.
---
## 8. 설정·기능 플래그·환경 차이
설정 없음. 상수도 없다 — `ClaimCheckReference.SHA256` 정규식 하나가 private이다.
`OutboxRepository`의 두 `purge*` 메서드가 `limit` 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.
```java
// :143-147
* The unbounded version deletes everything before the cutoff in one statement. On a table that
* has been accumulating published rows since the last sweep that is a single long transaction
* holding locks and generating WAL in proportion to the backlog, 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.
```
`InboxRepository`도 같은 쌍을 갖는다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다 — 포트만 정의한다. 다만 **포트가 저장소 기술을 전제한다.**
- `InboxRepository.reserve`의 메커니즘이 "the uniqueness constraint on the inbox row"다 — 유니크 제약이 있는 저장소를 전제
- `OutboxRepository.claimBatch`의 의미가 "a record claimed by one relay is invisible to the others"다 — 행 잠금 또는 그에 준하는 것을 전제
- `OutboxTransitionResult.STALE_LEASE`가 "its update matches zero rows"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제
세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(`*-jdbc-postgresql`)이 실제 선택을 드러낸다.
---
## 10. 테스트 레인과 실제 증명 범위
**이 leaf에는 테스트가 없다.** `src/test` 디렉터리 자체가 존재하지 않는다 — `src` 아래에 `main`만 있다.
13개 타입 중 record 생성자 검증이 있는 것이 여섯(`ClaimCheckReference`, `InboxRecord`, `OutboxCanonicalMetadata`, `OutboxLease`, `OutboxRecord`, `OutboxTransitionResult`는 enum), 술어가 있는 것이 셋(`isExpired`, `expiredAt`, `isSafeToSettle`)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.
**검증은 전부 구현 leaf에서 일어난다.**
| 검증 위치 | 무엇을 |
|---|---|
| `messaging-outbox-jdbc-postgresql` 테스트 4개 | `OutboxRepository` 구현, 릴레이 |
| `messaging-inbox-jdbc-postgresql` 테스트 4개 | `InboxRepository` 구현, 멱등 핸들러 |
| `messaging-claim-check` 테스트 3개 | claim check |
| starter `MessagingOutboxRelayLifecycleTest` | 릴레이 수명주기 |
그 결과 이 leaf의 **계약 불변식**(예: `OutboxCanonicalMetadata`의 `schemaUri` 없이 `schemaSubject` 금지, `OutboxLease`의 `token >= 1`, `InboxResult.isSafeToSettle`의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.
**그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.**
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | 벤더 의존성 0 |
| **`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`** | `..application..`이 이 leaf를 포함한 `dev.caskeleton.messaging..`을 참조하는 것을 금지. **규칙의 근거가 이 leaf의 `OutboxStatus.FAILED` 의미다** |
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
| ArchUnit 전용 규칙 | 없음 |
네 번째가 특이하다 — ArchUnit 규칙 하나가 **이 leaf의 enum 상수 의미**를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/289-reliability-api-two-generations.txt`.
### 12.1 Public surface reachability
| 타입 | leaf 밖 파일 | 판정 |
|---|---:|---|
| `OutboxRecord` | 13 | 활발 |
| `OutboxCanonicalMetadata` | 8 | 활발 |
| `OutboxRepository` | 7 | 구현 1 + 릴레이 + 테스트 |
| `OutboxStatus` | 7 | 활발 |
| `OutboxLease` | 6 | 활발 |
| `OutboxTransitionResult` | 6 | 활발 |
| `InboxRepository` | 6 | 구현 1 + 테스트 |
| `ClaimCheckReference` | 6 | 활발 |
| `InboxResult` | 2 | |
| `IdempotentMessageHandler` | 1 | `TransactionalInboxHandler` |
| `TransactionalMessageAction` | 1 | 같음 |
| **`InboxRecord`** | **0** | |
| **`ReliableMessagePublisher`** | **0** | |
**(a) Outbox 쓰기 진입점에 구현이 없다**
`ReliableMessagePublisher`는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.
`OutboxRepository.append`는 존재하지만 그것은 저장소 포트다 — javadoc이 "must be callable inside the caller's business transaction"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 `ReliableMessagePublisher`가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.
**그리고 애플리케이션은 이 leaf를 참조할 수 없다** — `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`이 금지한다. 즉 `ReliableMessagePublisher`를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. `messaging-spring-cloud-stream-bridge`가 후보 이름이지만 그 leaf는 `runtime_memberships: []`다.
**(b) `InboxRecord`가 쓰이지 않는다**
`InboxRepository`의 어느 메서드도 `InboxRecord`를 주고받지 않는다 — `reserve`는 `boolean`, `isProcessed`는 `boolean`, `purge*`는 `int`다. record는 "One row of the consumer inbox"를 서술하지만 그 행을 반환하는 API가 없다.
같은 leaf의 `OutboxRecord`는 정반대다 — `leaseBatch`/`find`가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.
**(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다**
`OutboxRepository`는 같은 다섯 전이에 대해 **두 세대**를 갖는다.
| 전이 | 구세대 (MessageId) | 신세대 (OutboxLease) |
|---|---|---|
| 배치 획득 | `leaseBatch(size, lease, now)` → `List The token is what a terminal write is checked against. {@link #leaseBatch} returns records
* without one, so its callers cannot prove a write belongs to their claim; it remains for
* inspection paths and is deprecated for the relay's use.
```
`@Deprecated` 애노테이션이 **이 leaf 전체에 하나도 없다**(`git grep '@Deprecated' -- src/messaging/messaging-reliability-api` exit 1).
결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.
**(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다**
> 이 항목은 `messaging-inbox-jdbc-postgresql` 분석 중에 확인됐다. 이 문서의 초판은 §17의 "확인된 설계"에 "purge에 `limit` 파라미터를 둔 것"을 넣었는데, 그것은 파라미터의 **존재**만 본 판정이었다. 호출 여부를 재측정해 정정한다.
`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`.
`OutboxRepository:140-151`의 javadoc이 그 상황을 예고한다.
> 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.**
그 파라미터를 아무도 넘기지 않는다. 판정은 `analysis/messaging/messaging-inbox-jdbc-postgresql.md` §17(P1)이 소유하고, 이 문서는 **포트가 두 오버로드를 나란히 노출했다는 것**을 기여한다 — (a)의 두 세대 전이와 같은 형태다.
### 12.2 Conditional sibling comparison
이 leaf에 bean은 없다. **구현 leaf 셋의 sibling 비교가 유의미하다.**
| 포트 | 구현 leaf | membership | starter bean |
|---|---|---|---|
| `OutboxRepository` | `messaging-outbox-jdbc-postgresql` | `["app-bootstrap"]` | `MessagingReliabilityAutoConfiguration` |
| `InboxRepository` | `messaging-inbox-jdbc-postgresql` | `["app-bootstrap"]` | 같음 |
| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql` | 같음 | `transactionalInboxHandler` bean |
| `ReliableMessagePublisher` | **없음** | — | — |
네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.
### 12.3 Duplicate mechanism sweep
**(a) 같은 전이의 두 세대** — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.
**(b) outbox 개념이 저장소에 둘 있다**
| | 이 leaf | `application-core` |
|---|---|---|
| 상태 enum | `OutboxStatus` | `OutboxEventStatus` |
| `FAILED`의 뜻 | 브로커가 확정적으로 거절 — **재시도 안 함** | (반대 의미, ArchUnit javadoc이 명시) |
| 행 타입 | `OutboxRecord` | `NewOutboxEvent` 등 |
| 사용처 | messaging family | application + persistence-jpa |
**의도된 분리다.** ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 `.because(...)`가 이유를 적는다 — "the two outbox status models mean opposite things under the same names". 중복 경쟁이 아니라 **명시적으로 격리된 두 모델**이다.
다만 그 결과 `ReliableMessagePublisher`가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.
**(c) 이름 충돌 주의**
`markPublished`·`markFailed`·`releaseLease`라는 메서드 이름이 저장소의 **완전히 다른 인터페이스** 여러 곳에 있다 — `persistence-jpa`의 `OutboxStoreAdapter`·`JpaCleanupQueue`·`JpaUploadSessionStore`, `cache-redis`의 `RedisIdempotencyStoreAdapter`, `notification`의 `JpaProviderEventLedger`. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 `src/messaging/**`로 범위를 좁혀 얻은 것이다.
### 12.4 Documentation / measured-count drift
| 문서 주장 | 재측정 | 결과 |
|---|---|---|
| `OutboxRepository:43`: `leaseBatch`가 "deprecated for the relay's use" | `@Deprecated` 0건, 컨테이너 테스트가 사용 | **미강제** |
| `OutboxRecord` javadoc: outbox만으로는 중복 제거 안 됨 | `InboxRepository`가 별도 존재 | **일치** |
| `InboxRepository.purge*` javadoc: 보존이 브로커 재전달 창보다 길어야 함 | 그 비교를 하는 코드 없음 | **미강제** |
| `TransactionalMessageAction` javadoc: 구현이 settle/publish/트랜잭션 시작 금지 | 타입이 강제하지 않음 | **미강제** |
| `ReliableMessagePublisher` javadoc: dual-write의 답 | 구현 0 | **미실현** |
| `OutboxTransitionResult.STALE_LEASE` javadoc: "it belongs on a metric" | 이 leaf에 메트릭 없음. outbox leaf가 답함 | **미확인** |
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `OutboxLease` javadoc | 모든 terminal 전이가 `MessageId`만 받음 | lease를 넘긴 릴레이가 다른 릴레이의 `PUBLISHED` 위에 `AMBIGUOUS`를 기록 → 행이 다시 claim 가능해짐 → **한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서** |
| `OutboxTransitionResult` javadoc | 전이가 `void` 반환 | 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 **그 불일치를 아무도 세지 않음** |
| `OutboxCanonicalMetadata` javadoc | provenance가 어디에도 없음 | 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 `Optional.empty()`가 되거나 헤더 맵에 예약 이름으로 밀반입 → **outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착**, 즉 발행 경로가 메시지의 의미의 일부가 됨 |
| `OutboxRepository.purgePublishedBefore` javadoc | 무제한 삭제 | 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → **릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤** |
첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.
세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — **"which makes the publish path — direct, polling or CDC — part of the message's meaning."** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.
---
## 14. 런타임·터미널 Evidence
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|---|---|---|---|---|
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 | 정적 검색. `messaging-inbox-jdbc-postgresql`이 판정 소유 |
| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | `src/test` 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, `OutboxRepository`의 두 세대 시그니처 전수, `@Deprecated` 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 | 정적 검색. 이 leaf에 실행할 테스트 레인이 없음 |
**이 leaf에는 test lane evidence가 없다** — `src/test`가 존재하지 않으므로 `:messaging:messaging-reliability-api:test`는 실행할 소스가 없다.
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**명시적**
- outbox만으로 중복이 제거되지 않는 이유 — `OutboxRecord` javadoc
- fencing token이 필요한 이유와 이전 이중 발행 — `OutboxLease` javadoc
- 전이가 결과를 반환해야 하는 이유 — `OutboxTransitionResult` javadoc
- `AMBIGUOUS`가 실패의 한 종류가 아닌 이유, `EXHAUSTED`가 `FAILED`와 다른 이유 — `OutboxStatus` javadoc
- provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — `OutboxCanonicalMetadata` javadoc
- 두 반쪽의 소유자가 다른 이유 — `OutboxRecord` javadoc
- inbox 키가 (message, consumer)인 이유 — `InboxRecord`·`IdempotentMessageHandler` javadoc
- `InboxResult`가 셋인 이유 — 그 javadoc
- 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — `InboxRepository`·`TransactionalMessageAction` javadoc
- `addToOutbox`가 `void`인 이유 — `ReliableMessagePublisher` javadoc
- claim check digest와 만료가 필수인 이유 — `ClaimCheckReference` javadoc
- purge에 `limit`이 필요한 이유 — `OutboxRepository` javadoc
- inbox 보존이 재전달 창보다 길어야 하는 이유 — `InboxRepository` javadoc
**추론**
- `ReliableMessagePublisher` 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → **추론**. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.
- `OutboxRecord.equals`가 다섯 필드만 보는 이유 → **미상**.
- `sha256`이 소문자만 받는 이유 → **미상**(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).
- 구세대를 남긴 이유 → **부분 명시**("remains for inspection paths"). 제거 시점은 미상.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- 13개 타입 817줄 전문의 계약과 불변식
- 이 leaf에 테스트가 하나도 없다는 것(`src/test` 부재)
- `ReliableMessagePublisher`와 `InboxRecord`의 참조 0
- `OutboxRepository`가 같은 다섯 전이의 두 세대를 갖고 `@Deprecated`가 하나도 없다는 것
- production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것
- 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태
- `OutboxStatus.FAILED`의 의미가 저장소 ArchUnit 규칙의 근거라는 것
**확인하지 못한 것**
- **fencing token SQL이 실제 PostgreSQL에서 정확한지.** 그것을 검증할 레인이 다른 세대를 쓴다. `messaging-outbox-jdbc-postgresql` leaf가 이 판정을 소유한다.
- `STALE_LEASE`가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.
- inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.
- `ReliableMessagePublisher`를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.
- `OutboxRecord.equals`의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.
---
## 17. 손볼 것
### P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 `@Deprecated`가 없다
- **사실.** `OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다. javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다.
- **근거.** `evidence/raw/289` §E·§F.
- **왜 문제인가.** 전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
- **확인 방법.** `git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api` → 없음. `evidence/raw/289` §E.
- **후보.** (a) 구세대 다섯에 `@Deprecated`를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(`OutboxInspection`)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** "prose deprecation은 컴파일러가 읽지 않는다"가 재사용 가능한 기준이다.
### P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다
- **사실.** `OutboxRelay`는 `claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT`는 `leaseBatch`/`MessageId` 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository`와 `RecordingRepository` — SQL이 없는 fake다.
- **근거.** `evidence/raw/289` §G.
- **왜 문제인가.** fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 "its update matches zero rows"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**
- **확인 방법.** `evidence/raw/289` §G 재실행. `OutboxPostgresIT`에서 `claimBatch` 검색 → 없음.
- **후보.** 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.
- **다음 단계.** **판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. **CASE 후보**(그 leaf).
### P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다
- **사실.** `ReliableMessagePublisher`가 구현 0, 참조 0이다. javadoc은 "This is the answer to the dual-write problem"이라고 한다.
- **근거.** `evidence/raw/289` §B·§C·§D.
- **왜 문제인가.** `OutboxRepository.append`가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, `ReliableMessagePublisher`는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 **애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로** 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 "릴레이가 읽는 쪽"만 배선돼 있고 "애플리케이션이 쓰는 쪽"이 비어 있다.
- **확인 방법.** `git grep -n -E 'implements .*ReliableMessagePublisher' -- src` → 없음.
- **후보.** (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 "파생 프로젝트가 구현하는 확장점"임을 명시한다.
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "두 outbox 모델 중 어느 쪽이 정본인가"에 걸리고, 그 질문은 `application-core`와 cross-scope가 함께 답한다.
### P3 — 이 leaf에 테스트가 없다
- **사실.** `src/test` 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.
- **근거.** `evidence/raw/289` §A.
- **왜 문제인가.** 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: `OutboxCanonicalMetadata`가 `schemaUri` 있고 `schemaSubject` 없는 조합을 거절하는 것, `OutboxLease`가 `token < 1`을 거절하는 것, `InboxResult.isSafeToSettle`의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(`messaging-core-api` 79개, `messaging-policy` 42개 등).
- **확인 방법.** `ls src/messaging/messaging-reliability-api/src` → `main`만.
- **후보.** record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.
- **다음 단계.** **REFERENCE 후보**(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).
### P3 — inbox 보존 규칙이 문서로만 있다
- **사실.** `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`의 프로파일 검증기에도 없다.
- **근거.** 해당 javadoc. `DestinationProfileValidator` 16규칙 전수(재전달 창 관련 없음).
- **왜 문제인가.** 위반의 결과가 **부작용의 이중 실행**이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
- **확인 방법.** `git grep -n -i 'redelivery window\|retention' -- 'src/messaging/**/*.java'`
- **후보.** 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 `messaging-policy`나 starter에 추가한다.
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).
### P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다
- **사실.** `OutboxRepository.append`가 호출자 트랜잭션 안, `InboxRepository.reserve`가 부작용과 같은 트랜잭션, `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.
- **근거.** 세 javadoc.
- **왜 문제인가.** `ReliableMessagePublisher`는 `void` 반환으로 계약의 일부를 타입에 담았다("Handing back a `PublishResult` here would be a lie"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — `InboxRepository.reserve`를 별도 트랜잭션에서 부르면 "exactly the gap the Inbox exists to close"가 다시 열린다.
- **확인 방법.** 세 javadoc과 구현의 `@Transactional` 배치 대조 — 구현 leaf가 소유한다.
- **후보.** 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 `append`/`reserve` 호출부의 트랜잭션 컨텍스트를 검사한다.
- **다음 단계.** **REFERENCE 후보**(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).
### P3 — `OutboxRecord.equals`가 다섯 필드만 비교하고 이유가 없다
- **사실.** `equals`/`hashCode`가 `messageId`·`status`·`attempts`·`payload` 넷만 본다. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 무시한다.
- **근거.** `OutboxRecord.java:114-126`.
- **왜 문제인가.** record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(`messaging-schema-api`의 `EncodedMessage`도 같다). 그러나 `EncodedMessage`는 **모든 필드**를 비교하고 이쪽은 아니다. 같은 `messageId`·`status`·`attempts`·`payload`를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.
- **확인 방법.** 두 record의 `equals` 대조.
- **후보.** 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.
- **다음 단계.** **REFERENCE 후보**(record의 `equals`를 좁히면 이유를 적는다).
### P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
- **사실.** `InboxRepository`와 `OutboxRepository`가 각각 `purge*Before(Instant)`와 `purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
- **근거.** `evidence/raw/294-bounded-purge-never-called.txt`.
- **왜 문제인가.** §12.1(a)의 두 세대 전이와 같은 형태다 — **한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.** 두 경우 모두 포트의 형태가 오용을 가능하게 했다.
- **확인 방법.** `git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'`
- **다음 단계.** 판정은 `analysis/messaging/messaging-inbox-jdbc-postgresql.md` §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 **같은 CASE로 묶을 후보**다.
### 확인된 설계(문제 아님)
- outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것
- fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계
- `AMBIGUOUS`/`FAILED`/`EXHAUSTED` 세 상태의 구분과 각각의 운영 행동 차이
- `InboxResult`가 셋이고 `isSafeToSettle()`이 그 판단을 모으는 것
- inbox 키가 (message, consumer)인 것
- provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것
- `withStatus`가 `messageId`를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것
- `addToOutbox`의 `void` 반환이 계약인 것
- claim check의 digest와 만료가 필수인 것
- 두 outbox 모델을 ArchUnit으로 격리한 것
---
## Source anchors
| id | kind | path | revision | what it proves | limitations |
|---|---|---|---|---|---|
| MRA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
| MRA-002 | build | `messaging-reliability-api/build.gradle` | same | 벤더 의존성 0 | — |
| MRA-003 | code | `.../reliability/OutboxRepository.java` 전문 | same | 두 세대 17메서드, purge limit 이유 | `@Deprecated` 없음 |
| MRA-004 | code | `.../reliability/OutboxLease.java` | same | fencing token과 이중 발행 이력 | — |
| MRA-005 | code | `.../reliability/OutboxTransitionResult.java` | same | void 반환이 삼킨 것 | — |
| MRA-006 | code | `.../reliability/OutboxStatus.java` | same | 여섯 상태와 두 구분의 이유 | — |
| MRA-007 | code | `.../reliability/OutboxCanonicalMetadata.java` | same | provenance 결함 이력, 기각된 대안 | — |
| MRA-008 | code | `.../reliability/OutboxRecord.java` | same | 두 반쪽 분리, 방어 복사, 좁은 equals | equals 이유 없음(§17) |
| MRA-009 | code | `.../reliability/{InboxRepository,InboxRecord,InboxResult}.java` | same | 트랜잭션 계약, (message,consumer) 키, 세 판정 | `InboxRecord` 참조 0 |
| MRA-010 | code | `.../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java` | same | 멱등 핸들러 계약과 세 금지 | 금지 미강제 |
| MRA-011 | code | `.../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java` | same | dual-write 답, digest 필수 | publisher 구현 0 |
| MRA-012 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205` | same | production이 신세대만 사용 | 해당 leaf SSOT가 소유 |
| MRA-013 | cross-leaf test | `messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200` | same | 컨테이너 테스트가 구세대만 사용 | 해당 leaf SSOT가 소유 |
| MRA-014 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `OutboxStatus.FAILED` 의미가 규칙의 근거 | 정적 분석 |
| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | same | §12.1 전부, `src/test` 부재 | 정적 검색. 이 leaf에 테스트 레인 없음 |