# 13 · adapter-outbound-notification ## SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-notification` - canonical state `analysisFile`: `analysis/13-adapter-outbound-notification.md` (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/notification` · Gradle `:adapter:outbound:notification` - registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]` - registry `runtime_memberships`: `["app-bootstrap"]` - coverage ledger: `FULL_READ` **240** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: IN_PROGRESS > revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 경로: `src/adapter/outbound/notification` · Gradle: `:adapter:outbound:notification` ## 0. Denominator와 coverage ledger tracked file **240개** — main 171 (14,695 LOC, 리소스 0), test 65 (Java 61 / 10,045 LOC + 템플릿 리소스 4), governance 4. 총 약 24.7k LOC. ```json { "id": "adapter-outbound-notification", "gradle_path": ":adapter:outbound:notification", "allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"], "runtime_memberships": ["app-bootstrap"] } ``` main 패키지 배치(171): `platform/dispatch` 23 · `provider` 15 · `platform/template` 14 · `catalog` 12 · `platform/autoconfigure` 11 · `platform/provider/{ses 11, webpush 10, fcm 10, twilio 9, smtp 7, http 6, apns 4, webhook 3}` 60 · `template` 7 · `platform/security` 7 · `platform/observation` 5 · `platform/reactor` 3 · `slack/webhook` 3 · `email/google` 3 · `core` 3 · 루트 2. `build.gradle`이 앞선 모듈들에서 반복 발견한 drift를 **스스로 고친 기록**을 담고 있다. > "Jackson's YAML dataformat is excluded because schemas arrive as JSON strings… **It does not remove YAML from the runtime** — `org.yaml:snakeyaml` is on this classpath via spring-boot-starter, which is how Spring Boot reads application.yml. **The comment here used to claim the stronger outcome, and the resolved graph had said otherwise for as long as it stood**; `dependencyPolicy` below now states the claim the build can check." 그리고 그 자리에 검사 가능한 선언을 놓았다. ```groovy dependencyPolicy { absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml', because: '…' absent 'tools.jackson.dataformat:jackson-dataformat-yaml', because: '…' } ``` 바로 앞 모듈(`adapter-outbound-messaging`)의 P2가 정확히 이 문제 — 주석이 주장하는 것과 해석된 그래프가 다르고, 검사 태스크는 버전을 하드코딩해 통과 불가 — 였다는 점에서, 같은 저장소 안에 **문제와 그 해법이 나란히** 있다. 또 하나 이 leaf에만 있는 장치가 `gradle/notification-api-surface.gradle`(NTF-022)이다. > "Nearly every top-level type in the platform is public, which means the boundary between 'the API other code may build on' and 'an implementation detail that happens to be reachable' is not written down anywhere. Enforcing internal-by-default across several hundred types is a design change; **pinning the surface is not**, and it converts surface growth from something that happens silently into something a reviewer sees. A new public type is then a line in a diff." 공개 타입 목록을 `docs/notification/api-surface-snapshot.txt`에 고정하고, 변경에는 `-PapproveNotificationApiChange`가 필요하다. 의존성 주석도 각 선택의 근거를 적는다 — SMTP는 `JavaMailSender`/`MimeMessageHelper` 위에 세운다("Rebuilding MIME by hand to avoid one dependency would be the more dangerous choice"), Thymeleaf는 **엔진만** 받고 Spring starter는 받지 않는다("which would drag a view resolver and a servlet integration onto an outbound adapter that renders strings and never serves a request"), JSON Schema는 messaging 어댑터와 **같은 검증기·같은 버전**을 쓴다("rather than a second implementation of the same spec"). ### 하위 범위 ledger | # | 범위 | main | test | 기타 | 합 | 상태 | |---|---|---|---|---|---|---| | 1 | governance + 루트 + `platform/autoconfigure` | 13 | 5 | 4 | 22 | **COMPLETE** | | 2 | `catalog/**` + `template/**` — 바인딩 컴파일 · 템플릿 카탈로그 | 19 | 4 | – | 23 | **COMPLETE** | | 3 | `platform/dispatch/**` — 스케줄러 · 런타임 · 시도 | 23 | 7 | – | 30 | **COMPLETE** | | 4 | `platform/template/**` + `platform/security/**` | 21 | 11 | – | 32 | **COMPLETE** | | 5 | `provider` + `core` + `platform/{provider,observation,reactor}` | 29 | 9 | – | 38 | **COMPLETE** | | 6 | `platform/provider/*` 8종 구현 | 60 | 16 | – | 76 | **COMPLETE** | | 7 | `slack/webhook` + `email/google` + testkit + 템플릿 리소스 | 6 | 9 | 4 | 19 | **COMPLETE** | | | **TOTAL** | **171** | **61** | **8** | **240** | **7 / 7** | manifest: `evidence/raw/181-notification-module-inventory.txt`. --- ## 1. Sub-scope 01 범위와 denominator > 내부 상태: COMPLETE — **22 / 22 FULL_READ** > 범위: governance 4 + 루트 2 + `platform/autoconfigure` 11 (main 13, 1,357 LOC) + 전용 test 5 > 역할: 이 플랫폼이 켜지는 조건, 무엇을 조립하고 무엇을 거부하는가 manifest와 probe: `evidence/raw/182-notification-activation-probes.txt`. ## 2. "이름 없는 상태"를 없애는 것이 이 sub-scope의 주제다 세 클래스가 각각 이전에는 **구분되지 않던 두 상황**을 구분한다. **`NotificationPlatformMode`** — 공급자가 하나도 조립되지 않은 플랫폼이 공급자가 있는 플랫폼과 똑같이 보였다. > "A platform with no assembled provider used to look identical to one with providers: the same beans, the same scheduler, the same readiness. **Requests were accepted durably and then sat in the queue with no eligible route.** Naming the state makes it a decision an operator takes rather than a situation they discover." `INGEST_ONLY`는 **명시적으로 선택해야** 하고("A deployment that reaches zero providers by accident is a misconfiguration, and the whole point of this enum is that the two are told apart"), `NotificationProviderAssembly:183`이 그것을 강제한다 — 경로가 비었는데 모드가 `INGEST_ONLY`가 아니면 조립을 거부하고 메시지로 그 모드를 안내한다. app-bootstrap 쪽에서도 `NotificationPlatformWorkerConfig`가 "everything that starts a thread, and therefore everything `INGEST_ONLY` must not have"를 그 모드로 가른다. **`ProviderType`** — 설정이 타입을 자유 문자열로 날랐고 "the only thing that read it was a" 비교였다. 지금은 닫힌 enum이라 "the unknown type a binding failure at startup"이고 채널도 타입에서 유도된다. **`NotificationSecretRequirements`** — 이 sub-scope에서 가장 미묘한 판단이다. 이전에는 여덟 개 키를 **항상** 요구했다. > "That is **fail-closed in the wrong direction**: it made every deployment provision and rotate keys for capabilities it had switched off — a Web Push signing key for a platform with no Web Push profile… and **a key that exists but is never used is a key nobody notices leaking.** It also made the eight look equally load-bearing." 지금은 네 개(`CONTACT_ENCRYPTION`·`CONTACT_LOOKUP_HMAC`·`PAYLOAD_ENCRYPTION`·`PROVIDER_REQUEST_LOOKUP_HMAC`)가 모든 모드에 필요하고 — **수용 경로**에 있으므로 `INGEST_ONLY`에서도 필요하다 — 나머지 넷은 능력을 따라간다. 약해지면 안 되는 방향은 명시된다: "a capability that is switched *on* and whose key is missing still refuses the boot, because the alternative is discovering it on a user's notification." 그리고 "validation of whatever is supplied happens regardless, so an unused key that is configured is still checked rather than trusted." SMTP가 `PROVIDER_CREDENTIAL`을 요구하지 않는 이유도 구체적이다 — 릴레이 주소·사용자·비밀번호가 Spring의 `spring.mail.*`에서 오므로 "Demanding a provider credential for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate to." `NotificationProviderAssembly`도 같은 종류의 기록을 갖는다 — "This is the step that did not exist. The registry was constructed empty, the route planner…" 그리고 거부 목록을 dispatch worker 시작 **전에** 적용한다. ## 3. Confirmed — 이 leaf의 두 검증 태스크는 실제로 통과한다 바로 앞 두 모듈에서 검증 장치가 빨간 채로 남아 있는 것을 발견했으므로(httpclient §51, messaging §3), 이 leaf의 두 태스크를 **실행해 확인했다**. ``` $ ./gradlew :adapter:outbound:notification:verifyDependencyPolicy → BUILD SUCCESSFUL (EXIT=0) $ ./gradlew verifyNotificationApiSurface → OK — 586 public types, unchanged (EXIT=0) ``` 둘 다 통과한다. 그리고 `dependencyPolicy`가 존재하는 이유가 messaging의 P2와 정확히 같은 문제를 이 leaf가 **이미 겪고 고친** 결과라는 점이 중요하다. > "It does **not** remove YAML from the runtime — `org.yaml:snakeyaml` is on this classpath via spring-boot-starter… **The comment here used to claim the stronger outcome, and the resolved graph had said otherwise for as long as it stood**; `dependencyPolicy` below now states the claim the build can check." 즉 "주석이 주장하고 그래프가 반박하는" 상태를 발견하고, 주석을 **빌드가 확인할 수 있는 선언**으로 바꿨다. messaging 모듈에는 그 교훈이 적용되지 않았고(§ messaging 3·4), 같은 저장소 안에 문제와 해법이 나란히 있다. `verifyNotificationApiSurface`는 NTF-022의 장치다 — 공개 타입 586개를 `docs/notification/api-surface-snapshot.txt`에 고정하고 변경에 `-PapproveNotificationApiChange`를 요구한다. 그 근거도 정직하다: "Enforcing internal-by-default across several hundred types is a design change; **pinning the surface is not**, and it converts surface growth from something that happens silently into something a reviewer sees." ## 4. Negative-space probes — sub-scope 01 - **8.1 활성화 게이트 전수**: `@ConditionalOnProperty` 2곳(플랫폼 루트, `spring.mail.host`)과 `@ConditionalOnMissingBean` 11곳. main 리소스 0 — 이 leaf는 `AutoConfiguration.imports`를 스스로 갖지 않고 app-bootstrap의 `NotificationRootAutoConfiguration`이 명시적으로 `@Import`한다. - **8.1b `@ConditionalOnBean` 제거 기록**: `SecretMaterialProvider` 조건이 있었으나 "Nothing is lost by dropping it. Both configurations are reached only through the same root"으로 제거됨 — 조건 중복 제거. - **8.2 이름 붙인 상태의 강제**: `INGEST_ONLY`가 조립(`:183`)과 worker 배선 양쪽에서 실제로 갈림(§2). - **8.3 실행 probe 2건**: 의존성 정책과 API 표면 스냅샷 모두 통과(§3). - **8.4 비밀 요구 축소**: `ALWAYS` 4종 + 능력별 4종의 분리와 그 방향성 논증 확인(§2). ## 5. Sub-scope 01 findings backlog | 우선순위 | finding | reachability | |---|---|---| | — | **없음.** 이름 없는 상태 세 가지를 각각 enum·타입·요구집합으로 갈랐고, 두 검증 태스크가 실제로 통과하며, 의존성 주석이 빌드가 확인 가능한 선언으로 대체돼 있다 | — | ## 6. Sub-scope 01 완료 조건 - denominator 22 / 22 FULL_READ (`182-...` OWNED FILES) - §8.1~§8.4 probe 수행, 실행 probe 2건 - 소스 미변경 --- # Sub-scope 02 — `catalog/**` + `template/**` (23 files, 19 main + 4 test) > 내부 상태: COMPLETE — **23 / 23 FULL_READ** · 근거 `evidence/raw/183-notification-catalog-dispatch-probes.txt` ## 7. 무엇을 하는 코드인가 체크인된 알림 카탈로그(provider · template · route · runtime-profile 4개 목록)를 **순수 결정적 컴파일러**로 돌려 불변 그래프 하나를 만들고, 그 그래프에서 애플리케이션이 소유한 `NotificationFrozenPlan`을 뽑아낸다. 그래프에는 SHA-256 manifest digest가 붙어 릴리스 증거가 된다. digest는 이 저장소가 반복해서 쓰는 형태다 — `NotificationCatalogException.update`가 문자열을 **길이 프레이밍**(`ByteBuffer.allocate(4).putInt(len)` 후 바이트)으로 먹이고, int/long/boolean은 고정폭으로 먹인다(`NotificationCatalogException.java:48-66`). 인접 필드 연결로 인한 충돌이 구조적으로 불가능하다. `LocalEmailRenderer`(473 LOC)는 이 leaf에서 가장 밀도 높은 보안 코드다. 사슬 전체를 확인했다: 1. **정적 HTML 허용목록** — 태그 27종(`a b body br div em h1..h6 head html i img li ol p span strong table tbody td th thead tr ul`), 무해 속성 3종(`alt`, `aria-label`, `title`), `href`는 ``에서만 그리고 `https://`로만 (`LocalEmailRenderer.java:20-26`) 2. **토큰 문법** — `\{\{([a-z][A-Za-z0-9]{0,63})\|([a-z_]+)}}` — 이름과 **컨텍스트**를 함께 요구 3. **위치 검증** — `html_text`는 태그 바깥에서만, `html_attr`는 승인된 무해 속성 안에서만, `url_component`는 승인된 HTTPS 링크 안에서만 (`:182-198`) 4. **치환 시점 컨텍스트별 이스케이프** — `text`는 원문, `html_text`→`html()`, `html_attr`→`htmlAttribute()`, `url_component`→`urlComponent()` + `SafeText` 타입 요구 (`:231-241`) 5. `Matcher.quoteReplacement` (`:243`), 미해결 `{{` 거부 (`:247`), `header` 컨텍스트 CR/LF 거부 (`:62`, `:227`) 선언한 컨텍스트와 **실제 삽입 위치**를 둘 다 검사한다는 점이 핵심이다. 대부분의 템플릿 엔진은 둘 중 하나만 한다. 템플릿 자산은 체크섬으로 고정된다: `NotificationTemplateCatalog`가 manifest가 지명한 classpath 자산만 로드하고 정확한 체크섬을 대조하며(`:52-54`), 렌더러는 **계획에 얼어붙은 체크섬과 렌더러 리비전**이 manifest와 일치하는지 다시 확인한다(`LocalEmailRenderer.java:93-96`). 계획 시점과 렌더 시점 사이에 템플릿이 바뀌면 렌더가 실패한다. ## 8. Negative-space probes — sub-scope 02 - **8.1 도달성 (P2 발견)** — `NotificationBindingCompiler.validateRouteShape`의 전략 분기. 아래 §9. - **8.2 조건 형제 비교 (P2 발견)** — 같은 곳. 테스트가 다른 가드에 걸려 통과한다. - **8.3 중복 메커니즘** — digest 계산이 `bindingDigest` · `manifestDigest` · `NotificationCutoverRouteCatalog.digest` 세 곳에 있으나 모두 같은 길이프레이밍 헬퍼를 경유하고 필드 집합이 다르다. 중복 아님. - **8.4 카운트 드리프트** — `INITIAL_CARD_IDS` 3종과 `APPROVED_CARD_PROVIDERS` 3항목이 일치하며, 카드는 `NotificationProviderCapabilityCard.initial(cardId)`와 **값 동등성**까지 대조된다(`:284-290`). 체크인된 카드가 승인된 정의에서 한 필드라도 벗어나면 컴파일이 실패한다. 드리프트 불가. ## 9. Sub-scope 02 findings ### P2 — `SINGLE` 전용 가드가 먼저 던져 다중 타깃 검증 전체가 도달 불가이고, 그것을 검증한다는 테스트는 다른 가드에 걸려 통과한다 `validateRouteShape`는 **같은 메서드 안에서** 두 번 전략을 본다: ```java // NotificationBindingCompiler.java:141-145 if (route.routeStrategy() != NotificationRouteStrategy.SINGLE) { throw new NotificationCatalogException( "initial notification catalog supports SINGLE route strategy only"); } ... // :159-184 switch (route.routeStrategy()) { case SINGLE -> { ... } case FAN_OUT_ALL -> { ... } // 도달 불가 case ORDERED_FALLBACK -> { ... } // 도달 불가 default -> throw ... // 도달 불가 } validateFallbackGraph(route); ``` `:141`이 통과시킨 값은 `SINGLE`뿐이므로 `:168`·`:174`·`:181` 세 분기는 **어떤 입력으로도 실행되지 않는다**. 이어서 `SINGLE` 분기(`:161-166`)가 `maximumTargets() == 1`이고 `fallbackTargetId()`가 하나도 없을 것을 요구하므로, `validateFallbackGraph`(`:188-218`)의 `edges` 맵은 **항상 비어 있다**. 따라서: - `"fallback references unknown target"` (`:204`) — 도달 불가 - `"cyclic notification fallback graph"` (`:213`) — 도달 불가 여기까지는 "초기 카탈로그는 SINGLE만"이라는 명시적 선언에 딸린 전방 스캐폴딩으로 볼 수 있다. 문제는 **테스트가 그렇게 읽지 않는다**는 점이다. ```java // NotificationBindingCompilerTest.java:279-307 (테스트명: legacyReceiptUnsafeFallbackBoundsAndCyclesAreRejected) NotificationRouteDescriptor cyclic = new NotificationRouteDescriptor( ..., NotificationRouteStrategy.ORDERED_FALLBACK, ..., List.of(new Target("target-a", "slack-runtime-r1", Optional.of("target-b")), new Target("target-b", "slack-runtime-r1", Optional.of("target-a")))); assertThatThrownBy(() -> new NotificationBindingCompiler(...).compile()) .isInstanceOf(NotificationCatalogException.class) .hasMessageContaining("strategy"); ``` `a → b → a` 순환을 만들고 "순환이 거부된다"는 이름을 붙였다. 그러나 실제로 던져지는 것은 `:143`의 `"initial notification catalog supports SINGLE route strategy only"`이고, 이 문자열은 `"strategy"`를 **포함한다**. 단언이 통과한다. 순환 탐지기는 한 번도 실행되지 않는다. 바로 위의 `unsupportedFanOut` 케이스(`:246-277`)도 같은 문자열을 단언하므로, 이름이 약속한 두 성질(bounds, cycles) 중 검증되는 것은 **"SINGLE이 아닌 전략은 거부된다"** 하나이며 그것을 두 번 확인한다. **실패 시나리오** — 누군가 `ORDERED_FALLBACK`을 열려고 `:141-145`의 가드를 제거한다. 그것이 이 기능을 켜는 자연스러운 한 줄이고, 테스트 스위트는 초록색을 유지한다(단언은 `hasMessageContaining("strategy")`이고, 가드가 없어지면 `:178`의 `"ORDERED_FALLBACK route has invalid bounds"`나 `:233`의 `"provider route strategy does not match route"`가 대신 걸려 여전히 `"strategy"`를 포함한다). 순환 fallback 카탈로그가 통과할지 여부는 그날 처음 알게 된다. **권고** — 단언을 메시지 전문 또는 고유 부분문자열(`"cyclic"`, `"invalid bounds"`)로 좁힌다. `"strategy"`는 이 컴파일러의 서로 다른 네 개 예외 메시지에 등장하는 부분문자열이라 어떤 가드가 걸렸는지 식별하지 못한다. ### P3/기록 — `NotificationPlanAdapter`가 이미 정렬된 리스트를 타깃마다 다시 정렬한 뒤 `indexOf`로 순번을 구한다 ```java // NotificationPlanAdapter.java:196-210 binding.targets().stream() .sorted(Comparator.comparing(target -> target.target().targetId())) .map(target -> new FrozenTarget( binding.targets().stream() .sorted(Comparator.comparing(item -> item.target().targetId())) .toList() .indexOf(target), // ← 타깃마다 재정렬 + 선형 탐색 ...)) ``` `NotificationRouteDescriptor`의 표준 생성자가 이미 `targetId` 기준 정렬(`:68`)과 유일성(`:73`)을 보장한다. 현재 `maximumTargets == 1`이므로 실행 비용은 없고 결과도 옳다. `FAN_OUT_ALL`을 열 때(위 P2와 같은 변경) 타깃 수만큼 제곱으로 늘어나며, 순번은 스트림 위치가 아니라 `equals` 일치 위치로 정해진다. 기록만 남긴다. ## 10. Sub-scope 02 완료 조건 - denominator 23 / 23 FULL_READ - §8.1~§8.4 수행 — 도달성에서 P2 1건, 기록 1건 - 소스 미변경 --- # Sub-scope 03 — `platform/dispatch/**` (30 files, 23 main + 7 test) > 내부 상태: COMPLETE — **30 / 30 FULL_READ** · 근거 `evidence/raw/183-notification-catalog-dispatch-probes.txt` ## 11. 무엇을 하는 코드인가 배달의 실행 평면이다. 리스를 claim하는 스케줄러, 자격증명 세대를 들고 있는 provider 런타임 레지스트리, 시도 단위 permit, 그리고 세 개의 배경 pass(회수 · 이벤트 재생 · 재대사). 이 패키지의 javadoc은 이 저장소에서 **가장 많이 "과거에 무엇이 틀렸는지"를 적어 둔** 곳이다. 표본: - `NotificationSchedulerWorker.runOnce:71-74` — "배치를 통째로 claim한 뒤 세마포어 뒤에 줄 세웠기 때문에, 동시성 한도보다 큰 배치는 **아무도 작업하지 않는 배달에 리스를 걸고 있었다**. 짧은 리스에서는 차례가 오기 전에 만료되어 다른 워커가 같은 배달을 가져갔다." → 지금은 `min(claimBatchSize, availablePermits)`만 claim한다. - `NotificationSchedulerWorker.runOnce:80-83` — "이 게이지는 `claimed.size()`를 보고했는데 그것은 배치 크기로 상한이 잡힌다. 큐 10과 큐 1,000만이 **같은 숫자를 발행했다** — '큐 깊이'라는 이름의 유일한 지표가 큐가 자라는 것을 보여줄 수 없는 지표였다." → 지금은 `servingState.currentState().backlogDepth()`. - `ProviderAttemptLimiter.window:224-231` — 창 시작과 카운트가 두 개의 atomic이었다. "한 스레드가 초 경계를 넘으며 창을 CAS한 뒤 별도 연산으로 카운트를 리셋했으므로, 그 사이 다른 스레드의 증가는 전부 버려졌다 — **트래픽이 롤오버하는 바로 그 순간에** 설정보다 많이 통과시켰다." → 지금은 `AtomicReference` 하나에 `accumulateAndGet`. - `ProviderRuntime.LimiterPermit:238-244` — "`close`가 무조건 release했다. 두 번 닫힌 permit은 한 번의 획득에 두 슬롯을 반납했고, `Semaphore`는 가져간 것보다 많이 반납하면 **자란다**. 동시성 상한이 설정보다 영구히 높아진다 — 조용히, provider를 과부하시키는 방향으로." → 지금은 `AtomicBoolean` CAS. - `ProviderRuntimeRegistry.draining:21-24` — `CopyOnWriteArrayList`를 락 밖에서 변형했다. → 지금은 `compute` 안에서 리스트 전체를 교체. - `ProviderRuntimeRegistry.replace:66-69` — 교체·드레인·등록이 세 단계로 나뉘어 있어 두 로테이션이 인터리브하면 **더 오래된 세대가 current로 남을 수 있었다**. → 지금은 `current.compute` 한 연산. - `NotificationSchedulerWorker.close:167-170` — "폴링 스레드를 시작하고 잊었다. `close()`가 executor를 내리고 반환하는 동안 루프는 여전히 다음 배치를 claim할 수 있었으므로, **이미 사라진 프로세스가 리스를 쥔 채로** 종료될 수 있었다." → 지금은 interrupt + join. - `NotificationBackgroundWorkers:159-163` — 아래 §12.3. ## 12. Negative-space probes — sub-scope 03 ### 12.1 (8.1) 도달성 — 배경 작업자 배선 이 저장소에서 반복 발견되는 "구현했으나 호출자가 없는 절반" 패턴을, 이 패키지는 **스스로 찾아 고쳤고 그 사실을 적어 두었다**: > `NotificationBackgroundWorkers:159-163` — "둘 다 호출자 없는 클래스로 존재했다. `LeaseRecoveryService`는 죽은 워커가 남긴 in-flight 배달을 회수하려고 작성되었으나 **한 번도 스케줄되지 않았다**; 원장의 재생 질의는 persistence에 구현되어 **한 번도 읽히지 않았다**. 디스패치 스케줄러의 오류 경로는 회수가 존재한다고 **가정하고 있었다** — 낙관적으로 리스를 해제하는 대신 만료되게 두는데, 회수가 결정할 것이라는 근거에서다 — 그래서 그 부재가 의도된 설계를 누수로 바꿨다." `ProviderEventReplayWorker:5-11`도 같은 종류를 적는다: `pendingProjection`과 `unmatched` 두 질의가 persistence에 구현되어 있었고 둘 다 호출자가 없었다. "콜백 경로가 그 재생에 **의존한다**. 제출 트랜잭션이 커밋되기 전에 콜백을 보내는 provider는 정상이고 수집 경로는 그런 이벤트를 버리지 않고 `PENDING`으로 저장한다. 재생하는 것이 없으면 '나중에 매칭한다'는 주석 안에서만 참이었다." `ReconciliationJobWorker:5-11`도 같다: 재대사는 리스가 만료되어 회수가 미완 시도를 지나칠 때만 실행됐다. "제출이 애매한 채 워커가 **깨끗하게 종료한** 흔한 경우는 다시 물어보는 일이 없었다. 배달은 무기한 `RECONCILIATION_REQUIRED`로 남았고, 이는 결과를 아무도 모르는 상태가 아니라 **큐가 멈춘 것처럼 읽힌다**." **실행 확인** — 세 작업자가 실제로 배선되어 있는가: ``` $ git grep -rn 'NotificationBackgroundWorkers|LeaseRecoveryService|ProviderEventReplayWorker|ReconciliationJobWorker' -- adapter/outbound/notification/src/main app-bootstrap/src/main | grep -v platform/dispatch/ app-bootstrap/.../NotificationPlatformWorkerConfig.java:57: public ReconciliationJobWorker reconciliationJobWorker( app-bootstrap/.../NotificationPlatformWorkerConfig.java:90: public LeaseRecoveryService notificationLeaseRecoveryService( app-bootstrap/.../NotificationPlatformWorkerConfig.java:119: public ProviderEventReplayWorker notificationProviderEventReplayWorker( exit=0 ``` 세 개 모두 app-bootstrap에 빈으로 등록되어 있고 `NotificationBackgroundWorkers.start()`가 셋을 각각 `scheduleWithFixedDelay`한다(`:220-222`). **배선 확인됨.** cache-redis의 `RedisStartupProbe`(호출자 0)나 messaging의 `contract/**`(외부 소비자 0)와 대조된다 — 같은 저장소가 같은 함정을 한쪽에서는 메우고 한쪽에서는 남겨두었다. ### 12.2 (8.2) 조건 형제 비교 — 상태 전이 행렬 `ProviderRuntime`의 여섯 전이를 형제로 놓고 비교하면 두 종류로 갈린다: | 전이 | 형태 | 현재 상태를 보는가 | |---|---|---| | `markAuthenticationFailed` | `health.set(...)` | 아니오 (무조건) | | `markThrottled` | `updateAndGet(HEALTHY이면 전이)` | 예 | | `markDegraded` | `updateAndGet(HEALTHY이면 전이)` | 예 | | `markHealthy` | `updateAndGet(THROTTLED·DEGRADED만)` | 예 | | `resumeHealthy` | `updateAndGet(AUTHENTICATION_FAILED이면 거부)` | 예 | | `markDraining` | `updateAndGet(current -> new RuntimeHealth(DRAINING, ...))` | **아니오** | | `markDisabled` | `updateAndGet(current -> new RuntimeHealth(DISABLED, ...))` | **아니오** | 마지막 두 개만 `current`를 받아 놓고 쓰지 않는다. 이것이 §13의 P2다. ### 12.3 (8.3) 중복 메커니즘 — 종료 경로 `NotificationSchedulerWorker.close`와 `NotificationBackgroundWorkers.close` 둘 다 "취소 → shutdown → awaitTermination(grace) → shutdownNow"를 수행한다. 형태는 같지만 대상이 다르다(폴링 스레드 + virtual-thread executor vs. 단일 데몬 scheduler). 중복 아님. 한 가지 기록: 스케줄러의 `close()`는 폴링 스레드를 `interrupt()`하지만(`:173`), `runOnce`의 `globalConcurrency.acquireUninterruptibly()`(`:90`)는 인터럽트에 반응하지 않는다. 주석(`:169`)은 "인터럽트가 poll-interval sleep을 깬다"고만 말하고 그 점은 정확하다. 세마포어는 in-flight 작업이 `finally`에서 반납하므로 결국 풀리고, 최악의 경우 `join(shutdownGrace)`가 만료된 뒤 종료가 계속된다. 결함 아님. ### 12.4 (8.4) 문서/카운트 드리프트 `RuntimeDrainCoordinator`의 javadoc은 "deadline은 주입된 clock이 아니라 `System.nanoTime()`에서 온다"고 주장한다. 코드 `:172`가 정확히 그렇다. `deadlineNanos` 비교도 오버플로 안전한 `System.nanoTime() - deadlineNanos >= 0` 형태다(`:174`). 드리프트 없음. `LeaseRecoveryService`의 javadoc은 두 경우(시도 행 없음 → requeue, 시도 행 있고 미완 → reconcile)를 약속하는데 코드는 **세 경우**를 구현한다 — 세 번째는 `provablyNeverStarted(attempt)`(`:94-100`)로, 지속된 증거의 확실성이 `PROVEN`이고 값이 false일 때 reconcile 없이 requeue한다. 코드 주석(`:126-129`)이 그 경우를 설명하지만 클래스 javadoc의 `