# 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