# messaging-admin-api 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-admin-api` > SSOT owner: `messaging-admin-api` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-admin-api` - canonical state `analysisFile`: `analysis/messaging/messaging-admin-api.md` - source path: `src/messaging/messaging-admin-api` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy"]` - registry `runtime_memberships`: **`["app-bootstrap"]`** — 배포된다 ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | **24** | | test Java 파일 | **1** | | 전체 LOC | 1,760 | | 패키지 | 1 (`dev.caskeleton.messaging.admin`) | | test 메서드(실행 확인) | **9** (`EVD-305`) | | 선언된 의존 | project 2 | | **실제 import되는 의존** | project 2 (core-api O, policy X — §12.4) | | leaf 밖에서 import 하는 파일 | 21 (4개 모듈) | 24 타입을 역할로 묶으면 다섯 덩어리다. | 덩어리 | 타입 | 수 | |---|---|---:| | 승인 신원·서명 | `AdminApproval`, `ApprovalGrant`, `ApprovalVerifier`, `HmacApprovalVerifier`, `VerifiedApproval`, `PlanDigest` | 6 | | 파괴적 작업 게이트 | `DestructiveOperation`, `DestructiveOperationGuard` | 2 | | 리플레이 | `ReplayRequest`, `ReplayPlan`, `ApprovedReplayPlan`, `ReplayResult` | 4 | | 리드라이브 | `RedriveRequest`, `RedrivePlan`, `ApprovedRedrivePlan`, `RedriveResult` | 4 | | 실행 저널 | `AdminOperationJournal`, `AdminOperationRecord`, `AdminOperationLease`, `AdminOperationState` | 4 | | 토폴로지 | `TopologyManifest`, `DestinationTopology`, `TopologyIssue`, `TopologyValidationReport`, `TopologyManagementMode` | 5 | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (24) | 24 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (1) | 1 | `FULL_READ` | 147줄, 9개 테스트 | | `build.gradle` | 1 | `FULL_READ` | 6줄 | | 하류 소비자 (starter·admin-runtime) | — | `STRUCTURAL_ONLY` | 도달성 판정에 필요한 범위만. SSOT 는 각 리프 소유 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 **되돌릴 수 없는 작업을 사람의 승인에 묶는 타입 집합**이다. 실행 코드는 하나도 없다 — 브로커를 만지는 것도, 메시지를 옮기는 것도 전부 `messaging-admin-runtime` 과 어댑터가 한다. 이 리프가 정의하는 것은 "무엇이 승인이고, 승인이 무엇을 인가하며, 인가되지 않은 것이 왜 컴파일되지 않는가" 다. 설계의 축은 하나다: **권한을 불리언이 아니라 타입으로 만든다.** ```java // VerifiedApproval.java:6-17 /** * A grant that an {@link ApprovalVerifier} has checked, and that nobody else can manufacture. * *

The approved-plan types used to hold a plain {@code AdminApproval} record with a public * constructor, so "this plan was approved" was a claim the caller made about itself. Any code that * could reach the execute method could write {@code new AdminApproval("TICKET-1", "someone", now, * later)} and the platform believed it. * *

This class has no public constructor and no public factory. The only way to obtain one is to * present a grant and a signature to a verifier, which is exactly the property the type is for: * holding a {@code VerifiedApproval} is itself the evidence that verification happened. */ ``` 이 문단이 이 리프 전체의 요약이다. 그리고 그 위에 같은 기법이 한 층 더 쌓인다. ```java // ApprovedReplayPlan.java:8-13 /** * A replay plan that a named human has approved. * *

A distinct type from {@link ReplayPlan} rather than a boolean on it. The execute method takes * this type, so an unapproved plan cannot reach it — the authorisation is enforced by the compiler * instead of by a runtime check somebody can forget to write. */ ``` `ReplayPlan` → `ApprovedReplayPlan` → 실행. 각 화살표가 타입 경계이고, 각 경계에서 검사가 **생성자 안에** 있어 우회 경로가 없다. 경계 밖: 이 리프는 브로커를 모른다(`DestinationTopology` 는 브로커가 보고한 값을 담는 record 일 뿐 조회하지 않는다), 저장소를 모른다(`AdminOperationJournal` 은 인터페이스), 스프링을 모른다. --- ## 2. 의존성과 런타임 배선 ```groovy // messaging-admin-api/build.gradle 전문 apply plugin: 'java-library' dependencies { api project(':messaging:messaging-core-api') api project(':messaging:messaging-policy') } ``` `messaging-core-api` 에서 쓰는 것: `DestinationName`, `MessageAuthorizationException`, `MessagingConfigurationException`. `messaging-policy` 는 import 0건이다(§12.4). leaf 밖 소비자 21개 파일 / 4개 모듈: | 모듈 | src/main | src/test | 역할 | |---|---:|---:|---| | `messaging-admin-runtime` | 10 | 6 | 실제 실행·검증 서비스 | | `messaging-spring-boot-starter` | 2 | 0 | 빈 배선 + 저널 내구성 검사 | | `messaging-outbox-jdbc-postgresql` | 1 | 1 | `JdbcAdminOperationJournal` | | `messaging-kafka` | 0 | 1 | `KafkaTopologyValidationIT` | 부팅된 애플리케이션에서 이 리프의 타입 중 실제로 살아나는 것은 **둘뿐**이다(`EVD-302`, `EVD-303`). ```java // MessagingAdminAutoConfiguration.java (@ConditionalOnProperty app.messaging.admin.enabled=true) :37 DestructiveOperationGuard destructiveOperationGuard() -> new DestructiveOperationGuard(false) :56 AdminOperationJournal adminOperationJournal() -> new InMemoryAdminOperationJournal() :69 MessagingAdminDurabilityValidator … (InitializingBean) :83 CompositeTopologyValidator compositeTopologyValidator(BrokerTopologyInspector) ``` `MessagingAdminService` 빈은 없고 `ApprovalVerifier` 빈도 없다. 이는 명시된 설계다. ```java // MessagingAdminAutoConfiguration.java:14-24 /** * Wires the admin plane, and only when a deployment has explicitly asked for it. * *

Off unless {@code app.messaging.admin.enabled=true}. An application that acquires the admin * plane by adding a starter to its classpath is exactly the situation the plane's guards exist to * prevent … * *

{@link …DestructiveMessagingAdmin} is deliberately absent from this class. No bean for it is * ever auto-configured: an operator tool that needs purge or delete registers one itself, with an * admin credential this runtime does not hold. */ ``` 그러나 이 스탠스가 **토폴로지 검증까지 덮지는 않는다** — §12.1 과 §17 의 첫 항목이 그것이다. --- ## 3. 패키지/컴포넌트 지도 단일 패키지. 신뢰 흐름은 왼쪽에서 오른쪽으로만 흐른다. ``` [변경관리 시스템 = 발급자] issuingKey 보유 | ApprovalGrant 조립 (approver != operator 가 생성자에서 강제) | HmacApprovalVerifier.sign(grant) -> signature v (grant, signature) 전달 | v [실행 런타임] ApprovalVerifier.verify(grant, signature, executingDigest, now) | - planDigest == executingDigest ? | - 윈도우 열려 있나 ? | - HMAC 상수시간 일치 ? v VerifiedApproval <- 생성자 private, 팩토리 package-private | +--> ApprovedReplayPlan / ApprovedRedrivePlan (생성자에서 4가지 재검사) | | requireExecutable(now, currentTopologyVersion) | v +--> DestructiveOperationGuard.authorize(...) (6가지 검사) | v [실행 — 이 리프 밖] AdminOperationJournal.begin/checkpoint/complete/fail ``` 같은 검사가 두 자리에 나타나는 것은 중복이 아니라 의도된 이중화다(§12.3(a)). --- ## 4. 계약·불변식·상태 모델 ### 4.1 `ApprovalGrant` — 서명되는 것의 전부 ```java // ApprovalGrant.java:8-18 /** * Everything an issuer signs when granting one destructive operation. * *

{@link AdminApproval} carried a ticket, an approver, and a window. Nothing in it said which * operation, on which destination, against which topology, or up to what impact — so the same * approval authorised every plan presented inside the window, and the audit trail recorded a ticket * that proved nothing about what was executed. * *

Every field here is part of the signed canonical form, which is what makes the grant * non-transferable: change the target, the topology version, the impact ceiling, or the plan, and * the signature no longer verifies. */ ``` 8개 필드. 그중 `requestedBy` 가 생성자에서 4-eyes 를 강제한다. ```java // ApprovalGrant.java:50-55 if (approval.approvedBy().equals(requestedBy)) { // Four-eyes, expressed where it cannot be skipped: an operator approving their own // destructive operation is the control not existing. throw new IllegalArgumentException( "the approver and the operator must be different people; both are " + requestedBy); } ``` "우회할 수 없는 자리에 표현했다" — 검사기가 아니라 **record 생성자**에 두었으므로, 서명 대상 객체가 존재하는 것 자체가 4-eyes 통과를 뜻한다. `canonicalForm()` 은 길이 접두 인코딩이다. ```java // ApprovalGrant.java:61-87 /** *

Every field is length-prefixed rather than delimited. A delimiter can be smuggled into a * ticket or an identity to make two different grants render identically; a length prefix cannot. */ public String canonicalForm() { StringBuilder canonical = new StringBuilder("v1"); appendField(canonical, approval.ticket()); … 11개 필드 … } private static void appendField(StringBuilder canonical, String value) { canonical.append(value.length()).append(':').append(value); } ``` 버전 접두 `"v1"` 이 앞에 있어 형식 교체 여지를 남긴 것도 의도적으로 보인다. 이 규칙이 계획 다이제스트 쪽에는 적용되지 않았다 — §12.3(b). ### 4.2 `HmacApprovalVerifier` — 대칭키를 고른 이유와 그 대가 ```java // HmacApprovalVerifier.java:12-24 /** * Verifies grants with a shared HMAC-SHA256 issuing key. * *

A shared secret rather than a public-key signature is a deliberate scope choice: the issuer is * the change-management system operated by the same organisation as the runtime, so key * distribution is already solved and the property that matters — that an operator cannot mint an * approval for themselves — holds as long as the key is not on the operator's machine. A public-key * verifier can be substituted by implementing {@link ApprovalVerifier}; nothing else in the * platform depends on how the signature is produced. * *

Comparison is constant-time. A verifier that compares with {@code equals} leaks the signature * one byte at a time to a caller who can retry, which for an offline attacker with unlimited * attempts is the whole secret. */ ``` 세 가지가 코드로 지켜진다. ```java // :39-43 키 길이 하한 if (issuingKey.length < 32) { throw new IllegalArgumentException( "the approval issuing key must be at least 256 bits; a shorter key makes the signature" + " brute-forceable offline"); } // :44 방어적 복사 this.issuingKey = issuingKey.clone(); // :92 상수시간 비교 if (!MessageDigest.isEqual(mac(grant.canonicalForm()), presented)) { … } ``` 검증 순서도 의미가 있다: **다이제스트 대조 → 윈도우 → 16진 파싱 → HMAC**. 다이제스트를 먼저 보는 이유가 주석에 있다. ```java // :71-79 if (!grant.planDigest().equals(executingDigest)) { // The signature would verify: this is a genuine approval, for a different plan. Replaying one // approval across plans is the attack the digest binding exists to stop. throw new MessageAuthorizationException("APPROVAL_PLAN_MISMATCH", …); } ``` `sign(...)` 이 같은 클래스에 public 으로 있고, javadoc 이 그 위험을 스스로 명시한다. ```java // :47-57 /** * Produces the signature an issuer would attach to a grant. * *

Present because the issuer and the verifier must agree on the canonical form exactly, and a * second implementation of that agreement is a second thing to drift. Holding this key is what * makes a caller an issuer — it is not, and must not become, available to the runtime that * executes operations. */ ``` 이 문장과 대칭키 선택이 만드는 구조적 결과가 §17 의 한 항목이다: **검증하는 쪽은 반드시 서명할 수도 있다.** ### 4.3 `DestructiveOperationGuard` — 여섯 개의 검사 ```java // DestructiveOperationGuard.java:9-19 /** * The single gate every destructive messaging operation passes through. * *

Four conditions, all required. The caller must hold the admin credential — an application * runtime does not, by construction. The approval must be present and inside its validity window. * And a dry run is always permitted, because the way to make operators plan before they act is to * make planning free. * *

Centralised so that adding a new destructive operation means adding an enum constant, not * remembering to re-implement the checks. */ ``` 실제 분기는 여섯이다. | # | 조건 | 코드 | javadoc 에 서술됨 | |---:|---|---|---| | 1 | `dryRun` → 통과 | `:54-56` | O | | 2 | 관리자 자격증명 없음 | `:57-61` | O | | 3 | 승인 부재 | `:62-67` | O | | 4 | 윈도우 밖 | `:68-71` | O | | 5 | operation 불일치 | `:72-83` | **X** | | 6 | source 불일치 | `:84-93` | **X** | 5번에는 별도 인라인 주석이 있어 의도된 검사임이 분명하다. ```java // :72-75 if (granted.grant().operation() != operation) { // The approval verified — for something else. A guard that only checks presence and window // lets a verified redrive approval authorise a destination deletion. ``` "검사는 있는데 클래스 javadoc 이 세지 않는" 두 항목이, 동시에 **어떤 테스트에도 도달하지 않는** 두 항목이다(`EVD-303`, §12.1). "dry run 은 항상 허용" 의 근거도 적혀 있다 — "the way to make operators plan before they act is to make planning free". 이것은 보안 완화가 아니라 행동 설계다. ### 4.4 계획 → 승인된 계획: 생성자에서 네 가지, 실행 직전에 세 가지 `ApprovedReplayPlan` 생성자(`:19-52`): | 검사 | 실패 코드 | |---|---| | `approval.grant().planDigest() == plan.digest()` | `APPROVAL_PLAN_MISMATCH` | | `operation == REPLAY` | `APPROVAL_OPERATION_MISMATCH` | | `grant().source() == plan.request().destination()` | `APPROVAL_SOURCE_MISMATCH` | | `plan.estimatedMessages() <= grant().maxImpact()` | `APPROVAL_IMPACT_EXCEEDED` | `requireExecutable(now, currentTopologyVersion)`(`:54-85`): | 검사 | 실패 코드 | |---|---| | 승인 윈도우 | `APPROVAL_EXPIRED` | | `grant().topologyVersion() == 현재` | `TOPOLOGY_CHANGED_SINCE_APPROVAL` | | `plan.topologyVersion() == 현재` | `TOPOLOGY_CHANGED_SINCE_APPROVAL` | 토폴로지를 **두 번** 보는 이유가 주석에 있다. ```java // ApprovedReplayPlan.java:76-84 if (!plan.topologyVersion().equals(currentTopologyVersion)) { // Every number in the plan was computed against the old topology, so the approver agreed to // an impact estimate that no longer describes what would happen. ``` 승인이 서명된 토폴로지와 계획이 계산된 토폴로지가 다를 수 있으므로 둘 다 현재와 대조한다. `ApprovedRedrivePlan` 은 같은 네 검사에 더해 `loopAcknowledged` 를 별도 필드로 갖는다. ```java // ApprovedRedrivePlan.java:8-19 /** *

Carries {@code loopAcknowledged} separately from the approval itself. Approving a redrive of * 900 parked messages and approving a redrive that will re-fail 400 of them are different * decisions, and the second one needs the approver to have seen the number … */ ``` 그리고 `requireExecutable` 의 마지막 분기가 그것을 강제한다. ```java // :87-93 if (plan.risksALoop() && !loopAcknowledged) { throw new MessageAuthorizationException( "REDRIVE_LOOP_NOT_ACKNOWLEDGED", "%d of the %d candidates already failed a previous redrive; re-running them without " .formatted(plan.alreadyRedrivenCandidates(), plan.candidates()) + "fixing the cause produces a loop that looks like progress"); } ``` "진행처럼 보이는 루프" 는 이 리프에서 반복되는 관점이다 — 대시보드에서 옳아 보이는 실패를 타입으로 막는다. ### 4.5 실행 저널 — 리스와 펜싱 토큰 ```java // AdminOperationJournal.java:7-18 /** *

The implementation this replaced was a {@code ConcurrentHashMap} registered by the starter as * the default. Two consequences followed, and both are worse than having no store at all because * the map made the platform look protected. A restart forgot every claim, so the same approval * could be executed again by the same process. And two replicas each had their own map, so both * could claim the same approval at the same moment and each believe it was the only one. * *

Implementations must therefore be shared and durable, must enforce uniqueness on {@code * (approvalTicket, planDigest)}, and must hand out leases with a monotonic fencing token so a * process that stalled past its lease cannot write over the replica that took over from it. */ ``` 키를 `(approvalTicket, planDigest)` 로 잡은 이유: ```java // AdminOperationRecord.java:9-12 /** *

Keyed by {@code (approvalTicket, planDigest)} rather than by the ticket alone, because the * ticket alone cannot distinguish "this approval already ran" from "this approval is being reused * for a different plan" — and those need opposite answers. */ ``` 리스가 불리언이 아니라 `resumeFrom` 을 나르는 이유: ```java // AdminOperationLease.java:8-11 /** *

{@code resumeFrom} is the whole reason a lease is handed out rather than a boolean. A retry * after a crash is not a new execution of the approval — it is the same operation continuing, and * treating it as new is what republishes the items the first attempt already moved. */ ``` 상태 세 개(`STARTED`/`COMPLETED`/`FAILED`)를 만든 이유: ```java // AdminOperationState.java:5-9 /** *

The store this replaces recorded one fact — "this approval was claimed" — and recorded it * before any work happened. A run that died halfway had consumed its approval, left no record of * how far it got, and offered the operator two equally bad choices: request a fresh approval and * redo work that may already have been done, or leave the operation half-applied. */ ``` `isDurable()` 을 인터페이스에 둔 이유도 명시적이다 — "Declared rather than inferred so the starter can refuse to run a production profile on an in-memory journal instead of discovering the gap during an incident." 이 선언은 실제로 배선되어 있다(§12.2 의 대조군). ### 4.6 토폴로지 — 선언과 실측을 다른 타입으로 ```java // DestinationTopology.java:6-11 /** * What the broker actually reports for one destination. * *

The counterpart to {@link TopologyManifest}: the manifest is what was declared, this is what * exists. Kept as a separate type rather than reusing the manifest so that a comparison cannot * accidentally compare a manifest with itself and report success. */ ``` "자기 자신과 비교해서 성공을 보고하는 것" 을 타입으로 막았다 — `messaging-testkit` 의 인증 행렬이 고친 결함과 정확히 같은 형태이며, 여기서는 처음부터 타입으로 예방했다. 심각도를 finding 에 붙인 이유: ```java // TopologyIssue.java:8-12 /** *

Severity is part of the finding because the two kinds behave differently at startup. A {@link * Severity#BLOCKING} issue means the destination cannot deliver its declared guarantee — * replication factor 1 on a destination promising durability is not a warning, it is a promise the * platform cannot keep — so the context refuses to start. … */ ``` 전부 모아 보고하는 이유: ```java // TopologyValidationReport.java:10-12 /** *

Reports both severities together rather than failing on the first blocking issue. An operator * fixing a topology wants the whole list — fixing one attribute, redeploying, and discovering the * next one is how a ten-minute fix becomes an afternoon. */ ``` 두 문장 모두 "기동을 거부한다" 를 전제한다. 그 전제가 배선되지 않았다 — §12.1. --- ## 5. 주요 실행 경로 **경로 A — 계획 (승인 불필요, dry run 무료)** ``` ReplayRequest / RedriveRequest (생성자에서 창 역전·자기참조·배치 상한 검사) -> [admin-runtime] 브로커 조회로 추정치 계산 -> ReplayPlan / RedrivePlan (topologyVersion 캡처) -> plan.digest() -> PlanDigest (SHA-256) -> plan.describeImpact() -> 운영자에게 보여줄 한 줄 (프로덕션 소비자 없음, §12.1) ``` **경로 B — 승인 발급 (이 리프 밖, 변경관리 시스템)** ``` ApprovalGrant(approval, requestedBy, operation, source, target, topologyVersion, maxImpact, planDigest) <- 생성자에서 4-eyes -> HmacApprovalVerifier.sign(grant) -> hex signature ``` **경로 C — 실행** ``` verifier.verify(grant, signature, plan.digest(), now) -> VerifiedApproval -> new ApprovedReplayPlan(plan, approval) (4검사) -> approvedPlan.requireExecutable(now, inspector.topologyVersion()) (3검사) -> guard.authorize(operation, destination, Optional.of(approval), dryRun, now) (6검사) -> journal.begin(ticket, digest, operationId, owner, leaseDuration, now) -> AdminOperationLease(resumeFrom = 이전 체크포인트) -> [실행] … journal.checkpoint(...) 반복 … journal.complete(...) 또는 fail(...) -> ReplayResult / RedriveResult (dry run 은 0 이어야 함이 생성자에서 강제) ``` 경로 C 에서 검사가 세 지점(verify / Approved*Plan / guard)에 걸쳐 겹친다. `ApprovalVerifier` javadoc 이 그 이유를 설명한다. ```java // ApprovalVerifier.java:8-12 /** *

An implementation must check, at minimum: the issuer's signature over {@link * ApprovalGrant#canonicalForm()}, that the approval window is open at {@code now}, and that the * digest the caller is about to execute is the digest that was signed. Everything else the grant * binds — operation, source, target, topology version, impact ceiling, approver-versus-operator * separation — is inside the canonical form, so a signature check covers them all at once. */ ``` 즉 서명 검증이 통과하면 나머지는 이미 보장되지만, `ApprovedReplayPlan` 생성자와 guard 가 같은 것을 다시 본다. 방어적 중복이며 §12.3(a) 에서 다시 다룬다. --- ## 6. 실패 경로와 복구/번역 전부 `MessageAuthorizationException` 또는 `MessagingConfigurationException` 이고, 코드가 붙어 있다. | 코드 | 던지는 곳 | 의미 | |---|---|---| | `APPROVAL_PLAN_MISMATCH` | `HmacApprovalVerifier:74`, `ApprovedReplayPlan:26`, `ApprovedRedrivePlan:28` | 진짜 승인, 다른 계획 | | `APPROVAL_EXPIRED` | `HmacApprovalVerifier:81`, `DestructiveOperationGuard:69`, `Approved*Plan:66/71` | 윈도우 밖 | | `APPROVAL_SIGNATURE_INVALID` | `HmacApprovalVerifier:89, :93` | 16진 아님 / HMAC 불일치 | | `ADMIN_CREDENTIAL_REQUIRED` | `DestructiveOperationGuard:58` | 애플리케이션 런타임 | | `APPROVAL_REQUIRED` | `DestructiveOperationGuard:65` | 승인 없음 | | `APPROVAL_OPERATION_MISMATCH` | `DestructiveOperationGuard:75`, `Approved*Plan:32/34` | 다른 작업의 승인 | | `APPROVAL_SOURCE_MISMATCH` | `DestructiveOperationGuard:85`, `Approved*Plan:38/41` | 다른 목적지의 승인 | | `APPROVAL_IMPACT_EXCEEDED` | `Approved*Plan:47/52` | 승인 상한 초과 | | `TOPOLOGY_CHANGED_SINCE_APPROVAL` | `Approved*Plan:70/74, :79/82` | 추정치 무효 | | `REDRIVE_LOOP_NOT_ACKNOWLEDGED` | `ApprovedRedrivePlan:88` | 루프 위험 미승인 | | `AUTO_CREATE_IN_PRODUCTION` | `TopologyManagementMode:30` | 프로덕션 자동 생성 | | `TOPOLOGY_MISMATCH` | `TopologyValidationReport:78` | BLOCKING 존재 | 메시지가 전부 "무엇이 왜 거절되었는가" 를 서술형으로 쓴다. 예: ```java // ApprovedRedrivePlan.java:43-49 "approval %s authorises %s to %s, not %s to %s" ``` `IllegalArgumentException` 은 **구조적으로 불가능한 값**에만 쓴다 — 음수 카운터, 빈 문자열, 역전된 윈도우, 4-eyes 위반. 인가 실패와 프로그래밍 오류가 예외 타입으로 갈린다. `toString()` 하나가 로그 위생을 명시적으로 다룬다. ```java // VerifiedApproval.java:81-87 @Override public String toString() { // Deliberately not the whole grant: this string reaches logs, and the plan digest plus the // ticket identify the operation without restating who approved what to an audience that has // not been authorised to read it. return "VerifiedApproval[" + ticket() + " " + grant.planDigest().value().substring(0, 12) + "]"; } ``` `ApprovalGrant` 는 record 라 기본 `toString()` 이 전 필드를 찍는다는 점은 대비된다 — 다만 `ApprovalGrant` 자체가 로그에 닿는 경로는 확인되지 않았다. --- ## 7. 트랜잭션·동시성·수명주기 이 리프에 실행 코드가 없으므로 동시성 계약은 전부 **인터페이스 문서로 표현**되어 있고, 강제는 구현 리프의 몫이다. ```java // AdminOperationJournal.java:16-18 * Implementations must therefore be shared and durable, must enforce uniqueness on {@code * (approvalTicket, planDigest)}, and must hand out leases with a monotonic fencing token so a * process that stalled past its lease cannot write over the replica that took over from it. ``` 펜싱 토큰의 하한이 타입으로 강제된다. ```java // AdminOperationLease.java:39-41 / AdminOperationRecord.java:59-61 if (leaseToken < 1) { throw new IllegalArgumentException("a lease token starts at one"); } ``` `begin`/`checkpoint` 의 javadoc 이 각각 던져야 할 조건을 명시한다 — `begin` 은 "already completed, or another runtime holds a live lease", `checkpoint` 는 "the lease has been taken over by a newer token". 즉 **오래된 토큰의 쓰기를 거절하는 것이 구현 의무**로 문서화되어 있다. 이 리프의 모든 타입은 record 이거나 불변 final 클래스다. `DestructiveOperationGuard` 는 `final boolean` 하나만 갖고, `HmacApprovalVerifier` 는 키를 clone 해 보관한다. 공유해도 안전하다. 수명주기 훅은 없다. `MessagingAdminDurabilityValidator`(스타터, `InitializingBean`)가 유일한 기동 시점 훅이며 이 리프 밖이다. --- ## 8. 설정·기능 플래그·환경 차이 이 리프 자체에는 설정이 없다. 환경 차이를 만드는 지점은 셋이다. | 스위치 | 위치 | 기본 | 효과 | |---|---|---|---| | `app.messaging.admin.enabled` | 스타터 `@ConditionalOnProperty` | 미설정(=off) | admin 빈 전체 on/off | | `adminCredentialPresent` | `DestructiveOperationGuard` 생성자 인자 | 스타터에서 `false` 고정 | 파괴적 작업 전면 거절 | | 활성 프로파일 `prod`/`production` | `MessagingAdminDurabilityValidator` | — | 비내구 저널이면 기동 실패 | `TopologyManagementMode` 는 설정처럼 보이지만 어떤 프로퍼티에도 묶여 있지 않다(§12.1). --- ## 9. 퍼시스턴스/외부 시스템 세부 직접 접점 없음. 두 개의 포트로만 표현된다. - `AdminOperationJournal` — 유일한 프로덕션 구현은 `JdbcAdminOperationJournal`(outbox-jdbc-postgresql). 개발용 `InMemoryAdminOperationJournal`(admin-runtime)은 `isDurable()==false` 를 선언하고, 스타터가 프로덕션 프로파일에서 그것을 거절한다. - `DestinationTopology` / `TopologyManifest` — 브로커가 보고한 값과 선언값의 대조. `TopologyManifest.differencesFrom(...)` 은 `List` 을 돌려주고, `CompositeTopologyValidator` 는 `TopologyIssue` 를 만든다. 두 개의 비교 메커니즘이 공존한다(§12.3(c)). --- ## 10. 테스트 레인과 실제 증명 범위 `EVD-305`: `./gradlew :messaging:messaging-admin-api:test --rerun-tasks` → **9 tests, 0 failures, 0 skipped**. production 타입 24개에 대해 자체 테스트가 파일 1개·9건이다. 그 9건이 직접 겨냥하는 타입은 셋뿐이다. | 타입 | 테스트 | 내용 | |---|---:|---| | `DestructiveOperationGuard` | 5 | 자격증명 없음 / 승인 없음 / 만료 / dry run / 통과 | | `RedriveRequest` | 2 | 자기 참조 금지, 배치 상한 100 | | `TopologyManifest` | 2 | `differencesFrom` 차이 열거 / 일치 | 나머지 21개 타입의 불변식은 **하류 리프의 테스트**가 검증한다. | 위치 | 테스트 | 겨냥 | |---|---:|---| | `messaging-admin-runtime` `ApprovalForgeryTest` | 10 | 위조·변조·타 발급자·계획 불일치·소스 불일치·상한 초과·작업 불일치·만료·4-eyes·정상 | | `messaging-admin-runtime` `ApprovedPlanExecutionTest` | 11 | 만료·토폴로지 변경·루프 승인·영향 서술·미달 신호·정산 누락·dry run | | `messaging-admin-runtime` `AdminOperationJournalTest` | — | 리스·재개·펜싱 | | `messaging-admin-runtime` `TopologyValidatorTest` | — | BLOCKING/ADVISORY·`requireAcceptable`·`requireSafeFor` | | `messaging-kafka` `KafkaTopologyValidationIT` | — | 실제 브로커 대상 `requireAcceptable` | `ApprovalForgeryTest.aVerifiedApprovalCannotBeConstructedOutsideTheVerifier` 가 이 리프의 핵심 타입 속성을 직접 검증한다는 점은 좋다. 다만 **SSOT 를 소유한 리프가 자기 불변식의 대부분을 하류에서 검증받는 구조**이며, 그 결과 이 리프만 빌드·테스트해서는 24개 타입 중 3개만 증명된다. 증명되지 않는 두 분기가 있다(`EVD-303`): `DestructiveOperationGuard` 의 operation 불일치(`:72-83`)와 source 불일치(`:84-93`). §12.1 에서 상술한다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 `build.gradle` 6줄이 전부다. 이 리프 고유의 게이트는 없다. 루트에서 오는 공통 게이트(Checkstyle, SpotBugs+findsecbugs, ErrorProne `-Werror`)만 적용된다. 주목할 점: **`VerifiedApproval` 의 위조 방지가 package-private 에 의존하는데, 이 저장소는 JPMS 를 쓰지 않는다.** ``` find src -name "module-info.java" -not -path "*/build/*" | wc -l 0 ``` 즉 어떤 모듈이든 `package dev.caskeleton.messaging.admin;` 을 선언한 클래스를 만들면 `VerifiedApproval.of(grant)` 를 부를 수 있다. 현재 그런 파일은 없다. ``` git grep -ln "^package dev.caskeleton.messaging.admin;" -- src | grep -v "messaging-admin-api/" (출력 없음) ``` 이것을 지키는 자동 검사(ArchUnit 규칙 등)는 이 리프에 없다. §17 의 P3 항목이다. --- ## 12. 실제 사용 여부와 negative-space probes ### 12.1 Public surface reachability **방법 주의.** 이 리프의 메서드 이름은 저장소 전체와 충돌이 심하다. `git grep "\.absent("` 는 60건, `"\.blocking("` 은 19건, `"\.authorize("` 는 48건을 내지만 대부분 fileserver·graphql·web 의 동명 메서드다. 아래 수치는 전부 **소유 타입을 확인한 뒤** 센 것이다(`EVD-302`, `EVD-303`). | 멤버 | src/main 호출 | 테스트 호출 | 판정 | |---|---:|---:|---| | `DestructiveOperationGuard.authorize` | 2 (`RedriveService:97`, `ReplayService:59`) | 5 | 사용됨 | | `HmacApprovalVerifier.verify` / `.sign` | 0 / 0 | O | **프로덕션 배선 없음** | | `AdminOperationJournal.*` | O (admin-runtime, outbox-jdbc) | O | 사용됨 | | `TopologyManifest.differencesFrom` | 1 (`TopologyValidationRuntime:48`) | 2 | 사용됨 | | `TopologyValidationReport.requireAcceptable` | **0** | 4 | **§17 첫 항목** | | `TopologyManagementMode` (타입 전체) | **0** | 4 | **미배선** | | `ReplayPlan.describeImpact` | **0** | 2 | 테스트 전용 | | `RedrivePlan.describeImpact` | 0 (자기 내부 1) | 2 | 테스트 전용 | | `ReplayResult.fellShortOfTheEstimate` | **0** | 1 | 테스트 전용 | | `RedriveResult.isFullyAccounted` | **0** | 2 | 테스트 전용 | | `AdminOperationLease.isResumption` | **0** | 1 | 테스트 전용 | | `RedrivePlan.risksALoop` | 1 (`ApprovedRedrivePlan:87`) | — | 사용됨 | | `Approved*Plan.requireExecutable` | 2 (`DefaultMessagingAdminService:127, :178`) | O | 사용됨 | 세 덩어리로 읽힌다. **(a) 토폴로지 보장이 배선되지 않았다.** `TopologyIssue.Severity.BLOCKING` 의 javadoc 은 "the context refuses to start", `requireAcceptable()` 의 javadoc 은 "Fails startup when any blocking issue was found" 라고 쓴다. 그러나: ``` git grep -n "requireAcceptable" -- src 선언 1 + 테스트 4. src/main 호출 0건. git grep -n "validateTopology" -- src MessagingAdminService:32 (선언) + DefaultMessagingAdminService:106 (구현). 호출 0건. ``` 스타터는 `CompositeTopologyValidator` 빈을 만들지만 그것을 호출하는 빈을 만들지 않고, `MessagingAdminService` 빈 자체가 없다. 따라서 **부팅된 애플리케이션에서 토폴로지 검증이 실행되는 경로가 없다.** 대조군이 같은 파일에 있다: `MessagingAdminDurabilityValidator` 는 `InitializingBean` 이고 `afterPropertiesSet()` → `validate()` → 프로덕션+비내구면 `MessagingConfigurationException` 을 던진다. **저널 쪽에는 기동 실패 배선이 있고 토폴로지 쪽에는 없다.** 패턴은 이미 존재한다. **(b) `TopologyManagementMode` 는 완전히 고아다.** 자기 선언 + 테스트 4건이 전부이며, 이 타입의 필드·파라미터·설정 프로퍼티가 저장소에 하나도 없다. enum javadoc 은 "VALIDATE_ONLY in production, always" 라고 쓰지만 이 모드를 읽어 분기하는 코드가 없다. **(c) 운영자용 표면 전체가 프로덕션 소비자를 갖지 않는다.** `describeImpact`(영향 요약), `fellShortOfTheEstimate`(추정치 미달), `isFullyAccounted`(후보 정산 누락), `isResumption`(재개 여부) — 이 리프가 "운영자가 결정을 내리기 위해 봐야 할 것" 으로 정의한 네 가지가 전부 테스트에서만 호출된다. 이것들을 렌더링하는 API·CLI·로그가 `src/main` 에 없다. **(d) `DestructiveOperationGuard` 의 분기 5·6 에 도달하는 테스트가 없다.** `guard.authorize` 를 부르는 테스트는 `DestructiveOperationGuardTest` 의 5건뿐이고, 그 5건이 각각 분기 1~4와 통과 경로를 덮는다. 두 에러 코드를 단언하는 테스트는 저장소 전체에 없다. 특히 눈에 띄는 것은, 유일하게 **불일치 조합을 실제로 넘기는** 테스트가 앞 분기에서 먼저 걸린다는 점이다. ```java // DestructiveOperationGuardTest.java:24-25, 50-59 private static final VerifiedApproval VALID = verified(DestructiveOperation.REPLAY, ORDERS, NOW.minusSeconds(60), NOW.plusSeconds(3600)); … void anApplicationRuntimeCannotRedrive() { DestructiveOperationGuard guard = new DestructiveOperationGuard(false); // 자격증명 없음 assertThatThrownBy(() -> guard.authorize( DestructiveOperation.REDRIVE, ORDERS, Optional.of(VALID), false, NOW)) .hasMessageContaining("admin credential"); } ``` `REDRIVE` 요청에 `REPLAY` 승인을 넘기므로 분기 5의 조건은 참이지만, 분기 2가 먼저 던진다. 테스트 이름이 겨냥한 것도 자격증명이므로 테스트 자체는 옳다 — 다만 이 조합이 존재하는 탓에 "불일치가 검사된다" 는 인상이 생긴다. **(e) 프로덕션에서는 분기 3~6이 도달 불가다.** 스타터가 `new DestructiveOperationGuard(false)` 로 고정하므로 부팅된 애플리케이션에서는 항상 분기 2에서 멈춘다. 이는 의도된 설계이며 주석이 명시한다("An operator tool overrides this bean with true."). 즉 분기 3~6은 **운영자 도구가 존재할 때만** 쓰이는 코드이고, 그 운영자 도구는 이 저장소에 없다. ### 12.2 Conditional sibling comparison **대조군 1 — 같은 스타터의 두 검사.** `MessagingAdminDurabilityValidator`(저널) vs 토폴로지 검증. 전자는 `InitializingBean` 으로 기동을 실제로 막고, 후자는 타입에 "기동을 막는다" 고 쓰여 있으나 호출부가 없다. 같은 팀, 같은 파일, 같은 위험 서술, 다른 결과. **대조군 2 — 검사의 이중화.** `verify` → `Approved*Plan` 생성자 → `guard.authorize` 세 곳이 operation/source/expiry 를 겹쳐 본다. `ApprovalVerifier` javadoc 은 "a signature check covers them all at once" 라고 하므로 뒤의 두 곳은 원리상 잉여다. 그러나 `ApprovedReplayPlan:24-25` 의 주석이 이유를 밝힌다: "Checking it here means the mismatch cannot survive to the execute call under any code path." — 서명 검증을 거치지 않고 `VerifiedApproval` 을 얻는 경로가 생기더라도 계획 결합은 유지된다는 심층 방어다. **대조군 3 — `messaging-testkit` 과의 관점 공유.** `DestinationTopology` 의 "manifest 를 자기 자신과 비교해 성공을 보고하는 것을 타입으로 막는다" 는 `messaging-testkit` 의 `BrokerFailureMatrix` 가 사후에 고쳐야 했던 결함과 같은 것이다. 여기서는 처음부터 예방했고, 저기서는 발생한 뒤 고쳤다. ### 12.3 Duplicate mechanism sweep **(a) 인가 검사 3중화.** 위 대조군 2. 의도된 심층 방어이며 주석 근거가 있다. 다만 대가가 있다: `APPROVAL_OPERATION_MISMATCH` 같은 코드가 세 파일에 각각 문자열로 존재하고, 세 곳의 메시지 문구가 서로 다르다. 상수 하나로 모으는 편이 검색·집계에 낫다. **(b) 정규 형식 인코딩 2종.** (`EVD-304`) ```java // ApprovalGrant.canonicalForm() — 길이 접두 canonical.append(value.length()).append(':').append(value); // javadoc: "A delimiter can be smuggled into a ticket or an identity to make two different // grants render identically; a length prefix cannot." // ReplayPlan.digest() / RedrivePlan.digest() — 구분자 결합 PlanDigest.ofCanonical(String.join("|", "REPLAY", replayId, destination, …, topologyVersion, …)); ``` 계획 다이제스트 입력 중 `|` 를 담을 수 있는 필드는 `topologyVersion` 하나뿐이다 — `DestinationName` 은 `[a-z0-9][a-z0-9.-]{0,159}` 로 막혀 있고 나머지는 UUID·불리언·Instant·10진수다. `topologyVersion` 은 `isBlank()` 만 검사하며 `BrokerTopologyInspector.topologyVersion()`(애플리케이션이 구현하는 SPI)에서 온다. **현재 충돌은 만들 수 없다.** 자유 형식 필드가 하나뿐이고 그 앞뒤 필드가 `|` 를 담을 수 없으므로 인코딩이 단사다. 기록하는 이유는 두 가지다: (1) 바로 옆 파일이 정확히 이 위험을 이유로 다른 방식을 쓰고 그 이유를 남겼다, (2) 단사성이 "자유 필드가 하나뿐" 이라는 조건에 의존하며 그 조건이 깨졌을 때의 결과가 "한 승인이 다른 계획을 인가" — `PlanDigest` 가 존재하는 이유 자체의 무력화다. **(c) 토폴로지 비교 2종.** `TopologyManifest.differencesFrom(...)` 은 `List` 을, `CompositeTopologyValidator`(admin-runtime)는 `TopologyIssue` 를 만든다. 전자에는 severity 개념이 없다. 두 경로가 같은 판단을 서로 다른 표현으로 내리며, `differencesFrom` 의 프로덕션 호출부는 `TopologyValidationRuntime:48` 한 곳이다. ### 12.4 Documentation / measured-count drift **(a) "Four conditions" vs 실제 여섯.** `DestructiveOperationGuard` 클래스 javadoc(`:12-15`)이 세는 조건은 넷이고 코드의 분기는 여섯이다. 빠진 둘(operation·source 불일치)은 인라인 주석으로 의도가 설명되어 있으므로 누락은 서술 쪽이다. 그리고 그 둘이 §12.1(d)의 미검증 분기와 정확히 같다. **(b) `messaging-policy` 의존이 import 0건.** ``` git grep -n "import dev.caskeleton.messaging.policy" -- src/messaging/messaging-admin-api (출력 없음) ``` `allowed_dependencies` 와 `build.gradle` 이 선언하지만 쓰이지 않는다. `messaging-spring-cloud-stream-bridge`, `messaging-kafka-share-experimental`, `messaging-testkit` 에 이어 네 번째 사례다. **(c) `TopologyManagementMode` 의 javadoc 이 강제 주체 없는 규칙을 서술한다.** "VALIDATE_ONLY in production, always" 와 `requireSafeFor(boolean production)` 가 있으나, 이 enum 을 읽는 프로덕션 코드가 없으므로 규칙을 적용할 지점이 없다. **(d) 테스트 파일 수 대비 타입 수.** production 24 : test 1. §10 참조. --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 `messaging-testkit` 과 마찬가지로 이 리프도 **javadoc 이 커밋 로그를 대신한다**. 여섯 개의 "이전에는 이랬다" 기록이 있고, 전부 같은 결함 계열을 가리킨다: **자기 자신을 근거로 삼는 주장.** | 위치 | 기록된 과거 결함 | |---|---| | `VerifiedApproval.java:9-13` | "…a plain `AdminApproval` record with a public constructor, so 'this plan was approved' was a claim the caller made about itself." | | `ApprovalGrant.java:11-14` | "`AdminApproval` carried a ticket, an approver, and a window. Nothing in it said which operation… the audit trail recorded a ticket that proved nothing about what was executed." | | `PlanDigest.java:12-16` | "The operator who got a redrive of one dead-letter destination approved could execute a redrive of a different one with the same ticket, and every audit record would look correct." | | `AdminOperationJournal.java:10-14` | "…a `ConcurrentHashMap` registered by the starter as the default… both are worse than having no store at all because the map made the platform look protected." | | `AdminOperationState.java:5-9` | "…recorded one fact — 'this approval was claimed' — and recorded it before any work happened." | | `DestructiveOperationGuardTest.java:30-32` | "The guard used to take a plain `AdminApproval` record, which any caller could construct." | | `MessagingAdminDurabilityValidator.java:14-18` (스타터) | "The previous default was an in-memory map registered by this starter, and nothing in the application said so." | 일곱 개가 하나의 이야기다: **승인이 처음에는 데이터였고, 지금은 타입이다.** 커밋 로그 자체는 정보가 없다. ``` a24ece9c feat: web, websocket 어댑터 추가 구현 01372634 refactor: 각 어댑터터별 리펙토링 진행 2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가 d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가 ``` --- ## 14. 런타임·터미널 Evidence | ID | 파일 | 내용 | |---|---|---| | EVD-302 | `evidence/raw/302-admin-api-topology-guarantee-unwired.txt` | `requireAcceptable`/`validateTopology` 프로덕션 호출 0건, 스타터 빈 전수, `TopologyManagementMode` 미사용, 운영자 표면 테스트 전용 | | EVD-303 | `evidence/raw/303-destructive-guard-untested-branches.txt` | guard 6분기 중 5·6 미검증, 불일치 조합이 앞 분기에 걸리는 정황, `HmacApprovalVerifier` src/main 생성 0건 | | EVD-304 | `evidence/raw/304-canonical-form-asymmetry.txt` | 길이 접두 vs `\|` 결합, 필드별 `\|` 포함 가능성 조사, 현재 단사성 판정 | | EVD-305 | `evidence/raw/305-messaging-admin-api-test-lane.txt` | 테스트 레인 9건 통과, 하류 리프 검증 분포 | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **코드/주석에 명시된 것** - 승인을 타입으로 만든 이유 — 호출자가 자기 자신에 대해 주장하던 것을 검증 증거로 바꾼다 (`VerifiedApproval.java:9-16`). - 승인된 계획을 별도 타입으로 만든 이유 — 컴파일러가 인가를 강제한다 (`ApprovedReplayPlan.java:10-13`). - 4-eyes 를 record 생성자에 둔 이유 — 건너뛸 수 없는 자리 (`ApprovalGrant.java:51-52`). - 길이 접두 정규 형식의 이유 — 구분자 밀반입 방지 (`ApprovalGrant.java:64-65`). - 대칭키를 고른 이유와 그 전제 (`HmacApprovalVerifier.java:15-20`). - `sign` 을 같은 클래스에 둔 이유 — 정규 형식 합의가 두 곳으로 갈라지지 않도록 (`:50-53`). - 상수시간 비교의 이유 (`:22-24`). - 다이제스트를 먼저 검사하는 이유 — 진짜 승인의 계획 간 재사용이 막으려는 공격 (`:72-73`). - guard 를 중앙화한 이유 — 새 작업 추가가 enum 상수 추가로 끝나도록 (`DestructiveOperationGuard.java:17-18`). - dry run 을 항상 허용하는 이유 — 계획을 공짜로 만들어야 계획한다 (`:14-15`). - operation 검사가 필요한 이유 (`:73-74`). - 저널 키를 `(ticket, digest)` 로 잡은 이유 (`AdminOperationRecord.java:10-12`). - 리스가 `resumeFrom` 을 나르는 이유 (`AdminOperationLease.java:8-11`). - 상태 셋으로 나눈 이유 (`AdminOperationState.java:5-9`). - `isDurable()` 을 선언값으로 둔 이유 (`AdminOperationJournal.java:87-89`). - 토폴로지 선언/실측을 다른 타입으로 둔 이유 (`DestinationTopology.java:9-11`). - severity 를 finding 에 붙인 이유 (`TopologyIssue.java:8-12`). - 전부 모아 보고하는 이유 (`TopologyValidationReport.java:10-12`). - 프로덕션 자동 생성 금지의 이유 (`TopologyManagementMode.java:8-12`). - `loopAcknowledged` 를 분리한 이유 (`ApprovedRedrivePlan.java:11-14`). - 토폴로지를 두 번 보는 이유 (`ApprovedReplayPlan.java:77-78`). - `redriveId` 를 `MessageId` 와 분리한 이유 (`RedriveRequest.java:10-13`). - `stillParked != candidates - moved` 인 이유 (`RedriveResult.java:10-13`). - `VerifiedApproval.toString()` 을 줄인 이유 (`:83-85`). - 스타터가 `false` 를 고정하고 `DestructiveMessagingAdmin` 을 배선하지 않는 이유 (`MessagingAdminAutoConfiguration.java:17-24, 38-39`). **추론 (근거는 있으나 문서에 없음)** - `canonicalForm()` 의 `"v1"` 접두는 형식 버전 관리용으로 보인다. 명시된 문장은 없고, 버전을 읽어 분기하는 코드도 없다. - 계획 다이제스트가 길이 접두를 쓰지 않은 것은 의도적 예외가 아니라 누락으로 보인다 — 같은 리프에 반대 규칙의 명시적 근거가 있기 때문. 확인할 근거는 없다. - `messaging-policy` 의존이 남아 있는 이유는 알 수 없다. - 토폴로지 검증이 기동에 배선되지 않은 이유가 "운영자 도구 전용" 스탠스의 연장인지, 누락인지 판단할 근거가 코드에 없다. 스타터 javadoc 은 파괴적 작업에 대해서만 그 스탠스를 밝힌다. - 운영자 표면(`describeImpact` 등)의 소비자가 없는 것은 API·CLI 계층이 아직 없기 때문으로 보이나, 그 계획을 서술한 문서는 이 리프에 없다. --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - production 24 + test 1 = 25개 Java 파일 전부 본문 확인. - 테스트 레인 9건 전건 통과 (`EVD-305`). - `requireAcceptable`·`validateTopology` 프로덕션 호출 0건, 스타터가 만드는 admin 빈 4종 전수 (`EVD-302`). - `TopologyManagementMode` 프로덕션 사용 0건 (`EVD-302`). - guard 6분기 중 5·6에 도달하는 테스트 부재, 그 이유까지 (`EVD-303`). - `HmacApprovalVerifier` src/main 생성 0건, `ApprovalVerifier` 구현체 1개 (`EVD-303`). - 계획 다이제스트 입력 필드별 `|` 포함 가능성 전수 조사와 현재 단사성 판정 (`EVD-304`). - `module-info.java` 저장소 전체 0개, 소유 모듈 밖에서 같은 패키지를 선언한 파일 0개. **확인하지 못한 것** - `JdbcAdminOperationJournal` 이 `AdminOperationJournal` javadoc 의 세 의무(공유·내구, `(ticket,digest)` 유일성, 단조 펜싱 토큰)를 실제로 지키는지 — 그 리프의 SSOT 이며 Postgres 컨테이너가 필요하다. 이 세션에서 실행하지 않았다. - `messaging-admin-runtime` 의 서비스들이 검사 순서를 올바르게 호출하는지 — 다음 문서에서 다룬다. - 부팅된 컨텍스트에서 `app.messaging.admin.enabled=true` 로 빈 그래프가 실제로 어떻게 되는지 — 런타임 관측 미수행. - 운영자 도구(`adminCredentialPresent=true` 를 등록하는 쪽)가 이 저장소 밖에 존재하는지. - `topologyVersion` 이 실제 배포에서 어떤 형식인지 — `BrokerTopologyInspector` 구현이 이 저장소에 없다. --- ## 17. 손볼 것 ### P2 — "BLOCKING 이면 기동이 실패한다" 는 보장이 어떤 배선에서도 실행되지 않는다 `TopologyIssue.Severity.BLOCKING` javadoc: "The destination cannot deliver a declared guarantee; startup must fail." `TopologyValidationReport.requireAcceptable()` javadoc: "Fails startup when any blocking issue was found." 그러나 `requireAcceptable()` 의 프로덕션 호출부는 0건이고, 그것을 부를 수 있는 유일한 진입점 `validateTopology()` 도 호출부가 0건이며, `MessagingAdminService` 빈은 스타터가 만들지 않는다(`EVD-302`). 결과: 복제 계수 1인 목적지에 durability 를 선언해도 컨텍스트는 정상 기동한다. 타입은 그 상황을 정확히 표현할 수 있고, 표현한 것을 아무도 읽지 않는다. 고치는 방법이 같은 파일에 이미 있다. `MessagingAdminDurabilityValidator` 는 `InitializingBean` 으로 저널 내구성을 기동 시점에 검사하고 실패시킨다. 같은 모양의 빈 하나 — `CompositeTopologyValidator` + 선언된 `TopologyManifest` 목록을 받아 `afterPropertiesSet()` 에서 `validate(...).requireAcceptable()` 을 호출 — 이면 된다. 단, `@ConditionalOnBean(BrokerTopologyInspector.class)` 는 유지해야 한다(inspector 없이 검증할 수 없으므로). 이 항목이 P2 인 이유: 이 리프는 `runtime_memberships: ["app-bootstrap"]` 이고, 보장이 문서·타입·테스트에 모두 존재하는데 배선만 없다. 읽는 사람은 보장이 있다고 믿을 근거가 세 겹으로 있다. ### P2 — `DestructiveOperationGuard` 의 두 분기가 문서에도 없고 테스트에도 없다 operation 불일치(`:72-83`)와 source 불일치(`:84-93`)는 클래스 javadoc 의 "Four conditions" 에 포함되지 않고, 두 에러 코드를 단언하는 테스트도 저장소 전체에 없다(`EVD-303`). 이 둘은 사소한 검사가 아니다 — 5번 분기의 인라인 주석이 정확히 말한다: "A guard that only checks presence and window lets a verified redrive approval authorise a destination deletion." 즉 **검증된 승인으로 목적지 삭제를 인가하는 것**을 막는 검사다. 혼동을 키우는 정황이 하나 더 있다. `anApplicationRuntimeCannotRedrive` 는 `REDRIVE` 요청에 `REPLAY` 승인을 넘기지만 `adminCredentialPresent=false` 라 분기 2에서 먼저 걸린다. 불일치 조합이 테스트에 등장하지만 그 분기는 실행되지 않는다. 수정: javadoc 을 여섯으로 고치고, `new DestructiveOperationGuard(true)` 위에서 operation 불일치·source 불일치 각각 1건씩 테스트를 추가한다. 이 리프에는 이미 `verified(operation, source, from, until)` 헬퍼가 있어 두 줄이면 된다. ### P3 — 서명 능력과 검증 능력이 같은 객체에 있다 `HmacApprovalVerifier` 는 `sign()` 과 `verify()` 를 같은 키로 제공한다. javadoc 이 위험을 명시한다: "Holding this key is what makes a caller an issuer — it is not, and must not become, available to the runtime that executes operations." 그러나 대칭키에서는 **검증하려면 서명할 수 있는 키를 가져야 한다**. 승인을 검증하는 프로세스는 정의상 승인을 발급할 수 있고, 그 프로세스가 운영자 도구라면 "an operator cannot mint an approval for themselves" 는 성립하지 않는다. 이 리프의 테스트가 그 구조를 그대로 보여준다. ```java // DestructiveOperationGuardTest.java:20-22, 47 private static final HmacApprovalVerifier ISSUER = new HmacApprovalVerifier(…); … return ISSUER.verify(grant, ISSUER.sign(grant), digest, from); ``` 같은 객체가 발급자이자 검증자이며, 변수 이름이 `ISSUER` 다. 현재 프로덕션 배선이 `ApprovalVerifier` 빈을 만들지 않으므로 지금 문제가 발생하지는 않는다(`EVD-303`). 그러나 운영자 도구가 등장하는 순간 이 구조가 활성화된다. 이 리프는 다른 모든 곳에서 "능력을 타입으로 표현" 하는데(`VerifiedApproval` 이 검증 사실을 증명하듯), 서명 능력만 타입으로 분리되어 있지 않다. 두 가지 방향이 있고 둘 다 javadoc 이 이미 열어 두었다. (1) `ApprovalIssuer` 를 별도 타입으로 분리해 "누가 서명 능력을 쥐는가" 를 타입에 드러낸다 — 클래스는 나뉘고 정규 형식은 `ApprovalGrant.canonicalForm()` 하나로 유지되므로 javadoc 이 우려한 "두 번째 구현이 드리프트한다" 는 발생하지 않는다. (2) javadoc 이 이미 언급한 대로 공개키 검증자를 구현해 검증 측이 서명 키를 갖지 않게 한다. ### P3 — 계획 다이제스트가 승인 정규 형식과 다른 인코딩을 쓴다 `ApprovalGrant.canonicalForm()` 은 길이 접두를, `ReplayPlan.digest()`/`RedrivePlan.digest()` 는 `String.join("|", …)` 를 쓴다(§12.3(b), `EVD-304`). 현재는 충돌을 만들 수 없다 — 자유 형식 필드가 `topologyVersion` 하나뿐이기 때문이다. 그러나 그 조건은 코드 어디에도 적혀 있지 않고, 필드가 하나 추가되면 조용히 깨진다. `ApprovalGrant` 의 `appendField` 를 `PlanDigest` 쪽으로 옮겨 재사용하는 편이 낫다 — 규칙과 그 근거가 이미 같은 리프에 있다. 부수적으로 `topologyVersion` 에 형식 제약을 주는 것도 검토할 만하다. 지금은 `isBlank()` 만 본다. ### P3 — `TopologyManagementMode` 가 어디에도 연결되어 있지 않다 자기 선언과 테스트 4건이 전부다. 이 enum 을 읽는 프로덕션 코드도, 이것으로 매핑되는 설정 프로퍼티도 없다(`EVD-302`). 두 선택지가 있다: 실제로 배선하거나(선언된 토폴로지 관리 모드를 설정에서 읽고 `requireSafeFor(isProduction)` 를 기동 시 호출), 제거한다. 지금 상태는 "규칙이 코드에 있다" 는 인상만 준다. ### P3 — 운영자용 표면 전체에 프로덕션 소비자가 없다 `ReplayPlan.describeImpact`, `RedrivePlan.describeImpact`, `ReplayResult.fellShortOfTheEstimate`, `RedriveResult.isFullyAccounted`, `AdminOperationLease.isResumption` — 다섯 개가 전부 테스트에서만 호출된다(`EVD-302`). 이것들은 잉여 코드가 아니라 **아직 소비자가 없는 잘 설계된 표면**이다. `describeImpact` 의 javadoc 이 "operator-facing" 이라고 쓰고 `ApprovedPlanExecutionTest.aReplayIntoTheLiveGroupSaysSoInCapitals` 가 대문자 `LIVE` 까지 검증한다. 문제는 그 문자열이 도달할 화면이 없다는 것이다. admin API·CLI 계층을 만들 때 이 다섯이 그 계층의 명세라는 점을 문서에 남겨 두는 것이 낫다. ### P3 — `VerifiedApproval` 의 위조 방지가 package-private 에만 의존한다 이 저장소는 JPMS 를 쓰지 않는다(`module-info.java` 0개). 따라서 어떤 모듈이든 `package dev.caskeleton.messaging.admin;` 을 선언하면 `VerifiedApproval.of(grant)` 를 호출할 수 있다. 현재 그런 파일은 없지만, 이 타입의 존재 이유가 "아무도 만들 수 없다" 이므로 그 조건을 자동으로 지키는 검사가 있어야 한다. `ApprovalForgeryTest.aVerifiedApprovalCannotBeConstructedOutsideTheVerifier` 가 있으나, 그것은 같은 패키지 안에서 API 표면을 확인하는 테스트지 다른 모듈의 패키지 선언을 막지 못한다. ArchUnit 규칙 한 줄 — "`dev.caskeleton.messaging.admin` 패키지는 `messaging-admin-api` 소스 경로에만 존재한다" — 이면 된다. ### P3 — `messaging-policy` 의존이 import 0건이다 선언만 남아 있다. 제거 후보. ### P3 — 같은 인가 실패 코드가 세 파일에 문자열 리터럴로 흩어져 있다 `APPROVAL_OPERATION_MISMATCH`, `APPROVAL_SOURCE_MISMATCH`, `APPROVAL_PLAN_MISMATCH`, `APPROVAL_EXPIRED` 가 `HmacApprovalVerifier`, `DestructiveOperationGuard`, `ApprovedReplayPlan`, `ApprovedRedrivePlan` 에 각각 리터럴로 존재하며 메시지 문구가 서로 다르다. 검사의 3중화 자체는 의도된 심층 방어지만(§12.2 대조군 2), 코드 문자열은 상수 하나로 모으는 편이 집계와 검색에 낫다. ### 확인된 설계(문제 아님) - **`VerifiedApproval` 의 unforgeable-token 패턴.** private 생성자 + package-private 팩토리 + 검증자만 호출. 타입을 쥔 것이 검증 증거다. - **`Approved*Plan` 을 별도 타입으로 만든 것.** 인가를 컴파일러가 강제한다. - **4-eyes 를 record 생성자에 둔 것.** 서명 대상 객체의 존재가 곧 통과 증거다. - **길이 접두 정규 형식.** 구분자 밀반입을 원천 차단. - **상수시간 HMAC 비교 + 256비트 키 하한 + 키 방어적 복사.** - **다이제스트를 서명에 묶은 것.** 승인이 "기간" 이 아니라 "계획" 에 대한 것이 된다. - **검사의 3중화.** `ApprovedReplayPlan:24-25` 가 이유를 밝힌다 — 어떤 코드 경로로도 불일치가 execute 까지 살아남지 못한다. - **`(approvalTicket, planDigest)` 복합 키.** "이미 실행됨" 과 "다른 계획에 재사용" 을 구분한다. - **리스가 `resumeFrom` 을 나르는 것.** 재시도가 새 실행이 되지 않는다. - **펜싱 토큰 하한을 타입으로 강제.** - **`isDurable()` 선언값 + 스타터의 기동 실패 검사.** 이 리프에서 실제로 배선까지 완료된 유일한 안전 장치다. - **선언 토폴로지와 실측 토폴로지를 다른 타입으로 둔 것.** 자기 자신과 비교해 성공을 보고하는 것을 타입으로 막는다. - **BLOCKING 을 첫 발견에서 던지지 않고 전부 모아 보고하는 것.** - **dry run 무조건 허용.** 계획을 공짜로 만들어 계획을 유도한다. - **`loopAcknowledged` 분리.** 900건 승인과 400건 재실패 승인은 다른 결정이다. - **`redriveId` 와 `MessageId` 분리.** 리드라이브 루프를 일반 트래픽과 구별 가능하게 한다. - **`stillParked` 를 뺄셈으로 계산하지 않는 것.** DLQ-confirm-before-settle 규칙이 리드라이브에도 적용된다. - **dry run 이 0을 넘기면 생성자가 거절.** 결과 타입이 스스로를 검증한다. - **`VerifiedApproval.toString()` 축약.** 로그에 승인자를 다시 쓰지 않는다. - **인가 실패(`MessageAuthorizationException`)와 프로그래밍 오류(`IllegalArgumentException`)의 예외 타입 분리.** --- ## Source anchors ``` src/messaging/messaging-admin-api/build.gradle:1-6 src/config/architecture/modules.json (messaging-admin-api 항목) main/…/AdminApproval.java:6-17,21-33,35-44 main/…/ApprovalGrant.java:8-28,39-59,61-87,89-97 main/…/ApprovalVerifier.java:5-13,16-28 main/…/HmacApprovalVerifier.java:12-25,28-45,47-61,63-100,102-110 main/…/VerifiedApproval.java:6-17,20-32,34-69,71-87 main/…/PlanDigest.java:9-19,22-28,30-46 main/…/DestructiveOperation.java:3-25 main/…/DestructiveOperationGuard.java:9-19,22-31,33-94 main/…/ReplayRequest.java:9-18,27-35 main/…/ReplayPlan.java:6-23,31-40,42-64,66-78 main/…/ApprovedReplayPlan.java:7-16,19-52,54-85 main/…/ReplayResult.java:7-21,30-39,41-48 main/…/RedriveRequest.java:7-20,24,26-36 main/…/RedrivePlan.java:6-19,27-40,42-62,64-71,73-85 main/…/ApprovedRedrivePlan.java:8-19,23-57,59-94 main/…/RedriveResult.java:7-21,25-38,40-50 main/…/AdminOperationJournal.java:7-19,22-41,43-54,56-63,65-73,75-82,84-92 main/…/AdminOperationRecord.java:7-25,39-62 main/…/AdminOperationLease.java:5-18,27-45,47-54 main/…/AdminOperationState.java:3-21 main/…/TopologyManifest.java:7-19,27-42,44-75 main/…/DestinationTopology.java:6-18,26-35,37-45 main/…/TopologyIssue.java:5-19,23-29,31-37,39-65,67-75 main/…/TopologyValidationReport.java:7-16,19-25,27-35,37-57,59-66,68-82 main/…/TopologyManagementMode.java:5-13,14-20,22-36 test/…/DestructiveOperationGuardTest.java:17-25,27-48,50-110,112-125,127-146 src/messaging/messaging-core-api/.../destination/DestinationName.java:5-23 src/messaging/messaging-spring-boot-starter/.../MessagingAdminAutoConfiguration.java:14-86 src/messaging/messaging-spring-boot-starter/.../MessagingAdminDurabilityValidator.java:11-67 src/messaging/messaging-admin-runtime/.../DefaultMessagingAdminService.java:106,117,127,169,178 src/messaging/messaging-admin-runtime/.../MessagingAdminService.java:32 src/messaging/messaging-admin-runtime/.../CompositeTopologyValidator.java:19-50 src/messaging/messaging-admin-runtime/.../TopologyValidationRuntime.java:48 src/messaging/messaging-admin-runtime/.../RedriveService.java:97 src/messaging/messaging-admin-runtime/.../ReplayService.java:59 src/messaging/messaging-admin-runtime/src/test/.../ApprovalForgeryTest.java:51,69,84,96,110,133,146,158,172,192 src/messaging/messaging-admin-runtime/src/test/.../ApprovedPlanExecutionTest.java:121-221 ```