{ "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": 26228, "line": 26228 }, "current_section": { "heading": { "line": 26228, "level": 3, "text": "messaging-claim-check 완전 해부" }, "start_line": 26228, "end_line": 26811, "text": "### messaging-claim-check 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-claim-check`\n> SSOT owner: `messaging-claim-check`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-claim-check`\n- canonical state `analysisFile`: §A19-MESSAGING-CLAIM-CHECK\n- source path: `src/messaging/messaging-claim-check`\n- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-reliability-api\"]`\n- registry `runtime_memberships`: **`[\"app-bootstrap\"]`**\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 6 |\n| production LOC | 418 |\n| 패키지 | 1 (`dev.caskeleton.messaging.claimcheck`) |\n| test 파일 | 3 |\n| test 메서드(실행 확인) | 22 |\n| 외부(비프로젝트) 의존성 | **0** |\n\n여섯 타입:\n\n| 타입 | 종류 | 역할 | leaf 밖 참조 |\n|---|---|---|---:|\n| `ClaimCheckStore` | interface | payload 저장·조회·삭제 port | **0** |\n| `ClaimCheckPolicy` | record | 문턱과 보존 규칙 | **0** |\n| `ClaimCheckPublisher` | class | 발행 측 오프로드 결정 | **0** |\n| `ClaimCheckResolver` | class | 소비 측 조회 + 검증 | **0** |\n| `ClaimCheckIntegrityGuard` | class | digest·크기·만료 검사 | **0** |\n| `ClaimCheckIntegrityException` | exception | digest 불일치 | **0** |\n\n**여섯 전부 leaf 밖 참조가 0이다.**\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/**` (3) | 3 | `FULL_READ` | 테스트명·fake 구현 확인 |\n| `build.gradle` | 1 | `FULL_READ` | 6줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n**Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다.\n\n`messaging-reliability-api`의 `ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.\n\n경계 진술이 두 클래스에 있다.\n\n```java\n// ClaimCheckIntegrityGuard.java:14-17\n *
A claim check turns one message into two systems that can drift. The payload store has its own\n * retention, its own replication, and its own access control, and none of them are coordinated with\n * the broker's. So a consumer that fetches bytes and decodes them without checking is trusting\n * something the message never proved.\n```\n\n```java\n// ClaimCheckResolver.java:11-15\n *
Verification is not optional and cannot be skipped by a caller. An object store key is a\n * string, and a message carrying the wrong one — through a bug, a replay against a rotated bucket,\n * or a deliberate tamper — fetches bytes that decode perfectly into the wrong object. The digest is\n * the only thing standing between that and a handler acting on someone else's data.\n```\n\n**\"decode perfectly into the wrong object\"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공.\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api).\n\n나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다.\n\n**배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다.\n\n그런데 **`runtime_memberships`가 `[\"app-bootstrap\"]`이다.** starter closure를 통해 배포 아티팩트에 실린다.\n\n`messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(§A19-MESSAGING-CLOUDEVENTS §12.1).\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\n발행 측\n ClaimCheckPublisher(store, policy)\n └── offload(byte[]) → Offloaded(payload, Optional The retention rule is the one that matters. A claim check object deleted while its message is\n * still deliverable turns a large message into an undeliverable one — the consumer fetches, gets\n * nothing, and the message dead-letters for a reason that has nothing to do with the message. So\n * retention must exceed the broker's own retention plus the full retry and dead-letter window, and\n * the constructor refuses a configuration where it does not.\n```\n\n**이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.** 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다.\n\n문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다.\n\n```java\n// :16-18\n * The threshold is separate from the destination's payload limit. Offloading starts well below\n * the limit, because the limit is where the broker refuses the message and the threshold is where\n * carrying it inline stops being a good idea.\n```\n\n`DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다.\n\n`defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유.\n\n##### 4.2 `ClaimCheckPublisher` — 순서와 미삭제\n\n```java\n// :9-17\n * The object is written before the message is published, and that order is the whole\n * design. Publishing first would let a consumer receive a reference to an object that does not\n * exist yet — a race that is rare in a test and routine under load, because the broker hop is\n * faster than the object store write.\n *\n * Nothing here deletes on failure. If the publish is rejected the object is left behind, and the\n * retention sweep reclaims it; deleting eagerly would delete the object out from under a publish\n * that turned out to be ambiguous rather than rejected.\n```\n\n두 번째가 `messaging-core-api`의 3상태와 직접 연결된다 — `REJECTED`와 `AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다.\n\n오프로드된 메시지는 payload를 **아예 갖지 않는다**.\n\n```java\n// The published message carries no payload bytes at all, only the reference. Carrying both\n// would double the transfer for no benefit and let the two disagree.\nreturn new Offloaded(new byte[0], Optional.of(reference));\n```\n\n`Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다.\n\n**`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다(\"Nothing here deletes on failure\"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다.\n\n##### 4.3 `ClaimCheckIntegrityGuard` — 세 검사, 전부 fail-closed\n\n| 순서 | 검사 | 코드 |\n|---:|---|---|\n| 1 | `reference.isExpired(now)` | `CLAIM_CHECK_EXPIRED` |\n| 2 | `payload.length != reference.sizeBytes()` | `CLAIM_CHECK_SIZE_MISMATCH` |\n| 3 | `sha256(payload) != reference.sha256()` | `CLAIM_CHECK_DIGEST_MISMATCH` |\n\n```java\n// :19-22\n * Both checks fail closed. An expired reference is reported before the fetch, because a\n * not-found from the store is ambiguous between \"reaped\" and \"never written\". A digest mismatch is\n * reported as validation rather than deserialization, because the bytes are not corrupt JSON — they\n * are the wrong bytes.\n```\n\n크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다.\n\n`sha256(byte[])`가 `HexFormat.of().formatHex(...)`로 **소문자** hex를 만든다. `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다.\n\n`verify`가 검증된 payload의 **복사본**을 반환한다.\n\n##### 4.4 `ClaimCheckResolver` — 만료를 fetch 전에 본다\n\n```java\nif (claimCheck.isExpired(now)) {\n // Checked before fetching. A store that still returns the object past its retention would\n // otherwise hide a misconfiguration until the day the sweep caught up.\n throw new MessageValidationException(\"CLAIM_CHECK_EXPIRED\", ...);\n}\n```\n\n**저장소가 아직 반환하더라도 거절한다.** 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.\n\n`fetch`가 `null`을 `CLAIM_CHECK_NOT_FOUND`로 번역하고 메시지가 두 원인을 나열한다 — \"it was either reaped early or never written\".\n\n**예외 승격이 코드 접미사로 판정된다.**\n\n```java\n} catch (MessageValidationException validation) {\n // A size or digest mismatch is a poison message, not a validation failure to be retried:\n // fetching the same key again returns the same wrong bytes.\n if (validation.failure().code().endsWith(\"_MISMATCH\")) {\n throw new ClaimCheckIntegrityException(\n validation.failure().code(), validation.failure().sanitizedMessage());\n }\n throw validation;\n}\n```\n\n`endsWith(\"_MISMATCH\")` — **문자열 접미사로 분기한다.** guard가 코드 이름을 바꾸거나 `_MISMATCH`로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17.\n\n##### 4.5 `ClaimCheckIntegrityException` — 카테고리가 `POISON_MESSAGE`\n\n```java\n// :12-18\n * Not retryable. A digest mismatch means the object at that key is not the object the producer\n * wrote — the key was reused, the object was overwritten, or something truncated it — and fetching\n * it again returns the same wrong bytes. Retrying would only delay the dead-letter.\n *\n * Deliberately distinct from \"the object is gone\". An expired claim check is an operational\n * problem with a known cause and a known fix; a digest mismatch means something wrote data nobody\n * expected, and the two must not be diagnosed as one.\n```\n\n`FailureCategory.POISON_MESSAGE`, `retryable = false`. `messaging-core-api`의 `FailureDescriptor.defaultRetryable`이 `POISON_MESSAGE`를 false로 두는 것과 일치한다.\n\n**이 예외가 `MessagingException`을 확장하는 저장소 내 두 곳 중 하나다**(다른 하나는 core-api 자신의 23개). §A19-MESSAGING-CORE-API §12.1(b)가 그 사실을 관측했다.\n\n---\n\n#### 5. 주요 실행 경로\n\n**발행:** `publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put` → `Offloaded(빈 바이트, reference)`\n\n**소비:** `resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH`면 `ClaimCheckIntegrityException`\n\n두 경로 모두 production에서 호출되지 않는다(§12.1).\n\n---\n\n#### 6. 실패 경로와 복구/번역\n\n| 코드 | 예외 | 카테고리 | retryable | 조건 |\n|---|---|---|:---:|---|\n| `CLAIM_CHECK_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | `CONFIGURATION` | false | 정책 생성 시 |\n| `CLAIM_CHECK_EXPIRED` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 만료 |\n| `CLAIM_CHECK_NOT_FOUND` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 객체 없음 |\n| `CLAIM_CHECK_SIZE_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | 크기 불일치 |\n| `CLAIM_CHECK_DIGEST_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | digest 불일치 |\n\n**분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다.\n\n`ClaimCheckIntegrityGuard.sha256`이 `NoSuchAlgorithmException`을 `IllegalStateException(\"Java runtime does not provide SHA-256\")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다.\n\n---\n\n#### 7. 트랜잭션·동시성·수명주기\n\n트랜잭션 없음.\n\n`ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다.\n\n`MessageDigest.getInstance(\"SHA-256\")`이 **호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다.\n\n`ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다.\n\n수명주기 참여 없음.\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n| 상수/기본값 | 값 |\n|---|---|\n| `ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES` | 262,144 (1 MiB의 1/4) |\n| `ClaimCheckPolicy.defaults()` | 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일 |\n\n설정 파일 없음. 모든 값이 생성자 인자다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n`ClaimCheckStore`가 객체 저장소를 가리키는 port다. **구현이 없다** — production에도, 다른 messaging leaf에도.\n\n저장소의 `adapter/outbound/objectstorage` leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(`messaging-claim-check`의 `allowed_dependencies`에 없고, 반대 방향도 없다).\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n레인: `./gradlew :messaging:messaging-claim-check:test`. **BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures**.\n\n| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n|---|---:|---|---|\n| `ClaimCheckIntegrityGuardTest` | 6 | 만료·크기·digest 세 검사 | 실제 저장소 |\n| `ClaimCheckResolverTest` | 8 | 인라인 통과, 만료 사전 거절, NOT_FOUND, `_MISMATCH` 승격 | **production 호출 여부** |\n| `ClaimCheckRetentionValidatorTest` | 8 | 보존 불변식과 문턱 판정 | — |\n\n`ClaimCheckStore`의 유일한 구현이 `ClaimCheckResolverTest:22`의 `FakeStore`다. 즉 **이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.**\n\n`ClaimCheckPublisher`를 겨냥한 테스트 클래스가 **없다.** 오프로드 결정·객체 선기록 순서·`Offloaded`의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다.\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-reliability-api\"]` |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n| vendor `api` 규칙 | 벤더 의존성 0 |\n| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |\n| ArchUnit | 전용 규칙 없음 |\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt`.\n\n##### 12.1 Public surface reachability\n\n**여섯 타입 전부 leaf 밖 참조 0이다.**\n\n| 타입 | leaf 밖 |\n|---|---:|\n| `ClaimCheckStore` | 0 |\n| `ClaimCheckPolicy` | 0 |\n| `ClaimCheckPublisher` | 0 |\n| `ClaimCheckResolver` | 0 |\n| `ClaimCheckIntegrityGuard` | 0 |\n| `ClaimCheckIntegrityException` | 0 |\n\n`ClaimCheckStore` 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다.\n\n**그런데 이 leaf는 배포 아티팩트에 실린다.**\n\n```\nmessaging-claim-check runtime_memberships=['app-bootstrap']\nmessaging-spring-boot-starter runtime_memberships=['app-bootstrap']\n starter deps include claim-check: True\n```\n\n`messaging-cloudevents`와 같은 조합이다. 형제 비교:\n\n| leaf | 소비자 | membership | 정합 |\n|---|:---:|---|---|\n| `messaging-schema-avro` | 0 | `[]` | o |\n| `messaging-schema-protobuf` | 0 | `[]` | o |\n| `messaging-kafka-share-experimental` | 0 | `[]` | o |\n| **`messaging-cloudevents`** | **0** | **`[\"app-bootstrap\"]`** | **x** |\n| **`messaging-claim-check`** | **0** | **`[\"app-bootstrap\"]`** | **x** |\n\n**\"싣고 쓰지 않는\" leaf가 둘이다.** 오늘 실행되는 코드가 없으므로 사고는 아니다.\n\n**한 가지 정황이 이 leaf를 다르게 만든다.** `messaging-policy`의 `PayloadPolicy`가 `claimCheckThresholdBytes` 필드를 갖고, `DestinationProfileValidator`가 그 값을 검사한다(`:49`). 즉 **목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다.** payload가 문턱을 넘어도 오프로드되지 않고, `PayloadLimitGuard`가 상한 초과로 거절한다 — `MessageTooLargeException(\"PAYLOAD_LIMIT_EXCEEDED\", \"... use claim check\")`. **에러 메시지가 존재하지 않는 경로를 권한다.**\n\n##### 12.2 Conditional sibling comparison\n\nSpring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다.\n\n`messaging-reliability-api`의 세 port 중 둘(`OutboxRepository`, `InboxRepository`)은 구현 leaf와 starter bean을 갖고 `ClaimCheckStore`는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다.\n\n##### 12.3 Duplicate mechanism sweep\n\n**(a) claim check 문턱이 두 곳에 있고 서로를 모른다**\n\n| 위치 | 필드 | 검사 |\n|---|---|---|\n| `messaging-policy` `PayloadPolicy` | `claimCheckThresholdBytes` | `DestinationProfileValidator:49`가 `<= maxBytes` 확인 |\n| 이 leaf `ClaimCheckPolicy` | `thresholdBytes` | 생성자가 `>= 1` 확인 |\n\n**두 값을 대조하는 코드가 없다.** 목적지 프로파일이 문턱 512 KiB를 선언하고 `ClaimCheckPolicy`가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다.\n\n**(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다**\n\n| 규칙 | 위치 | 강제 |\n|---|---|---|\n| claim check 보존 ≥ 브로커 보존 + 재전달 창 | `ClaimCheckPolicy` 생성자 | **강제됨** |\n| inbox 보존 > 브로커 최대 재전달 창 | `InboxRepository` javadoc | **문서만** |\n\n같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. §A19-MESSAGING-RELIABILITY-API §17이 후자를 소유한다.\n\n**(c) digest 계산이 저장소에 여럿 있는가**\n\n`MessageDigest.getInstance(\"SHA-256\")`을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님.\n\n**(d) `_MISMATCH` 접미사 분기**\n\n`ClaimCheckResolver.verify`가 `validation.failure().code().endsWith(\"_MISMATCH\")`로 예외를 승격한다. `ClaimCheckIntegrityGuard`의 코드 셋 중 둘이 그 접미사를 갖고 하나(`CLAIM_CHECK_EXPIRED`)가 갖지 않는다. **문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다.** §17.\n\n##### 12.4 Documentation / measured-count drift\n\n| 문서 주장 | 재측정 | 결과 |\n|---|---|---|\n| `ClaimCheckPolicy` javadoc: 문턱이 \"a quarter of the portable payload limit\" | 262,144 = 1,048,576 / 4 | **일치** |\n| `ClaimCheckPublisher` javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 | 이 leaf에 sweep 없음 | **미실현** |\n| `ClaimCheckIntegrityGuard` javadoc: 두 검사가 fail closed | 세 검사 전부 예외 | **일치**(검사가 셋인데 javadoc은 \"Both\") |\n| `PayloadLimitGuard` 에러 메시지: \"use claim check\" | claim check 경로 미배선 | **불일치** |\n| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |\n\n세 번째가 작은 표현 drift다 — javadoc이 \"Both checks fail closed\"라고 하는데 `verify`는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다.\n\n---\n\n#### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n\n이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다.\n\n| 위치 | 막으려는 것 |\n|---|---|\n| `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. \"rare in a test and routine under load\" |\n| `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 |\n| `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 |\n| `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 |\n| `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter |\n| `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 |\n\n**\"rare in a test and routine under load\"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다.\n\n---\n\n#### 14. 런타임·터미널 Evidence\n\n| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n|---|---|---|---|---|\n| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §A·§B | 여섯 타입 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 | 정적 검색 |\n| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | BUILD SUCCESSFUL, 22 / 0 / 0 | 저장소가 fake. publisher 미검증 |\n\n---\n\n#### 15. 명시적 설계 이유와 추론을 구분한 정리\n\n**명시적**\n\n- 두 시스템이 drift한다는 위협 모델 — `ClaimCheckIntegrityGuard` javadoc\n- 검증이 선택 불가인 이유 — `ClaimCheckResolver` javadoc\n- 저장이 발행보다 먼저인 이유 — `ClaimCheckPublisher` javadoc\n- 실패 시 삭제하지 않는 이유 — 같은 javadoc\n- payload와 참조를 함께 나르지 않는 이유 — 인라인 주석\n- 만료를 fetch 전에 보는 이유 — `resolve` 인라인 주석\n- 보존 규칙과 그것을 생성자가 강제하는 이유 — `ClaimCheckPolicy` javadoc\n- 문턱과 목적지 상한이 다른 이유 — 같은 javadoc\n- digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — `ClaimCheckIntegrityException` javadoc\n- `_MISMATCH` 승격이 poison message인 이유 — `verify` 인라인 주석\n\n**추론**\n\n- 배선되지 않은 것이 미완인지 확장점인지 → **미상**. `ClaimCheckStore` 구현이 없다는 관측만 있다.\n- `_MISMATCH` 접미사 규약이 의도인지 → **미상**. 선언된 곳이 없다.\n- javadoc의 \"Both checks\"가 세 검사가 되기 전 표현인지 → **추론**.\n\n---\n\n#### 16. 확인한 것 / 확인하지 못한 것\n\n**확인한 것**\n\n- 6개 타입 418줄 전문의 계약\n- 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 `ClaimCheckPublisher`가 미검증이라는 것\n- 여섯 타입 전부 leaf 밖 참조 0이고 `ClaimCheckStore` 구현이 테스트 fake뿐이라는 것\n- `runtime_memberships`가 `[\"app-bootstrap\"]`이라 배포 아티팩트에 실린다는 것\n- `PayloadLimitGuard`의 에러 메시지가 배선되지 않은 경로를 권한다는 것\n- 문턱이 두 곳에 있고 대조되지 않는다는 것\n\n**확인하지 못한 것**\n\n- **`ClaimCheckStore`를 구현할 계획이 있는지.** `adapter/outbound/objectstorage`가 후보이지만 두 leaf가 registry에서 연결되지 않는다.\n- 보존 sweep을 누가 도는지 — `ClaimCheckStore.delete`의 호출자가 없다.\n- 실제 객체 저장소에서 `store.get`이 만료 후에도 반환하는지 — `resolve`의 사전 만료 검사가 그 경우를 상정한다.\n- 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가.\n\n---\n\n#### 17. 손볼 것\n\n##### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다\n\n- **사실.** 여섯 타입 전부 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\"`라고 안내한다.\n- **근거.** `evidence/raw/290` §A. `PayloadLimitGuard.java:46-49`.\n- **왜 문제인가.** 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 `DestinationProfile`이 `claimCheckThresholdBytes`를 선언하고 검증까지 하므로 **설정 표면은 존재한다.** 설정할 수 있고 아무 효과가 없는 값이다.\n- **확인 방법.** `evidence/raw/290` §A 재실행. `git grep -n 'use claim check' -- src`.\n- **후보.** (a) `ClaimCheckStore` 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 `[]`로 되돌리고 `PayloadLimitGuard` 메시지에서 안내를 뺀다. (c) 미완임을 `support-matrix.md`에 표시한다.\n- **다음 단계.** **CASE 후보.** `messaging-cloudevents` §17의 \"싣고 쓰지 않는다\"와 같은 계열이지만, 여기서는 **다른 컴포넌트가 이 경로를 권한다**는 점이 추가된다.\n\n##### P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다\n\n- **사실.** `messaging-policy`의 `PayloadPolicy.claimCheckThresholdBytes`(목적지별, `DestinationProfileValidator:49`가 검사)와 이 leaf의 `ClaimCheckPolicy.thresholdBytes`(전역). 두 값을 대조하는 코드가 없다.\n- **근거.** `evidence/raw/290` §B.\n- **왜 문제인가.** 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다.\n- **확인 방법.** 두 필드와 검증기 확인.\n- **후보.** `ClaimCheckPublisher`가 목적지 프로파일의 값을 읽거나, `PayloadPolicy`에서 그 필드를 제거한다.\n- **다음 단계.** **REFERENCE 후보**(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다).\n\n##### P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다\n\n- **사실.** `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith(\"_MISMATCH\")`로 `ClaimCheckIntegrityException` 승격을 결정한다. `ClaimCheckIntegrityGuard`의 세 코드 중 둘이 그 접미사를 갖는다.\n- **근거.** `ClaimCheckResolver.java:84`.\n- **왜 문제인가.** 두 클래스 사이의 계약이 **문자열 명명 규약**이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: `CLAIM_CHECK_DIGEST_INVALID`) 승격이 조용히 멈추고 poison message가 `PERMANENT_BUSINESS`로 분류된다 — 재시도 정책이 달라진다.\n- **확인 방법.** `git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check`\n- **후보.** guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다.\n- **다음 단계.** **CASE 후보 + REFERENCE 후보**(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다).\n\n##### P3 — `ClaimCheckPublisher`가 이 leaf의 테스트에 등장하지 않는다\n\n- **사실.** 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다.\n- **근거.** `find src/test -name '*Test.java'` → 셋.\n- **왜 문제인가.** publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(`shouldOffload`), 오프로드 시 payload를 비우는 것, `Offloaded`의 양방향 방어 복사. 특히 \"저장이 발행보다 먼저\"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다.\n- **확인 방법.** 세 테스트 클래스 이름 확인.\n- **후보.** `ClaimCheckPublisherTest`를 추가한다.\n- **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다).\n\n##### P3 — 보존 sweep이 없다\n\n- **사실.** `ClaimCheckStore.delete`가 선언돼 있고 이 leaf에서 호출되지 않는다. `ClaimCheckPublisher` javadoc이 \"the retention sweep reclaims it\"이라고 그 존재를 전제한다.\n- **근거.** `git grep -n 'delete(' -- src/messaging/messaging-claim-check` → 인터페이스 선언만.\n- **왜 문제인가.** 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 `ClaimCheckPolicy.retention`이 그것과 연결되지 않는다.\n- **확인 방법.** `delete` 호출자 검색.\n- **후보.** sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다.\n- **다음 단계.** **OPEN QUESTION 후보.** 판정이 `ClaimCheckStore` 구현 계획에 걸린다.\n\n##### 확인된 설계(문제 아님)\n\n- 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것\n- 문턱과 목적지 상한을 분리하고 그 이유를 적은 것\n- 객체를 발행보다 먼저 저장하는 순서\n- 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것\n- 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것\n- 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것\n- 크기 검사를 digest보다 먼저 두는 것\n- 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것\n- `MessageDigest`를 호출마다 새로 만드는 것\n\n---\n\n#### Source anchors\n\n| id | kind | path | revision | what it proves | limitations |\n|---|---|---|---|---|---|\n| MCC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `[\"app-bootstrap\"]` | 선언 |\n| MCC-002 | build | `messaging-claim-check/build.gradle` | same | 벤더 의존성 0 | — |\n| MCC-003 | code | `.../claimcheck/ClaimCheckPolicy.java` | same | §4.1 보존 불변식과 문턱 | — |\n| MCC-004 | code | `.../claimcheck/ClaimCheckPublisher.java` | same | §4.2 순서·미삭제·빈 payload | 전용 테스트 없음 |\n| MCC-005 | code | `.../claimcheck/ClaimCheckIntegrityGuard.java` | same | §4.3 세 검사 | — |\n| MCC-006 | code | `.../claimcheck/ClaimCheckResolver.java` | same | §4.4 사전 만료 확인, 접미사 승격 | 접미사 의존(§17) |\n| MCC-007 | code | `.../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java` | same | port 계약, POISON_MESSAGE 분류 | 구현 없음 |\n| MCC-008 | test | 3 클래스 / 22 테스트 | same | §10 표 | fake 저장소. publisher 미검증 |\n| MCC-009 | cross-leaf code | `messaging-policy/.../PayloadLimitGuard.java:46-49` | same | \"use claim check\" 안내 | 해당 leaf SSOT가 소유 |\n| MCC-010 | cross-leaf code | `messaging-policy/.../PayloadPolicy.java:14`, `DestinationProfileValidator.java:49` | same | 두 번째 문턱과 그 검증 | 해당 leaf SSOT가 소유 |\n| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` | same | §12.1·§12.3 | 정적 검색 |\n| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | same | 22 / 0 / 0 | — |\n\n---\n"
},
"previous_section": {
"heading": {
"line": 26224,
"level": 2,
"text": "A19-MESSAGING-CLAIM-CHECK. messaging-claim-check"
},
"start_line": 26224,
"end_line": 26227,
"text": "## A19-MESSAGING-CLAIM-CHECK. messaging-claim-check\n\n> 분석 중에는 `messaging/MESSAGING-CLAIM-CHECK.md` 파일이었다. 581줄.\n"
},
"next_section": {
"heading": {
"line": 26812,
"level": 2,
"text": "A19-MESSAGING-CLOUDEVENTS. messaging-cloudevents"
},
"start_line": 26812,
"end_line": 27412,
"text": "## A19-MESSAGING-CLOUDEVENTS. messaging-cloudevents\n\n> 분석 중에는 `messaging/MESSAGING-CLOUDEVENTS.md` 파일이었다. 594줄.\n\n### messaging-cloudevents 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-cloudevents`\n> SSOT owner: `messaging-cloudevents`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-cloudevents`\n- canonical state `analysisFile`: §A19-MESSAGING-CLOUDEVENTS\n- source path: `src/messaging/messaging-cloudevents`\n- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`\n- registry `runtime_memberships`: **`[\"app-bootstrap\"]`**\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 3 |\n| production LOC | 228 |\n| 패키지 | 1 (`dev.caskeleton.messaging.cloudevents`) |\n| test 파일 | 1 |\n| test 메서드(실행 확인) | 7 |\n| 외부 의존성 | 2 (`cloudevents-api:4.0.1` **api**, `cloudevents-core:4.0.1` implementation) |\n\n세 타입: `CloudEventMapper`(인터페이스), `DefaultCloudEventMapper`(구현), `CloudEventExtensions`(확장 속성 이름 4개).\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `.../cloudevents/CloudEventMapper.java` | 1 | `FULL_READ` | 33줄 전문 |\n| `.../cloudevents/DefaultCloudEventMapper.java` | 1 | `FULL_READ` | 171줄 전문 |\n| `.../cloudevents/CloudEventExtensions.java` | 1 | `FULL_READ` | 24줄 전문 |\n| `src/test/java/**` | 1 | `FULL_READ` | 162줄 전문 |\n| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |\n| `gradle.lockfile` | 1 | `FULL_READ` | cloudevents 좌표 2건 확인 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼.\n\n적용 범위를 인터페이스 javadoc이 한정한다.\n\n```java\n// CloudEventMapper.java:11-13\n * Offered for domain and integration events only. Commands and work items are not forced through\n * CloudEvents: they are internal contracts where the interoperability the specification buys does\n * not pay for the attributes it requires.\n```\n\n의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다.\n\n```groovy\n// api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent.\n//\n// Declared `implementation`, the type appeared in this module's public API while the\n// dependency was hidden from consumers: an adopter calling the documented method could not\n// name its return type without adding CloudEvents to their own build, and Gradle gave them no\n// hint why. A type in a public signature is part of the artifact's contract.\napi 'io.cloudevents:cloudevents-api:4.0.1'\nimplementation 'io.cloudevents:cloudevents-core:4.0.1'\n```\n\n**둘의 scope가 다른 것이 정확하다.** `cloudevents-api`(`CloudEvent`, `CloudEventData`)는 public 시그니처에 나오므로 `api`, `cloudevents-core`(`CloudEventBuilder`, `BytesCloudEventData`)는 구현 안에서만 쓰이므로 `implementation`이다. `src/messaging/CLAUDE.md:40-43`의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다.\n\n**이 leaf의 위치가 형제들과 다르다.** `runtime_memberships`가 `[\"app-bootstrap\"]`이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 \"싣지도 않고 쓰지도 않는다\"로 정합하지만, 이 leaf는 **싣고 쓰지 않는다.**\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `cloudevents-api:4.0.1`(api), `cloudevents-core:4.0.1`(implementation).\n\n나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다. 그래서 `app-bootstrap` → starter → 이 leaf 경로로 런타임 classpath에 오른다.\n\n**그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다.** starter의 어느 `@Bean`도 `CloudEventMapper`를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1).\n\nbean 없음(Spring 주석 0개).\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\nCloudEventMapper (interface)\n├── toCloudEvent(MessageEnvelope>, URI) → CloudEvent\n└── fromCloudEvent(CloudEvent) → MessageEnvelope Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather\n * than defaulted to the production instant, because {@code time} is read downstream as when the\n * fact happened, not when the platform got around to serialising it. And an event with no data maps\n * to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and\n * inventing one from an absent CloudEvent payload would turn an empty notification into a deletion.\n```\n\n두 번째는 `messaging-core-api`의 `MessageEnvelope` javadoc과 정확히 짝을 이룬다 — \"A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics.\" 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다.\n\n##### 4.3 `producerFrom`: 무한 URI를 유한 이름으로\n\n```java\n// :158-163\n * The last path or scheme-specific segment is used so that a long URI does not become an\n * unbounded producer name, which would leak straight into metric tags.\nprivate static String producerFrom(URI source) {\n String text = source.toString();\n int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));\n String candidate =\n separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;\n return candidate.isBlank() ? \"unknown\" : candidate;\n}\n```\n\n`ProducerId`가 \"deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags\"라고 선언한 것과 같은 관심사다.\n\n**다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5.\n\n`urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`.\n\n##### 4.4 `time`이 두 필드로 복제된다\n\n```java\nInstant occurredAt = time.toInstant();\nreturn new MessageEnvelope<>(\n ..., occurredAt, // producedAt\n Optional.of(occurredAt), // occurredAt\n ...);\n```\n\nCloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.\n\n##### 4.5 왕복에서 소실되는 것\n\n`fromCloudEvent`가 항상 비우는 필드가 다섯이다.\n\n| 필드 | 결과 |\n|---|---|\n| `partitionKey` | `Optional.empty()` |\n| `orderingKey` | `Optional.empty()` |\n| `traceContext` | `TraceContext.none()` |\n| `headers` | `MessageHeaders.empty()` |\n| `producedAt` | `occurredAt`으로 덮임 |\n\n**`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — \"Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet.\" CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.\n\n`toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다.\n\n##### 4.6 `id`의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약\n\n```java\nnew MessageId(UUID.fromString(event.getId()))\n```\n\nCloudEvents 1.0.2는 `id`를 **\"Type: String; Constraints: REQUIRED, MUST be a non-empty string\"**으로 정의한다. UUID 형식 요구가 없다.\n\n`MessageId`(messaging-core-api)는 UUID이면서 **version 7 · variant 2**를 요구한다.\n\n두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(`evidence/raw/273-cloudevents-inbound-id-probe.txt`).\n\n```\n--- spec-conformant opaque string id\n id = A234-1234-1234\n result = REJECTED\n thrown = java.lang.IllegalArgumentException\n message = Invalid UUID string: A234-1234-1234\n is a MessagingException (carries FailureDescriptor) = false\n\n--- UUIDv4 id\n id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11\n result = REJECTED\n thrown = java.lang.IllegalArgumentException\n message = a message identity is UUIDv7 (time-ordered); this is version 4\n is a MessagingException (carries FailureDescriptor) = false\n\n--- UUIDv7 id (what this platform mints)\n result = ACCEPTED\n```\n\n`A234-1234-1234`는 CloudEvents 명세 자신의 예시가 쓰는 id다.\n\n**의도는 문서화돼 있다.** 테스트에 주석이 있다.\n\n```java\n// CloudEventMappingTest.java:90-91\n// A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign\n// producer is refused here exactly as it would be on the wire.\n```\n\n즉 \"외부 producer의 v4를 거절한다\"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다.\n\n1. **비UUID id는 명세 위반이 아니다.** v4 거절은 정책 선택이지만, `A234-1234-1234` 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다.\n2. **실패가 플랫폼 어휘 밖이다.** 이 매퍼의 다른 모든 검증 실패는 `MessageValidationException`(→ `FailureDescriptor`, `PERMANENT_BUSINESS`, 안정 코드)이다. id 실패만 raw `IllegalArgumentException`이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다.\n\n§17에서 다룬다.\n\n##### 4.7 `schemaversion` 확장이 필수다\n\n```java\nprivate static int intExtension(CloudEvent event, String name) {\n return stringExtension(event, name)\n .map(value -> { try { return Integer.valueOf(value); }\n catch (NumberFormatException e) {\n throw new MessageValidationException(\"CLOUDEVENT_SCHEMA_VERSION_INVALID\", ...); } })\n .orElseThrow(() -> new MessageValidationException(\"CLOUDEVENT_SCHEMA_VERSION_REQUIRED\",\n \"schemaversion extension is required by this profile\"));\n}\n```\n\n에러 메시지가 \"**by this profile**\"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — `id`의 UUIDv7 요구에는 그런 표시가 없다.\n\n이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. `id`와 합치면 **이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.**\n\n##### 4.8 `toCloudEvent`의 payload 계약\n\n```java\nif (envelope.payload() instanceof EncodedMessage encoded) { ... }\nelse if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); }\nelse { throw new MessageValidationException(\"CLOUDEVENT_PAYLOAD_NOT_ENCODED\", ...); }\n```\n\n이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. `byte[]` 분기에서 `clone()`하는 것도 `EncodedMessage.bytes()`가 이미 복사본을 주는 것과 대칭이다.\n\n---\n\n#### 5. 주요 실행 경로\n\n**나가는 방향:** `occurredAt` 확인(없으면 거절) → `CloudEventBuilder.v1()`에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → `dataschema`(있을 때) → `build()`\n\n**들어오는 방향:** `time` 확인(없으면 거절) → `datacontenttype`(기본 `application/json`) → `data`(없으면 빈 배열) → `MessageId`·`MessageType`·`SchemaVersion`·`ProducerId`·확장 셋 → `MessageEnvelope` 조립\n\n---\n\n#### 6. 실패 경로와 복구/번역\n\n| 코드 | 예외 | 방향 | 조건 |\n|---|---|---|---|\n| `CLOUDEVENT_TIME_REQUIRED` | `MessageValidationException` | 양방향 | `occurredAt` 없음 / `time` 없음 |\n| `CLOUDEVENT_PAYLOAD_NOT_ENCODED` | `MessageValidationException` | 나가는 | payload가 `EncodedMessage`도 `byte[]`도 아님 |\n| `CLOUDEVENT_SCHEMA_VERSION_REQUIRED` | `MessageValidationException` | 들어오는 | 확장 없음 |\n| `CLOUDEVENT_SCHEMA_VERSION_INVALID` | `MessageValidationException` | 들어오는 | 확장이 정수가 아님 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `id`가 UUID가 아니거나 v7이 아님 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `causationid`가 UUID가 아니거나 v7이 아님 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `type`이 `MessageType` 제약 위반(240바이트·제어문자) |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `correlationid`가 160바이트 초과 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `tenantcontext`가 슬러그 패턴 위반 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | 유도된 producer 이름이 120바이트 초과 또는 제어문자 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `datacontenttype`이 미디어 타입 문법 위반 |\n| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `schemaversion`이 0 이하 |\n\n**분류된 실패 넷, 분류되지 않은 실패 여덟.** 매퍼가 직접 던지는 것은 전부 `MessageValidationException`이지만, 값 객체 생성자에 위임한 검증은 전부 raw `IllegalArgumentException`이다. `fromCloudEvent`는 **외부에서 온 데이터**를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다.\n\n`messaging-core-api`의 `FailureDescriptor` 설계 전체가 \"예외 클래스로 분기하지 말고 선언된 분류로 판단하라\"였다. 이 경로는 그 분류를 만들지 않는다.\n\n---\n\n#### 7. 트랜잭션·동시성·수명주기\n\n트랜잭션 없음.\n\n`DefaultCloudEventMapper`는 **상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다.\n\n`CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다.\n\n`CloudEventBuilder`는 호출마다 새로 만들어진다.\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n설정 없음.\n\n| 상수 | 값 | 위치 |\n|---|---|---|\n| `SPEC_CONTENT_TYPE_FALLBACK` | `\"application/json\"` | `DefaultCloudEventMapper.java:39` (private) |\n| `CloudEventExtensions.CORRELATION_ID` | `\"correlationid\"` | public |\n| `CloudEventExtensions.CAUSATION_ID` | `\"causationid\"` | public |\n| `CloudEventExtensions.SCHEMA_VERSION` | `\"schemaversion\"` | public |\n| `CloudEventExtensions.TENANT_CONTEXT` | `\"tenantcontext\"` | public |\n\nCloudEvents 버전은 `4.0.1`로 고정(lockfile 확인). CloudEvents **명세** 버전은 `CloudEventBuilder.v1()`이 고정한다 — javadoc은 1.0.2를 명시한다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n없다.\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n레인: `./gradlew :messaging:messaging-cloudevents:test`. **BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures**.\n\n| 테스트 | 증명하는 것 |\n|---|---|\n| `mapsLogicalIdentityAndExtensions` | id·type·schemaversion·source·datacontenttype |\n| `mapsCorrelationAndTenantAsExtensions` | 두 확장 |\n| `mapsOccurredAtToEventTime` | `occurredAt` → `time` |\n| `rejectsAnEventEnvelopeWithoutOccurredAt` | 나가는 방향의 `time` 필수 |\n| `roundTripsBackToAnEnvelopeWithoutInventingATombstone` | 왕복 시 6개 필드 보존 |\n| `aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue` | 빈 data → 빈 바이트(tombstone 아님) |\n| `rejectsAnUnencodedPayload` | 인코딩되지 않은 payload 거절 |\n\n**이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.**\n\n`fromCloudEvent`를 부르는 두 테스트 중 하나는 `mapper.toCloudEvent(original, SOURCE)`의 출력을 되돌리고, 다른 하나는 `MessageId.newId()`로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — \"A v7 id: MessageId enforces the version it documents\".\n\n즉 **외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다.** 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다.\n\n**왕복 검증의 선택적 비교.** `roundTripsBackToAnEnvelopeWithoutInventingATombstone`이 `producedAt`·`traceContext`·`headers`·`partitionKey`·`orderingKey`를 비교하지 않는다. fixture는 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 \"roundTrips\"인데 실제로는 6개 필드의 부분 보존을 확인한다.\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-schema-api\"]` |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` — 편입이 강제됨 |\n| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | `cloudevents-api`는 public 시그니처에 등장 → `api`. `cloudevents-core`는 구현 전용 → `implementation`. **통과** |\n| ArchUnit | 전용 규칙 없음 |\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/272-schema-family-reachability.txt`, `evidence/raw/273-cloudevents-inbound-id-probe.txt`.\n\n##### 12.1 Public surface reachability\n\n| 타입 | leaf 밖 참조 | 판정 |\n|---|---:|---|\n| `CloudEventMapper` | **0** | 소비자 없음 |\n| `DefaultCloudEventMapper` | **0** | 소비자 없음 |\n| `CloudEventExtensions` | **0** | 소비자 없음 |\n\n세 타입 모두 `git grep` exit 1.\n\n**형제와 다른 조합이다.**\n\n| leaf | 소비자 | starter codec 등록 | `runtime_memberships` | 정합 |\n|---|:---:|:---:|---|---|\n| `messaging-schema-json` | 1 | o | `[\"app-bootstrap\"]` | o |\n| `messaging-schema-avro` | 0 | x | `[]` | o |\n| `messaging-schema-protobuf` | 0 | x | `[]` | o |\n| **`messaging-cloudevents`** | **0** | 해당 없음 | **`[\"app-bootstrap\"]`** | **x** |\n\nAvro·Protobuf는 \"싣지 않고 쓰지 않는다\"로 정합한다. 이 leaf는 **싣고 쓰지 않는다.** `messaging-spring-boot-starter`의 `allowed_dependencies`에 들어 있어 배포 아티팩트가 `cloudevents-api`와 `cloudevents-core` 두 jar를 함께 싣는다.\n\n지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, \"이 의존성이 왜 여기 있지?\"를 나중에 조사할 사람의 시간이다.\n\n##### 12.2 Conditional sibling comparison\n\nSpring 주석 0개, bean 없음.\n\n**조립 비대칭은 starter 쪽에서 관측된다.** `MessagingCoreAutoConfiguration`이 `JacksonMessageCodec`으로 codec registry를 만드는 `@Bean`을 갖는데, `CloudEventMapper`를 만드는 `@Bean`은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 `messaging-spring-boot-starter` leaf SSOT가 소유한다.\n\n##### 12.3 Duplicate mechanism sweep\n\n**(a) 다른 CloudEvents 구현이 있는가 — 없다**\n\n`git grep -l 'io.cloudevents' -- src`가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다.\n\n**(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다**\n\n`messaging-kafka`의 `KafkaHeaderMapper`/`KafkaDeliveryMapper`, `messaging-rabbit`의 `RabbitDeliveryMapper`가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 **transport 매핑**이고 이것은 **interchange 포맷 매핑**이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다).\n\n다만 겹치는 관심사가 하나 있다 — `traceContext`. 브로커 매퍼들은 `traceparent`/`tracestate`/`baggage`를 예약 헤더로 실어 나르고(`ReservedHeaders`가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다.\n\n**(c) UUID 파싱** — `UUID.fromString`을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다.\n\n##### 12.4 Documentation / measured-count drift\n\n| 문서 주장 | 재측정 | 결과 |\n|---|---|---|\n| build.gradle 주석: `cloudevents-api`가 public 시그니처에 등장 | `CloudEventMapper`의 두 메서드가 `CloudEvent`를 반환/수취 | **일치** |\n| build.gradle 주석: `cloudevents-core`는 구현 전용 | `CloudEventBuilder`·`BytesCloudEventData`가 `DefaultCloudEventMapper` 안에서만 | **일치** |\n| 클래스 javadoc: \"CloudEvents 1.0.2 compatible profile\" | `id` 제약이 명세보다 엄격(§4.6). `schemaversion` 확장 필수 | **부분 불일치** — 아래 참조 |\n| `CloudEventMapper` javadoc: domain/integration event 전용 | 코드에 그 구분을 강제하는 것 없음 | **미강제** — 정책 진술이고 게이트가 없다 |\n| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치** — family drift의 사례(`messaging-core-api` §12.4) |\n\n**\"compatible profile\"의 정확한 의미.** 명세는 `id`를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. **나가는 방향은 명세를 만족한다**(UUID 문자열은 유효한 id다). **들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다.** javadoc의 \"compatible\"이 어느 방향을 말하는지 밝히지 않는다. `schemaversion` 에러 메시지는 \"required by this profile\"이라고 정확히 적는 반면 `id` 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다.\n\n---\n\n#### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n\nbuild.gradle 주석이 이전 결함 하나를 보존한다.\n\n> Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract.\n\n이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — \"source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조\". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다.\n\n코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — \"defaulted to the production instant\"와 \"inventing a tombstone\"은 하지 않기로 한 것들이다.\n\n---\n\n#### 14. 런타임·터미널 Evidence\n\n| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n|---|---|---|---|---|\n| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 세 타입의 소비자 0, membership `[\"app-bootstrap\"]` | 정적 검색 |\n| **EVD-273** | **runtime probe** | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, `MessagingException` 여부 | 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인 |\n| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | BUILD SUCCESSFUL, 7 / 0 / 0 | 외부 producer 입력 없음 |\n\nEVD-273의 실행 방법: `:messaging:messaging-cloudevents` test runtimeClasspath에 대해 `/tmp/CeProbe.java`를 컴파일·실행. 저장소 파일은 읽기만 했다.\n\n---\n\n#### 15. 명시적 설계 이유와 추론을 구분한 정리\n\n**명시적**\n\n- domain/integration event 전용인 이유 — `CloudEventMapper` javadoc\n- `occurredAt` 없는 이벤트를 거절하는 이유 — `DefaultCloudEventMapper` javadoc\n- 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc\n- producer 이름을 마지막 세그먼트로 자르는 이유 — `producerFrom` javadoc\n- 확장 이름이 봉투 필드명과 다른 이유 — `CloudEventExtensions` javadoc\n- `cloudevents-api`가 `api`여야 하는 이유 — build.gradle 주석\n- v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(`CloudEventMappingTest.java:90-91`)\n\n**추론**\n\n- 비UUID id 거절이 의도인지 → **미상**. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다.\n- `traceContext`·`headers`를 버리는 것이 의도인지 → **미상**. 어디에도 언급이 없다.\n- `producedAt`을 `occurredAt`으로 덮는 것이 의도인지 → **추론**. CloudEvents에 `time`이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다.\n- membership이 있고 소비자가 없는 이유 → **미상**.\n\n---\n\n#### 16. 확인한 것 / 확인하지 못한 것\n\n**확인한 것**\n\n- 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표\n- 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지\n- 소비자 0인데 `runtime_memberships`가 `[\"app-bootstrap\"]`이라는 비정합\n- **명세 예시 id와 UUIDv4가 분류되지 않은 `IllegalArgumentException`으로 거절된다는 것 — 런타임 probe로 실행 확인**\n- 왕복에서 다섯 필드가 소실된다는 것\n- `api`/`implementation` 분리가 정확하다는 것\n\n**확인하지 못한 것**\n\n- 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다.\n- 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다.\n- `dataschema`가 실제로 쓰이는지 — `EncodedMessage.schemaReference().schemaUri()`가 채워지는 경로가 이 저장소에 없다(세 codec 모두 `SchemaReference.of(subject, version)`로 URI 없이 만든다). 즉 `dataschema`는 현재 항상 비어 있다.\n- CloudEvents distributed-tracing extension을 쓸 계획이 있는지.\n\n---\n\n#### 17. 손볼 것\n\n##### P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다\n\n- **사실.** `fromCloudEvent`가 `new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id `A234-1234-1234` → `java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.**\n- **근거.** `evidence/raw/273-cloudevents-inbound-id-probe.txt` (실행 확인). `DefaultCloudEventMapper.java:116`.\n- **왜 문제인가.** 두 층이다.\n - **(1) 범위.** v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 \"우리 정책\", 비UUID 거절은 \"CloudEvents 상호운용 포기\"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.\n - **(2) 실패 어휘.** 같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 \"코드 없음\" 여덟 행).\n- **확인 방법.** `evidence/raw/273`의 probe 재실행. 또는 `MessageId` 생성자와 `UUID.fromString`의 계약 대조.\n- **후보.** (a) `fromCloudEvent`의 값 객체 생성을 전부 감싸 `MessageValidationException`으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, `id`를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 \"compatible profile\"이 나가는 방향만 뜻함을 밝힌다.\n- **다음 단계.** **CASE 후보.** 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 **DECISION 후보**이며 지금은 근거가 없으므로 `NEEDS_DECISION`이다.\n\n##### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다\n\n- **사실.** 세 타입의 leaf 밖 참조가 0인데 `runtime_memberships`가 `[\"app-bootstrap\"]`이다. `messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다.\n- **근거.** `evidence/raw/272` §D·§E. `MessagingCoreAutoConfiguration` 전수(`CloudEvent` 참조 0).\n- **왜 문제인가.** 형제 Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 \"이 의존성이 왜 있지\"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 \"모든 messaging leaf가 unwired\"라고 적고 있어 문서에서도 이 사실을 알 수 없다.\n- **확인 방법.** `git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents'` → exit 1. registry의 membership 확인.\n- **후보.** (a) starter에서 `@ConditionalOnClass`/`@ConditionalOnProperty`로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 `[]`로 되돌려 Avro·Protobuf와 같은 상태로 만든다.\n- **다음 단계.** **CASE 후보.** \"장치는 있고 회로가 닫히지 않았다\"의 변형 — 여기서는 회로가 닫히지 않았는데 **부품은 배송됐다.**\n\n##### P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다\n\n- **사실.** `fromCloudEvent`가 `partitionKey`·`orderingKey`를 empty로, `traceContext`를 `none()`으로, `headers`를 `empty()`로 두고, `producedAt`을 `occurredAt` 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)이 다르므로 비교했다면 실패했을 것이다.\n- **근거.** `DefaultCloudEventMapper.java:115-131`, `CloudEventMappingTest.java:72-84, 143-161`.\n- **왜 문제인가.** `traceContext` 소실이 가장 무겁다. `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 \"a trace survives an Outbox round trip through the database, where broker headers do not exist yet\"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 `roundTrips…`인데 실제로는 부분 보존 확인이다.\n- **확인 방법.** 왕복 테스트에 `producedAt`·`traceContext` 비교를 추가하면 실패한다.\n- **후보.** (a) 소실 필드를 javadoc에 명시한다. (b) `traceparent`/`tracestate`/`baggage`를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다.\n- **다음 단계.** **REFERENCE 후보**(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다).\n\n##### P3 — `dataschema`가 채워질 경로가 없다\n\n- **사실.** `toCloudEvent`가 `encoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)`로 `dataschema`를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 `SchemaReference.of(subject, version)`로 만들고, 그 factory는 `schemaUri`를 `Optional.empty()`로 둔다.\n- **근거.** `DefaultCloudEventMapper.java:81-86`, `SchemaReference.java:36-38`, 세 codec의 `encode`.\n- **왜 문제인가.** `dataschema`는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. `schemaversion` 확장이 그 자리를 대신하지만 그것은 비표준 확장이다.\n- **확인 방법.** `git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java'` — 3인자 생성자를 부르는 production 코드가 있는지 확인.\n- **후보.** schema registry URI를 갖는 배포에서 `SchemaReference`의 3인자 생성자를 쓰게 하거나, `dataschema` 분기가 현재 도달 불가임을 주석으로 남긴다.\n- **다음 단계.** **OPEN QUESTION 후보.** 판정이 \"이 저장소가 외부 schema registry를 쓸 것인가\"에 걸리고, 그 질문은 `messaging-schema-api`의 `SchemaRegistry` port가 구현 0인 것과 같은 뿌리다.\n\n##### P3 — `CloudEventMapper` javadoc의 범위 제한이 강제되지 않는다\n\n- **사실.** \"Offered for domain and integration events only. Commands and work items are not forced through CloudEvents.\" 코드에 `DestinationKind`를 보는 분기가 없다.\n- **근거.** `CloudEventMapper.java:11-13`, `DefaultCloudEventMapper` 전문.\n- **왜 문제인가.** 소비자가 0이므로 지금은 무해하다. 배선되면 `ASYNC_COMMAND`·`WORK_QUEUE` 봉투도 이 매퍼를 통과한다.\n- **확인 방법.** `git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**'` → 매치 없음.\n- **후보.** 진술을 유지하되 \"호출자 책임\"임을 명시하거나, `toCloudEvent`가 `DestinationKind`를 받아 검사한다.\n- **다음 단계.** **REFERENCE 후보**(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다).\n\n##### 확인된 설계(문제 아님)\n\n- `occurredAt` 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것\n- 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — `MessageEnvelope`의 non-null payload 계약과 정확히 짝을 이룸\n- producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것\n- `cloudevents-api`를 `api`로, `cloudevents-core`를 `implementation`으로 나눈 것과 그 근거 주석\n- `schemaversion` 에러 메시지가 \"by this profile\"이라고 밝히는 것\n- `byte[]` payload를 `clone()`해서 넘기는 것\n- 매퍼가 상태를 갖지 않는 것\n\n---\n\n#### Source anchors\n\n| id | kind | path | revision | what it proves | limitations |\n|---|---|---|---|---|---|\n| MCE-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: [\"app-bootstrap\"]` | 선언 |\n| MCE-002 | build | `messaging-cloudevents/build.gradle` | same | `api`/`implementation` 분리와 그 근거 | — |\n| MCE-003 | build | `messaging-cloudevents/gradle.lockfile:33-34` | same | cloudevents 4.0.1 두 좌표 | — |\n| MCE-004 | code | `.../cloudevents/CloudEventMapper.java` 전문 | same | 계약과 적용 범위 진술 | 범위 미강제(§17) |\n| MCE-005 | code | `.../cloudevents/DefaultCloudEventMapper.java` 전문 | same | §4 전체 매핑표와 두 명시적 결정 | — |\n| MCE-006 | code | `.../cloudevents/CloudEventExtensions.java` | same | 확장 이름 4개와 명명 이유 | — |\n| MCE-007 | test | `CloudEventMappingTest` (7) | same | §10 표 | 외부 producer 입력 없음. 왕복이 5개 필드 미비교 |\n| MCE-008 | cross-leaf code | `messaging-core-api/.../MessageId.java:20-32` | same | UUIDv7 강제의 출처 | 해당 leaf SSOT가 소유 |\n| MCE-009 | cross-leaf code | `messaging-core-api/.../TraceContext.java:11-13` | same | 봉투가 trace를 갖는 이유(§17 왕복 소실) | 해당 leaf SSOT가 소유 |\n| MCE-010 | cross-leaf code | `messaging-schema-api/.../SchemaReference.java:36-38` | same | `of`가 URI를 비움 → `dataschema` 도달 불가 | 해당 leaf SSOT가 소유 |\n| MCE-011 | external spec | CloudEvents 1.0.2, `id` 속성 정의 | — | `id`는 비어 있지 않은 String이며 형식 제약 없음 | 외부 표준. 저장소 밖 지식으로 명시 분리 |\n| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | 세 타입 소비자 0, membership | 정적 검색 |\n| EVD-273 | runtime probe | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | same | 세 id 형태의 실제 결과와 예외 타입 | 세 형태만. 저장소 소스 미수정 |\n| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | same | 7 / 0 / 0 | — |\n\n---\n"
},
"context_range": {
"start_line": 26224,
"end_line": 27412
},
"context_lines": [
{
"line": 26224,
"text": "## A19-MESSAGING-CLAIM-CHECK. messaging-claim-check"
},
{
"line": 26225,
"text": ""
},
{
"line": 26226,
"text": "> 분석 중에는 `messaging/MESSAGING-CLAIM-CHECK.md` 파일이었다. 581줄."
},
{
"line": 26227,
"text": ""
},
{
"line": 26228,
"text": "### messaging-claim-check 완전 해부"
},
{
"line": 26229,
"text": ""
},
{
"line": 26230,
"text": "> 상태: COMPLETE"
},
{
"line": 26231,
"text": "> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`"
},
{
"line": 26232,
"text": "> 분석 범위: `src/messaging/messaging-claim-check`"
},
{
"line": 26233,
"text": "> SSOT owner: `messaging-claim-check`"
},
{
"line": 26234,
"text": "> integration/family document: §A19 (secondary, INTEGRATION_ONLY)"
},
{
"line": 26235,
"text": ""
},
{
"line": 26236,
"text": "---"
},
{
"line": 26237,
"text": ""
},
{
"line": 26238,
"text": "#### 0. SSOT identity / 커버리지와 숫자 지도"
},
{
"line": 26239,
"text": ""
},
{
"line": 26240,
"text": "- registered leaf id: `messaging-claim-check`"
},
{
"line": 26241,
"text": "- canonical state `analysisFile`: §A19-MESSAGING-CLAIM-CHECK"
},
{
"line": 26242,
"text": "- source path: `src/messaging/messaging-claim-check`"
},
{
"line": 26243,
"text": "- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-reliability-api\"]`"
},
{
"line": 26244,
"text": "- registry `runtime_memberships`: **`[\"app-bootstrap\"]`**"
},
{
"line": 26245,
"text": ""
},
{
"line": 26246,
"text": "##### 숫자"
},
{
"line": 26247,
"text": ""
},
{
"line": 26248,
"text": "| 항목 | 수 |"
},
{
"line": 26249,
"text": "|---|---:|"
},
{
"line": 26250,
"text": "| production Java 파일 | 6 |"
},
{
"line": 26251,
"text": "| production LOC | 418 |"
},
{
"line": 26252,
"text": "| 패키지 | 1 (`dev.caskeleton.messaging.claimcheck`) |"
},
{
"line": 26253,
"text": "| test 파일 | 3 |"
},
{
"line": 26254,
"text": "| test 메서드(실행 확인) | 22 |"
},
{
"line": 26255,
"text": "| 외부(비프로젝트) 의존성 | **0** |"
},
{
"line": 26256,
"text": ""
},
{
"line": 26257,
"text": "여섯 타입:"
},
{
"line": 26258,
"text": ""
},
{
"line": 26259,
"text": "| 타입 | 종류 | 역할 | leaf 밖 참조 |"
},
{
"line": 26260,
"text": "|---|---|---|---:|"
},
{
"line": 26261,
"text": "| `ClaimCheckStore` | interface | payload 저장·조회·삭제 port | **0** |"
},
{
"line": 26262,
"text": "| `ClaimCheckPolicy` | record | 문턱과 보존 규칙 | **0** |"
},
{
"line": 26263,
"text": "| `ClaimCheckPublisher` | class | 발행 측 오프로드 결정 | **0** |"
},
{
"line": 26264,
"text": "| `ClaimCheckResolver` | class | 소비 측 조회 + 검증 | **0** |"
},
{
"line": 26265,
"text": "| `ClaimCheckIntegrityGuard` | class | digest·크기·만료 검사 | **0** |"
},
{
"line": 26266,
"text": "| `ClaimCheckIntegrityException` | exception | digest 불일치 | **0** |"
},
{
"line": 26267,
"text": ""
},
{
"line": 26268,
"text": "**여섯 전부 leaf 밖 참조가 0이다.**"
},
{
"line": 26269,
"text": ""
},
{
"line": 26270,
"text": "##### Coverage ledger"
},
{
"line": 26271,
"text": ""
},
{
"line": 26272,
"text": "| scope/file group | count | disposition | reason |"
},
{
"line": 26273,
"text": "|---|---:|---|---|"
},
{
"line": 26274,
"text": "| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |"
},
{
"line": 26275,
"text": "| `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·fake 구현 확인 |"
},
{
"line": 26276,
"text": "| `build.gradle` | 1 | `FULL_READ` | 6줄 |"
},
{
"line": 26277,
"text": "| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |"
},
{
"line": 26278,
"text": "| `build/**` | — | `EXCLUDED` | 빌드 산출물 |"
},
{
"line": 26279,
"text": ""
},
{
"line": 26280,
"text": "`UNCLASSIFIED` 0."
},
{
"line": 26281,
"text": ""
},
{
"line": 26282,
"text": "---"
},
{
"line": 26283,
"text": ""
},
{
"line": 26284,
"text": "#### 1. 모듈의 정체와 경계"
},
{
"line": 26285,
"text": ""
},
{
"line": 26286,
"text": "**Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다."
},
{
"line": 26287,
"text": ""
},
{
"line": 26288,
"text": "`messaging-reliability-api`의 `ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다."
},
{
"line": 26289,
"text": ""
},
{
"line": 26290,
"text": "경계 진술이 두 클래스에 있다."
},
{
"line": 26291,
"text": ""
},
{
"line": 26292,
"text": "```java"
},
{
"line": 26293,
"text": "// ClaimCheckIntegrityGuard.java:14-17"
},
{
"line": 26294,
"text": " * A claim check turns one message into two systems that can drift. The payload store has its own"
},
{
"line": 26295,
"text": " * retention, its own replication, and its own access control, and none of them are coordinated with"
},
{
"line": 26296,
"text": " * the broker's. So a consumer that fetches bytes and decodes them without checking is trusting"
},
{
"line": 26297,
"text": " * something the message never proved."
},
{
"line": 26298,
"text": "```"
},
{
"line": 26299,
"text": ""
},
{
"line": 26300,
"text": "```java"
},
{
"line": 26301,
"text": "// ClaimCheckResolver.java:11-15"
},
{
"line": 26302,
"text": " * Verification is not optional and cannot be skipped by a caller. An object store key is a"
},
{
"line": 26303,
"text": " * string, and a message carrying the wrong one — through a bug, a replay against a rotated bucket,"
},
{
"line": 26304,
"text": " * or a deliberate tamper — fetches bytes that decode perfectly into the wrong object. The digest is"
},
{
"line": 26305,
"text": " * the only thing standing between that and a handler acting on someone else's data."
},
{
"line": 26306,
"text": "```"
},
{
"line": 26307,
"text": ""
},
{
"line": 26308,
"text": "**\"decode perfectly into the wrong object\"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공."
},
{
"line": 26309,
"text": ""
},
{
"line": 26310,
"text": "---"
},
{
"line": 26311,
"text": ""
},
{
"line": 26312,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 26313,
"text": ""
},
{
"line": 26314,
"text": "들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api)."
},
{
"line": 26315,
"text": ""
},
{
"line": 26316,
"text": "나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다."
},
{
"line": 26317,
"text": ""
},
{
"line": 26318,
"text": "**배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다."
},
{
"line": 26319,
"text": ""
},
{
"line": 26320,
"text": "그런데 **`runtime_memberships`가 `[\"app-bootstrap\"]`이다.** starter closure를 통해 배포 아티팩트에 실린다."
},
{
"line": 26321,
"text": ""
},
{
"line": 26322,
"text": "`messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(§A19-MESSAGING-CLOUDEVENTS §12.1)."
},
{
"line": 26323,
"text": ""
},
{
"line": 26324,
"text": "---"
},
{
"line": 26325,
"text": ""
},
{
"line": 26326,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 26327,
"text": ""
},
{
"line": 26328,
"text": "```"
},
{
"line": 26329,
"text": "발행 측"
},
{
"line": 26330,
"text": " ClaimCheckPublisher(store, policy)"
},
{
"line": 26331,
"text": " └── offload(byte[]) → Offloaded(payload, Optional The retention rule is the one that matters. A claim check object deleted while its message is"
},
{
"line": 26367,
"text": " * still deliverable turns a large message into an undeliverable one — the consumer fetches, gets"
},
{
"line": 26368,
"text": " * nothing, and the message dead-letters for a reason that has nothing to do with the message. So"
},
{
"line": 26369,
"text": " * retention must exceed the broker's own retention plus the full retry and dead-letter window, and"
},
{
"line": 26370,
"text": " * the constructor refuses a configuration where it does not."
},
{
"line": 26371,
"text": "```"
},
{
"line": 26372,
"text": ""
},
{
"line": 26373,
"text": "**이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.** 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다."
},
{
"line": 26374,
"text": ""
},
{
"line": 26375,
"text": "문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다."
},
{
"line": 26376,
"text": ""
},
{
"line": 26377,
"text": "```java"
},
{
"line": 26378,
"text": "// :16-18"
},
{
"line": 26379,
"text": " * The threshold is separate from the destination's payload limit. Offloading starts well below"
},
{
"line": 26380,
"text": " * the limit, because the limit is where the broker refuses the message and the threshold is where"
},
{
"line": 26381,
"text": " * carrying it inline stops being a good idea."
},
{
"line": 26382,
"text": "```"
},
{
"line": 26383,
"text": ""
},
{
"line": 26384,
"text": "`DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다."
},
{
"line": 26385,
"text": ""
},
{
"line": 26386,
"text": "`defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유."
},
{
"line": 26387,
"text": ""
},
{
"line": 26388,
"text": "##### 4.2 `ClaimCheckPublisher` — 순서와 미삭제"
},
{
"line": 26389,
"text": ""
},
{
"line": 26390,
"text": "```java"
},
{
"line": 26391,
"text": "// :9-17"
},
{
"line": 26392,
"text": " * The object is written before the message is published, and that order is the whole"
},
{
"line": 26393,
"text": " * design. Publishing first would let a consumer receive a reference to an object that does not"
},
{
"line": 26394,
"text": " * exist yet — a race that is rare in a test and routine under load, because the broker hop is"
},
{
"line": 26395,
"text": " * faster than the object store write."
},
{
"line": 26396,
"text": " *"
},
{
"line": 26397,
"text": " * Nothing here deletes on failure. If the publish is rejected the object is left behind, and the"
},
{
"line": 26398,
"text": " * retention sweep reclaims it; deleting eagerly would delete the object out from under a publish"
},
{
"line": 26399,
"text": " * that turned out to be ambiguous rather than rejected."
},
{
"line": 26400,
"text": "```"
},
{
"line": 26401,
"text": ""
},
{
"line": 26402,
"text": "두 번째가 `messaging-core-api`의 3상태와 직접 연결된다 — `REJECTED`와 `AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다."
},
{
"line": 26403,
"text": ""
},
{
"line": 26404,
"text": "오프로드된 메시지는 payload를 **아예 갖지 않는다**."
},
{
"line": 26405,
"text": ""
},
{
"line": 26406,
"text": "```java"
},
{
"line": 26407,
"text": "// The published message carries no payload bytes at all, only the reference. Carrying both"
},
{
"line": 26408,
"text": "// would double the transfer for no benefit and let the two disagree."
},
{
"line": 26409,
"text": "return new Offloaded(new byte[0], Optional.of(reference));"
},
{
"line": 26410,
"text": "```"
},
{
"line": 26411,
"text": ""
},
{
"line": 26412,
"text": "`Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다."
},
{
"line": 26413,
"text": ""
},
{
"line": 26414,
"text": "**`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다(\"Nothing here deletes on failure\"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다."
},
{
"line": 26415,
"text": ""
},
{
"line": 26416,
"text": "##### 4.3 `ClaimCheckIntegrityGuard` — 세 검사, 전부 fail-closed"
},
{
"line": 26417,
"text": ""
},
{
"line": 26418,
"text": "| 순서 | 검사 | 코드 |"
},
{
"line": 26419,
"text": "|---:|---|---|"
},
{
"line": 26420,
"text": "| 1 | `reference.isExpired(now)` | `CLAIM_CHECK_EXPIRED` |"
},
{
"line": 26421,
"text": "| 2 | `payload.length != reference.sizeBytes()` | `CLAIM_CHECK_SIZE_MISMATCH` |"
},
{
"line": 26422,
"text": "| 3 | `sha256(payload) != reference.sha256()` | `CLAIM_CHECK_DIGEST_MISMATCH` |"
},
{
"line": 26423,
"text": ""
},
{
"line": 26424,
"text": "```java"
},
{
"line": 26425,
"text": "// :19-22"
},
{
"line": 26426,
"text": " * Both checks fail closed. An expired reference is reported before the fetch, because a"
},
{
"line": 26427,
"text": " * not-found from the store is ambiguous between \"reaped\" and \"never written\". A digest mismatch is"
},
{
"line": 26428,
"text": " * reported as validation rather than deserialization, because the bytes are not corrupt JSON — they"
},
{
"line": 26429,
"text": " * are the wrong bytes."
},
{
"line": 26430,
"text": "```"
},
{
"line": 26431,
"text": ""
},
{
"line": 26432,
"text": "크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다."
},
{
"line": 26433,
"text": ""
},
{
"line": 26434,
"text": "`sha256(byte[])`가 `HexFormat.of().formatHex(...)`로 **소문자** hex를 만든다. `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다."
},
{
"line": 26435,
"text": ""
},
{
"line": 26436,
"text": "`verify`가 검증된 payload의 **복사본**을 반환한다."
},
{
"line": 26437,
"text": ""
},
{
"line": 26438,
"text": "##### 4.4 `ClaimCheckResolver` — 만료를 fetch 전에 본다"
},
{
"line": 26439,
"text": ""
},
{
"line": 26440,
"text": "```java"
},
{
"line": 26441,
"text": "if (claimCheck.isExpired(now)) {"
},
{
"line": 26442,
"text": " // Checked before fetching. A store that still returns the object past its retention would"
},
{
"line": 26443,
"text": " // otherwise hide a misconfiguration until the day the sweep caught up."
},
{
"line": 26444,
"text": " throw new MessageValidationException(\"CLAIM_CHECK_EXPIRED\", ...);"
},
{
"line": 26445,
"text": "}"
},
{
"line": 26446,
"text": "```"
},
{
"line": 26447,
"text": ""
},
{
"line": 26448,
"text": "**저장소가 아직 반환하더라도 거절한다.** 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다."
},
{
"line": 26449,
"text": ""
},
{
"line": 26450,
"text": "`fetch`가 `null`을 `CLAIM_CHECK_NOT_FOUND`로 번역하고 메시지가 두 원인을 나열한다 — \"it was either reaped early or never written\"."
},
{
"line": 26451,
"text": ""
},
{
"line": 26452,
"text": "**예외 승격이 코드 접미사로 판정된다.**"
},
{
"line": 26453,
"text": ""
},
{
"line": 26454,
"text": "```java"
},
{
"line": 26455,
"text": "} catch (MessageValidationException validation) {"
},
{
"line": 26456,
"text": " // A size or digest mismatch is a poison message, not a validation failure to be retried:"
},
{
"line": 26457,
"text": " // fetching the same key again returns the same wrong bytes."
},
{
"line": 26458,
"text": " if (validation.failure().code().endsWith(\"_MISMATCH\")) {"
},
{
"line": 26459,
"text": " throw new ClaimCheckIntegrityException("
},
{
"line": 26460,
"text": " validation.failure().code(), validation.failure().sanitizedMessage());"
},
{
"line": 26461,
"text": " }"
},
{
"line": 26462,
"text": " throw validation;"
},
{
"line": 26463,
"text": "}"
},
{
"line": 26464,
"text": "```"
},
{
"line": 26465,
"text": ""
},
{
"line": 26466,
"text": "`endsWith(\"_MISMATCH\")` — **문자열 접미사로 분기한다.** guard가 코드 이름을 바꾸거나 `_MISMATCH`로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17."
},
{
"line": 26467,
"text": ""
},
{
"line": 26468,
"text": "##### 4.5 `ClaimCheckIntegrityException` — 카테고리가 `POISON_MESSAGE`"
},
{
"line": 26469,
"text": ""
},
{
"line": 26470,
"text": "```java"
},
{
"line": 26471,
"text": "// :12-18"
},
{
"line": 26472,
"text": " * Not retryable. A digest mismatch means the object at that key is not the object the producer"
},
{
"line": 26473,
"text": " * wrote — the key was reused, the object was overwritten, or something truncated it — and fetching"
},
{
"line": 26474,
"text": " * it again returns the same wrong bytes. Retrying would only delay the dead-letter."
},
{
"line": 26475,
"text": " *"
},
{
"line": 26476,
"text": " * Deliberately distinct from \"the object is gone\". An expired claim check is an operational"
},
{
"line": 26477,
"text": " * problem with a known cause and a known fix; a digest mismatch means something wrote data nobody"
},
{
"line": 26478,
"text": " * expected, and the two must not be diagnosed as one."
},
{
"line": 26479,
"text": "```"
},
{
"line": 26480,
"text": ""
},
{
"line": 26481,
"text": "`FailureCategory.POISON_MESSAGE`, `retryable = false`. `messaging-core-api`의 `FailureDescriptor.defaultRetryable`이 `POISON_MESSAGE`를 false로 두는 것과 일치한다."
},
{
"line": 26482,
"text": ""
},
{
"line": 26483,
"text": "**이 예외가 `MessagingException`을 확장하는 저장소 내 두 곳 중 하나다**(다른 하나는 core-api 자신의 23개). §A19-MESSAGING-CORE-API §12.1(b)가 그 사실을 관측했다."
},
{
"line": 26484,
"text": ""
},
{
"line": 26485,
"text": "---"
},
{
"line": 26486,
"text": ""
},
{
"line": 26487,
"text": "#### 5. 주요 실행 경로"
},
{
"line": 26488,
"text": ""
},
{
"line": 26489,
"text": "**발행:** `publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put` → `Offloaded(빈 바이트, reference)`"
},
{
"line": 26490,
"text": ""
},
{
"line": 26491,
"text": "**소비:** `resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH`면 `ClaimCheckIntegrityException`"
},
{
"line": 26492,
"text": ""
},
{
"line": 26493,
"text": "두 경로 모두 production에서 호출되지 않는다(§12.1)."
},
{
"line": 26494,
"text": ""
},
{
"line": 26495,
"text": "---"
},
{
"line": 26496,
"text": ""
},
{
"line": 26497,
"text": "#### 6. 실패 경로와 복구/번역"
},
{
"line": 26498,
"text": ""
},
{
"line": 26499,
"text": "| 코드 | 예외 | 카테고리 | retryable | 조건 |"
},
{
"line": 26500,
"text": "|---|---|---|:---:|---|"
},
{
"line": 26501,
"text": "| `CLAIM_CHECK_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | `CONFIGURATION` | false | 정책 생성 시 |"
},
{
"line": 26502,
"text": "| `CLAIM_CHECK_EXPIRED` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 만료 |"
},
{
"line": 26503,
"text": "| `CLAIM_CHECK_NOT_FOUND` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 객체 없음 |"
},
{
"line": 26504,
"text": "| `CLAIM_CHECK_SIZE_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | 크기 불일치 |"
},
{
"line": 26505,
"text": "| `CLAIM_CHECK_DIGEST_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | digest 불일치 |"
},
{
"line": 26506,
"text": ""
},
{
"line": 26507,
"text": "**분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다."
},
{
"line": 26508,
"text": ""
},
{
"line": 26509,
"text": "`ClaimCheckIntegrityGuard.sha256`이 `NoSuchAlgorithmException`을 `IllegalStateException(\"Java runtime does not provide SHA-256\")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다."
},
{
"line": 26510,
"text": ""
},
{
"line": 26511,
"text": "---"
},
{
"line": 26512,
"text": ""
},
{
"line": 26513,
"text": "#### 7. 트랜잭션·동시성·수명주기"
},
{
"line": 26514,
"text": ""
},
{
"line": 26515,
"text": "트랜잭션 없음."
},
{
"line": 26516,
"text": ""
},
{
"line": 26517,
"text": "`ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다."
},
{
"line": 26518,
"text": ""
},
{
"line": 26519,
"text": "`MessageDigest.getInstance(\"SHA-256\")`이 **호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다."
},
{
"line": 26520,
"text": ""
},
{
"line": 26521,
"text": "`ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다."
},
{
"line": 26522,
"text": ""
},
{
"line": 26523,
"text": "수명주기 참여 없음."
},
{
"line": 26524,
"text": ""
},
{
"line": 26525,
"text": "---"
},
{
"line": 26526,
"text": ""
},
{
"line": 26527,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 26528,
"text": ""
},
{
"line": 26529,
"text": "| 상수/기본값 | 값 |"
},
{
"line": 26530,
"text": "|---|---|"
},
{
"line": 26531,
"text": "| `ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES` | 262,144 (1 MiB의 1/4) |"
},
{
"line": 26532,
"text": "| `ClaimCheckPolicy.defaults()` | 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일 |"
},
{
"line": 26533,
"text": ""
},
{
"line": 26534,
"text": "설정 파일 없음. 모든 값이 생성자 인자다."
},
{
"line": 26535,
"text": ""
},
{
"line": 26536,
"text": "---"
},
{
"line": 26537,
"text": ""
},
{
"line": 26538,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 26539,
"text": ""
},
{
"line": 26540,
"text": "`ClaimCheckStore`가 객체 저장소를 가리키는 port다. **구현이 없다** — production에도, 다른 messaging leaf에도."
},
{
"line": 26541,
"text": ""
},
{
"line": 26542,
"text": "저장소의 `adapter/outbound/objectstorage` leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(`messaging-claim-check`의 `allowed_dependencies`에 없고, 반대 방향도 없다)."
},
{
"line": 26543,
"text": ""
},
{
"line": 26544,
"text": "---"
},
{
"line": 26545,
"text": ""
},
{
"line": 26546,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 26547,
"text": ""
},
{
"line": 26548,
"text": "레인: `./gradlew :messaging:messaging-claim-check:test`. **BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures**."
},
{
"line": 26549,
"text": ""
},
{
"line": 26550,
"text": "| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |"
},
{
"line": 26551,
"text": "|---|---:|---|---|"
},
{
"line": 26552,
"text": "| `ClaimCheckIntegrityGuardTest` | 6 | 만료·크기·digest 세 검사 | 실제 저장소 |"
},
{
"line": 26553,
"text": "| `ClaimCheckResolverTest` | 8 | 인라인 통과, 만료 사전 거절, NOT_FOUND, `_MISMATCH` 승격 | **production 호출 여부** |"
},
{
"line": 26554,
"text": "| `ClaimCheckRetentionValidatorTest` | 8 | 보존 불변식과 문턱 판정 | — |"
},
{
"line": 26555,
"text": ""
},
{
"line": 26556,
"text": "`ClaimCheckStore`의 유일한 구현이 `ClaimCheckResolverTest:22`의 `FakeStore`다. 즉 **이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.**"
},
{
"line": 26557,
"text": ""
},
{
"line": 26558,
"text": "`ClaimCheckPublisher`를 겨냥한 테스트 클래스가 **없다.** 오프로드 결정·객체 선기록 순서·`Offloaded`의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다."
},
{
"line": 26559,
"text": ""
},
{
"line": 26560,
"text": "---"
},
{
"line": 26561,
"text": ""
},
{
"line": 26562,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 26563,
"text": ""
},
{
"line": 26564,
"text": "| 게이트 | 이 leaf에 대해 |"
},
{
"line": 26565,
"text": "|---|---|"
},
{
"line": 26566,
"text": "| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-reliability-api\"]` |"
},
{
"line": 26567,
"text": "| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |"
},
{
"line": 26568,
"text": "| vendor `api` 규칙 | 벤더 의존성 0 |"
},
{
"line": 26569,
"text": "| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |"
},
{
"line": 26570,
"text": "| ArchUnit | 전용 규칙 없음 |"
},
{
"line": 26571,
"text": ""
},
{
"line": 26572,
"text": "---"
},
{
"line": 26573,
"text": ""
},
{
"line": 26574,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 26575,
"text": ""
},
{
"line": 26576,
"text": "원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt`."
},
{
"line": 26577,
"text": ""
},
{
"line": 26578,
"text": "##### 12.1 Public surface reachability"
},
{
"line": 26579,
"text": ""
},
{
"line": 26580,
"text": "**여섯 타입 전부 leaf 밖 참조 0이다.**"
},
{
"line": 26581,
"text": ""
},
{
"line": 26582,
"text": "| 타입 | leaf 밖 |"
},
{
"line": 26583,
"text": "|---|---:|"
},
{
"line": 26584,
"text": "| `ClaimCheckStore` | 0 |"
},
{
"line": 26585,
"text": "| `ClaimCheckPolicy` | 0 |"
},
{
"line": 26586,
"text": "| `ClaimCheckPublisher` | 0 |"
},
{
"line": 26587,
"text": "| `ClaimCheckResolver` | 0 |"
},
{
"line": 26588,
"text": "| `ClaimCheckIntegrityGuard` | 0 |"
},
{
"line": 26589,
"text": "| `ClaimCheckIntegrityException` | 0 |"
},
{
"line": 26590,
"text": ""
},
{
"line": 26591,
"text": "`ClaimCheckStore` 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다."
},
{
"line": 26592,
"text": ""
},
{
"line": 26593,
"text": "**그런데 이 leaf는 배포 아티팩트에 실린다.**"
},
{
"line": 26594,
"text": ""
},
{
"line": 26595,
"text": "```"
},
{
"line": 26596,
"text": "messaging-claim-check runtime_memberships=['app-bootstrap']"
},
{
"line": 26597,
"text": "messaging-spring-boot-starter runtime_memberships=['app-bootstrap']"
},
{
"line": 26598,
"text": " starter deps include claim-check: True"
},
{
"line": 26599,
"text": "```"
},
{
"line": 26600,
"text": ""
},
{
"line": 26601,
"text": "`messaging-cloudevents`와 같은 조합이다. 형제 비교:"
},
{
"line": 26602,
"text": ""
},
{
"line": 26603,
"text": "| leaf | 소비자 | membership | 정합 |"
},
{
"line": 26604,
"text": "|---|:---:|---|---|"
},
{
"line": 26605,
"text": "| `messaging-schema-avro` | 0 | `[]` | o |"
},
{
"line": 26606,
"text": "| `messaging-schema-protobuf` | 0 | `[]` | o |"
},
{
"line": 26607,
"text": "| `messaging-kafka-share-experimental` | 0 | `[]` | o |"
},
{
"line": 26608,
"text": "| **`messaging-cloudevents`** | **0** | **`[\"app-bootstrap\"]`** | **x** |"
},
{
"line": 26609,
"text": "| **`messaging-claim-check`** | **0** | **`[\"app-bootstrap\"]`** | **x** |"
},
{
"line": 26610,
"text": ""
},
{
"line": 26611,
"text": "**\"싣고 쓰지 않는\" leaf가 둘이다.** 오늘 실행되는 코드가 없으므로 사고는 아니다."
},
{
"line": 26612,
"text": ""
},
{
"line": 26613,
"text": "**한 가지 정황이 이 leaf를 다르게 만든다.** `messaging-policy`의 `PayloadPolicy`가 `claimCheckThresholdBytes` 필드를 갖고, `DestinationProfileValidator`가 그 값을 검사한다(`:49`). 즉 **목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다.** payload가 문턱을 넘어도 오프로드되지 않고, `PayloadLimitGuard`가 상한 초과로 거절한다 — `MessageTooLargeException(\"PAYLOAD_LIMIT_EXCEEDED\", \"... use claim check\")`. **에러 메시지가 존재하지 않는 경로를 권한다.**"
},
{
"line": 26614,
"text": ""
},
{
"line": 26615,
"text": "##### 12.2 Conditional sibling comparison"
},
{
"line": 26616,
"text": ""
},
{
"line": 26617,
"text": "Spring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다."
},
{
"line": 26618,
"text": ""
},
{
"line": 26619,
"text": "`messaging-reliability-api`의 세 port 중 둘(`OutboxRepository`, `InboxRepository`)은 구현 leaf와 starter bean을 갖고 `ClaimCheckStore`는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다."
},
{
"line": 26620,
"text": ""
},
{
"line": 26621,
"text": "##### 12.3 Duplicate mechanism sweep"
},
{
"line": 26622,
"text": ""
},
{
"line": 26623,
"text": "**(a) claim check 문턱이 두 곳에 있고 서로를 모른다**"
},
{
"line": 26624,
"text": ""
},
{
"line": 26625,
"text": "| 위치 | 필드 | 검사 |"
},
{
"line": 26626,
"text": "|---|---|---|"
},
{
"line": 26627,
"text": "| `messaging-policy` `PayloadPolicy` | `claimCheckThresholdBytes` | `DestinationProfileValidator:49`가 `<= maxBytes` 확인 |"
},
{
"line": 26628,
"text": "| 이 leaf `ClaimCheckPolicy` | `thresholdBytes` | 생성자가 `>= 1` 확인 |"
},
{
"line": 26629,
"text": ""
},
{
"line": 26630,
"text": "**두 값을 대조하는 코드가 없다.** 목적지 프로파일이 문턱 512 KiB를 선언하고 `ClaimCheckPolicy`가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다."
},
{
"line": 26631,
"text": ""
},
{
"line": 26632,
"text": "**(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다**"
},
{
"line": 26633,
"text": ""
},
{
"line": 26634,
"text": "| 규칙 | 위치 | 강제 |"
},
{
"line": 26635,
"text": "|---|---|---|"
},
{
"line": 26636,
"text": "| claim check 보존 ≥ 브로커 보존 + 재전달 창 | `ClaimCheckPolicy` 생성자 | **강제됨** |"
},
{
"line": 26637,
"text": "| inbox 보존 > 브로커 최대 재전달 창 | `InboxRepository` javadoc | **문서만** |"
},
{
"line": 26638,
"text": ""
},
{
"line": 26639,
"text": "같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. §A19-MESSAGING-RELIABILITY-API §17이 후자를 소유한다."
},
{
"line": 26640,
"text": ""
},
{
"line": 26641,
"text": "**(c) digest 계산이 저장소에 여럿 있는가**"
},
{
"line": 26642,
"text": ""
},
{
"line": 26643,
"text": "`MessageDigest.getInstance(\"SHA-256\")`을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님."
},
{
"line": 26644,
"text": ""
},
{
"line": 26645,
"text": "**(d) `_MISMATCH` 접미사 분기**"
},
{
"line": 26646,
"text": ""
},
{
"line": 26647,
"text": "`ClaimCheckResolver.verify`가 `validation.failure().code().endsWith(\"_MISMATCH\")`로 예외를 승격한다. `ClaimCheckIntegrityGuard`의 코드 셋 중 둘이 그 접미사를 갖고 하나(`CLAIM_CHECK_EXPIRED`)가 갖지 않는다. **문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다.** §17."
},
{
"line": 26648,
"text": ""
},
{
"line": 26649,
"text": "##### 12.4 Documentation / measured-count drift"
},
{
"line": 26650,
"text": ""
},
{
"line": 26651,
"text": "| 문서 주장 | 재측정 | 결과 |"
},
{
"line": 26652,
"text": "|---|---|---|"
},
{
"line": 26653,
"text": "| `ClaimCheckPolicy` javadoc: 문턱이 \"a quarter of the portable payload limit\" | 262,144 = 1,048,576 / 4 | **일치** |"
},
{
"line": 26654,
"text": "| `ClaimCheckPublisher` javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 | 이 leaf에 sweep 없음 | **미실현** |"
},
{
"line": 26655,
"text": "| `ClaimCheckIntegrityGuard` javadoc: 두 검사가 fail closed | 세 검사 전부 예외 | **일치**(검사가 셋인데 javadoc은 \"Both\") |"
},
{
"line": 26656,
"text": "| `PayloadLimitGuard` 에러 메시지: \"use claim check\" | claim check 경로 미배선 | **불일치** |"
},
{
"line": 26657,
"text": "| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |"
},
{
"line": 26658,
"text": ""
},
{
"line": 26659,
"text": "세 번째가 작은 표현 drift다 — javadoc이 \"Both checks fail closed\"라고 하는데 `verify`는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다."
},
{
"line": 26660,
"text": ""
},
{
"line": 26661,
"text": "---"
},
{
"line": 26662,
"text": ""
},
{
"line": 26663,
"text": "#### 13. Git/설계 문서에서 확인한 변화와 실패 기록"
},
{
"line": 26664,
"text": ""
},
{
"line": 26665,
"text": "이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다."
},
{
"line": 26666,
"text": ""
},
{
"line": 26667,
"text": "| 위치 | 막으려는 것 |"
},
{
"line": 26668,
"text": "|---|---|"
},
{
"line": 26669,
"text": "| `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. \"rare in a test and routine under load\" |"
},
{
"line": 26670,
"text": "| `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 |"
},
{
"line": 26671,
"text": "| `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 |"
},
{
"line": 26672,
"text": "| `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 |"
},
{
"line": 26673,
"text": "| `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter |"
},
{
"line": 26674,
"text": "| `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 |"
},
{
"line": 26675,
"text": ""
},
{
"line": 26676,
"text": "**\"rare in a test and routine under load\"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다."
},
{
"line": 26677,
"text": ""
},
{
"line": 26678,
"text": "---"
},
{
"line": 26679,
"text": ""
},
{
"line": 26680,
"text": "#### 14. 런타임·터미널 Evidence"
},
{
"line": 26681,
"text": ""
},
{
"line": 26682,
"text": "| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |"
},
{
"line": 26683,
"text": "|---|---|---|---|---|"
},
{
"line": 26684,
"text": "| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §A·§B | 여섯 타입 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 | 정적 검색 |"
},
{
"line": 26685,
"text": "| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | BUILD SUCCESSFUL, 22 / 0 / 0 | 저장소가 fake. publisher 미검증 |"
},
{
"line": 26686,
"text": ""
},
{
"line": 26687,
"text": "---"
},
{
"line": 26688,
"text": ""
},
{
"line": 26689,
"text": "#### 15. 명시적 설계 이유와 추론을 구분한 정리"
},
{
"line": 26690,
"text": ""
},
{
"line": 26691,
"text": "**명시적**"
},
{
"line": 26692,
"text": ""
},
{
"line": 26693,
"text": "- 두 시스템이 drift한다는 위협 모델 — `ClaimCheckIntegrityGuard` javadoc"
},
{
"line": 26694,
"text": "- 검증이 선택 불가인 이유 — `ClaimCheckResolver` javadoc"
},
{
"line": 26695,
"text": "- 저장이 발행보다 먼저인 이유 — `ClaimCheckPublisher` javadoc"
},
{
"line": 26696,
"text": "- 실패 시 삭제하지 않는 이유 — 같은 javadoc"
},
{
"line": 26697,
"text": "- payload와 참조를 함께 나르지 않는 이유 — 인라인 주석"
},
{
"line": 26698,
"text": "- 만료를 fetch 전에 보는 이유 — `resolve` 인라인 주석"
},
{
"line": 26699,
"text": "- 보존 규칙과 그것을 생성자가 강제하는 이유 — `ClaimCheckPolicy` javadoc"
},
{
"line": 26700,
"text": "- 문턱과 목적지 상한이 다른 이유 — 같은 javadoc"
},
{
"line": 26701,
"text": "- digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — `ClaimCheckIntegrityException` javadoc"
},
{
"line": 26702,
"text": "- `_MISMATCH` 승격이 poison message인 이유 — `verify` 인라인 주석"
},
{
"line": 26703,
"text": ""
},
{
"line": 26704,
"text": "**추론**"
},
{
"line": 26705,
"text": ""
},
{
"line": 26706,
"text": "- 배선되지 않은 것이 미완인지 확장점인지 → **미상**. `ClaimCheckStore` 구현이 없다는 관측만 있다."
},
{
"line": 26707,
"text": "- `_MISMATCH` 접미사 규약이 의도인지 → **미상**. 선언된 곳이 없다."
},
{
"line": 26708,
"text": "- javadoc의 \"Both checks\"가 세 검사가 되기 전 표현인지 → **추론**."
},
{
"line": 26709,
"text": ""
},
{
"line": 26710,
"text": "---"
},
{
"line": 26711,
"text": ""
},
{
"line": 26712,
"text": "#### 16. 확인한 것 / 확인하지 못한 것"
},
{
"line": 26713,
"text": ""
},
{
"line": 26714,
"text": "**확인한 것**"
},
{
"line": 26715,
"text": ""
},
{
"line": 26716,
"text": "- 6개 타입 418줄 전문의 계약"
},
{
"line": 26717,
"text": "- 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 `ClaimCheckPublisher`가 미검증이라는 것"
},
{
"line": 26718,
"text": "- 여섯 타입 전부 leaf 밖 참조 0이고 `ClaimCheckStore` 구현이 테스트 fake뿐이라는 것"
},
{
"line": 26719,
"text": "- `runtime_memberships`가 `[\"app-bootstrap\"]`이라 배포 아티팩트에 실린다는 것"
},
{
"line": 26720,
"text": "- `PayloadLimitGuard`의 에러 메시지가 배선되지 않은 경로를 권한다는 것"
},
{
"line": 26721,
"text": "- 문턱이 두 곳에 있고 대조되지 않는다는 것"
},
{
"line": 26722,
"text": ""
},
{
"line": 26723,
"text": "**확인하지 못한 것**"
},
{
"line": 26724,
"text": ""
},
{
"line": 26725,
"text": "- **`ClaimCheckStore`를 구현할 계획이 있는지.** `adapter/outbound/objectstorage`가 후보이지만 두 leaf가 registry에서 연결되지 않는다."
},
{
"line": 26726,
"text": "- 보존 sweep을 누가 도는지 — `ClaimCheckStore.delete`의 호출자가 없다."
},
{
"line": 26727,
"text": "- 실제 객체 저장소에서 `store.get`이 만료 후에도 반환하는지 — `resolve`의 사전 만료 검사가 그 경우를 상정한다."
},
{
"line": 26728,
"text": "- 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가."
},
{
"line": 26729,
"text": ""
},
{
"line": 26730,
"text": "---"
},
{
"line": 26731,
"text": ""
},
{
"line": 26732,
"text": "#### 17. 손볼 것"
},
{
"line": 26733,
"text": ""
},
{
"line": 26734,
"text": "##### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다"
},
{
"line": 26735,
"text": ""
},
{
"line": 26736,
"text": "- **사실.** 여섯 타입 전부 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\"`라고 안내한다."
},
{
"line": 26737,
"text": "- **근거.** `evidence/raw/290` §A. `PayloadLimitGuard.java:46-49`."
},
{
"line": 26738,
"text": "- **왜 문제인가.** 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 `DestinationProfile`이 `claimCheckThresholdBytes`를 선언하고 검증까지 하므로 **설정 표면은 존재한다.** 설정할 수 있고 아무 효과가 없는 값이다."
},
{
"line": 26739,
"text": "- **확인 방법.** `evidence/raw/290` §A 재실행. `git grep -n 'use claim check' -- src`."
},
{
"line": 26740,
"text": "- **후보.** (a) `ClaimCheckStore` 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 `[]`로 되돌리고 `PayloadLimitGuard` 메시지에서 안내를 뺀다. (c) 미완임을 `support-matrix.md`에 표시한다."
},
{
"line": 26741,
"text": "- **다음 단계.** **CASE 후보.** `messaging-cloudevents` §17의 \"싣고 쓰지 않는다\"와 같은 계열이지만, 여기서는 **다른 컴포넌트가 이 경로를 권한다**는 점이 추가된다."
},
{
"line": 26742,
"text": ""
},
{
"line": 26743,
"text": "##### P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다"
},
{
"line": 26744,
"text": ""
},
{
"line": 26745,
"text": "- **사실.** `messaging-policy`의 `PayloadPolicy.claimCheckThresholdBytes`(목적지별, `DestinationProfileValidator:49`가 검사)와 이 leaf의 `ClaimCheckPolicy.thresholdBytes`(전역). 두 값을 대조하는 코드가 없다."
},
{
"line": 26746,
"text": "- **근거.** `evidence/raw/290` §B."
},
{
"line": 26747,
"text": "- **왜 문제인가.** 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다."
},
{
"line": 26748,
"text": "- **확인 방법.** 두 필드와 검증기 확인."
},
{
"line": 26749,
"text": "- **후보.** `ClaimCheckPublisher`가 목적지 프로파일의 값을 읽거나, `PayloadPolicy`에서 그 필드를 제거한다."
},
{
"line": 26750,
"text": "- **다음 단계.** **REFERENCE 후보**(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다)."
},
{
"line": 26751,
"text": ""
},
{
"line": 26752,
"text": "##### P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다"
},
{
"line": 26753,
"text": ""
},
{
"line": 26754,
"text": "- **사실.** `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith(\"_MISMATCH\")`로 `ClaimCheckIntegrityException` 승격을 결정한다. `ClaimCheckIntegrityGuard`의 세 코드 중 둘이 그 접미사를 갖는다."
},
{
"line": 26755,
"text": "- **근거.** `ClaimCheckResolver.java:84`."
},
{
"line": 26756,
"text": "- **왜 문제인가.** 두 클래스 사이의 계약이 **문자열 명명 규약**이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: `CLAIM_CHECK_DIGEST_INVALID`) 승격이 조용히 멈추고 poison message가 `PERMANENT_BUSINESS`로 분류된다 — 재시도 정책이 달라진다."
},
{
"line": 26757,
"text": "- **확인 방법.** `git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check`"
},
{
"line": 26758,
"text": "- **후보.** guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다."
},
{
"line": 26759,
"text": "- **다음 단계.** **CASE 후보 + REFERENCE 후보**(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다)."
},
{
"line": 26760,
"text": ""
},
{
"line": 26761,
"text": "##### P3 — `ClaimCheckPublisher`가 이 leaf의 테스트에 등장하지 않는다"
},
{
"line": 26762,
"text": ""
},
{
"line": 26763,
"text": "- **사실.** 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다."
},
{
"line": 26764,
"text": "- **근거.** `find src/test -name '*Test.java'` → 셋."
},
{
"line": 26765,
"text": "- **왜 문제인가.** publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(`shouldOffload`), 오프로드 시 payload를 비우는 것, `Offloaded`의 양방향 방어 복사. 특히 \"저장이 발행보다 먼저\"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다."
},
{
"line": 26766,
"text": "- **확인 방법.** 세 테스트 클래스 이름 확인."
},
{
"line": 26767,
"text": "- **후보.** `ClaimCheckPublisherTest`를 추가한다."
},
{
"line": 26768,
"text": "- **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다)."
},
{
"line": 26769,
"text": ""
},
{
"line": 26770,
"text": "##### P3 — 보존 sweep이 없다"
},
{
"line": 26771,
"text": ""
},
{
"line": 26772,
"text": "- **사실.** `ClaimCheckStore.delete`가 선언돼 있고 이 leaf에서 호출되지 않는다. `ClaimCheckPublisher` javadoc이 \"the retention sweep reclaims it\"이라고 그 존재를 전제한다."
},
{
"line": 26773,
"text": "- **근거.** `git grep -n 'delete(' -- src/messaging/messaging-claim-check` → 인터페이스 선언만."
},
{
"line": 26774,
"text": "- **왜 문제인가.** 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 `ClaimCheckPolicy.retention`이 그것과 연결되지 않는다."
},
{
"line": 26775,
"text": "- **확인 방법.** `delete` 호출자 검색."
},
{
"line": 26776,
"text": "- **후보.** sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다."
},
{
"line": 26777,
"text": "- **다음 단계.** **OPEN QUESTION 후보.** 판정이 `ClaimCheckStore` 구현 계획에 걸린다."
},
{
"line": 26778,
"text": ""
},
{
"line": 26779,
"text": "##### 확인된 설계(문제 아님)"
},
{
"line": 26780,
"text": ""
},
{
"line": 26781,
"text": "- 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것"
},
{
"line": 26782,
"text": "- 문턱과 목적지 상한을 분리하고 그 이유를 적은 것"
},
{
"line": 26783,
"text": "- 객체를 발행보다 먼저 저장하는 순서"
},
{
"line": 26784,
"text": "- 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것"
},
{
"line": 26785,
"text": "- 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것"
},
{
"line": 26786,
"text": "- 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것"
},
{
"line": 26787,
"text": "- 크기 검사를 digest보다 먼저 두는 것"
},
{
"line": 26788,
"text": "- 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것"
},
{
"line": 26789,
"text": "- `MessageDigest`를 호출마다 새로 만드는 것"
},
{
"line": 26790,
"text": ""
},
{
"line": 26791,
"text": "---"
},
{
"line": 26792,
"text": ""
},
{
"line": 26793,
"text": "#### Source anchors"
},
{
"line": 26794,
"text": ""
},
{
"line": 26795,
"text": "| id | kind | path | revision | what it proves | limitations |"
},
{
"line": 26796,
"text": "|---|---|---|---|---|---|"
},
{
"line": 26797,
"text": "| MCC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `[\"app-bootstrap\"]` | 선언 |"
},
{
"line": 26798,
"text": "| MCC-002 | build | `messaging-claim-check/build.gradle` | same | 벤더 의존성 0 | — |"
},
{
"line": 26799,
"text": "| MCC-003 | code | `.../claimcheck/ClaimCheckPolicy.java` | same | §4.1 보존 불변식과 문턱 | — |"
},
{
"line": 26800,
"text": "| MCC-004 | code | `.../claimcheck/ClaimCheckPublisher.java` | same | §4.2 순서·미삭제·빈 payload | 전용 테스트 없음 |"
},
{
"line": 26801,
"text": "| MCC-005 | code | `.../claimcheck/ClaimCheckIntegrityGuard.java` | same | §4.3 세 검사 | — |"
},
{
"line": 26802,
"text": "| MCC-006 | code | `.../claimcheck/ClaimCheckResolver.java` | same | §4.4 사전 만료 확인, 접미사 승격 | 접미사 의존(§17) |"
},
{
"line": 26803,
"text": "| MCC-007 | code | `.../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java` | same | port 계약, POISON_MESSAGE 분류 | 구현 없음 |"
},
{
"line": 26804,
"text": "| MCC-008 | test | 3 클래스 / 22 테스트 | same | §10 표 | fake 저장소. publisher 미검증 |"
},
{
"line": 26805,
"text": "| MCC-009 | cross-leaf code | `messaging-policy/.../PayloadLimitGuard.java:46-49` | same | \"use claim check\" 안내 | 해당 leaf SSOT가 소유 |"
},
{
"line": 26806,
"text": "| MCC-010 | cross-leaf code | `messaging-policy/.../PayloadPolicy.java:14`, `DestinationProfileValidator.java:49` | same | 두 번째 문턱과 그 검증 | 해당 leaf SSOT가 소유 |"
},
{
"line": 26807,
"text": "| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` | same | §12.1·§12.3 | 정적 검색 |"
},
{
"line": 26808,
"text": "| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | same | 22 / 0 / 0 | — |"
},
{
"line": 26809,
"text": ""
},
{
"line": 26810,
"text": "---"
},
{
"line": 26811,
"text": ""
},
{
"line": 26812,
"text": "## A19-MESSAGING-CLOUDEVENTS. messaging-cloudevents"
},
{
"line": 26813,
"text": ""
},
{
"line": 26814,
"text": "> 분석 중에는 `messaging/MESSAGING-CLOUDEVENTS.md` 파일이었다. 594줄."
},
{
"line": 26815,
"text": ""
},
{
"line": 26816,
"text": "### messaging-cloudevents 완전 해부"
},
{
"line": 26817,
"text": ""
},
{
"line": 26818,
"text": "> 상태: COMPLETE"
},
{
"line": 26819,
"text": "> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`"
},
{
"line": 26820,
"text": "> 분석 범위: `src/messaging/messaging-cloudevents`"
},
{
"line": 26821,
"text": "> SSOT owner: `messaging-cloudevents`"
},
{
"line": 26822,
"text": "> integration/family document: §A19 (secondary, INTEGRATION_ONLY)"
},
{
"line": 26823,
"text": ""
},
{
"line": 26824,
"text": "---"
},
{
"line": 26825,
"text": ""
},
{
"line": 26826,
"text": "#### 0. SSOT identity / 커버리지와 숫자 지도"
},
{
"line": 26827,
"text": ""
},
{
"line": 26828,
"text": "- registered leaf id: `messaging-cloudevents`"
},
{
"line": 26829,
"text": "- canonical state `analysisFile`: §A19-MESSAGING-CLOUDEVENTS"
},
{
"line": 26830,
"text": "- source path: `src/messaging/messaging-cloudevents`"
},
{
"line": 26831,
"text": "- registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`"
},
{
"line": 26832,
"text": "- registry `runtime_memberships`: **`[\"app-bootstrap\"]`**"
},
{
"line": 26833,
"text": ""
},
{
"line": 26834,
"text": "##### 숫자"
},
{
"line": 26835,
"text": ""
},
{
"line": 26836,
"text": "| 항목 | 수 |"
},
{
"line": 26837,
"text": "|---|---:|"
},
{
"line": 26838,
"text": "| production Java 파일 | 3 |"
},
{
"line": 26839,
"text": "| production LOC | 228 |"
},
{
"line": 26840,
"text": "| 패키지 | 1 (`dev.caskeleton.messaging.cloudevents`) |"
},
{
"line": 26841,
"text": "| test 파일 | 1 |"
},
{
"line": 26842,
"text": "| test 메서드(실행 확인) | 7 |"
},
{
"line": 26843,
"text": "| 외부 의존성 | 2 (`cloudevents-api:4.0.1` **api**, `cloudevents-core:4.0.1` implementation) |"
},
{
"line": 26844,
"text": ""
},
{
"line": 26845,
"text": "세 타입: `CloudEventMapper`(인터페이스), `DefaultCloudEventMapper`(구현), `CloudEventExtensions`(확장 속성 이름 4개)."
},
{
"line": 26846,
"text": ""
},
{
"line": 26847,
"text": "##### Coverage ledger"
},
{
"line": 26848,
"text": ""
},
{
"line": 26849,
"text": "| scope/file group | count | disposition | reason |"
},
{
"line": 26850,
"text": "|---|---:|---|---|"
},
{
"line": 26851,
"text": "| `.../cloudevents/CloudEventMapper.java` | 1 | `FULL_READ` | 33줄 전문 |"
},
{
"line": 26852,
"text": "| `.../cloudevents/DefaultCloudEventMapper.java` | 1 | `FULL_READ` | 171줄 전문 |"
},
{
"line": 26853,
"text": "| `.../cloudevents/CloudEventExtensions.java` | 1 | `FULL_READ` | 24줄 전문 |"
},
{
"line": 26854,
"text": "| `src/test/java/**` | 1 | `FULL_READ` | 162줄 전문 |"
},
{
"line": 26855,
"text": "| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |"
},
{
"line": 26856,
"text": "| `gradle.lockfile` | 1 | `FULL_READ` | cloudevents 좌표 2건 확인 |"
},
{
"line": 26857,
"text": "| `build/**` | — | `EXCLUDED` | 빌드 산출물 |"
},
{
"line": 26858,
"text": ""
},
{
"line": 26859,
"text": "`UNCLASSIFIED` 0."
},
{
"line": 26860,
"text": ""
},
{
"line": 26861,
"text": "---"
},
{
"line": 26862,
"text": ""
},
{
"line": 26863,
"text": "#### 1. 모듈의 정체와 경계"
},
{
"line": 26864,
"text": ""
},
{
"line": 26865,
"text": "플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼."
},
{
"line": 26866,
"text": ""
},
{
"line": 26867,
"text": "적용 범위를 인터페이스 javadoc이 한정한다."
},
{
"line": 26868,
"text": ""
},
{
"line": 26869,
"text": "```java"
},
{
"line": 26870,
"text": "// CloudEventMapper.java:11-13"
},
{
"line": 26871,
"text": " * Offered for domain and integration events only. Commands and work items are not forced through"
},
{
"line": 26872,
"text": " * CloudEvents: they are internal contracts where the interoperability the specification buys does"
},
{
"line": 26873,
"text": " * not pay for the attributes it requires."
},
{
"line": 26874,
"text": "```"
},
{
"line": 26875,
"text": ""
},
{
"line": 26876,
"text": "의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다."
},
{
"line": 26877,
"text": ""
},
{
"line": 26878,
"text": "```groovy"
},
{
"line": 26879,
"text": "// api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent."
},
{
"line": 26880,
"text": "//"
},
{
"line": 26881,
"text": "// Declared `implementation`, the type appeared in this module's public API while the"
},
{
"line": 26882,
"text": "// dependency was hidden from consumers: an adopter calling the documented method could not"
},
{
"line": 26883,
"text": "// name its return type without adding CloudEvents to their own build, and Gradle gave them no"
},
{
"line": 26884,
"text": "// hint why. A type in a public signature is part of the artifact's contract."
},
{
"line": 26885,
"text": "api 'io.cloudevents:cloudevents-api:4.0.1'"
},
{
"line": 26886,
"text": "implementation 'io.cloudevents:cloudevents-core:4.0.1'"
},
{
"line": 26887,
"text": "```"
},
{
"line": 26888,
"text": ""
},
{
"line": 26889,
"text": "**둘의 scope가 다른 것이 정확하다.** `cloudevents-api`(`CloudEvent`, `CloudEventData`)는 public 시그니처에 나오므로 `api`, `cloudevents-core`(`CloudEventBuilder`, `BytesCloudEventData`)는 구현 안에서만 쓰이므로 `implementation`이다. `src/messaging/CLAUDE.md:40-43`의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다."
},
{
"line": 26890,
"text": ""
},
{
"line": 26891,
"text": "**이 leaf의 위치가 형제들과 다르다.** `runtime_memberships`가 `[\"app-bootstrap\"]`이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 \"싣지도 않고 쓰지도 않는다\"로 정합하지만, 이 leaf는 **싣고 쓰지 않는다.**"
},
{
"line": 26892,
"text": ""
},
{
"line": 26893,
"text": "---"
},
{
"line": 26894,
"text": ""
},
{
"line": 26895,
"text": "#### 2. 의존성과 런타임 배선"
},
{
"line": 26896,
"text": ""
},
{
"line": 26897,
"text": "들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `cloudevents-api:4.0.1`(api), `cloudevents-core:4.0.1`(implementation)."
},
{
"line": 26898,
"text": ""
},
{
"line": 26899,
"text": "나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다. 그래서 `app-bootstrap` → starter → 이 leaf 경로로 런타임 classpath에 오른다."
},
{
"line": 26900,
"text": ""
},
{
"line": 26901,
"text": "**그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다.** starter의 어느 `@Bean`도 `CloudEventMapper`를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1)."
},
{
"line": 26902,
"text": ""
},
{
"line": 26903,
"text": "bean 없음(Spring 주석 0개)."
},
{
"line": 26904,
"text": ""
},
{
"line": 26905,
"text": "---"
},
{
"line": 26906,
"text": ""
},
{
"line": 26907,
"text": "#### 3. 패키지/컴포넌트 지도"
},
{
"line": 26908,
"text": ""
},
{
"line": 26909,
"text": "```"
},
{
"line": 26910,
"text": "CloudEventMapper (interface)"
},
{
"line": 26911,
"text": "├── toCloudEvent(MessageEnvelope>, URI) → CloudEvent"
},
{
"line": 26912,
"text": "└── fromCloudEvent(CloudEvent) → MessageEnvelope Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather"
},
{
"line": 26971,
"text": " * than defaulted to the production instant, because {@code time} is read downstream as when the"
},
{
"line": 26972,
"text": " * fact happened, not when the platform got around to serialising it. And an event with no data maps"
},
{
"line": 26973,
"text": " * to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and"
},
{
"line": 26974,
"text": " * inventing one from an absent CloudEvent payload would turn an empty notification into a deletion."
},
{
"line": 26975,
"text": "```"
},
{
"line": 26976,
"text": ""
},
{
"line": 26977,
"text": "두 번째는 `messaging-core-api`의 `MessageEnvelope` javadoc과 정확히 짝을 이룬다 — \"A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics.\" 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다."
},
{
"line": 26978,
"text": ""
},
{
"line": 26979,
"text": "##### 4.3 `producerFrom`: 무한 URI를 유한 이름으로"
},
{
"line": 26980,
"text": ""
},
{
"line": 26981,
"text": "```java"
},
{
"line": 26982,
"text": "// :158-163"
},
{
"line": 26983,
"text": " * The last path or scheme-specific segment is used so that a long URI does not become an"
},
{
"line": 26984,
"text": " * unbounded producer name, which would leak straight into metric tags."
},
{
"line": 26985,
"text": "private static String producerFrom(URI source) {"
},
{
"line": 26986,
"text": " String text = source.toString();"
},
{
"line": 26987,
"text": " int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));"
},
{
"line": 26988,
"text": " String candidate ="
},
{
"line": 26989,
"text": " separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;"
},
{
"line": 26990,
"text": " return candidate.isBlank() ? \"unknown\" : candidate;"
},
{
"line": 26991,
"text": "}"
},
{
"line": 26992,
"text": "```"
},
{
"line": 26993,
"text": ""
},
{
"line": 26994,
"text": "`ProducerId`가 \"deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags\"라고 선언한 것과 같은 관심사다."
},
{
"line": 26995,
"text": ""
},
{
"line": 26996,
"text": "**다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5."
},
{
"line": 26997,
"text": ""
},
{
"line": 26998,
"text": "`urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`."
},
{
"line": 26999,
"text": ""
},
{
"line": 27000,
"text": "##### 4.4 `time`이 두 필드로 복제된다"
},
{
"line": 27001,
"text": ""
},
{
"line": 27002,
"text": "```java"
},
{
"line": 27003,
"text": "Instant occurredAt = time.toInstant();"
},
{
"line": 27004,
"text": "return new MessageEnvelope<>("
},
{
"line": 27005,
"text": " ..., occurredAt, // producedAt"
},
{
"line": 27006,
"text": " Optional.of(occurredAt), // occurredAt"
},
{
"line": 27007,
"text": " ...);"
},
{
"line": 27008,
"text": "```"
},
{
"line": 27009,
"text": ""
},
{
"line": 27010,
"text": "CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다."
},
{
"line": 27011,
"text": ""
},
{
"line": 27012,
"text": "##### 4.5 왕복에서 소실되는 것"
},
{
"line": 27013,
"text": ""
},
{
"line": 27014,
"text": "`fromCloudEvent`가 항상 비우는 필드가 다섯이다."
},
{
"line": 27015,
"text": ""
},
{
"line": 27016,
"text": "| 필드 | 결과 |"
},
{
"line": 27017,
"text": "|---|---|"
},
{
"line": 27018,
"text": "| `partitionKey` | `Optional.empty()` |"
},
{
"line": 27019,
"text": "| `orderingKey` | `Optional.empty()` |"
},
{
"line": 27020,
"text": "| `traceContext` | `TraceContext.none()` |"
},
{
"line": 27021,
"text": "| `headers` | `MessageHeaders.empty()` |"
},
{
"line": 27022,
"text": "| `producedAt` | `occurredAt`으로 덮임 |"
},
{
"line": 27023,
"text": ""
},
{
"line": 27024,
"text": "**`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — \"Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet.\" CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다."
},
{
"line": 27025,
"text": ""
},
{
"line": 27026,
"text": "`toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다."
},
{
"line": 27027,
"text": ""
},
{
"line": 27028,
"text": "##### 4.6 `id`의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약"
},
{
"line": 27029,
"text": ""
},
{
"line": 27030,
"text": "```java"
},
{
"line": 27031,
"text": "new MessageId(UUID.fromString(event.getId()))"
},
{
"line": 27032,
"text": "```"
},
{
"line": 27033,
"text": ""
},
{
"line": 27034,
"text": "CloudEvents 1.0.2는 `id`를 **\"Type: String; Constraints: REQUIRED, MUST be a non-empty string\"**으로 정의한다. UUID 형식 요구가 없다."
},
{
"line": 27035,
"text": ""
},
{
"line": 27036,
"text": "`MessageId`(messaging-core-api)는 UUID이면서 **version 7 · variant 2**를 요구한다."
},
{
"line": 27037,
"text": ""
},
{
"line": 27038,
"text": "두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(`evidence/raw/273-cloudevents-inbound-id-probe.txt`)."
},
{
"line": 27039,
"text": ""
},
{
"line": 27040,
"text": "```"
},
{
"line": 27041,
"text": "--- spec-conformant opaque string id"
},
{
"line": 27042,
"text": " id = A234-1234-1234"
},
{
"line": 27043,
"text": " result = REJECTED"
},
{
"line": 27044,
"text": " thrown = java.lang.IllegalArgumentException"
},
{
"line": 27045,
"text": " message = Invalid UUID string: A234-1234-1234"
},
{
"line": 27046,
"text": " is a MessagingException (carries FailureDescriptor) = false"
},
{
"line": 27047,
"text": ""
},
{
"line": 27048,
"text": "--- UUIDv4 id"
},
{
"line": 27049,
"text": " id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11"
},
{
"line": 27050,
"text": " result = REJECTED"
},
{
"line": 27051,
"text": " thrown = java.lang.IllegalArgumentException"
},
{
"line": 27052,
"text": " message = a message identity is UUIDv7 (time-ordered); this is version 4"
},
{
"line": 27053,
"text": " is a MessagingException (carries FailureDescriptor) = false"
},
{
"line": 27054,
"text": ""
},
{
"line": 27055,
"text": "--- UUIDv7 id (what this platform mints)"
},
{
"line": 27056,
"text": " result = ACCEPTED"
},
{
"line": 27057,
"text": "```"
},
{
"line": 27058,
"text": ""
},
{
"line": 27059,
"text": "`A234-1234-1234`는 CloudEvents 명세 자신의 예시가 쓰는 id다."
},
{
"line": 27060,
"text": ""
},
{
"line": 27061,
"text": "**의도는 문서화돼 있다.** 테스트에 주석이 있다."
},
{
"line": 27062,
"text": ""
},
{
"line": 27063,
"text": "```java"
},
{
"line": 27064,
"text": "// CloudEventMappingTest.java:90-91"
},
{
"line": 27065,
"text": "// A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign"
},
{
"line": 27066,
"text": "// producer is refused here exactly as it would be on the wire."
},
{
"line": 27067,
"text": "```"
},
{
"line": 27068,
"text": ""
},
{
"line": 27069,
"text": "즉 \"외부 producer의 v4를 거절한다\"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다."
},
{
"line": 27070,
"text": ""
},
{
"line": 27071,
"text": "1. **비UUID id는 명세 위반이 아니다.** v4 거절은 정책 선택이지만, `A234-1234-1234` 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다."
},
{
"line": 27072,
"text": "2. **실패가 플랫폼 어휘 밖이다.** 이 매퍼의 다른 모든 검증 실패는 `MessageValidationException`(→ `FailureDescriptor`, `PERMANENT_BUSINESS`, 안정 코드)이다. id 실패만 raw `IllegalArgumentException`이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다."
},
{
"line": 27073,
"text": ""
},
{
"line": 27074,
"text": "§17에서 다룬다."
},
{
"line": 27075,
"text": ""
},
{
"line": 27076,
"text": "##### 4.7 `schemaversion` 확장이 필수다"
},
{
"line": 27077,
"text": ""
},
{
"line": 27078,
"text": "```java"
},
{
"line": 27079,
"text": "private static int intExtension(CloudEvent event, String name) {"
},
{
"line": 27080,
"text": " return stringExtension(event, name)"
},
{
"line": 27081,
"text": " .map(value -> { try { return Integer.valueOf(value); }"
},
{
"line": 27082,
"text": " catch (NumberFormatException e) {"
},
{
"line": 27083,
"text": " throw new MessageValidationException(\"CLOUDEVENT_SCHEMA_VERSION_INVALID\", ...); } })"
},
{
"line": 27084,
"text": " .orElseThrow(() -> new MessageValidationException(\"CLOUDEVENT_SCHEMA_VERSION_REQUIRED\","
},
{
"line": 27085,
"text": " \"schemaversion extension is required by this profile\"));"
},
{
"line": 27086,
"text": "}"
},
{
"line": 27087,
"text": "```"
},
{
"line": 27088,
"text": ""
},
{
"line": 27089,
"text": "에러 메시지가 \"**by this profile**\"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — `id`의 UUIDv7 요구에는 그런 표시가 없다."
},
{
"line": 27090,
"text": ""
},
{
"line": 27091,
"text": "이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. `id`와 합치면 **이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.**"
},
{
"line": 27092,
"text": ""
},
{
"line": 27093,
"text": "##### 4.8 `toCloudEvent`의 payload 계약"
},
{
"line": 27094,
"text": ""
},
{
"line": 27095,
"text": "```java"
},
{
"line": 27096,
"text": "if (envelope.payload() instanceof EncodedMessage encoded) { ... }"
},
{
"line": 27097,
"text": "else if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); }"
},
{
"line": 27098,
"text": "else { throw new MessageValidationException(\"CLOUDEVENT_PAYLOAD_NOT_ENCODED\", ...); }"
},
{
"line": 27099,
"text": "```"
},
{
"line": 27100,
"text": ""
},
{
"line": 27101,
"text": "이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. `byte[]` 분기에서 `clone()`하는 것도 `EncodedMessage.bytes()`가 이미 복사본을 주는 것과 대칭이다."
},
{
"line": 27102,
"text": ""
},
{
"line": 27103,
"text": "---"
},
{
"line": 27104,
"text": ""
},
{
"line": 27105,
"text": "#### 5. 주요 실행 경로"
},
{
"line": 27106,
"text": ""
},
{
"line": 27107,
"text": "**나가는 방향:** `occurredAt` 확인(없으면 거절) → `CloudEventBuilder.v1()`에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → `dataschema`(있을 때) → `build()`"
},
{
"line": 27108,
"text": ""
},
{
"line": 27109,
"text": "**들어오는 방향:** `time` 확인(없으면 거절) → `datacontenttype`(기본 `application/json`) → `data`(없으면 빈 배열) → `MessageId`·`MessageType`·`SchemaVersion`·`ProducerId`·확장 셋 → `MessageEnvelope` 조립"
},
{
"line": 27110,
"text": ""
},
{
"line": 27111,
"text": "---"
},
{
"line": 27112,
"text": ""
},
{
"line": 27113,
"text": "#### 6. 실패 경로와 복구/번역"
},
{
"line": 27114,
"text": ""
},
{
"line": 27115,
"text": "| 코드 | 예외 | 방향 | 조건 |"
},
{
"line": 27116,
"text": "|---|---|---|---|"
},
{
"line": 27117,
"text": "| `CLOUDEVENT_TIME_REQUIRED` | `MessageValidationException` | 양방향 | `occurredAt` 없음 / `time` 없음 |"
},
{
"line": 27118,
"text": "| `CLOUDEVENT_PAYLOAD_NOT_ENCODED` | `MessageValidationException` | 나가는 | payload가 `EncodedMessage`도 `byte[]`도 아님 |"
},
{
"line": 27119,
"text": "| `CLOUDEVENT_SCHEMA_VERSION_REQUIRED` | `MessageValidationException` | 들어오는 | 확장 없음 |"
},
{
"line": 27120,
"text": "| `CLOUDEVENT_SCHEMA_VERSION_INVALID` | `MessageValidationException` | 들어오는 | 확장이 정수가 아님 |"
},
{
"line": 27121,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `id`가 UUID가 아니거나 v7이 아님 |"
},
{
"line": 27122,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `causationid`가 UUID가 아니거나 v7이 아님 |"
},
{
"line": 27123,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `type`이 `MessageType` 제약 위반(240바이트·제어문자) |"
},
{
"line": 27124,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `correlationid`가 160바이트 초과 |"
},
{
"line": 27125,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `tenantcontext`가 슬러그 패턴 위반 |"
},
{
"line": 27126,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | 유도된 producer 이름이 120바이트 초과 또는 제어문자 |"
},
{
"line": 27127,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `datacontenttype`이 미디어 타입 문법 위반 |"
},
{
"line": 27128,
"text": "| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `schemaversion`이 0 이하 |"
},
{
"line": 27129,
"text": ""
},
{
"line": 27130,
"text": "**분류된 실패 넷, 분류되지 않은 실패 여덟.** 매퍼가 직접 던지는 것은 전부 `MessageValidationException`이지만, 값 객체 생성자에 위임한 검증은 전부 raw `IllegalArgumentException`이다. `fromCloudEvent`는 **외부에서 온 데이터**를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다."
},
{
"line": 27131,
"text": ""
},
{
"line": 27132,
"text": "`messaging-core-api`의 `FailureDescriptor` 설계 전체가 \"예외 클래스로 분기하지 말고 선언된 분류로 판단하라\"였다. 이 경로는 그 분류를 만들지 않는다."
},
{
"line": 27133,
"text": ""
},
{
"line": 27134,
"text": "---"
},
{
"line": 27135,
"text": ""
},
{
"line": 27136,
"text": "#### 7. 트랜잭션·동시성·수명주기"
},
{
"line": 27137,
"text": ""
},
{
"line": 27138,
"text": "트랜잭션 없음."
},
{
"line": 27139,
"text": ""
},
{
"line": 27140,
"text": "`DefaultCloudEventMapper`는 **상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다."
},
{
"line": 27141,
"text": ""
},
{
"line": 27142,
"text": "`CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다."
},
{
"line": 27143,
"text": ""
},
{
"line": 27144,
"text": "`CloudEventBuilder`는 호출마다 새로 만들어진다."
},
{
"line": 27145,
"text": ""
},
{
"line": 27146,
"text": "---"
},
{
"line": 27147,
"text": ""
},
{
"line": 27148,
"text": "#### 8. 설정·기능 플래그·환경 차이"
},
{
"line": 27149,
"text": ""
},
{
"line": 27150,
"text": "설정 없음."
},
{
"line": 27151,
"text": ""
},
{
"line": 27152,
"text": "| 상수 | 값 | 위치 |"
},
{
"line": 27153,
"text": "|---|---|---|"
},
{
"line": 27154,
"text": "| `SPEC_CONTENT_TYPE_FALLBACK` | `\"application/json\"` | `DefaultCloudEventMapper.java:39` (private) |"
},
{
"line": 27155,
"text": "| `CloudEventExtensions.CORRELATION_ID` | `\"correlationid\"` | public |"
},
{
"line": 27156,
"text": "| `CloudEventExtensions.CAUSATION_ID` | `\"causationid\"` | public |"
},
{
"line": 27157,
"text": "| `CloudEventExtensions.SCHEMA_VERSION` | `\"schemaversion\"` | public |"
},
{
"line": 27158,
"text": "| `CloudEventExtensions.TENANT_CONTEXT` | `\"tenantcontext\"` | public |"
},
{
"line": 27159,
"text": ""
},
{
"line": 27160,
"text": "CloudEvents 버전은 `4.0.1`로 고정(lockfile 확인). CloudEvents **명세** 버전은 `CloudEventBuilder.v1()`이 고정한다 — javadoc은 1.0.2를 명시한다."
},
{
"line": 27161,
"text": ""
},
{
"line": 27162,
"text": "---"
},
{
"line": 27163,
"text": ""
},
{
"line": 27164,
"text": "#### 9. 퍼시스턴스/외부 시스템 세부"
},
{
"line": 27165,
"text": ""
},
{
"line": 27166,
"text": "없다."
},
{
"line": 27167,
"text": ""
},
{
"line": 27168,
"text": "---"
},
{
"line": 27169,
"text": ""
},
{
"line": 27170,
"text": "#### 10. 테스트 레인과 실제 증명 범위"
},
{
"line": 27171,
"text": ""
},
{
"line": 27172,
"text": "레인: `./gradlew :messaging:messaging-cloudevents:test`. **BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures**."
},
{
"line": 27173,
"text": ""
},
{
"line": 27174,
"text": "| 테스트 | 증명하는 것 |"
},
{
"line": 27175,
"text": "|---|---|"
},
{
"line": 27176,
"text": "| `mapsLogicalIdentityAndExtensions` | id·type·schemaversion·source·datacontenttype |"
},
{
"line": 27177,
"text": "| `mapsCorrelationAndTenantAsExtensions` | 두 확장 |"
},
{
"line": 27178,
"text": "| `mapsOccurredAtToEventTime` | `occurredAt` → `time` |"
},
{
"line": 27179,
"text": "| `rejectsAnEventEnvelopeWithoutOccurredAt` | 나가는 방향의 `time` 필수 |"
},
{
"line": 27180,
"text": "| `roundTripsBackToAnEnvelopeWithoutInventingATombstone` | 왕복 시 6개 필드 보존 |"
},
{
"line": 27181,
"text": "| `aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue` | 빈 data → 빈 바이트(tombstone 아님) |"
},
{
"line": 27182,
"text": "| `rejectsAnUnencodedPayload` | 인코딩되지 않은 payload 거절 |"
},
{
"line": 27183,
"text": ""
},
{
"line": 27184,
"text": "**이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.**"
},
{
"line": 27185,
"text": ""
},
{
"line": 27186,
"text": "`fromCloudEvent`를 부르는 두 테스트 중 하나는 `mapper.toCloudEvent(original, SOURCE)`의 출력을 되돌리고, 다른 하나는 `MessageId.newId()`로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — \"A v7 id: MessageId enforces the version it documents\"."
},
{
"line": 27187,
"text": ""
},
{
"line": 27188,
"text": "즉 **외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다.** 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다."
},
{
"line": 27189,
"text": ""
},
{
"line": 27190,
"text": "**왕복 검증의 선택적 비교.** `roundTripsBackToAnEnvelopeWithoutInventingATombstone`이 `producedAt`·`traceContext`·`headers`·`partitionKey`·`orderingKey`를 비교하지 않는다. fixture는 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 \"roundTrips\"인데 실제로는 6개 필드의 부분 보존을 확인한다."
},
{
"line": 27191,
"text": ""
},
{
"line": 27192,
"text": "---"
},
{
"line": 27193,
"text": ""
},
{
"line": 27194,
"text": "#### 11. 빌드/ArchUnit/CI 강제 지점"
},
{
"line": 27195,
"text": ""
},
{
"line": 27196,
"text": "| 게이트 | 이 leaf에 대해 |"
},
{
"line": 27197,
"text": "|---|---|"
},
{
"line": 27198,
"text": "| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-schema-api\"]` |"
},
{
"line": 27199,
"text": "| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` — 편입이 강제됨 |"
},
{
"line": 27200,
"text": "| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | `cloudevents-api`는 public 시그니처에 등장 → `api`. `cloudevents-core`는 구현 전용 → `implementation`. **통과** |"
},
{
"line": 27201,
"text": "| ArchUnit | 전용 규칙 없음 |"
},
{
"line": 27202,
"text": ""
},
{
"line": 27203,
"text": "---"
},
{
"line": 27204,
"text": ""
},
{
"line": 27205,
"text": "#### 12. 실제 사용 여부와 negative-space probes"
},
{
"line": 27206,
"text": ""
},
{
"line": 27207,
"text": "원시 증거: `evidence/raw/272-schema-family-reachability.txt`, `evidence/raw/273-cloudevents-inbound-id-probe.txt`."
},
{
"line": 27208,
"text": ""
},
{
"line": 27209,
"text": "##### 12.1 Public surface reachability"
},
{
"line": 27210,
"text": ""
},
{
"line": 27211,
"text": "| 타입 | leaf 밖 참조 | 판정 |"
},
{
"line": 27212,
"text": "|---|---:|---|"
},
{
"line": 27213,
"text": "| `CloudEventMapper` | **0** | 소비자 없음 |"
},
{
"line": 27214,
"text": "| `DefaultCloudEventMapper` | **0** | 소비자 없음 |"
},
{
"line": 27215,
"text": "| `CloudEventExtensions` | **0** | 소비자 없음 |"
},
{
"line": 27216,
"text": ""
},
{
"line": 27217,
"text": "세 타입 모두 `git grep` exit 1."
},
{
"line": 27218,
"text": ""
},
{
"line": 27219,
"text": "**형제와 다른 조합이다.**"
},
{
"line": 27220,
"text": ""
},
{
"line": 27221,
"text": "| leaf | 소비자 | starter codec 등록 | `runtime_memberships` | 정합 |"
},
{
"line": 27222,
"text": "|---|:---:|:---:|---|---|"
},
{
"line": 27223,
"text": "| `messaging-schema-json` | 1 | o | `[\"app-bootstrap\"]` | o |"
},
{
"line": 27224,
"text": "| `messaging-schema-avro` | 0 | x | `[]` | o |"
},
{
"line": 27225,
"text": "| `messaging-schema-protobuf` | 0 | x | `[]` | o |"
},
{
"line": 27226,
"text": "| **`messaging-cloudevents`** | **0** | 해당 없음 | **`[\"app-bootstrap\"]`** | **x** |"
},
{
"line": 27227,
"text": ""
},
{
"line": 27228,
"text": "Avro·Protobuf는 \"싣지 않고 쓰지 않는다\"로 정합한다. 이 leaf는 **싣고 쓰지 않는다.** `messaging-spring-boot-starter`의 `allowed_dependencies`에 들어 있어 배포 아티팩트가 `cloudevents-api`와 `cloudevents-core` 두 jar를 함께 싣는다."
},
{
"line": 27229,
"text": ""
},
{
"line": 27230,
"text": "지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, \"이 의존성이 왜 여기 있지?\"를 나중에 조사할 사람의 시간이다."
},
{
"line": 27231,
"text": ""
},
{
"line": 27232,
"text": "##### 12.2 Conditional sibling comparison"
},
{
"line": 27233,
"text": ""
},
{
"line": 27234,
"text": "Spring 주석 0개, bean 없음."
},
{
"line": 27235,
"text": ""
},
{
"line": 27236,
"text": "**조립 비대칭은 starter 쪽에서 관측된다.** `MessagingCoreAutoConfiguration`이 `JacksonMessageCodec`으로 codec registry를 만드는 `@Bean`을 갖는데, `CloudEventMapper`를 만드는 `@Bean`은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 `messaging-spring-boot-starter` leaf SSOT가 소유한다."
},
{
"line": 27237,
"text": ""
},
{
"line": 27238,
"text": "##### 12.3 Duplicate mechanism sweep"
},
{
"line": 27239,
"text": ""
},
{
"line": 27240,
"text": "**(a) 다른 CloudEvents 구현이 있는가 — 없다**"
},
{
"line": 27241,
"text": ""
},
{
"line": 27242,
"text": "`git grep -l 'io.cloudevents' -- src`가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다."
},
{
"line": 27243,
"text": ""
},
{
"line": 27244,
"text": "**(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다**"
},
{
"line": 27245,
"text": ""
},
{
"line": 27246,
"text": "`messaging-kafka`의 `KafkaHeaderMapper`/`KafkaDeliveryMapper`, `messaging-rabbit`의 `RabbitDeliveryMapper`가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 **transport 매핑**이고 이것은 **interchange 포맷 매핑**이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다)."
},
{
"line": 27247,
"text": ""
},
{
"line": 27248,
"text": "다만 겹치는 관심사가 하나 있다 — `traceContext`. 브로커 매퍼들은 `traceparent`/`tracestate`/`baggage`를 예약 헤더로 실어 나르고(`ReservedHeaders`가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다."
},
{
"line": 27249,
"text": ""
},
{
"line": 27250,
"text": "**(c) UUID 파싱** — `UUID.fromString`을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다."
},
{
"line": 27251,
"text": ""
},
{
"line": 27252,
"text": "##### 12.4 Documentation / measured-count drift"
},
{
"line": 27253,
"text": ""
},
{
"line": 27254,
"text": "| 문서 주장 | 재측정 | 결과 |"
},
{
"line": 27255,
"text": "|---|---|---|"
},
{
"line": 27256,
"text": "| build.gradle 주석: `cloudevents-api`가 public 시그니처에 등장 | `CloudEventMapper`의 두 메서드가 `CloudEvent`를 반환/수취 | **일치** |"
},
{
"line": 27257,
"text": "| build.gradle 주석: `cloudevents-core`는 구현 전용 | `CloudEventBuilder`·`BytesCloudEventData`가 `DefaultCloudEventMapper` 안에서만 | **일치** |"
},
{
"line": 27258,
"text": "| 클래스 javadoc: \"CloudEvents 1.0.2 compatible profile\" | `id` 제약이 명세보다 엄격(§4.6). `schemaversion` 확장 필수 | **부분 불일치** — 아래 참조 |"
},
{
"line": 27259,
"text": "| `CloudEventMapper` javadoc: domain/integration event 전용 | 코드에 그 구분을 강제하는 것 없음 | **미강제** — 정책 진술이고 게이트가 없다 |"
},
{
"line": 27260,
"text": "| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치** — family drift의 사례(`messaging-core-api` §12.4) |"
},
{
"line": 27261,
"text": ""
},
{
"line": 27262,
"text": "**\"compatible profile\"의 정확한 의미.** 명세는 `id`를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. **나가는 방향은 명세를 만족한다**(UUID 문자열은 유효한 id다). **들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다.** javadoc의 \"compatible\"이 어느 방향을 말하는지 밝히지 않는다. `schemaversion` 에러 메시지는 \"required by this profile\"이라고 정확히 적는 반면 `id` 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다."
},
{
"line": 27263,
"text": ""
},
{
"line": 27264,
"text": "---"
},
{
"line": 27265,
"text": ""
},
{
"line": 27266,
"text": "#### 13. Git/설계 문서에서 확인한 변화와 실패 기록"
},
{
"line": 27267,
"text": ""
},
{
"line": 27268,
"text": "build.gradle 주석이 이전 결함 하나를 보존한다."
},
{
"line": 27269,
"text": ""
},
{
"line": 27270,
"text": "> Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract."
},
{
"line": 27271,
"text": ""
},
{
"line": 27272,
"text": "이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — \"source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조\". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다."
},
{
"line": 27273,
"text": ""
},
{
"line": 27274,
"text": "코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — \"defaulted to the production instant\"와 \"inventing a tombstone\"은 하지 않기로 한 것들이다."
},
{
"line": 27275,
"text": ""
},
{
"line": 27276,
"text": "---"
},
{
"line": 27277,
"text": ""
},
{
"line": 27278,
"text": "#### 14. 런타임·터미널 Evidence"
},
{
"line": 27279,
"text": ""
},
{
"line": 27280,
"text": "| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |"
},
{
"line": 27281,
"text": "|---|---|---|---|---|"
},
{
"line": 27282,
"text": "| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 세 타입의 소비자 0, membership `[\"app-bootstrap\"]` | 정적 검색 |"
},
{
"line": 27283,
"text": "| **EVD-273** | **runtime probe** | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, `MessagingException` 여부 | 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인 |"
},
{
"line": 27284,
"text": "| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | BUILD SUCCESSFUL, 7 / 0 / 0 | 외부 producer 입력 없음 |"
},
{
"line": 27285,
"text": ""
},
{
"line": 27286,
"text": "EVD-273의 실행 방법: `:messaging:messaging-cloudevents` test runtimeClasspath에 대해 `/tmp/CeProbe.java`를 컴파일·실행. 저장소 파일은 읽기만 했다."
},
{
"line": 27287,
"text": ""
},
{
"line": 27288,
"text": "---"
},
{
"line": 27289,
"text": ""
},
{
"line": 27290,
"text": "#### 15. 명시적 설계 이유와 추론을 구분한 정리"
},
{
"line": 27291,
"text": ""
},
{
"line": 27292,
"text": "**명시적**"
},
{
"line": 27293,
"text": ""
},
{
"line": 27294,
"text": "- domain/integration event 전용인 이유 — `CloudEventMapper` javadoc"
},
{
"line": 27295,
"text": "- `occurredAt` 없는 이벤트를 거절하는 이유 — `DefaultCloudEventMapper` javadoc"
},
{
"line": 27296,
"text": "- 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc"
},
{
"line": 27297,
"text": "- producer 이름을 마지막 세그먼트로 자르는 이유 — `producerFrom` javadoc"
},
{
"line": 27298,
"text": "- 확장 이름이 봉투 필드명과 다른 이유 — `CloudEventExtensions` javadoc"
},
{
"line": 27299,
"text": "- `cloudevents-api`가 `api`여야 하는 이유 — build.gradle 주석"
},
{
"line": 27300,
"text": "- v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(`CloudEventMappingTest.java:90-91`)"
},
{
"line": 27301,
"text": ""
},
{
"line": 27302,
"text": "**추론**"
},
{
"line": 27303,
"text": ""
},
{
"line": 27304,
"text": "- 비UUID id 거절이 의도인지 → **미상**. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다."
},
{
"line": 27305,
"text": "- `traceContext`·`headers`를 버리는 것이 의도인지 → **미상**. 어디에도 언급이 없다."
},
{
"line": 27306,
"text": "- `producedAt`을 `occurredAt`으로 덮는 것이 의도인지 → **추론**. CloudEvents에 `time`이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다."
},
{
"line": 27307,
"text": "- membership이 있고 소비자가 없는 이유 → **미상**."
},
{
"line": 27308,
"text": ""
},
{
"line": 27309,
"text": "---"
},
{
"line": 27310,
"text": ""
},
{
"line": 27311,
"text": "#### 16. 확인한 것 / 확인하지 못한 것"
},
{
"line": 27312,
"text": ""
},
{
"line": 27313,
"text": "**확인한 것**"
},
{
"line": 27314,
"text": ""
},
{
"line": 27315,
"text": "- 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표"
},
{
"line": 27316,
"text": "- 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지"
},
{
"line": 27317,
"text": "- 소비자 0인데 `runtime_memberships`가 `[\"app-bootstrap\"]`이라는 비정합"
},
{
"line": 27318,
"text": "- **명세 예시 id와 UUIDv4가 분류되지 않은 `IllegalArgumentException`으로 거절된다는 것 — 런타임 probe로 실행 확인**"
},
{
"line": 27319,
"text": "- 왕복에서 다섯 필드가 소실된다는 것"
},
{
"line": 27320,
"text": "- `api`/`implementation` 분리가 정확하다는 것"
},
{
"line": 27321,
"text": ""
},
{
"line": 27322,
"text": "**확인하지 못한 것**"
},
{
"line": 27323,
"text": ""
},
{
"line": 27324,
"text": "- 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다."
},
{
"line": 27325,
"text": "- 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다."
},
{
"line": 27326,
"text": "- `dataschema`가 실제로 쓰이는지 — `EncodedMessage.schemaReference().schemaUri()`가 채워지는 경로가 이 저장소에 없다(세 codec 모두 `SchemaReference.of(subject, version)`로 URI 없이 만든다). 즉 `dataschema`는 현재 항상 비어 있다."
},
{
"line": 27327,
"text": "- CloudEvents distributed-tracing extension을 쓸 계획이 있는지."
},
{
"line": 27328,
"text": ""
},
{
"line": 27329,
"text": "---"
},
{
"line": 27330,
"text": ""
},
{
"line": 27331,
"text": "#### 17. 손볼 것"
},
{
"line": 27332,
"text": ""
},
{
"line": 27333,
"text": "##### P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다"
},
{
"line": 27334,
"text": ""
},
{
"line": 27335,
"text": "- **사실.** `fromCloudEvent`가 `new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id `A234-1234-1234` → `java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.**"
},
{
"line": 27336,
"text": "- **근거.** `evidence/raw/273-cloudevents-inbound-id-probe.txt` (실행 확인). `DefaultCloudEventMapper.java:116`."
},
{
"line": 27337,
"text": "- **왜 문제인가.** 두 층이다."
},
{
"line": 27338,
"text": " - **(1) 범위.** v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 \"우리 정책\", 비UUID 거절은 \"CloudEvents 상호운용 포기\"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다."
},
{
"line": 27339,
"text": " - **(2) 실패 어휘.** 같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 \"코드 없음\" 여덟 행)."
},
{
"line": 27340,
"text": "- **확인 방법.** `evidence/raw/273`의 probe 재실행. 또는 `MessageId` 생성자와 `UUID.fromString`의 계약 대조."
},
{
"line": 27341,
"text": "- **후보.** (a) `fromCloudEvent`의 값 객체 생성을 전부 감싸 `MessageValidationException`으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, `id`를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 \"compatible profile\"이 나가는 방향만 뜻함을 밝힌다."
},
{
"line": 27342,
"text": "- **다음 단계.** **CASE 후보.** 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 **DECISION 후보**이며 지금은 근거가 없으므로 `NEEDS_DECISION`이다."
},
{
"line": 27343,
"text": ""
},
{
"line": 27344,
"text": "##### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다"
},
{
"line": 27345,
"text": ""
},
{
"line": 27346,
"text": "- **사실.** 세 타입의 leaf 밖 참조가 0인데 `runtime_memberships`가 `[\"app-bootstrap\"]`이다. `messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다."
},
{
"line": 27347,
"text": "- **근거.** `evidence/raw/272` §D·§E. `MessagingCoreAutoConfiguration` 전수(`CloudEvent` 참조 0)."
},
{
"line": 27348,
"text": "- **왜 문제인가.** 형제 Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 \"이 의존성이 왜 있지\"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 \"모든 messaging leaf가 unwired\"라고 적고 있어 문서에서도 이 사실을 알 수 없다."
},
{
"line": 27349,
"text": "- **확인 방법.** `git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents'` → exit 1. registry의 membership 확인."
},
{
"line": 27350,
"text": "- **후보.** (a) starter에서 `@ConditionalOnClass`/`@ConditionalOnProperty`로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 `[]`로 되돌려 Avro·Protobuf와 같은 상태로 만든다."
},
{
"line": 27351,
"text": "- **다음 단계.** **CASE 후보.** \"장치는 있고 회로가 닫히지 않았다\"의 변형 — 여기서는 회로가 닫히지 않았는데 **부품은 배송됐다.**"
},
{
"line": 27352,
"text": ""
},
{
"line": 27353,
"text": "##### P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다"
},
{
"line": 27354,
"text": ""
},
{
"line": 27355,
"text": "- **사실.** `fromCloudEvent`가 `partitionKey`·`orderingKey`를 empty로, `traceContext`를 `none()`으로, `headers`를 `empty()`로 두고, `producedAt`을 `occurredAt` 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)이 다르므로 비교했다면 실패했을 것이다."
},
{
"line": 27356,
"text": "- **근거.** `DefaultCloudEventMapper.java:115-131`, `CloudEventMappingTest.java:72-84, 143-161`."
},
{
"line": 27357,
"text": "- **왜 문제인가.** `traceContext` 소실이 가장 무겁다. `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 \"a trace survives an Outbox round trip through the database, where broker headers do not exist yet\"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 `roundTrips…`인데 실제로는 부분 보존 확인이다."
},
{
"line": 27358,
"text": "- **확인 방법.** 왕복 테스트에 `producedAt`·`traceContext` 비교를 추가하면 실패한다."
},
{
"line": 27359,
"text": "- **후보.** (a) 소실 필드를 javadoc에 명시한다. (b) `traceparent`/`tracestate`/`baggage`를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다."
},
{
"line": 27360,
"text": "- **다음 단계.** **REFERENCE 후보**(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다)."
},
{
"line": 27361,
"text": ""
},
{
"line": 27362,
"text": "##### P3 — `dataschema`가 채워질 경로가 없다"
},
{
"line": 27363,
"text": ""
},
{
"line": 27364,
"text": "- **사실.** `toCloudEvent`가 `encoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)`로 `dataschema`를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 `SchemaReference.of(subject, version)`로 만들고, 그 factory는 `schemaUri`를 `Optional.empty()`로 둔다."
},
{
"line": 27365,
"text": "- **근거.** `DefaultCloudEventMapper.java:81-86`, `SchemaReference.java:36-38`, 세 codec의 `encode`."
},
{
"line": 27366,
"text": "- **왜 문제인가.** `dataschema`는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. `schemaversion` 확장이 그 자리를 대신하지만 그것은 비표준 확장이다."
},
{
"line": 27367,
"text": "- **확인 방법.** `git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java'` — 3인자 생성자를 부르는 production 코드가 있는지 확인."
},
{
"line": 27368,
"text": "- **후보.** schema registry URI를 갖는 배포에서 `SchemaReference`의 3인자 생성자를 쓰게 하거나, `dataschema` 분기가 현재 도달 불가임을 주석으로 남긴다."
},
{
"line": 27369,
"text": "- **다음 단계.** **OPEN QUESTION 후보.** 판정이 \"이 저장소가 외부 schema registry를 쓸 것인가\"에 걸리고, 그 질문은 `messaging-schema-api`의 `SchemaRegistry` port가 구현 0인 것과 같은 뿌리다."
},
{
"line": 27370,
"text": ""
},
{
"line": 27371,
"text": "##### P3 — `CloudEventMapper` javadoc의 범위 제한이 강제되지 않는다"
},
{
"line": 27372,
"text": ""
},
{
"line": 27373,
"text": "- **사실.** \"Offered for domain and integration events only. Commands and work items are not forced through CloudEvents.\" 코드에 `DestinationKind`를 보는 분기가 없다."
},
{
"line": 27374,
"text": "- **근거.** `CloudEventMapper.java:11-13`, `DefaultCloudEventMapper` 전문."
},
{
"line": 27375,
"text": "- **왜 문제인가.** 소비자가 0이므로 지금은 무해하다. 배선되면 `ASYNC_COMMAND`·`WORK_QUEUE` 봉투도 이 매퍼를 통과한다."
},
{
"line": 27376,
"text": "- **확인 방법.** `git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**'` → 매치 없음."
},
{
"line": 27377,
"text": "- **후보.** 진술을 유지하되 \"호출자 책임\"임을 명시하거나, `toCloudEvent`가 `DestinationKind`를 받아 검사한다."
},
{
"line": 27378,
"text": "- **다음 단계.** **REFERENCE 후보**(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다)."
},
{
"line": 27379,
"text": ""
},
{
"line": 27380,
"text": "##### 확인된 설계(문제 아님)"
},
{
"line": 27381,
"text": ""
},
{
"line": 27382,
"text": "- `occurredAt` 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것"
},
{
"line": 27383,
"text": "- 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — `MessageEnvelope`의 non-null payload 계약과 정확히 짝을 이룸"
},
{
"line": 27384,
"text": "- producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것"
},
{
"line": 27385,
"text": "- `cloudevents-api`를 `api`로, `cloudevents-core`를 `implementation`으로 나눈 것과 그 근거 주석"
},
{
"line": 27386,
"text": "- `schemaversion` 에러 메시지가 \"by this profile\"이라고 밝히는 것"
},
{
"line": 27387,
"text": "- `byte[]` payload를 `clone()`해서 넘기는 것"
},
{
"line": 27388,
"text": "- 매퍼가 상태를 갖지 않는 것"
},
{
"line": 27389,
"text": ""
},
{
"line": 27390,
"text": "---"
},
{
"line": 27391,
"text": ""
},
{
"line": 27392,
"text": "#### Source anchors"
},
{
"line": 27393,
"text": ""
},
{
"line": 27394,
"text": "| id | kind | path | revision | what it proves | limitations |"
},
{
"line": 27395,
"text": "|---|---|---|---|---|---|"
},
{
"line": 27396,
"text": "| MCE-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: [\"app-bootstrap\"]` | 선언 |"
},
{
"line": 27397,
"text": "| MCE-002 | build | `messaging-cloudevents/build.gradle` | same | `api`/`implementation` 분리와 그 근거 | — |"
},
{
"line": 27398,
"text": "| MCE-003 | build | `messaging-cloudevents/gradle.lockfile:33-34` | same | cloudevents 4.0.1 두 좌표 | — |"
},
{
"line": 27399,
"text": "| MCE-004 | code | `.../cloudevents/CloudEventMapper.java` 전문 | same | 계약과 적용 범위 진술 | 범위 미강제(§17) |"
},
{
"line": 27400,
"text": "| MCE-005 | code | `.../cloudevents/DefaultCloudEventMapper.java` 전문 | same | §4 전체 매핑표와 두 명시적 결정 | — |"
},
{
"line": 27401,
"text": "| MCE-006 | code | `.../cloudevents/CloudEventExtensions.java` | same | 확장 이름 4개와 명명 이유 | — |"
},
{
"line": 27402,
"text": "| MCE-007 | test | `CloudEventMappingTest` (7) | same | §10 표 | 외부 producer 입력 없음. 왕복이 5개 필드 미비교 |"
},
{
"line": 27403,
"text": "| MCE-008 | cross-leaf code | `messaging-core-api/.../MessageId.java:20-32` | same | UUIDv7 강제의 출처 | 해당 leaf SSOT가 소유 |"
},
{
"line": 27404,
"text": "| MCE-009 | cross-leaf code | `messaging-core-api/.../TraceContext.java:11-13` | same | 봉투가 trace를 갖는 이유(§17 왕복 소실) | 해당 leaf SSOT가 소유 |"
},
{
"line": 27405,
"text": "| MCE-010 | cross-leaf code | `messaging-schema-api/.../SchemaReference.java:36-38` | same | `of`가 URI를 비움 → `dataschema` 도달 불가 | 해당 leaf SSOT가 소유 |"
},
{
"line": 27406,
"text": "| MCE-011 | external spec | CloudEvents 1.0.2, `id` 속성 정의 | — | `id`는 비어 있지 않은 String이며 형식 제약 없음 | 외부 표준. 저장소 밖 지식으로 명시 분리 |"
},
{
"line": 27407,
"text": "| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | 세 타입 소비자 0, membership | 정적 검색 |"
},
{
"line": 27408,
"text": "| EVD-273 | runtime probe | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | same | 세 id 형태의 실제 결과와 예외 타입 | 세 형태만. 저장소 소스 미수정 |"
},
{
"line": 27409,
"text": "| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | same | 7 / 0 / 0 | — |"
},
{
"line": 27410,
"text": ""
},
{
"line": 27411,
"text": "---"
},
{
"line": 27412,
"text": ""
}
],
"numbered_context": "26224 | ## A19-MESSAGING-CLAIM-CHECK. messaging-claim-check\n26225 | \n26226 | > 분석 중에는 `messaging/MESSAGING-CLAIM-CHECK.md` 파일이었다. 581줄.\n26227 | \n26228 | ### messaging-claim-check 완전 해부\n26229 | \n26230 | > 상태: COMPLETE\n26231 | > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n26232 | > 분석 범위: `src/messaging/messaging-claim-check`\n26233 | > SSOT owner: `messaging-claim-check`\n26234 | > integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n26235 | \n26236 | ---\n26237 | \n26238 | #### 0. SSOT identity / 커버리지와 숫자 지도\n26239 | \n26240 | - registered leaf id: `messaging-claim-check`\n26241 | - canonical state `analysisFile`: §A19-MESSAGING-CLAIM-CHECK\n26242 | - source path: `src/messaging/messaging-claim-check`\n26243 | - registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-reliability-api\"]`\n26244 | - registry `runtime_memberships`: **`[\"app-bootstrap\"]`**\n26245 | \n26246 | ##### 숫자\n26247 | \n26248 | | 항목 | 수 |\n26249 | |---|---:|\n26250 | | production Java 파일 | 6 |\n26251 | | production LOC | 418 |\n26252 | | 패키지 | 1 (`dev.caskeleton.messaging.claimcheck`) |\n26253 | | test 파일 | 3 |\n26254 | | test 메서드(실행 확인) | 22 |\n26255 | | 외부(비프로젝트) 의존성 | **0** |\n26256 | \n26257 | 여섯 타입:\n26258 | \n26259 | | 타입 | 종류 | 역할 | leaf 밖 참조 |\n26260 | |---|---|---|---:|\n26261 | | `ClaimCheckStore` | interface | payload 저장·조회·삭제 port | **0** |\n26262 | | `ClaimCheckPolicy` | record | 문턱과 보존 규칙 | **0** |\n26263 | | `ClaimCheckPublisher` | class | 발행 측 오프로드 결정 | **0** |\n26264 | | `ClaimCheckResolver` | class | 소비 측 조회 + 검증 | **0** |\n26265 | | `ClaimCheckIntegrityGuard` | class | digest·크기·만료 검사 | **0** |\n26266 | | `ClaimCheckIntegrityException` | exception | digest 불일치 | **0** |\n26267 | \n26268 | **여섯 전부 leaf 밖 참조가 0이다.**\n26269 | \n26270 | ##### Coverage ledger\n26271 | \n26272 | | scope/file group | count | disposition | reason |\n26273 | |---|---:|---|---|\n26274 | | `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |\n26275 | | `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·fake 구현 확인 |\n26276 | | `build.gradle` | 1 | `FULL_READ` | 6줄 |\n26277 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n26278 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n26279 | \n26280 | `UNCLASSIFIED` 0.\n26281 | \n26282 | ---\n26283 | \n26284 | #### 1. 모듈의 정체와 경계\n26285 | \n26286 | **Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다.\n26287 | \n26288 | `messaging-reliability-api`의 `ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.\n26289 | \n26290 | 경계 진술이 두 클래스에 있다.\n26291 | \n26292 | ```java\n26293 | // ClaimCheckIntegrityGuard.java:14-17\n26294 | * A claim check turns one message into two systems that can drift. The payload store has its own\n26295 | * retention, its own replication, and its own access control, and none of them are coordinated with\n26296 | * the broker's. So a consumer that fetches bytes and decodes them without checking is trusting\n26297 | * something the message never proved.\n26298 | ```\n26299 | \n26300 | ```java\n26301 | // ClaimCheckResolver.java:11-15\n26302 | * Verification is not optional and cannot be skipped by a caller. An object store key is a\n26303 | * string, and a message carrying the wrong one — through a bug, a replay against a rotated bucket,\n26304 | * or a deliberate tamper — fetches bytes that decode perfectly into the wrong object. The digest is\n26305 | * the only thing standing between that and a handler acting on someone else's data.\n26306 | ```\n26307 | \n26308 | **\"decode perfectly into the wrong object\"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공.\n26309 | \n26310 | ---\n26311 | \n26312 | #### 2. 의존성과 런타임 배선\n26313 | \n26314 | 들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api).\n26315 | \n26316 | 나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다.\n26317 | \n26318 | **배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다.\n26319 | \n26320 | 그런데 **`runtime_memberships`가 `[\"app-bootstrap\"]`이다.** starter closure를 통해 배포 아티팩트에 실린다.\n26321 | \n26322 | `messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(§A19-MESSAGING-CLOUDEVENTS §12.1).\n26323 | \n26324 | ---\n26325 | \n26326 | #### 3. 패키지/컴포넌트 지도\n26327 | \n26328 | ```\n26329 | 발행 측\n26330 | ClaimCheckPublisher(store, policy)\n26331 | └── offload(byte[]) → Offloaded(payload, Optional The retention rule is the one that matters. A claim check object deleted while its message is\n26367 | * still deliverable turns a large message into an undeliverable one — the consumer fetches, gets\n26368 | * nothing, and the message dead-letters for a reason that has nothing to do with the message. So\n26369 | * retention must exceed the broker's own retention plus the full retry and dead-letter window, and\n26370 | * the constructor refuses a configuration where it does not.\n26371 | ```\n26372 | \n26373 | **이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.** 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다.\n26374 | \n26375 | 문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다.\n26376 | \n26377 | ```java\n26378 | // :16-18\n26379 | * The threshold is separate from the destination's payload limit. Offloading starts well below\n26380 | * the limit, because the limit is where the broker refuses the message and the threshold is where\n26381 | * carrying it inline stops being a good idea.\n26382 | ```\n26383 | \n26384 | `DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다.\n26385 | \n26386 | `defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유.\n26387 | \n26388 | ##### 4.2 `ClaimCheckPublisher` — 순서와 미삭제\n26389 | \n26390 | ```java\n26391 | // :9-17\n26392 | * The object is written before the message is published, and that order is the whole\n26393 | * design. Publishing first would let a consumer receive a reference to an object that does not\n26394 | * exist yet — a race that is rare in a test and routine under load, because the broker hop is\n26395 | * faster than the object store write.\n26396 | *\n26397 | * Nothing here deletes on failure. If the publish is rejected the object is left behind, and the\n26398 | * retention sweep reclaims it; deleting eagerly would delete the object out from under a publish\n26399 | * that turned out to be ambiguous rather than rejected.\n26400 | ```\n26401 | \n26402 | 두 번째가 `messaging-core-api`의 3상태와 직접 연결된다 — `REJECTED`와 `AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다.\n26403 | \n26404 | 오프로드된 메시지는 payload를 **아예 갖지 않는다**.\n26405 | \n26406 | ```java\n26407 | // The published message carries no payload bytes at all, only the reference. Carrying both\n26408 | // would double the transfer for no benefit and let the two disagree.\n26409 | return new Offloaded(new byte[0], Optional.of(reference));\n26410 | ```\n26411 | \n26412 | `Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다.\n26413 | \n26414 | **`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다(\"Nothing here deletes on failure\"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다.\n26415 | \n26416 | ##### 4.3 `ClaimCheckIntegrityGuard` — 세 검사, 전부 fail-closed\n26417 | \n26418 | | 순서 | 검사 | 코드 |\n26419 | |---:|---|---|\n26420 | | 1 | `reference.isExpired(now)` | `CLAIM_CHECK_EXPIRED` |\n26421 | | 2 | `payload.length != reference.sizeBytes()` | `CLAIM_CHECK_SIZE_MISMATCH` |\n26422 | | 3 | `sha256(payload) != reference.sha256()` | `CLAIM_CHECK_DIGEST_MISMATCH` |\n26423 | \n26424 | ```java\n26425 | // :19-22\n26426 | * Both checks fail closed. An expired reference is reported before the fetch, because a\n26427 | * not-found from the store is ambiguous between \"reaped\" and \"never written\". A digest mismatch is\n26428 | * reported as validation rather than deserialization, because the bytes are not corrupt JSON — they\n26429 | * are the wrong bytes.\n26430 | ```\n26431 | \n26432 | 크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다.\n26433 | \n26434 | `sha256(byte[])`가 `HexFormat.of().formatHex(...)`로 **소문자** hex를 만든다. `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다.\n26435 | \n26436 | `verify`가 검증된 payload의 **복사본**을 반환한다.\n26437 | \n26438 | ##### 4.4 `ClaimCheckResolver` — 만료를 fetch 전에 본다\n26439 | \n26440 | ```java\n26441 | if (claimCheck.isExpired(now)) {\n26442 | // Checked before fetching. A store that still returns the object past its retention would\n26443 | // otherwise hide a misconfiguration until the day the sweep caught up.\n26444 | throw new MessageValidationException(\"CLAIM_CHECK_EXPIRED\", ...);\n26445 | }\n26446 | ```\n26447 | \n26448 | **저장소가 아직 반환하더라도 거절한다.** 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.\n26449 | \n26450 | `fetch`가 `null`을 `CLAIM_CHECK_NOT_FOUND`로 번역하고 메시지가 두 원인을 나열한다 — \"it was either reaped early or never written\".\n26451 | \n26452 | **예외 승격이 코드 접미사로 판정된다.**\n26453 | \n26454 | ```java\n26455 | } catch (MessageValidationException validation) {\n26456 | // A size or digest mismatch is a poison message, not a validation failure to be retried:\n26457 | // fetching the same key again returns the same wrong bytes.\n26458 | if (validation.failure().code().endsWith(\"_MISMATCH\")) {\n26459 | throw new ClaimCheckIntegrityException(\n26460 | validation.failure().code(), validation.failure().sanitizedMessage());\n26461 | }\n26462 | throw validation;\n26463 | }\n26464 | ```\n26465 | \n26466 | `endsWith(\"_MISMATCH\")` — **문자열 접미사로 분기한다.** guard가 코드 이름을 바꾸거나 `_MISMATCH`로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17.\n26467 | \n26468 | ##### 4.5 `ClaimCheckIntegrityException` — 카테고리가 `POISON_MESSAGE`\n26469 | \n26470 | ```java\n26471 | // :12-18\n26472 | * Not retryable. A digest mismatch means the object at that key is not the object the producer\n26473 | * wrote — the key was reused, the object was overwritten, or something truncated it — and fetching\n26474 | * it again returns the same wrong bytes. Retrying would only delay the dead-letter.\n26475 | *\n26476 | * Deliberately distinct from \"the object is gone\". An expired claim check is an operational\n26477 | * problem with a known cause and a known fix; a digest mismatch means something wrote data nobody\n26478 | * expected, and the two must not be diagnosed as one.\n26479 | ```\n26480 | \n26481 | `FailureCategory.POISON_MESSAGE`, `retryable = false`. `messaging-core-api`의 `FailureDescriptor.defaultRetryable`이 `POISON_MESSAGE`를 false로 두는 것과 일치한다.\n26482 | \n26483 | **이 예외가 `MessagingException`을 확장하는 저장소 내 두 곳 중 하나다**(다른 하나는 core-api 자신의 23개). §A19-MESSAGING-CORE-API §12.1(b)가 그 사실을 관측했다.\n26484 | \n26485 | ---\n26486 | \n26487 | #### 5. 주요 실행 경로\n26488 | \n26489 | **발행:** `publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put` → `Offloaded(빈 바이트, reference)`\n26490 | \n26491 | **소비:** `resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH`면 `ClaimCheckIntegrityException`\n26492 | \n26493 | 두 경로 모두 production에서 호출되지 않는다(§12.1).\n26494 | \n26495 | ---\n26496 | \n26497 | #### 6. 실패 경로와 복구/번역\n26498 | \n26499 | | 코드 | 예외 | 카테고리 | retryable | 조건 |\n26500 | |---|---|---|:---:|---|\n26501 | | `CLAIM_CHECK_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | `CONFIGURATION` | false | 정책 생성 시 |\n26502 | | `CLAIM_CHECK_EXPIRED` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 만료 |\n26503 | | `CLAIM_CHECK_NOT_FOUND` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 객체 없음 |\n26504 | | `CLAIM_CHECK_SIZE_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | 크기 불일치 |\n26505 | | `CLAIM_CHECK_DIGEST_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | digest 불일치 |\n26506 | \n26507 | **분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다.\n26508 | \n26509 | `ClaimCheckIntegrityGuard.sha256`이 `NoSuchAlgorithmException`을 `IllegalStateException(\"Java runtime does not provide SHA-256\")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다.\n26510 | \n26511 | ---\n26512 | \n26513 | #### 7. 트랜잭션·동시성·수명주기\n26514 | \n26515 | 트랜잭션 없음.\n26516 | \n26517 | `ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다.\n26518 | \n26519 | `MessageDigest.getInstance(\"SHA-256\")`이 **호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다.\n26520 | \n26521 | `ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다.\n26522 | \n26523 | 수명주기 참여 없음.\n26524 | \n26525 | ---\n26526 | \n26527 | #### 8. 설정·기능 플래그·환경 차이\n26528 | \n26529 | | 상수/기본값 | 값 |\n26530 | |---|---|\n26531 | | `ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES` | 262,144 (1 MiB의 1/4) |\n26532 | | `ClaimCheckPolicy.defaults()` | 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일 |\n26533 | \n26534 | 설정 파일 없음. 모든 값이 생성자 인자다.\n26535 | \n26536 | ---\n26537 | \n26538 | #### 9. 퍼시스턴스/외부 시스템 세부\n26539 | \n26540 | `ClaimCheckStore`가 객체 저장소를 가리키는 port다. **구현이 없다** — production에도, 다른 messaging leaf에도.\n26541 | \n26542 | 저장소의 `adapter/outbound/objectstorage` leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(`messaging-claim-check`의 `allowed_dependencies`에 없고, 반대 방향도 없다).\n26543 | \n26544 | ---\n26545 | \n26546 | #### 10. 테스트 레인과 실제 증명 범위\n26547 | \n26548 | 레인: `./gradlew :messaging:messaging-claim-check:test`. **BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures**.\n26549 | \n26550 | | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n26551 | |---|---:|---|---|\n26552 | | `ClaimCheckIntegrityGuardTest` | 6 | 만료·크기·digest 세 검사 | 실제 저장소 |\n26553 | | `ClaimCheckResolverTest` | 8 | 인라인 통과, 만료 사전 거절, NOT_FOUND, `_MISMATCH` 승격 | **production 호출 여부** |\n26554 | | `ClaimCheckRetentionValidatorTest` | 8 | 보존 불변식과 문턱 판정 | — |\n26555 | \n26556 | `ClaimCheckStore`의 유일한 구현이 `ClaimCheckResolverTest:22`의 `FakeStore`다. 즉 **이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.**\n26557 | \n26558 | `ClaimCheckPublisher`를 겨냥한 테스트 클래스가 **없다.** 오프로드 결정·객체 선기록 순서·`Offloaded`의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다.\n26559 | \n26560 | ---\n26561 | \n26562 | #### 11. 빌드/ArchUnit/CI 강제 지점\n26563 | \n26564 | | 게이트 | 이 leaf에 대해 |\n26565 | |---|---|\n26566 | | `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-reliability-api\"]` |\n26567 | | `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n26568 | | vendor `api` 규칙 | 벤더 의존성 0 |\n26569 | | `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |\n26570 | | ArchUnit | 전용 규칙 없음 |\n26571 | \n26572 | ---\n26573 | \n26574 | #### 12. 실제 사용 여부와 negative-space probes\n26575 | \n26576 | 원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt`.\n26577 | \n26578 | ##### 12.1 Public surface reachability\n26579 | \n26580 | **여섯 타입 전부 leaf 밖 참조 0이다.**\n26581 | \n26582 | | 타입 | leaf 밖 |\n26583 | |---|---:|\n26584 | | `ClaimCheckStore` | 0 |\n26585 | | `ClaimCheckPolicy` | 0 |\n26586 | | `ClaimCheckPublisher` | 0 |\n26587 | | `ClaimCheckResolver` | 0 |\n26588 | | `ClaimCheckIntegrityGuard` | 0 |\n26589 | | `ClaimCheckIntegrityException` | 0 |\n26590 | \n26591 | `ClaimCheckStore` 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다.\n26592 | \n26593 | **그런데 이 leaf는 배포 아티팩트에 실린다.**\n26594 | \n26595 | ```\n26596 | messaging-claim-check runtime_memberships=['app-bootstrap']\n26597 | messaging-spring-boot-starter runtime_memberships=['app-bootstrap']\n26598 | starter deps include claim-check: True\n26599 | ```\n26600 | \n26601 | `messaging-cloudevents`와 같은 조합이다. 형제 비교:\n26602 | \n26603 | | leaf | 소비자 | membership | 정합 |\n26604 | |---|:---:|---|---|\n26605 | | `messaging-schema-avro` | 0 | `[]` | o |\n26606 | | `messaging-schema-protobuf` | 0 | `[]` | o |\n26607 | | `messaging-kafka-share-experimental` | 0 | `[]` | o |\n26608 | | **`messaging-cloudevents`** | **0** | **`[\"app-bootstrap\"]`** | **x** |\n26609 | | **`messaging-claim-check`** | **0** | **`[\"app-bootstrap\"]`** | **x** |\n26610 | \n26611 | **\"싣고 쓰지 않는\" leaf가 둘이다.** 오늘 실행되는 코드가 없으므로 사고는 아니다.\n26612 | \n26613 | **한 가지 정황이 이 leaf를 다르게 만든다.** `messaging-policy`의 `PayloadPolicy`가 `claimCheckThresholdBytes` 필드를 갖고, `DestinationProfileValidator`가 그 값을 검사한다(`:49`). 즉 **목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다.** payload가 문턱을 넘어도 오프로드되지 않고, `PayloadLimitGuard`가 상한 초과로 거절한다 — `MessageTooLargeException(\"PAYLOAD_LIMIT_EXCEEDED\", \"... use claim check\")`. **에러 메시지가 존재하지 않는 경로를 권한다.**\n26614 | \n26615 | ##### 12.2 Conditional sibling comparison\n26616 | \n26617 | Spring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다.\n26618 | \n26619 | `messaging-reliability-api`의 세 port 중 둘(`OutboxRepository`, `InboxRepository`)은 구현 leaf와 starter bean을 갖고 `ClaimCheckStore`는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다.\n26620 | \n26621 | ##### 12.3 Duplicate mechanism sweep\n26622 | \n26623 | **(a) claim check 문턱이 두 곳에 있고 서로를 모른다**\n26624 | \n26625 | | 위치 | 필드 | 검사 |\n26626 | |---|---|---|\n26627 | | `messaging-policy` `PayloadPolicy` | `claimCheckThresholdBytes` | `DestinationProfileValidator:49`가 `<= maxBytes` 확인 |\n26628 | | 이 leaf `ClaimCheckPolicy` | `thresholdBytes` | 생성자가 `>= 1` 확인 |\n26629 | \n26630 | **두 값을 대조하는 코드가 없다.** 목적지 프로파일이 문턱 512 KiB를 선언하고 `ClaimCheckPolicy`가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다.\n26631 | \n26632 | **(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다**\n26633 | \n26634 | | 규칙 | 위치 | 강제 |\n26635 | |---|---|---|\n26636 | | claim check 보존 ≥ 브로커 보존 + 재전달 창 | `ClaimCheckPolicy` 생성자 | **강제됨** |\n26637 | | inbox 보존 > 브로커 최대 재전달 창 | `InboxRepository` javadoc | **문서만** |\n26638 | \n26639 | 같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. §A19-MESSAGING-RELIABILITY-API §17이 후자를 소유한다.\n26640 | \n26641 | **(c) digest 계산이 저장소에 여럿 있는가**\n26642 | \n26643 | `MessageDigest.getInstance(\"SHA-256\")`을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님.\n26644 | \n26645 | **(d) `_MISMATCH` 접미사 분기**\n26646 | \n26647 | `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith(\"_MISMATCH\")`로 예외를 승격한다. `ClaimCheckIntegrityGuard`의 코드 셋 중 둘이 그 접미사를 갖고 하나(`CLAIM_CHECK_EXPIRED`)가 갖지 않는다. **문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다.** §17.\n26648 | \n26649 | ##### 12.4 Documentation / measured-count drift\n26650 | \n26651 | | 문서 주장 | 재측정 | 결과 |\n26652 | |---|---|---|\n26653 | | `ClaimCheckPolicy` javadoc: 문턱이 \"a quarter of the portable payload limit\" | 262,144 = 1,048,576 / 4 | **일치** |\n26654 | | `ClaimCheckPublisher` javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 | 이 leaf에 sweep 없음 | **미실현** |\n26655 | | `ClaimCheckIntegrityGuard` javadoc: 두 검사가 fail closed | 세 검사 전부 예외 | **일치**(검사가 셋인데 javadoc은 \"Both\") |\n26656 | | `PayloadLimitGuard` 에러 메시지: \"use claim check\" | claim check 경로 미배선 | **불일치** |\n26657 | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치**(family drift) |\n26658 | \n26659 | 세 번째가 작은 표현 drift다 — javadoc이 \"Both checks fail closed\"라고 하는데 `verify`는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다.\n26660 | \n26661 | ---\n26662 | \n26663 | #### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n26664 | \n26665 | 이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다.\n26666 | \n26667 | | 위치 | 막으려는 것 |\n26668 | |---|---|\n26669 | | `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. \"rare in a test and routine under load\" |\n26670 | | `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 |\n26671 | | `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 |\n26672 | | `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 |\n26673 | | `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter |\n26674 | | `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 |\n26675 | \n26676 | **\"rare in a test and routine under load\"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다.\n26677 | \n26678 | ---\n26679 | \n26680 | #### 14. 런타임·터미널 Evidence\n26681 | \n26682 | | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n26683 | |---|---|---|---|---|\n26684 | | EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §A·§B | 여섯 타입 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 | 정적 검색 |\n26685 | | EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | BUILD SUCCESSFUL, 22 / 0 / 0 | 저장소가 fake. publisher 미검증 |\n26686 | \n26687 | ---\n26688 | \n26689 | #### 15. 명시적 설계 이유와 추론을 구분한 정리\n26690 | \n26691 | **명시적**\n26692 | \n26693 | - 두 시스템이 drift한다는 위협 모델 — `ClaimCheckIntegrityGuard` javadoc\n26694 | - 검증이 선택 불가인 이유 — `ClaimCheckResolver` javadoc\n26695 | - 저장이 발행보다 먼저인 이유 — `ClaimCheckPublisher` javadoc\n26696 | - 실패 시 삭제하지 않는 이유 — 같은 javadoc\n26697 | - payload와 참조를 함께 나르지 않는 이유 — 인라인 주석\n26698 | - 만료를 fetch 전에 보는 이유 — `resolve` 인라인 주석\n26699 | - 보존 규칙과 그것을 생성자가 강제하는 이유 — `ClaimCheckPolicy` javadoc\n26700 | - 문턱과 목적지 상한이 다른 이유 — 같은 javadoc\n26701 | - digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — `ClaimCheckIntegrityException` javadoc\n26702 | - `_MISMATCH` 승격이 poison message인 이유 — `verify` 인라인 주석\n26703 | \n26704 | **추론**\n26705 | \n26706 | - 배선되지 않은 것이 미완인지 확장점인지 → **미상**. `ClaimCheckStore` 구현이 없다는 관측만 있다.\n26707 | - `_MISMATCH` 접미사 규약이 의도인지 → **미상**. 선언된 곳이 없다.\n26708 | - javadoc의 \"Both checks\"가 세 검사가 되기 전 표현인지 → **추론**.\n26709 | \n26710 | ---\n26711 | \n26712 | #### 16. 확인한 것 / 확인하지 못한 것\n26713 | \n26714 | **확인한 것**\n26715 | \n26716 | - 6개 타입 418줄 전문의 계약\n26717 | - 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 `ClaimCheckPublisher`가 미검증이라는 것\n26718 | - 여섯 타입 전부 leaf 밖 참조 0이고 `ClaimCheckStore` 구현이 테스트 fake뿐이라는 것\n26719 | - `runtime_memberships`가 `[\"app-bootstrap\"]`이라 배포 아티팩트에 실린다는 것\n26720 | - `PayloadLimitGuard`의 에러 메시지가 배선되지 않은 경로를 권한다는 것\n26721 | - 문턱이 두 곳에 있고 대조되지 않는다는 것\n26722 | \n26723 | **확인하지 못한 것**\n26724 | \n26725 | - **`ClaimCheckStore`를 구현할 계획이 있는지.** `adapter/outbound/objectstorage`가 후보이지만 두 leaf가 registry에서 연결되지 않는다.\n26726 | - 보존 sweep을 누가 도는지 — `ClaimCheckStore.delete`의 호출자가 없다.\n26727 | - 실제 객체 저장소에서 `store.get`이 만료 후에도 반환하는지 — `resolve`의 사전 만료 검사가 그 경우를 상정한다.\n26728 | - 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가.\n26729 | \n26730 | ---\n26731 | \n26732 | #### 17. 손볼 것\n26733 | \n26734 | ##### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다\n26735 | \n26736 | - **사실.** 여섯 타입 전부 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\"`라고 안내한다.\n26737 | - **근거.** `evidence/raw/290` §A. `PayloadLimitGuard.java:46-49`.\n26738 | - **왜 문제인가.** 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 `DestinationProfile`이 `claimCheckThresholdBytes`를 선언하고 검증까지 하므로 **설정 표면은 존재한다.** 설정할 수 있고 아무 효과가 없는 값이다.\n26739 | - **확인 방법.** `evidence/raw/290` §A 재실행. `git grep -n 'use claim check' -- src`.\n26740 | - **후보.** (a) `ClaimCheckStore` 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 `[]`로 되돌리고 `PayloadLimitGuard` 메시지에서 안내를 뺀다. (c) 미완임을 `support-matrix.md`에 표시한다.\n26741 | - **다음 단계.** **CASE 후보.** `messaging-cloudevents` §17의 \"싣고 쓰지 않는다\"와 같은 계열이지만, 여기서는 **다른 컴포넌트가 이 경로를 권한다**는 점이 추가된다.\n26742 | \n26743 | ##### P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다\n26744 | \n26745 | - **사실.** `messaging-policy`의 `PayloadPolicy.claimCheckThresholdBytes`(목적지별, `DestinationProfileValidator:49`가 검사)와 이 leaf의 `ClaimCheckPolicy.thresholdBytes`(전역). 두 값을 대조하는 코드가 없다.\n26746 | - **근거.** `evidence/raw/290` §B.\n26747 | - **왜 문제인가.** 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다.\n26748 | - **확인 방법.** 두 필드와 검증기 확인.\n26749 | - **후보.** `ClaimCheckPublisher`가 목적지 프로파일의 값을 읽거나, `PayloadPolicy`에서 그 필드를 제거한다.\n26750 | - **다음 단계.** **REFERENCE 후보**(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다).\n26751 | \n26752 | ##### P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다\n26753 | \n26754 | - **사실.** `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith(\"_MISMATCH\")`로 `ClaimCheckIntegrityException` 승격을 결정한다. `ClaimCheckIntegrityGuard`의 세 코드 중 둘이 그 접미사를 갖는다.\n26755 | - **근거.** `ClaimCheckResolver.java:84`.\n26756 | - **왜 문제인가.** 두 클래스 사이의 계약이 **문자열 명명 규약**이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: `CLAIM_CHECK_DIGEST_INVALID`) 승격이 조용히 멈추고 poison message가 `PERMANENT_BUSINESS`로 분류된다 — 재시도 정책이 달라진다.\n26757 | - **확인 방법.** `git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check`\n26758 | - **후보.** guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다.\n26759 | - **다음 단계.** **CASE 후보 + REFERENCE 후보**(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다).\n26760 | \n26761 | ##### P3 — `ClaimCheckPublisher`가 이 leaf의 테스트에 등장하지 않는다\n26762 | \n26763 | - **사실.** 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다.\n26764 | - **근거.** `find src/test -name '*Test.java'` → 셋.\n26765 | - **왜 문제인가.** publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(`shouldOffload`), 오프로드 시 payload를 비우는 것, `Offloaded`의 양방향 방어 복사. 특히 \"저장이 발행보다 먼저\"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다.\n26766 | - **확인 방법.** 세 테스트 클래스 이름 확인.\n26767 | - **후보.** `ClaimCheckPublisherTest`를 추가한다.\n26768 | - **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다).\n26769 | \n26770 | ##### P3 — 보존 sweep이 없다\n26771 | \n26772 | - **사실.** `ClaimCheckStore.delete`가 선언돼 있고 이 leaf에서 호출되지 않는다. `ClaimCheckPublisher` javadoc이 \"the retention sweep reclaims it\"이라고 그 존재를 전제한다.\n26773 | - **근거.** `git grep -n 'delete(' -- src/messaging/messaging-claim-check` → 인터페이스 선언만.\n26774 | - **왜 문제인가.** 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 `ClaimCheckPolicy.retention`이 그것과 연결되지 않는다.\n26775 | - **확인 방법.** `delete` 호출자 검색.\n26776 | - **후보.** sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다.\n26777 | - **다음 단계.** **OPEN QUESTION 후보.** 판정이 `ClaimCheckStore` 구현 계획에 걸린다.\n26778 | \n26779 | ##### 확인된 설계(문제 아님)\n26780 | \n26781 | - 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것\n26782 | - 문턱과 목적지 상한을 분리하고 그 이유를 적은 것\n26783 | - 객체를 발행보다 먼저 저장하는 순서\n26784 | - 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것\n26785 | - 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것\n26786 | - 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것\n26787 | - 크기 검사를 digest보다 먼저 두는 것\n26788 | - 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것\n26789 | - `MessageDigest`를 호출마다 새로 만드는 것\n26790 | \n26791 | ---\n26792 | \n26793 | #### Source anchors\n26794 | \n26795 | | id | kind | path | revision | what it proves | limitations |\n26796 | |---|---|---|---|---|---|\n26797 | | MCC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `[\"app-bootstrap\"]` | 선언 |\n26798 | | MCC-002 | build | `messaging-claim-check/build.gradle` | same | 벤더 의존성 0 | — |\n26799 | | MCC-003 | code | `.../claimcheck/ClaimCheckPolicy.java` | same | §4.1 보존 불변식과 문턱 | — |\n26800 | | MCC-004 | code | `.../claimcheck/ClaimCheckPublisher.java` | same | §4.2 순서·미삭제·빈 payload | 전용 테스트 없음 |\n26801 | | MCC-005 | code | `.../claimcheck/ClaimCheckIntegrityGuard.java` | same | §4.3 세 검사 | — |\n26802 | | MCC-006 | code | `.../claimcheck/ClaimCheckResolver.java` | same | §4.4 사전 만료 확인, 접미사 승격 | 접미사 의존(§17) |\n26803 | | MCC-007 | code | `.../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java` | same | port 계약, POISON_MESSAGE 분류 | 구현 없음 |\n26804 | | MCC-008 | test | 3 클래스 / 22 테스트 | same | §10 표 | fake 저장소. publisher 미검증 |\n26805 | | MCC-009 | cross-leaf code | `messaging-policy/.../PayloadLimitGuard.java:46-49` | same | \"use claim check\" 안내 | 해당 leaf SSOT가 소유 |\n26806 | | MCC-010 | cross-leaf code | `messaging-policy/.../PayloadPolicy.java:14`, `DestinationProfileValidator.java:49` | same | 두 번째 문턱과 그 검증 | 해당 leaf SSOT가 소유 |\n26807 | | EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` | same | §12.1·§12.3 | 정적 검색 |\n26808 | | EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | same | 22 / 0 / 0 | — |\n26809 | \n26810 | ---\n26811 | \n26812 | ## A19-MESSAGING-CLOUDEVENTS. messaging-cloudevents\n26813 | \n26814 | > 분석 중에는 `messaging/MESSAGING-CLOUDEVENTS.md` 파일이었다. 594줄.\n26815 | \n26816 | ### messaging-cloudevents 완전 해부\n26817 | \n26818 | > 상태: COMPLETE\n26819 | > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n26820 | > 분석 범위: `src/messaging/messaging-cloudevents`\n26821 | > SSOT owner: `messaging-cloudevents`\n26822 | > integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n26823 | \n26824 | ---\n26825 | \n26826 | #### 0. SSOT identity / 커버리지와 숫자 지도\n26827 | \n26828 | - registered leaf id: `messaging-cloudevents`\n26829 | - canonical state `analysisFile`: §A19-MESSAGING-CLOUDEVENTS\n26830 | - source path: `src/messaging/messaging-cloudevents`\n26831 | - registry `allowed_dependencies`: `[\"messaging-core-api\", \"messaging-schema-api\"]`\n26832 | - registry `runtime_memberships`: **`[\"app-bootstrap\"]`**\n26833 | \n26834 | ##### 숫자\n26835 | \n26836 | | 항목 | 수 |\n26837 | |---|---:|\n26838 | | production Java 파일 | 3 |\n26839 | | production LOC | 228 |\n26840 | | 패키지 | 1 (`dev.caskeleton.messaging.cloudevents`) |\n26841 | | test 파일 | 1 |\n26842 | | test 메서드(실행 확인) | 7 |\n26843 | | 외부 의존성 | 2 (`cloudevents-api:4.0.1` **api**, `cloudevents-core:4.0.1` implementation) |\n26844 | \n26845 | 세 타입: `CloudEventMapper`(인터페이스), `DefaultCloudEventMapper`(구현), `CloudEventExtensions`(확장 속성 이름 4개).\n26846 | \n26847 | ##### Coverage ledger\n26848 | \n26849 | | scope/file group | count | disposition | reason |\n26850 | |---|---:|---|---|\n26851 | | `.../cloudevents/CloudEventMapper.java` | 1 | `FULL_READ` | 33줄 전문 |\n26852 | | `.../cloudevents/DefaultCloudEventMapper.java` | 1 | `FULL_READ` | 171줄 전문 |\n26853 | | `.../cloudevents/CloudEventExtensions.java` | 1 | `FULL_READ` | 24줄 전문 |\n26854 | | `src/test/java/**` | 1 | `FULL_READ` | 162줄 전문 |\n26855 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |\n26856 | | `gradle.lockfile` | 1 | `FULL_READ` | cloudevents 좌표 2건 확인 |\n26857 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n26858 | \n26859 | `UNCLASSIFIED` 0.\n26860 | \n26861 | ---\n26862 | \n26863 | #### 1. 모듈의 정체와 경계\n26864 | \n26865 | 플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼.\n26866 | \n26867 | 적용 범위를 인터페이스 javadoc이 한정한다.\n26868 | \n26869 | ```java\n26870 | // CloudEventMapper.java:11-13\n26871 | * Offered for domain and integration events only. Commands and work items are not forced through\n26872 | * CloudEvents: they are internal contracts where the interoperability the specification buys does\n26873 | * not pay for the attributes it requires.\n26874 | ```\n26875 | \n26876 | 의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다.\n26877 | \n26878 | ```groovy\n26879 | // api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent.\n26880 | //\n26881 | // Declared `implementation`, the type appeared in this module's public API while the\n26882 | // dependency was hidden from consumers: an adopter calling the documented method could not\n26883 | // name its return type without adding CloudEvents to their own build, and Gradle gave them no\n26884 | // hint why. A type in a public signature is part of the artifact's contract.\n26885 | api 'io.cloudevents:cloudevents-api:4.0.1'\n26886 | implementation 'io.cloudevents:cloudevents-core:4.0.1'\n26887 | ```\n26888 | \n26889 | **둘의 scope가 다른 것이 정확하다.** `cloudevents-api`(`CloudEvent`, `CloudEventData`)는 public 시그니처에 나오므로 `api`, `cloudevents-core`(`CloudEventBuilder`, `BytesCloudEventData`)는 구현 안에서만 쓰이므로 `implementation`이다. `src/messaging/CLAUDE.md:40-43`의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다.\n26890 | \n26891 | **이 leaf의 위치가 형제들과 다르다.** `runtime_memberships`가 `[\"app-bootstrap\"]`이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 \"싣지도 않고 쓰지도 않는다\"로 정합하지만, 이 leaf는 **싣고 쓰지 않는다.**\n26892 | \n26893 | ---\n26894 | \n26895 | #### 2. 의존성과 런타임 배선\n26896 | \n26897 | 들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `cloudevents-api:4.0.1`(api), `cloudevents-core:4.0.1`(implementation).\n26898 | \n26899 | 나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다. 그래서 `app-bootstrap` → starter → 이 leaf 경로로 런타임 classpath에 오른다.\n26900 | \n26901 | **그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다.** starter의 어느 `@Bean`도 `CloudEventMapper`를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1).\n26902 | \n26903 | bean 없음(Spring 주석 0개).\n26904 | \n26905 | ---\n26906 | \n26907 | #### 3. 패키지/컴포넌트 지도\n26908 | \n26909 | ```\n26910 | CloudEventMapper (interface)\n26911 | ├── toCloudEvent(MessageEnvelope>, URI) → CloudEvent\n26912 | └── fromCloudEvent(CloudEvent) → MessageEnvelope Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather\n26971 | * than defaulted to the production instant, because {@code time} is read downstream as when the\n26972 | * fact happened, not when the platform got around to serialising it. And an event with no data maps\n26973 | * to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and\n26974 | * inventing one from an absent CloudEvent payload would turn an empty notification into a deletion.\n26975 | ```\n26976 | \n26977 | 두 번째는 `messaging-core-api`의 `MessageEnvelope` javadoc과 정확히 짝을 이룬다 — \"A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics.\" 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다.\n26978 | \n26979 | ##### 4.3 `producerFrom`: 무한 URI를 유한 이름으로\n26980 | \n26981 | ```java\n26982 | // :158-163\n26983 | * The last path or scheme-specific segment is used so that a long URI does not become an\n26984 | * unbounded producer name, which would leak straight into metric tags.\n26985 | private static String producerFrom(URI source) {\n26986 | String text = source.toString();\n26987 | int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));\n26988 | String candidate =\n26989 | separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;\n26990 | return candidate.isBlank() ? \"unknown\" : candidate;\n26991 | }\n26992 | ```\n26993 | \n26994 | `ProducerId`가 \"deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags\"라고 선언한 것과 같은 관심사다.\n26995 | \n26996 | **다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5.\n26997 | \n26998 | `urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`.\n26999 | \n27000 | ##### 4.4 `time`이 두 필드로 복제된다\n27001 | \n27002 | ```java\n27003 | Instant occurredAt = time.toInstant();\n27004 | return new MessageEnvelope<>(\n27005 | ..., occurredAt, // producedAt\n27006 | Optional.of(occurredAt), // occurredAt\n27007 | ...);\n27008 | ```\n27009 | \n27010 | CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.\n27011 | \n27012 | ##### 4.5 왕복에서 소실되는 것\n27013 | \n27014 | `fromCloudEvent`가 항상 비우는 필드가 다섯이다.\n27015 | \n27016 | | 필드 | 결과 |\n27017 | |---|---|\n27018 | | `partitionKey` | `Optional.empty()` |\n27019 | | `orderingKey` | `Optional.empty()` |\n27020 | | `traceContext` | `TraceContext.none()` |\n27021 | | `headers` | `MessageHeaders.empty()` |\n27022 | | `producedAt` | `occurredAt`으로 덮임 |\n27023 | \n27024 | **`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — \"Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet.\" CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.\n27025 | \n27026 | `toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다.\n27027 | \n27028 | ##### 4.6 `id`의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약\n27029 | \n27030 | ```java\n27031 | new MessageId(UUID.fromString(event.getId()))\n27032 | ```\n27033 | \n27034 | CloudEvents 1.0.2는 `id`를 **\"Type: String; Constraints: REQUIRED, MUST be a non-empty string\"**으로 정의한다. UUID 형식 요구가 없다.\n27035 | \n27036 | `MessageId`(messaging-core-api)는 UUID이면서 **version 7 · variant 2**를 요구한다.\n27037 | \n27038 | 두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(`evidence/raw/273-cloudevents-inbound-id-probe.txt`).\n27039 | \n27040 | ```\n27041 | --- spec-conformant opaque string id\n27042 | id = A234-1234-1234\n27043 | result = REJECTED\n27044 | thrown = java.lang.IllegalArgumentException\n27045 | message = Invalid UUID string: A234-1234-1234\n27046 | is a MessagingException (carries FailureDescriptor) = false\n27047 | \n27048 | --- UUIDv4 id\n27049 | id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11\n27050 | result = REJECTED\n27051 | thrown = java.lang.IllegalArgumentException\n27052 | message = a message identity is UUIDv7 (time-ordered); this is version 4\n27053 | is a MessagingException (carries FailureDescriptor) = false\n27054 | \n27055 | --- UUIDv7 id (what this platform mints)\n27056 | result = ACCEPTED\n27057 | ```\n27058 | \n27059 | `A234-1234-1234`는 CloudEvents 명세 자신의 예시가 쓰는 id다.\n27060 | \n27061 | **의도는 문서화돼 있다.** 테스트에 주석이 있다.\n27062 | \n27063 | ```java\n27064 | // CloudEventMappingTest.java:90-91\n27065 | // A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign\n27066 | // producer is refused here exactly as it would be on the wire.\n27067 | ```\n27068 | \n27069 | 즉 \"외부 producer의 v4를 거절한다\"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다.\n27070 | \n27071 | 1. **비UUID id는 명세 위반이 아니다.** v4 거절은 정책 선택이지만, `A234-1234-1234` 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다.\n27072 | 2. **실패가 플랫폼 어휘 밖이다.** 이 매퍼의 다른 모든 검증 실패는 `MessageValidationException`(→ `FailureDescriptor`, `PERMANENT_BUSINESS`, 안정 코드)이다. id 실패만 raw `IllegalArgumentException`이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다.\n27073 | \n27074 | §17에서 다룬다.\n27075 | \n27076 | ##### 4.7 `schemaversion` 확장이 필수다\n27077 | \n27078 | ```java\n27079 | private static int intExtension(CloudEvent event, String name) {\n27080 | return stringExtension(event, name)\n27081 | .map(value -> { try { return Integer.valueOf(value); }\n27082 | catch (NumberFormatException e) {\n27083 | throw new MessageValidationException(\"CLOUDEVENT_SCHEMA_VERSION_INVALID\", ...); } })\n27084 | .orElseThrow(() -> new MessageValidationException(\"CLOUDEVENT_SCHEMA_VERSION_REQUIRED\",\n27085 | \"schemaversion extension is required by this profile\"));\n27086 | }\n27087 | ```\n27088 | \n27089 | 에러 메시지가 \"**by this profile**\"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — `id`의 UUIDv7 요구에는 그런 표시가 없다.\n27090 | \n27091 | 이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. `id`와 합치면 **이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.**\n27092 | \n27093 | ##### 4.8 `toCloudEvent`의 payload 계약\n27094 | \n27095 | ```java\n27096 | if (envelope.payload() instanceof EncodedMessage encoded) { ... }\n27097 | else if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); }\n27098 | else { throw new MessageValidationException(\"CLOUDEVENT_PAYLOAD_NOT_ENCODED\", ...); }\n27099 | ```\n27100 | \n27101 | 이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. `byte[]` 분기에서 `clone()`하는 것도 `EncodedMessage.bytes()`가 이미 복사본을 주는 것과 대칭이다.\n27102 | \n27103 | ---\n27104 | \n27105 | #### 5. 주요 실행 경로\n27106 | \n27107 | **나가는 방향:** `occurredAt` 확인(없으면 거절) → `CloudEventBuilder.v1()`에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → `dataschema`(있을 때) → `build()`\n27108 | \n27109 | **들어오는 방향:** `time` 확인(없으면 거절) → `datacontenttype`(기본 `application/json`) → `data`(없으면 빈 배열) → `MessageId`·`MessageType`·`SchemaVersion`·`ProducerId`·확장 셋 → `MessageEnvelope` 조립\n27110 | \n27111 | ---\n27112 | \n27113 | #### 6. 실패 경로와 복구/번역\n27114 | \n27115 | | 코드 | 예외 | 방향 | 조건 |\n27116 | |---|---|---|---|\n27117 | | `CLOUDEVENT_TIME_REQUIRED` | `MessageValidationException` | 양방향 | `occurredAt` 없음 / `time` 없음 |\n27118 | | `CLOUDEVENT_PAYLOAD_NOT_ENCODED` | `MessageValidationException` | 나가는 | payload가 `EncodedMessage`도 `byte[]`도 아님 |\n27119 | | `CLOUDEVENT_SCHEMA_VERSION_REQUIRED` | `MessageValidationException` | 들어오는 | 확장 없음 |\n27120 | | `CLOUDEVENT_SCHEMA_VERSION_INVALID` | `MessageValidationException` | 들어오는 | 확장이 정수가 아님 |\n27121 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `id`가 UUID가 아니거나 v7이 아님 |\n27122 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `causationid`가 UUID가 아니거나 v7이 아님 |\n27123 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `type`이 `MessageType` 제약 위반(240바이트·제어문자) |\n27124 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `correlationid`가 160바이트 초과 |\n27125 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `tenantcontext`가 슬러그 패턴 위반 |\n27126 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | 유도된 producer 이름이 120바이트 초과 또는 제어문자 |\n27127 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `datacontenttype`이 미디어 타입 문법 위반 |\n27128 | | **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `schemaversion`이 0 이하 |\n27129 | \n27130 | **분류된 실패 넷, 분류되지 않은 실패 여덟.** 매퍼가 직접 던지는 것은 전부 `MessageValidationException`이지만, 값 객체 생성자에 위임한 검증은 전부 raw `IllegalArgumentException`이다. `fromCloudEvent`는 **외부에서 온 데이터**를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다.\n27131 | \n27132 | `messaging-core-api`의 `FailureDescriptor` 설계 전체가 \"예외 클래스로 분기하지 말고 선언된 분류로 판단하라\"였다. 이 경로는 그 분류를 만들지 않는다.\n27133 | \n27134 | ---\n27135 | \n27136 | #### 7. 트랜잭션·동시성·수명주기\n27137 | \n27138 | 트랜잭션 없음.\n27139 | \n27140 | `DefaultCloudEventMapper`는 **상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다.\n27141 | \n27142 | `CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다.\n27143 | \n27144 | `CloudEventBuilder`는 호출마다 새로 만들어진다.\n27145 | \n27146 | ---\n27147 | \n27148 | #### 8. 설정·기능 플래그·환경 차이\n27149 | \n27150 | 설정 없음.\n27151 | \n27152 | | 상수 | 값 | 위치 |\n27153 | |---|---|---|\n27154 | | `SPEC_CONTENT_TYPE_FALLBACK` | `\"application/json\"` | `DefaultCloudEventMapper.java:39` (private) |\n27155 | | `CloudEventExtensions.CORRELATION_ID` | `\"correlationid\"` | public |\n27156 | | `CloudEventExtensions.CAUSATION_ID` | `\"causationid\"` | public |\n27157 | | `CloudEventExtensions.SCHEMA_VERSION` | `\"schemaversion\"` | public |\n27158 | | `CloudEventExtensions.TENANT_CONTEXT` | `\"tenantcontext\"` | public |\n27159 | \n27160 | CloudEvents 버전은 `4.0.1`로 고정(lockfile 확인). CloudEvents **명세** 버전은 `CloudEventBuilder.v1()`이 고정한다 — javadoc은 1.0.2를 명시한다.\n27161 | \n27162 | ---\n27163 | \n27164 | #### 9. 퍼시스턴스/외부 시스템 세부\n27165 | \n27166 | 없다.\n27167 | \n27168 | ---\n27169 | \n27170 | #### 10. 테스트 레인과 실제 증명 범위\n27171 | \n27172 | 레인: `./gradlew :messaging:messaging-cloudevents:test`. **BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures**.\n27173 | \n27174 | | 테스트 | 증명하는 것 |\n27175 | |---|---|\n27176 | | `mapsLogicalIdentityAndExtensions` | id·type·schemaversion·source·datacontenttype |\n27177 | | `mapsCorrelationAndTenantAsExtensions` | 두 확장 |\n27178 | | `mapsOccurredAtToEventTime` | `occurredAt` → `time` |\n27179 | | `rejectsAnEventEnvelopeWithoutOccurredAt` | 나가는 방향의 `time` 필수 |\n27180 | | `roundTripsBackToAnEnvelopeWithoutInventingATombstone` | 왕복 시 6개 필드 보존 |\n27181 | | `aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue` | 빈 data → 빈 바이트(tombstone 아님) |\n27182 | | `rejectsAnUnencodedPayload` | 인코딩되지 않은 payload 거절 |\n27183 | \n27184 | **이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.**\n27185 | \n27186 | `fromCloudEvent`를 부르는 두 테스트 중 하나는 `mapper.toCloudEvent(original, SOURCE)`의 출력을 되돌리고, 다른 하나는 `MessageId.newId()`로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — \"A v7 id: MessageId enforces the version it documents\".\n27187 | \n27188 | 즉 **외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다.** 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다.\n27189 | \n27190 | **왕복 검증의 선택적 비교.** `roundTripsBackToAnEnvelopeWithoutInventingATombstone`이 `producedAt`·`traceContext`·`headers`·`partitionKey`·`orderingKey`를 비교하지 않는다. fixture는 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 \"roundTrips\"인데 실제로는 6개 필드의 부분 보존을 확인한다.\n27191 | \n27192 | ---\n27193 | \n27194 | #### 11. 빌드/ArchUnit/CI 강제 지점\n27195 | \n27196 | | 게이트 | 이 leaf에 대해 |\n27197 | |---|---|\n27198 | | `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\",\"messaging-schema-api\"]` |\n27199 | | `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` — 편입이 강제됨 |\n27200 | | vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | `cloudevents-api`는 public 시그니처에 등장 → `api`. `cloudevents-core`는 구현 전용 → `implementation`. **통과** |\n27201 | | ArchUnit | 전용 규칙 없음 |\n27202 | \n27203 | ---\n27204 | \n27205 | #### 12. 실제 사용 여부와 negative-space probes\n27206 | \n27207 | 원시 증거: `evidence/raw/272-schema-family-reachability.txt`, `evidence/raw/273-cloudevents-inbound-id-probe.txt`.\n27208 | \n27209 | ##### 12.1 Public surface reachability\n27210 | \n27211 | | 타입 | leaf 밖 참조 | 판정 |\n27212 | |---|---:|---|\n27213 | | `CloudEventMapper` | **0** | 소비자 없음 |\n27214 | | `DefaultCloudEventMapper` | **0** | 소비자 없음 |\n27215 | | `CloudEventExtensions` | **0** | 소비자 없음 |\n27216 | \n27217 | 세 타입 모두 `git grep` exit 1.\n27218 | \n27219 | **형제와 다른 조합이다.**\n27220 | \n27221 | | leaf | 소비자 | starter codec 등록 | `runtime_memberships` | 정합 |\n27222 | |---|:---:|:---:|---|---|\n27223 | | `messaging-schema-json` | 1 | o | `[\"app-bootstrap\"]` | o |\n27224 | | `messaging-schema-avro` | 0 | x | `[]` | o |\n27225 | | `messaging-schema-protobuf` | 0 | x | `[]` | o |\n27226 | | **`messaging-cloudevents`** | **0** | 해당 없음 | **`[\"app-bootstrap\"]`** | **x** |\n27227 | \n27228 | Avro·Protobuf는 \"싣지 않고 쓰지 않는다\"로 정합한다. 이 leaf는 **싣고 쓰지 않는다.** `messaging-spring-boot-starter`의 `allowed_dependencies`에 들어 있어 배포 아티팩트가 `cloudevents-api`와 `cloudevents-core` 두 jar를 함께 싣는다.\n27229 | \n27230 | 지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, \"이 의존성이 왜 여기 있지?\"를 나중에 조사할 사람의 시간이다.\n27231 | \n27232 | ##### 12.2 Conditional sibling comparison\n27233 | \n27234 | Spring 주석 0개, bean 없음.\n27235 | \n27236 | **조립 비대칭은 starter 쪽에서 관측된다.** `MessagingCoreAutoConfiguration`이 `JacksonMessageCodec`으로 codec registry를 만드는 `@Bean`을 갖는데, `CloudEventMapper`를 만드는 `@Bean`은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 `messaging-spring-boot-starter` leaf SSOT가 소유한다.\n27237 | \n27238 | ##### 12.3 Duplicate mechanism sweep\n27239 | \n27240 | **(a) 다른 CloudEvents 구현이 있는가 — 없다**\n27241 | \n27242 | `git grep -l 'io.cloudevents' -- src`가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다.\n27243 | \n27244 | **(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다**\n27245 | \n27246 | `messaging-kafka`의 `KafkaHeaderMapper`/`KafkaDeliveryMapper`, `messaging-rabbit`의 `RabbitDeliveryMapper`가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 **transport 매핑**이고 이것은 **interchange 포맷 매핑**이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다).\n27247 | \n27248 | 다만 겹치는 관심사가 하나 있다 — `traceContext`. 브로커 매퍼들은 `traceparent`/`tracestate`/`baggage`를 예약 헤더로 실어 나르고(`ReservedHeaders`가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다.\n27249 | \n27250 | **(c) UUID 파싱** — `UUID.fromString`을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다.\n27251 | \n27252 | ##### 12.4 Documentation / measured-count drift\n27253 | \n27254 | | 문서 주장 | 재측정 | 결과 |\n27255 | |---|---|---|\n27256 | | build.gradle 주석: `cloudevents-api`가 public 시그니처에 등장 | `CloudEventMapper`의 두 메서드가 `CloudEvent`를 반환/수취 | **일치** |\n27257 | | build.gradle 주석: `cloudevents-core`는 구현 전용 | `CloudEventBuilder`·`BytesCloudEventData`가 `DefaultCloudEventMapper` 안에서만 | **일치** |\n27258 | | 클래스 javadoc: \"CloudEvents 1.0.2 compatible profile\" | `id` 제약이 명세보다 엄격(§4.6). `schemaversion` 확장 필수 | **부분 불일치** — 아래 참조 |\n27259 | | `CloudEventMapper` javadoc: domain/integration event 전용 | 코드에 그 구분을 강제하는 것 없음 | **미강제** — 정책 진술이고 게이트가 없다 |\n27260 | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `[\"app-bootstrap\"]` | **불일치** — family drift의 사례(`messaging-core-api` §12.4) |\n27261 | \n27262 | **\"compatible profile\"의 정확한 의미.** 명세는 `id`를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. **나가는 방향은 명세를 만족한다**(UUID 문자열은 유효한 id다). **들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다.** javadoc의 \"compatible\"이 어느 방향을 말하는지 밝히지 않는다. `schemaversion` 에러 메시지는 \"required by this profile\"이라고 정확히 적는 반면 `id` 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다.\n27263 | \n27264 | ---\n27265 | \n27266 | #### 13. Git/설계 문서에서 확인한 변화와 실패 기록\n27267 | \n27268 | build.gradle 주석이 이전 결함 하나를 보존한다.\n27269 | \n27270 | > Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract.\n27271 | \n27272 | 이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — \"source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조\". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다.\n27273 | \n27274 | 코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — \"defaulted to the production instant\"와 \"inventing a tombstone\"은 하지 않기로 한 것들이다.\n27275 | \n27276 | ---\n27277 | \n27278 | #### 14. 런타임·터미널 Evidence\n27279 | \n27280 | | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |\n27281 | |---|---|---|---|---|\n27282 | | EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 세 타입의 소비자 0, membership `[\"app-bootstrap\"]` | 정적 검색 |\n27283 | | **EVD-273** | **runtime probe** | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, `MessagingException` 여부 | 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인 |\n27284 | | EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | BUILD SUCCESSFUL, 7 / 0 / 0 | 외부 producer 입력 없음 |\n27285 | \n27286 | EVD-273의 실행 방법: `:messaging:messaging-cloudevents` test runtimeClasspath에 대해 `/tmp/CeProbe.java`를 컴파일·실행. 저장소 파일은 읽기만 했다.\n27287 | \n27288 | ---\n27289 | \n27290 | #### 15. 명시적 설계 이유와 추론을 구분한 정리\n27291 | \n27292 | **명시적**\n27293 | \n27294 | - domain/integration event 전용인 이유 — `CloudEventMapper` javadoc\n27295 | - `occurredAt` 없는 이벤트를 거절하는 이유 — `DefaultCloudEventMapper` javadoc\n27296 | - 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc\n27297 | - producer 이름을 마지막 세그먼트로 자르는 이유 — `producerFrom` javadoc\n27298 | - 확장 이름이 봉투 필드명과 다른 이유 — `CloudEventExtensions` javadoc\n27299 | - `cloudevents-api`가 `api`여야 하는 이유 — build.gradle 주석\n27300 | - v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(`CloudEventMappingTest.java:90-91`)\n27301 | \n27302 | **추론**\n27303 | \n27304 | - 비UUID id 거절이 의도인지 → **미상**. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다.\n27305 | - `traceContext`·`headers`를 버리는 것이 의도인지 → **미상**. 어디에도 언급이 없다.\n27306 | - `producedAt`을 `occurredAt`으로 덮는 것이 의도인지 → **추론**. CloudEvents에 `time`이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다.\n27307 | - membership이 있고 소비자가 없는 이유 → **미상**.\n27308 | \n27309 | ---\n27310 | \n27311 | #### 16. 확인한 것 / 확인하지 못한 것\n27312 | \n27313 | **확인한 것**\n27314 | \n27315 | - 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표\n27316 | - 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지\n27317 | - 소비자 0인데 `runtime_memberships`가 `[\"app-bootstrap\"]`이라는 비정합\n27318 | - **명세 예시 id와 UUIDv4가 분류되지 않은 `IllegalArgumentException`으로 거절된다는 것 — 런타임 probe로 실행 확인**\n27319 | - 왕복에서 다섯 필드가 소실된다는 것\n27320 | - `api`/`implementation` 분리가 정확하다는 것\n27321 | \n27322 | **확인하지 못한 것**\n27323 | \n27324 | - 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다.\n27325 | - 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다.\n27326 | - `dataschema`가 실제로 쓰이는지 — `EncodedMessage.schemaReference().schemaUri()`가 채워지는 경로가 이 저장소에 없다(세 codec 모두 `SchemaReference.of(subject, version)`로 URI 없이 만든다). 즉 `dataschema`는 현재 항상 비어 있다.\n27327 | - CloudEvents distributed-tracing extension을 쓸 계획이 있는지.\n27328 | \n27329 | ---\n27330 | \n27331 | #### 17. 손볼 것\n27332 | \n27333 | ##### P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다\n27334 | \n27335 | - **사실.** `fromCloudEvent`가 `new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id `A234-1234-1234` → `java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.**\n27336 | - **근거.** `evidence/raw/273-cloudevents-inbound-id-probe.txt` (실행 확인). `DefaultCloudEventMapper.java:116`.\n27337 | - **왜 문제인가.** 두 층이다.\n27338 | - **(1) 범위.** v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 \"우리 정책\", 비UUID 거절은 \"CloudEvents 상호운용 포기\"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.\n27339 | - **(2) 실패 어휘.** 같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 \"코드 없음\" 여덟 행).\n27340 | - **확인 방법.** `evidence/raw/273`의 probe 재실행. 또는 `MessageId` 생성자와 `UUID.fromString`의 계약 대조.\n27341 | - **후보.** (a) `fromCloudEvent`의 값 객체 생성을 전부 감싸 `MessageValidationException`으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, `id`를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 \"compatible profile\"이 나가는 방향만 뜻함을 밝힌다.\n27342 | - **다음 단계.** **CASE 후보.** 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 **DECISION 후보**이며 지금은 근거가 없으므로 `NEEDS_DECISION`이다.\n27343 | \n27344 | ##### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다\n27345 | \n27346 | - **사실.** 세 타입의 leaf 밖 참조가 0인데 `runtime_memberships`가 `[\"app-bootstrap\"]`이다. `messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다.\n27347 | - **근거.** `evidence/raw/272` §D·§E. `MessagingCoreAutoConfiguration` 전수(`CloudEvent` 참조 0).\n27348 | - **왜 문제인가.** 형제 Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 \"이 의존성이 왜 있지\"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 \"모든 messaging leaf가 unwired\"라고 적고 있어 문서에서도 이 사실을 알 수 없다.\n27349 | - **확인 방법.** `git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents'` → exit 1. registry의 membership 확인.\n27350 | - **후보.** (a) starter에서 `@ConditionalOnClass`/`@ConditionalOnProperty`로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 `[]`로 되돌려 Avro·Protobuf와 같은 상태로 만든다.\n27351 | - **다음 단계.** **CASE 후보.** \"장치는 있고 회로가 닫히지 않았다\"의 변형 — 여기서는 회로가 닫히지 않았는데 **부품은 배송됐다.**\n27352 | \n27353 | ##### P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다\n27354 | \n27355 | - **사실.** `fromCloudEvent`가 `partitionKey`·`orderingKey`를 empty로, `traceContext`를 `none()`으로, `headers`를 `empty()`로 두고, `producedAt`을 `occurredAt` 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)이 다르므로 비교했다면 실패했을 것이다.\n27356 | - **근거.** `DefaultCloudEventMapper.java:115-131`, `CloudEventMappingTest.java:72-84, 143-161`.\n27357 | - **왜 문제인가.** `traceContext` 소실이 가장 무겁다. `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 \"a trace survives an Outbox round trip through the database, where broker headers do not exist yet\"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 `roundTrips…`인데 실제로는 부분 보존 확인이다.\n27358 | - **확인 방법.** 왕복 테스트에 `producedAt`·`traceContext` 비교를 추가하면 실패한다.\n27359 | - **후보.** (a) 소실 필드를 javadoc에 명시한다. (b) `traceparent`/`tracestate`/`baggage`를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다.\n27360 | - **다음 단계.** **REFERENCE 후보**(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다).\n27361 | \n27362 | ##### P3 — `dataschema`가 채워질 경로가 없다\n27363 | \n27364 | - **사실.** `toCloudEvent`가 `encoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)`로 `dataschema`를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 `SchemaReference.of(subject, version)`로 만들고, 그 factory는 `schemaUri`를 `Optional.empty()`로 둔다.\n27365 | - **근거.** `DefaultCloudEventMapper.java:81-86`, `SchemaReference.java:36-38`, 세 codec의 `encode`.\n27366 | - **왜 문제인가.** `dataschema`는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. `schemaversion` 확장이 그 자리를 대신하지만 그것은 비표준 확장이다.\n27367 | - **확인 방법.** `git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java'` — 3인자 생성자를 부르는 production 코드가 있는지 확인.\n27368 | - **후보.** schema registry URI를 갖는 배포에서 `SchemaReference`의 3인자 생성자를 쓰게 하거나, `dataschema` 분기가 현재 도달 불가임을 주석으로 남긴다.\n27369 | - **다음 단계.** **OPEN QUESTION 후보.** 판정이 \"이 저장소가 외부 schema registry를 쓸 것인가\"에 걸리고, 그 질문은 `messaging-schema-api`의 `SchemaRegistry` port가 구현 0인 것과 같은 뿌리다.\n27370 | \n27371 | ##### P3 — `CloudEventMapper` javadoc의 범위 제한이 강제되지 않는다\n27372 | \n27373 | - **사실.** \"Offered for domain and integration events only. Commands and work items are not forced through CloudEvents.\" 코드에 `DestinationKind`를 보는 분기가 없다.\n27374 | - **근거.** `CloudEventMapper.java:11-13`, `DefaultCloudEventMapper` 전문.\n27375 | - **왜 문제인가.** 소비자가 0이므로 지금은 무해하다. 배선되면 `ASYNC_COMMAND`·`WORK_QUEUE` 봉투도 이 매퍼를 통과한다.\n27376 | - **확인 방법.** `git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**'` → 매치 없음.\n27377 | - **후보.** 진술을 유지하되 \"호출자 책임\"임을 명시하거나, `toCloudEvent`가 `DestinationKind`를 받아 검사한다.\n27378 | - **다음 단계.** **REFERENCE 후보**(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다).\n27379 | \n27380 | ##### 확인된 설계(문제 아님)\n27381 | \n27382 | - `occurredAt` 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것\n27383 | - 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — `MessageEnvelope`의 non-null payload 계약과 정확히 짝을 이룸\n27384 | - producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것\n27385 | - `cloudevents-api`를 `api`로, `cloudevents-core`를 `implementation`으로 나눈 것과 그 근거 주석\n27386 | - `schemaversion` 에러 메시지가 \"by this profile\"이라고 밝히는 것\n27387 | - `byte[]` payload를 `clone()`해서 넘기는 것\n27388 | - 매퍼가 상태를 갖지 않는 것\n27389 | \n27390 | ---\n27391 | \n27392 | #### Source anchors\n27393 | \n27394 | | id | kind | path | revision | what it proves | limitations |\n27395 | |---|---|---|---|---|---|\n27396 | | MCE-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: [\"app-bootstrap\"]` | 선언 |\n27397 | | MCE-002 | build | `messaging-cloudevents/build.gradle` | same | `api`/`implementation` 분리와 그 근거 | — |\n27398 | | MCE-003 | build | `messaging-cloudevents/gradle.lockfile:33-34` | same | cloudevents 4.0.1 두 좌표 | — |\n27399 | | MCE-004 | code | `.../cloudevents/CloudEventMapper.java` 전문 | same | 계약과 적용 범위 진술 | 범위 미강제(§17) |\n27400 | | MCE-005 | code | `.../cloudevents/DefaultCloudEventMapper.java` 전문 | same | §4 전체 매핑표와 두 명시적 결정 | — |\n27401 | | MCE-006 | code | `.../cloudevents/CloudEventExtensions.java` | same | 확장 이름 4개와 명명 이유 | — |\n27402 | | MCE-007 | test | `CloudEventMappingTest` (7) | same | §10 표 | 외부 producer 입력 없음. 왕복이 5개 필드 미비교 |\n27403 | | MCE-008 | cross-leaf code | `messaging-core-api/.../MessageId.java:20-32` | same | UUIDv7 강제의 출처 | 해당 leaf SSOT가 소유 |\n27404 | | MCE-009 | cross-leaf code | `messaging-core-api/.../TraceContext.java:11-13` | same | 봉투가 trace를 갖는 이유(§17 왕복 소실) | 해당 leaf SSOT가 소유 |\n27405 | | MCE-010 | cross-leaf code | `messaging-schema-api/.../SchemaReference.java:36-38` | same | `of`가 URI를 비움 → `dataschema` 도달 불가 | 해당 leaf SSOT가 소유 |\n27406 | | MCE-011 | external spec | CloudEvents 1.0.2, `id` 속성 정의 | — | `id`는 비어 있지 않은 String이며 형식 제약 없음 | 외부 표준. 저장소 밖 지식으로 명시 분리 |\n27407 | | EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | 세 타입 소비자 0, membership | 정적 검색 |\n27408 | | EVD-273 | runtime probe | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | same | 세 id 형태의 실제 결과와 예외 타입 | 세 형태만. 저장소 소스 미수정 |\n27409 | | EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | same | 7 / 0 / 0 | — |\n27410 | \n27411 | ---\n27412 | ",
"headings": [
{
"line": 1,
"level": 1,
"text": "clean-architecture-backend-template — 상세 분석 (통합 정본)"
},
{
"line": 40,
"level": 2,
"text": "0. 이 문서를 읽는 법"
},
{
"line": 60,
"level": 2,
"text": "1. Project map — 숫자로 먼저"
},
{
"line": 62,
"level": 3,
"text": "1.1 빌드와 레지스트리"
},
{
"line": 81,
"level": 3,
"text": "1.2 가족별 분모와 출하 여부"
},
{
"line": 94,
"level": 3,
"text": "1.3 leaf별 규모 (main Java 기준 상위)"
},
{
"line": 119,
"level": 3,
"text": "1.4 이 표에서 읽어야 할 것"
},
{
"line": 168,
"level": 2,
"text": "2. Architectural boundaries — 무엇이 경계를 강제하는가"
},
{
"line": 173,
"level": 3,
"text": "2.1 강제 장치 목록"
},
{
"line": 189,
"level": 3,
"text": "2.2 `CleanArchitectureTest`의 규칙 14종"
},
{
"line": 212,
"level": 3,
"text": "2.3 검증된 경계 — 실제로 성립하는 것"
},
{
"line": 266,
"level": 3,
"text": "2.4 경계가 열려 있는 지점"
},
{
"line": 300,
"level": 2,
"text": "3. Representative execution paths"
},
{
"line": 302,
"level": 3,
"text": "3.1 HTTP 요청 — 출하 경로"
},
{
"line": 364,
"level": 3,
"text": "3.2 트랜잭션 — `application-core` 포트에서 PostgreSQL local timeout까지"
},
{
"line": 453,
"level": 3,
"text": "3.3 메시지 발행 — messaging 플랫폼"
},
{
"line": 494,
"level": 3,
"text": "3.4 gRPC — 채택 시점 경로"
},
{
"line": 518,
"level": 3,
"text": "3.5 알림 발송 — 논리적 수락과 provider 불확실성"
},
{
"line": 539,
"level": 2,
"text": "4. Data and state"
},
{
"line": 541,
"level": 3,
"text": "4.1 관계형 — `persistence-jpa` (605 파일 / main 350 / 27,744 LOC)"
},
{
"line": 654,
"level": 3,
"text": "4.2 문서형 — `persistence-mongo` (497 파일 / main 351 / 22,924 LOC)"
},
{
"line": 705,
"level": 3,
"text": "4.3 messaging 신뢰성 저장소 (`19` §7)"
},
{
"line": 757,
"level": 3,
"text": "4.4 fileserver / objectstorage / cache-redis"
},
{
"line": 788,
"level": 2,
"text": "5. Failure and operational behavior"
},
{
"line": 790,
"level": 3,
"text": "5.1 실패 분류 — 세 개의 계층"
},
{
"line": 824,
"level": 3,
"text": "5.2 관측 — 태그를 유한하게, 그리고 그 대가"
},
{
"line": 854,
"level": 3,
"text": "5.3 시작 검증기 — 법칙과 그 예외"
},
{
"line": 903,
"level": 3,
"text": "5.4 admin plane — 가장 잘 조립된 게이트"
},
{
"line": 939,
"level": 3,
"text": "5.5 gRPC 구현 층의 원자성 (`20` §7)"
},
{
"line": 1011,
"level": 2,
"text": "6. Tests and verification coverage"
},
{
"line": 1013,
"level": 3,
"text": "6.1 실행한 것"
},
{
"line": 1025,
"level": 3,
"text": "6.2 실행하지 않은 것과 그 이유"
},
{
"line": 1047,
"level": 3,
"text": "6.3 fail-closed 레인 규약"
},
{
"line": 1071,
"level": 3,
"text": "6.4 완전히 닫힌 게이트 하나 — messaging 인증 체인"
},
{
"line": 1111,
"level": 3,
"text": "6.5 evidence manifest — JPA의 R1/R2 분리"
},
{
"line": 1125,
"level": 3,
"text": "6.6 게이트가 통과하면서 아무것도 증명하지 않는 경우 — 14건"
},
{
"line": 1156,
"level": 2,
"text": "7. 이 저장소에서 반복된 네 가지 형태"
},
{
"line": 1160,
"level": 3,
"text": "7.1 형태 A — 판정하는 코드는 있고, 부르는 코드가 없다"
},
{
"line": 1203,
"level": 3,
"text": "7.2 형태 B — 게이트가 통과하면서 아무것도 증명하지 않는다"
},
{
"line": 1214,
"level": 3,
"text": "7.3 형태 C — 중복 장치에서 조립된 쪽이 약한 쪽이다"
},
{
"line": 1239,
"level": 3,
"text": "7.4 형태 D — 문서 드리프트, 그리고 그 방향"
},
{
"line": 1274,
"level": 3,
"text": "7.5 공시 스펙트럼 — 자기 미완성을 얼마나 말했는가"
},
{
"line": 1289,
"level": 3,
"text": "7.6 학습 전이 — messaging → grpc"
},
{
"line": 1308,
"level": 2,
"text": "8. Confirmed problems"
},
{
"line": 1310,
"level": 3,
"text": "8.1 P1 — 지금 출하되는 아티팩트에서 틀린 동작"
},
{
"line": 1349,
"level": 3,
"text": "8.2 P2 — 명확한 실패 시나리오를 가진 실질적 공백"
},
{
"line": 1392,
"level": 3,
"text": "8.3 심각도가 등급 때문에 낮아진 것"
},
{
"line": 1403,
"level": 2,
"text": "9. Reusable criteria and rules"
},
{
"line": 1452,
"level": 2,
"text": "10. Explicit project decisions"
},
{
"line": 1457,
"level": 3,
"text": "10.1 계약과 경계"
},
{
"line": 1468,
"level": 3,
"text": "10.2 실패와 불확실성"
},
{
"line": 1480,
"level": 3,
"text": "10.3 조립과 활성화"
},
{
"line": 1492,
"level": 3,
"text": "10.4 데이터와 경계값"
},
{
"line": 1506,
"level": 3,
"text": "10.5 증거와 게이트"
},
{
"line": 1523,
"level": 2,
"text": "11. Unresolved questions"
},
{
"line": 1564,
"level": 2,
"text": "12. Evidence index"
},
{
"line": 1581,
"level": 2,
"text": "13. Limits of this analysis"
},
{
"line": 1632,
"level": 2,
"text": "14. 사이클 2 — 18개 리프 재검증과 23개 리프 전수 통독"
},
{
"line": 1634,
"level": 3,
"text": "14.1 18개 리프 재검증"
},
{
"line": 1668,
"level": 3,
"text": "14.2 23개 리프 전수 통독"
},
{
"line": 1747,
"level": 2,
"text": "부록 A. 모듈 문서 지도"
},
{
"line": 1779,
"level": 2,
"text": "부록 B. 자주 쓸 명령"
},
{
"line": 1825,
"level": 2,
"text": "부록 C. 다시 읽는다면 이 순서"
},
{
"line": 1839,
"level": 1,
"text": "제2부 — 모듈 분석 전문"
},
{
"line": 1845,
"level": 2,
"text": "A00. project-overview"
},
{
"line": 1849,
"level": 3,
"text": "Project Overview"
},
{
"line": 1856,
"level": 4,
"text": "분석 기준 revision"
},
{
"line": 1867,
"level": 4,
"text": "최종 커버리지"
},
{
"line": 1884,
"level": 4,
"text": "Build and module map"
},
{
"line": 1939,
"level": 4,
"text": "Dependency direction"
},
{
"line": 1945,
"level": 4,
"text": "Runtime entry points"
},
{
"line": 1951,
"level": 4,
"text": "Persistence / messaging / external systems"
},
{
"line": 1955,
"level": 4,
"text": "Test topology"
},
{
"line": 1960,
"level": 4,
"text": "Configuration and operational surfaces"
},
{
"line": 1964,
"level": 4,
"text": "분석할 bounded scopes (계획 — 실제 문서 배치는 위 \"최종 커버리지\" 참조)"
},
{
"line": 1977,
"level": 4,
"text": "아직 단정하지 않는 것 (분석 시작 시점의 목록)"
},
{
"line": 1993,
"level": 2,
"text": "A01. domain-core"
},
{
"line": 1997,
"level": 3,
"text": "domain-core 상세 분석"
},
{
"line": 2000,
"level": 4,
"text": "SSOT identity — 2026-08-31 재검증"
},
{
"line": 2015,
"level": 4,
"text": "분석 범위와 결론 상태"
},
{
"line": 2026,
"level": 4,
"text": "1. Quantified scope map"
},
{
"line": 2028,
"level": 5,
"text": "Owned source"
},
{
"line": 2042,
"level": 4,
"text": "2. Coverage ledger"
},
{
"line": 2062,
"level": 4,
"text": "3. 이 모듈이 실제로 소유하는 것"
},
{
"line": 2064,
"level": 5,
"text": "관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다"
},
{
"line": 2073,
"level": 4,
"text": "4. Identifier contract"
},
{
"line": 2075,
"level": 5,
"text": "`ResourceId