The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1046 lines
60 KiB
Markdown
1046 lines
60 KiB
Markdown
# 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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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 …
|
|
*
|
|
* <p>{@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.
|
|
*
|
|
* <p>{@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.
|
|
*
|
|
* <p>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
|
|
/**
|
|
* <p>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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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.
|
|
*
|
|
* <p>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
|
|
/**
|
|
* <p>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
|
|
/**
|
|
* <p>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.
|
|
*
|
|
* <p>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
|
|
/**
|
|
* <p>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
|
|
/**
|
|
* <p>{@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
|
|
/**
|
|
* <p>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.
|
|
*
|
|
* <p>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
|
|
/**
|
|
* <p>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
|
|
/**
|
|
* <p>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
|
|
/**
|
|
* <p>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<String>` 을 돌려주고, `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<String>` 을, `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
|
|
```
|