# messaging-claim-check 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-claim-check` > SSOT owner: `messaging-claim-check` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-claim-check` - canonical state `analysisFile`: `analysis/messaging/messaging-claim-check.md` - source path: `src/messaging/messaging-claim-check` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-reliability-api"]` - registry `runtime_memberships`: **`["app-bootstrap"]`** ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 6 | | production LOC | 418 | | 패키지 | 1 (`dev.caskeleton.messaging.claimcheck`) | | test 파일 | 3 | | test 메서드(실행 확인) | 22 | | 외부(비프로젝트) 의존성 | **0** | 여섯 타입: | 타입 | 종류 | 역할 | leaf 밖 참조 | |---|---|---|---:| | `ClaimCheckStore` | interface | payload 저장·조회·삭제 port | **0** | | `ClaimCheckPolicy` | record | 문턱과 보존 규칙 | **0** | | `ClaimCheckPublisher` | class | 발행 측 오프로드 결정 | **0** | | `ClaimCheckResolver` | class | 소비 측 조회 + 검증 | **0** | | `ClaimCheckIntegrityGuard` | class | digest·크기·만료 검사 | **0** | | `ClaimCheckIntegrityException` | exception | digest 불일치 | **0** | **여섯 전부 leaf 밖 참조가 0이다.** ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·fake 구현 확인 | | `build.gradle` | 1 | `FULL_READ` | 6줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 **Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다. `messaging-reliability-api`의 `ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다. 경계 진술이 두 클래스에 있다. ```java // ClaimCheckIntegrityGuard.java:14-17 *

A claim check turns one message into two systems that can drift. The payload store has its own * retention, its own replication, and its own access control, and none of them are coordinated with * the broker's. So a consumer that fetches bytes and decodes them without checking is trusting * something the message never proved. ``` ```java // ClaimCheckResolver.java:11-15 *

Verification is not optional and cannot be skipped by a caller. An object store key is a * string, and a message carrying the wrong one — through a bug, a replay against a rotated bucket, * or a deliberate tamper — fetches bytes that decode perfectly into the wrong object. The digest is * the only thing standing between that and a handler acting on someone else's data. ``` **"decode perfectly into the wrong object"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공. --- ## 2. 의존성과 런타임 배선 들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api). 나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다. **배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다. 그런데 **`runtime_memberships`가 `["app-bootstrap"]`이다.** starter closure를 통해 배포 아티팩트에 실린다. `messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(`analysis/messaging/messaging-cloudevents.md` §12.1). --- ## 3. 패키지/컴포넌트 지도 ``` 발행 측 ClaimCheckPublisher(store, policy) └── offload(byte[]) → Offloaded(payload, Optional) ├── policy.shouldOffload(len) == false → Offloaded(payload.clone(), empty) └── true → store.put(payload, retention) → Offloaded(new byte[0], reference) 소비 측 ClaimCheckResolver(store) └── resolve(inline, Optional, now) ├── reference 없음 → inline.clone() ├── reference.isExpired(now) → CLAIM_CHECK_EXPIRED ├── store.get(reference) == null → CLAIM_CHECK_NOT_FOUND └── guard.verify(...) → 검증된 바이트 └── *_MISMATCH → ClaimCheckIntegrityException으로 승격 정책 ClaimCheckPolicy(thresholdBytes, retention, brokerRetention, maxRedeliveryWindow) └── 생성자가 retention >= brokerRetention + maxRedeliveryWindow를 강제 ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 `ClaimCheckPolicy` — 보존이 생성자 불변식이다 ```java Duration required = brokerRetention.plus(maxRedeliveryWindow); if (retention.compareTo(required) < 0) { throw new MessagingConfigurationException("CLAIM_CHECK_RETENTION_TOO_SHORT", ...); } ``` javadoc이 이유를 적는다. ```java // :10-14 *

The retention rule is the one that matters. A claim check object deleted while its message is * still deliverable turns a large message into an undeliverable one — the consumer fetches, gets * nothing, and the message dead-letters for a reason that has nothing to do with the message. So * retention must exceed the broker's own retention plus the full retry and dead-letter window, and * the constructor refuses a configuration where it does not. ``` **이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.** 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다. 문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다. ```java // :16-18 *

The threshold is separate from the destination's payload limit. Offloading starts well below * the limit, because the limit is where the broker refuses the message and the threshold is where * carrying it inline stops being a good idea. ``` `DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다. `defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유. ### 4.2 `ClaimCheckPublisher` — 순서와 미삭제 ```java // :9-17 *

The object is written before the message is published, and that order is the whole * design. Publishing first would let a consumer receive a reference to an object that does not * exist yet — a race that is rare in a test and routine under load, because the broker hop is * faster than the object store write. * *

Nothing here deletes on failure. If the publish is rejected the object is left behind, and the * retention sweep reclaims it; deleting eagerly would delete the object out from under a publish * that turned out to be ambiguous rather than rejected. ``` 두 번째가 `messaging-core-api`의 3상태와 직접 연결된다 — `REJECTED`와 `AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다. 오프로드된 메시지는 payload를 **아예 갖지 않는다**. ```java // The published message carries no payload bytes at all, only the reference. Carrying both // would double the transfer for no benefit and let the two disagree. return new Offloaded(new byte[0], Optional.of(reference)); ``` `Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다. **`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다("Nothing here deletes on failure"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다. ### 4.3 `ClaimCheckIntegrityGuard` — 세 검사, 전부 fail-closed | 순서 | 검사 | 코드 | |---:|---|---| | 1 | `reference.isExpired(now)` | `CLAIM_CHECK_EXPIRED` | | 2 | `payload.length != reference.sizeBytes()` | `CLAIM_CHECK_SIZE_MISMATCH` | | 3 | `sha256(payload) != reference.sha256()` | `CLAIM_CHECK_DIGEST_MISMATCH` | ```java // :19-22 *

Both checks fail closed. An expired reference is reported before the fetch, because a * not-found from the store is ambiguous between "reaped" and "never written". A digest mismatch is * reported as validation rather than deserialization, because the bytes are not corrupt JSON — they * are the wrong bytes. ``` 크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다. `sha256(byte[])`가 `HexFormat.of().formatHex(...)`로 **소문자** hex를 만든다. `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다. `verify`가 검증된 payload의 **복사본**을 반환한다. ### 4.4 `ClaimCheckResolver` — 만료를 fetch 전에 본다 ```java if (claimCheck.isExpired(now)) { // Checked before fetching. A store that still returns the object past its retention would // otherwise hide a misconfiguration until the day the sweep caught up. throw new MessageValidationException("CLAIM_CHECK_EXPIRED", ...); } ``` **저장소가 아직 반환하더라도 거절한다.** 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다. `fetch`가 `null`을 `CLAIM_CHECK_NOT_FOUND`로 번역하고 메시지가 두 원인을 나열한다 — "it was either reaped early or never written". **예외 승격이 코드 접미사로 판정된다.** ```java } catch (MessageValidationException validation) { // A size or digest mismatch is a poison message, not a validation failure to be retried: // fetching the same key again returns the same wrong bytes. if (validation.failure().code().endsWith("_MISMATCH")) { throw new ClaimCheckIntegrityException( validation.failure().code(), validation.failure().sanitizedMessage()); } throw validation; } ``` `endsWith("_MISMATCH")` — **문자열 접미사로 분기한다.** guard가 코드 이름을 바꾸거나 `_MISMATCH`로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17. ### 4.5 `ClaimCheckIntegrityException` — 카테고리가 `POISON_MESSAGE` ```java // :12-18 *

Not retryable. A digest mismatch means the object at that key is not the object the producer * wrote — the key was reused, the object was overwritten, or something truncated it — and fetching * it again returns the same wrong bytes. Retrying would only delay the dead-letter. * *

Deliberately distinct from "the object is gone". An expired claim check is an operational * problem with a known cause and a known fix; a digest mismatch means something wrote data nobody * expected, and the two must not be diagnosed as one. ``` `FailureCategory.POISON_MESSAGE`, `retryable = false`. `messaging-core-api`의 `FailureDescriptor.defaultRetryable`이 `POISON_MESSAGE`를 false로 두는 것과 일치한다. **이 예외가 `MessagingException`을 확장하는 저장소 내 두 곳 중 하나다**(다른 하나는 core-api 자신의 23개). `analysis/messaging/messaging-core-api.md` §12.1(b)가 그 사실을 관측했다. --- ## 5. 주요 실행 경로 **발행:** `publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put` → `Offloaded(빈 바이트, reference)` **소비:** `resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH`면 `ClaimCheckIntegrityException` 두 경로 모두 production에서 호출되지 않는다(§12.1). --- ## 6. 실패 경로와 복구/번역 | 코드 | 예외 | 카테고리 | retryable | 조건 | |---|---|---|:---:|---| | `CLAIM_CHECK_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | `CONFIGURATION` | false | 정책 생성 시 | | `CLAIM_CHECK_EXPIRED` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 만료 | | `CLAIM_CHECK_NOT_FOUND` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 객체 없음 | | `CLAIM_CHECK_SIZE_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | 크기 불일치 | | `CLAIM_CHECK_DIGEST_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | digest 불일치 | **분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다. `ClaimCheckIntegrityGuard.sha256`이 `NoSuchAlgorithmException`을 `IllegalStateException("Java runtime does not provide SHA-256")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다. --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 없음. `ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다. `MessageDigest.getInstance("SHA-256")`이 **호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다. `ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다. 수명주기 참여 없음. --- ## 8. 설정·기능 플래그·환경 차이 | 상수/기본값 | 값 | |---|---| | `ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES` | 262,144 (1 MiB의 1/4) | | `ClaimCheckPolicy.defaults()` | 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일 | 설정 파일 없음. 모든 값이 생성자 인자다. --- ## 9. 퍼시스턴스/외부 시스템 세부 `ClaimCheckStore`가 객체 저장소를 가리키는 port다. **구현이 없다** — production에도, 다른 messaging leaf에도. 저장소의 `adapter/outbound/objectstorage` leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(`messaging-claim-check`의 `allowed_dependencies`에 없고, 반대 방향도 없다). --- ## 10. 테스트 레인과 실제 증명 범위 레인: `./gradlew :messaging:messaging-claim-check:test`. **BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures**. | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 | |---|---:|---|---| | `ClaimCheckIntegrityGuardTest` | 6 | 만료·크기·digest 세 검사 | 실제 저장소 | | `ClaimCheckResolverTest` | 8 | 인라인 통과, 만료 사전 거절, NOT_FOUND, `_MISMATCH` 승격 | **production 호출 여부** | | `ClaimCheckRetentionValidatorTest` | 8 | 보존 불변식과 문턱 판정 | — | `ClaimCheckStore`의 유일한 구현이 `ClaimCheckResolverTest:22`의 `FakeStore`다. 즉 **이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.** `ClaimCheckPublisher`를 겨냥한 테스트 클래스가 **없다.** 오프로드 결정·객체 선기록 순서·`Offloaded`의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 이 leaf에 대해 | |---|---| | `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-reliability-api"]` | | `verifyRuntimeModuleMembership` | `["app-bootstrap"]` | | vendor `api` 규칙 | 벤더 의존성 0 | | `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 | | ArchUnit | 전용 규칙 없음 | --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt`. ### 12.1 Public surface reachability **여섯 타입 전부 leaf 밖 참조 0이다.** | 타입 | leaf 밖 | |---|---:| | `ClaimCheckStore` | 0 | | `ClaimCheckPolicy` | 0 | | `ClaimCheckPublisher` | 0 | | `ClaimCheckResolver` | 0 | | `ClaimCheckIntegrityGuard` | 0 | | `ClaimCheckIntegrityException` | 0 | `ClaimCheckStore` 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다. **그런데 이 leaf는 배포 아티팩트에 실린다.** ``` messaging-claim-check runtime_memberships=['app-bootstrap'] messaging-spring-boot-starter runtime_memberships=['app-bootstrap'] starter deps include claim-check: True ``` `messaging-cloudevents`와 같은 조합이다. 형제 비교: | leaf | 소비자 | membership | 정합 | |---|:---:|---|---| | `messaging-schema-avro` | 0 | `[]` | o | | `messaging-schema-protobuf` | 0 | `[]` | o | | `messaging-kafka-share-experimental` | 0 | `[]` | o | | **`messaging-cloudevents`** | **0** | **`["app-bootstrap"]`** | **x** | | **`messaging-claim-check`** | **0** | **`["app-bootstrap"]`** | **x** | **"싣고 쓰지 않는" leaf가 둘이다.** 오늘 실행되는 코드가 없으므로 사고는 아니다. **한 가지 정황이 이 leaf를 다르게 만든다.** `messaging-policy`의 `PayloadPolicy`가 `claimCheckThresholdBytes` 필드를 갖고, `DestinationProfileValidator`가 그 값을 검사한다(`:49`). 즉 **목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다.** payload가 문턱을 넘어도 오프로드되지 않고, `PayloadLimitGuard`가 상한 초과로 거절한다 — `MessageTooLargeException("PAYLOAD_LIMIT_EXCEEDED", "... use claim check")`. **에러 메시지가 존재하지 않는 경로를 권한다.** ### 12.2 Conditional sibling comparison Spring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다. `messaging-reliability-api`의 세 port 중 둘(`OutboxRepository`, `InboxRepository`)은 구현 leaf와 starter bean을 갖고 `ClaimCheckStore`는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다. ### 12.3 Duplicate mechanism sweep **(a) claim check 문턱이 두 곳에 있고 서로를 모른다** | 위치 | 필드 | 검사 | |---|---|---| | `messaging-policy` `PayloadPolicy` | `claimCheckThresholdBytes` | `DestinationProfileValidator:49`가 `<= maxBytes` 확인 | | 이 leaf `ClaimCheckPolicy` | `thresholdBytes` | 생성자가 `>= 1` 확인 | **두 값을 대조하는 코드가 없다.** 목적지 프로파일이 문턱 512 KiB를 선언하고 `ClaimCheckPolicy`가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다. **(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다** | 규칙 | 위치 | 강제 | |---|---|---| | claim check 보존 ≥ 브로커 보존 + 재전달 창 | `ClaimCheckPolicy` 생성자 | **강제됨** | | inbox 보존 > 브로커 최대 재전달 창 | `InboxRepository` javadoc | **문서만** | 같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. `analysis/messaging/messaging-reliability-api.md` §17이 후자를 소유한다. **(c) digest 계산이 저장소에 여럿 있는가** `MessageDigest.getInstance("SHA-256")`을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님. **(d) `_MISMATCH` 접미사 분기** `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith("_MISMATCH")`로 예외를 승격한다. `ClaimCheckIntegrityGuard`의 코드 셋 중 둘이 그 접미사를 갖고 하나(`CLAIM_CHECK_EXPIRED`)가 갖지 않는다. **문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다.** §17. ### 12.4 Documentation / measured-count drift | 문서 주장 | 재측정 | 결과 | |---|---|---| | `ClaimCheckPolicy` javadoc: 문턱이 "a quarter of the portable payload limit" | 262,144 = 1,048,576 / 4 | **일치** | | `ClaimCheckPublisher` javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 | 이 leaf에 sweep 없음 | **미실현** | | `ClaimCheckIntegrityGuard` javadoc: 두 검사가 fail closed | 세 검사 전부 예외 | **일치**(검사가 셋인데 javadoc은 "Both") | | `PayloadLimitGuard` 에러 메시지: "use claim check" | claim check 경로 미배선 | **불일치** | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) | 세 번째가 작은 표현 drift다 — javadoc이 "Both checks fail closed"라고 하는데 `verify`는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다. | 위치 | 막으려는 것 | |---|---| | `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. "rare in a test and routine under load" | | `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 | | `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 | | `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 | | `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter | | `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 | **"rare in a test and routine under load"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다. --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §A·§B | 여섯 타입 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 | 정적 검색 | | EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | BUILD SUCCESSFUL, 22 / 0 / 0 | 저장소가 fake. publisher 미검증 | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적** - 두 시스템이 drift한다는 위협 모델 — `ClaimCheckIntegrityGuard` javadoc - 검증이 선택 불가인 이유 — `ClaimCheckResolver` javadoc - 저장이 발행보다 먼저인 이유 — `ClaimCheckPublisher` javadoc - 실패 시 삭제하지 않는 이유 — 같은 javadoc - payload와 참조를 함께 나르지 않는 이유 — 인라인 주석 - 만료를 fetch 전에 보는 이유 — `resolve` 인라인 주석 - 보존 규칙과 그것을 생성자가 강제하는 이유 — `ClaimCheckPolicy` javadoc - 문턱과 목적지 상한이 다른 이유 — 같은 javadoc - digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — `ClaimCheckIntegrityException` javadoc - `_MISMATCH` 승격이 poison message인 이유 — `verify` 인라인 주석 **추론** - 배선되지 않은 것이 미완인지 확장점인지 → **미상**. `ClaimCheckStore` 구현이 없다는 관측만 있다. - `_MISMATCH` 접미사 규약이 의도인지 → **미상**. 선언된 곳이 없다. - javadoc의 "Both checks"가 세 검사가 되기 전 표현인지 → **추론**. --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - 6개 타입 418줄 전문의 계약 - 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 `ClaimCheckPublisher`가 미검증이라는 것 - 여섯 타입 전부 leaf 밖 참조 0이고 `ClaimCheckStore` 구현이 테스트 fake뿐이라는 것 - `runtime_memberships`가 `["app-bootstrap"]`이라 배포 아티팩트에 실린다는 것 - `PayloadLimitGuard`의 에러 메시지가 배선되지 않은 경로를 권한다는 것 - 문턱이 두 곳에 있고 대조되지 않는다는 것 **확인하지 못한 것** - **`ClaimCheckStore`를 구현할 계획이 있는지.** `adapter/outbound/objectstorage`가 후보이지만 두 leaf가 registry에서 연결되지 않는다. - 보존 sweep을 누가 도는지 — `ClaimCheckStore.delete`의 호출자가 없다. - 실제 객체 저장소에서 `store.get`이 만료 후에도 반환하는지 — `resolve`의 사전 만료 검사가 그 경우를 상정한다. - 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가. --- ## 17. 손볼 것 ### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다 - **사실.** 여섯 타입 전부 leaf 밖 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, 조립 0건. 그런데 `runtime_memberships`가 `["app-bootstrap"]`이고 starter의 `allowed_dependencies`에 포함된다. 그리고 `messaging-policy`의 `PayloadLimitGuard`가 상한 초과 payload를 거절하며 `"payload of %d bytes exceeds the %d byte limit for %s; use claim check"`라고 안내한다. - **근거.** `evidence/raw/290` §A. `PayloadLimitGuard.java:46-49`. - **왜 문제인가.** 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 `DestinationProfile`이 `claimCheckThresholdBytes`를 선언하고 검증까지 하므로 **설정 표면은 존재한다.** 설정할 수 있고 아무 효과가 없는 값이다. - **확인 방법.** `evidence/raw/290` §A 재실행. `git grep -n 'use claim check' -- src`. - **후보.** (a) `ClaimCheckStore` 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 `[]`로 되돌리고 `PayloadLimitGuard` 메시지에서 안내를 뺀다. (c) 미완임을 `support-matrix.md`에 표시한다. - **다음 단계.** **CASE 후보.** `messaging-cloudevents` §17의 "싣고 쓰지 않는다"와 같은 계열이지만, 여기서는 **다른 컴포넌트가 이 경로를 권한다**는 점이 추가된다. ### P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다 - **사실.** `messaging-policy`의 `PayloadPolicy.claimCheckThresholdBytes`(목적지별, `DestinationProfileValidator:49`가 검사)와 이 leaf의 `ClaimCheckPolicy.thresholdBytes`(전역). 두 값을 대조하는 코드가 없다. - **근거.** `evidence/raw/290` §B. - **왜 문제인가.** 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다. - **확인 방법.** 두 필드와 검증기 확인. - **후보.** `ClaimCheckPublisher`가 목적지 프로파일의 값을 읽거나, `PayloadPolicy`에서 그 필드를 제거한다. - **다음 단계.** **REFERENCE 후보**(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다). ### P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다 - **사실.** `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith("_MISMATCH")`로 `ClaimCheckIntegrityException` 승격을 결정한다. `ClaimCheckIntegrityGuard`의 세 코드 중 둘이 그 접미사를 갖는다. - **근거.** `ClaimCheckResolver.java:84`. - **왜 문제인가.** 두 클래스 사이의 계약이 **문자열 명명 규약**이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: `CLAIM_CHECK_DIGEST_INVALID`) 승격이 조용히 멈추고 poison message가 `PERMANENT_BUSINESS`로 분류된다 — 재시도 정책이 달라진다. - **확인 방법.** `git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check` - **후보.** guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다. - **다음 단계.** **CASE 후보 + REFERENCE 후보**(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다). ### P3 — `ClaimCheckPublisher`가 이 leaf의 테스트에 등장하지 않는다 - **사실.** 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다. - **근거.** `find src/test -name '*Test.java'` → 셋. - **왜 문제인가.** publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(`shouldOffload`), 오프로드 시 payload를 비우는 것, `Offloaded`의 양방향 방어 복사. 특히 "저장이 발행보다 먼저"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다. - **확인 방법.** 세 테스트 클래스 이름 확인. - **후보.** `ClaimCheckPublisherTest`를 추가한다. - **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다). ### P3 — 보존 sweep이 없다 - **사실.** `ClaimCheckStore.delete`가 선언돼 있고 이 leaf에서 호출되지 않는다. `ClaimCheckPublisher` javadoc이 "the retention sweep reclaims it"이라고 그 존재를 전제한다. - **근거.** `git grep -n 'delete(' -- src/messaging/messaging-claim-check` → 인터페이스 선언만. - **왜 문제인가.** 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 `ClaimCheckPolicy.retention`이 그것과 연결되지 않는다. - **확인 방법.** `delete` 호출자 검색. - **후보.** sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다. - **다음 단계.** **OPEN QUESTION 후보.** 판정이 `ClaimCheckStore` 구현 계획에 걸린다. ### 확인된 설계(문제 아님) - 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것 - 문턱과 목적지 상한을 분리하고 그 이유를 적은 것 - 객체를 발행보다 먼저 저장하는 순서 - 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것 - 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것 - 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것 - 크기 검사를 digest보다 먼저 두는 것 - 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것 - `MessageDigest`를 호출마다 새로 만드는 것 --- ## Source anchors | id | kind | path | revision | what it proves | limitations | |---|---|---|---|---|---| | MCC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 | | MCC-002 | build | `messaging-claim-check/build.gradle` | same | 벤더 의존성 0 | — | | MCC-003 | code | `.../claimcheck/ClaimCheckPolicy.java` | same | §4.1 보존 불변식과 문턱 | — | | MCC-004 | code | `.../claimcheck/ClaimCheckPublisher.java` | same | §4.2 순서·미삭제·빈 payload | 전용 테스트 없음 | | MCC-005 | code | `.../claimcheck/ClaimCheckIntegrityGuard.java` | same | §4.3 세 검사 | — | | MCC-006 | code | `.../claimcheck/ClaimCheckResolver.java` | same | §4.4 사전 만료 확인, 접미사 승격 | 접미사 의존(§17) | | MCC-007 | code | `.../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java` | same | port 계약, POISON_MESSAGE 분류 | 구현 없음 | | MCC-008 | test | 3 클래스 / 22 테스트 | same | §10 표 | fake 저장소. publisher 미검증 | | MCC-009 | cross-leaf code | `messaging-policy/.../PayloadLimitGuard.java:46-49` | same | "use claim check" 안내 | 해당 leaf SSOT가 소유 | | MCC-010 | cross-leaf code | `messaging-policy/.../PayloadPolicy.java:14`, `DestinationProfileValidator.java:49` | same | 두 번째 문턱과 그 검증 | 해당 leaf SSOT가 소유 | | EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` | same | §12.1·§12.3 | 정적 검색 | | EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | same | 22 / 0 / 0 | — |