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>
1186 lines
99 KiB
Markdown
1186 lines
99 KiB
Markdown
# 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`는 `<a>`에서만 그리고 `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<RateWindow>` 하나에 `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의 `<ul>`은 두 항목뿐이다. 문서가 코드보다 **보수적인** 방향의 드리프트라 위험을 만들지 않는다. P3/기록.
|
||
|
||
## 13. Sub-scope 03 findings
|
||
|
||
### P2 — `AUTHENTICATION_FAILED`를 지우지 않는다는 `resumeHealthy`의 보장이, 관리자 평면에 노출된 2단계 시퀀스로 우회된다
|
||
|
||
`ProviderRuntime.resumeHealthy`는 자신이 지키는 성질을 javadoc에 명시한다:
|
||
|
||
> "It still refuses `AUTHENTICATION_FAILED`, because **declaring a provider healthy does not give it a credential the provider will accept** — the caller is told so rather than being handed a runtime that will fail on its first attempt."
|
||
|
||
그러나 `markDraining`(과 `markDisabled`)은 현재 상태를 **읽지 않는다**:
|
||
|
||
```java
|
||
// ProviderRuntime.java markDraining()
|
||
return health.updateAndGet(
|
||
current -> new RuntimeHealth(ProviderRuntimeState.DRAINING, Optional.of("DRAINING")))
|
||
.state() == ProviderRuntimeState.DRAINING;
|
||
```
|
||
|
||
`current`는 람다 파라미터로 받기만 하고 쓰이지 않는다. 그리고 두 전이 모두 관리자 포트에 노출되어 있다:
|
||
|
||
```java
|
||
// RegistryProviderRuntimeControl.java:30-39 (implements ProviderRuntimeControlPort)
|
||
return switch (desiredState) {
|
||
case DISABLED -> runtime.markDisabled();
|
||
case DRAINING -> runtime.markDraining();
|
||
case HEALTHY -> runtime.resumeHealthy();
|
||
...
|
||
```
|
||
|
||
**실패 시나리오**
|
||
|
||
1. provider가 자격증명을 거부 → `applyHealth`가 `markAuthenticationFailed("INVALID_CREDENTIAL")` (`RegistryProviderDispatchGateway.java:251-252`). 상태 `AUTHENTICATION_FAILED`, `admitsNewAttempts()` = false(`ProviderRuntimeState.java:19`). 라우트가 한 번 열리고 대기 중인 알림들이 재시도를 만들지 않는다 — 클래스가 의도한 동작.
|
||
2. 운영자가 `setState(profile, HEALTHY, ...)` → `resumeHealthy()` → **false 반환, 상태 유지**. 가드가 작동한다.
|
||
3. 운영자가 `setState(profile, DRAINING, ...)` → `markDraining()` → 무조건 덮어쓰기. 상태 `DRAINING`, reason `"DRAINING"`. **`"INVALID_CREDENTIAL"`이라는 진단이 사라진다.**
|
||
4. 운영자가 다시 `setState(profile, HEALTHY, ...)` → 현재 상태가 `AUTHENTICATION_FAILED`가 아니므로 → **`HEALTHY`, reason 없음.**
|
||
|
||
결과: provider가 거부한 자격증명을 그대로 든 런타임이 `admitsNewAttempts()` = true가 되어 큐를 다시 받는다. 첫 시도가 실패하면 `applyHealth`가 다시 `AUTHENTICATION_FAILED`로 돌려놓으므로 상태는 자가 복구되지만, (a) 시도 하나가 provider를 향해 실제로 나가고, (b) 그 시도 이전에 `RegistryProviderDispatchGateway` javadoc이 막으려 한 "만료된 키 하나가 큐에 쌓인 알림 수만큼 재시도를 만드는" 창이 매 리셋마다 다시 열리며, (c) 운영자가 필요로 하는 원인 코드가 3단계에서 이미 파괴되어 있다.
|
||
|
||
**테스트가 이것을 잡지 못하는 이유** — 해당 테스트는 세 전이를 **각각 새 런타임에서** 확인하고 합성을 확인하지 않는다:
|
||
|
||
```java
|
||
// ProviderRuntimeStateTest.java:125-140
|
||
ProviderRuntime drained = runtime(1, 1); drained.markDraining();
|
||
assertThat(drained.resumeHealthy()).isTrue();
|
||
|
||
ProviderRuntime disabled = runtime(1, 1); disabled.markDisabled();
|
||
assertThat(disabled.resumeHealthy()).isTrue();
|
||
|
||
ProviderRuntime failed = runtime(1, 1); failed.markAuthenticationFailed("INVALID_CREDENTIAL");
|
||
assertThat(failed.resumeHealthy())
|
||
.as("declaring it healthy does not give it a credential the provider accepts")
|
||
.isFalse();
|
||
```
|
||
|
||
`.as(...)` 설명문이 서술하는 성질이 정확히 합성으로 깨지는 성질이다. `failed.markDraining(); assertThat(failed.resumeHealthy())` 한 줄이면 드러난다.
|
||
|
||
**권고** — `markDraining`/`markDisabled`가 `AUTHENTICATION_FAILED`를 만나면 그 사실을 **보존**하도록 한다(예: 상태는 `DRAINING`으로 바꾸되 reason은 유지하고, `resumeHealthy`는 마지막으로 관측된 자격증명 거부가 로테이션으로 대체되지 않은 한 거부). 최소한으로는, 로테이션 경로(`ProviderRuntimeRegistry.replace:90`)만 무조건 드레인을 쓰고 관리자 경로는 조건부 드레인을 쓰도록 갈라야 한다 — 전자는 새 세대가 이미 설치된 뒤라 자격증명 이력이 무의미하지만, 후자는 같은 세대를 계속 쓴다.
|
||
|
||
### P3/기록 — `LeaseRecoveryService` javadoc의 경우 목록이 2개, 코드는 3개
|
||
|
||
§12.4. 문서가 코드보다 보수적이라 위험은 없다.
|
||
|
||
## 14. Sub-scope 03 완료 조건
|
||
|
||
- denominator 30 / 30 FULL_READ
|
||
- §8.1~§8.4 수행 — 배선 실행 확인 1건, 상태 행렬 비교에서 P2 1건, 기록 1건
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
# Sub-scope 04 — `platform/template/**` + `platform/security/**` (32 files, 21 main + 11 test)
|
||
|
||
> 내부 상태: COMPLETE — **32 / 32 FULL_READ** · 근거 `evidence/raw/184-notification-template-security-probes.txt`
|
||
|
||
## 15. 무엇을 하는 코드인가
|
||
|
||
두 패키지가 알림 어댑터의 보안 표면 전체를 담당한다.
|
||
|
||
**`platform/security`** — 저장 시 암호화와 키 사용처 분리. `SecretPurpose` 8종이 각각 **정확히 하나의 소유자**를 가진다(probe §8.4):
|
||
|
||
| purpose | 소유자 |
|
||
|---|---|
|
||
| `CONTACT_ENCRYPTION` · `CONTACT_LOOKUP_HMAC` | `AesGcmContactPointProtector` |
|
||
| `PAYLOAD_ENCRYPTION` | `AesGcmNotificationPayloadProtection` |
|
||
| `CALLBACK_FINGERPRINT_HMAC` | `AesGcmCallbackPayloadProtection` |
|
||
| `PROVIDER_REQUEST_LOOKUP_HMAC` | `HmacProviderRequestIdHasher` |
|
||
| `PROVIDER_CREDENTIAL` | `ProviderCredentialManager` |
|
||
| `CALLBACK_SIGNING` | `TwilioCallbackAdapter` · `WebhookNotificationProviderAdapter` |
|
||
| `VAPID_SIGNING` | `VapidKeyRegistry` |
|
||
|
||
`NotificationSecretRequirements`가 8종을 모두 열거하므로(§sub-scope 01) 조립 시점에 부재가 드러난다.
|
||
|
||
`AesGcmContactPointProtector`는 **두 키**를 요구하고 그 이유를 정확히 말한다: 암호문은 비결정적이어야 하고(같은 주소의 두 레코드가 눈에 띄게 동일하면 안 됨) 동등성 조회는 결정적이어야 하는데, "those are opposite requirements, and one key cannot serve both without leaking one of them." 그리고 두 키가 실제로 다른지 **런타임에 확인한다** — keyId 비교와 `MessageDigest.isEqual`로 자료 비교까지(`:115-120`). 지문이 keyless digest가 아니라 HMAC인 이유도 적혀 있다: "an unkeyed hash of a phone number is recoverable by enumeration in seconds."
|
||
|
||
AAD에 접촉점 종류를 묶어(`:122-126`) 암호문을 다른 종류로 옮기면 태그 검증이 깨진다. 키 길이는 256비트로 강제된다(`:108-111`).
|
||
|
||
`parseWebPush`(`:201-219`)의 javadoc은 이 저장소에서 반복되는 자기고발 중 가장 날카로운 축이다:
|
||
|
||
> "이 메서드는 auth secret으로 **16개의 0바이트**를, VAPID key id로 문자열 `"restored"`를 반환하곤 했다 — 둘 다 각자의 암호화 컬럼에 산다는 근거에서였다. **그 컬럼들은 존재하지 않는다** — 마이그레이션에도, 엔티티에도. 그래서 구독은 브라우저가 복호화할 수 있는 RFC 8291 페이로드를 만들 수 없는 상태로, 어떤 VAPID 키가 서명했는지 말할 수 없는 상태로 저장소에서 돌아왔다."
|
||
|
||
**`platform/template`** — 렌더링과 이스케이프. `TemplateSlotMode`가 목적지를 이름 붙이고(`TEXT`/`SUBJECT`/`HTML_TEXT`/`URI`), `TemplateSlotPolicy`가 모드별 규칙을 **두 엔진이 공유하는 한 곳**에 둔다. 이것도 자기고발이 붙어 있다: 규칙이 `PlaceholderTemplateEngine`의 private 헬퍼였고 "the other engine had no equivalent — it did not override the mode-aware render at all, so selecting it **dropped every slot to the unescaped path**."
|
||
|
||
`NotificationTemplateEngine`의 mode-aware `render`가 `default` 메서드가 아니라 **abstract**인 이유도 같은 사건에서 나온다: "A default that discards its own argument is not a fallback; it is the rule not applying, and the engine that skipped it **looked complete because the interface compiled**."
|
||
|
||
## 16. Negative-space probes — sub-scope 04
|
||
|
||
- **8.1 도달성** — `AccessContext`의 필드를 읽는 코드. 아래 §17.1.
|
||
- **8.2 조건 형제 비교** — `ThymeleafStringTemplateEngine`의 두 `render` 오버로드. 아래 §17.2.
|
||
- **8.3 중복 메커니즘** — `escape`와 `verifyRendered`는 같은 4개 모드를 다루지만 목적이 다르다(값 단위 이스케이프 vs. 완성된 슬롯 검증). `HTML_TEXT`에서만 갈리며(`escape`는 이스케이프, `verifyRendered`는 통과) 그 근거가 적혀 있다 — "asking it to render HTML and then escaping the result would escape the operator's markup too." 중복 아님.
|
||
- **8.4 키 분리** — 8종 purpose, 소유자 1:1, 조립 시점 요구 선언 존재. 드리프트 없음.
|
||
|
||
## 17. Sub-scope 04 findings
|
||
|
||
### 17.1 P2 — "모든 reveal은 감사된다"고 선언한 `AccessContext`를 읽는 코드가 저장소에 하나도 없다
|
||
|
||
타입과 포트가 이 성질을 두 번 선언한다:
|
||
|
||
```java
|
||
// AccessContext.java:5
|
||
/** Why a plaintext contact point is being revealed. Every reveal is auditable. */
|
||
public record AccessContext(String purposeCode, String actorRef, boolean auditRequired) { ... }
|
||
|
||
// ContactPointProtector.java:11
|
||
/** Decrypt a value for an audited purpose. */
|
||
ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context);
|
||
```
|
||
|
||
유일한 구현은 컨텍스트를 **null 검사만 하고 버린다**:
|
||
|
||
```java
|
||
// AesGcmContactPointProtector.java:86-97
|
||
public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) {
|
||
Objects.requireNonNull(protectedValue, "protectedValue");
|
||
Objects.requireNonNull(context, "context"); // ← context를 언급하는 유일한 줄
|
||
SecretKeyMaterial key = keys.keyById(protectedValue.keyId());
|
||
byte[] plaintext = decrypt(key, protectedValue.nonce(), associatedData(...), ...);
|
||
return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8));
|
||
}
|
||
```
|
||
|
||
`grep -n 'context'`가 이 파일에서 잡는 줄은 `:86`(시그니처)과 `:88`(null 검사) **두 개뿐이다**(probe §8.1).
|
||
|
||
저장소 전체 접근자 카운트:
|
||
|
||
```
|
||
$ grep -rn '\.auditRequired()' --include=*.java . | wc -l → 0
|
||
$ grep -rn '\.purposeCode()' --include=*.java . | wc -l → 0
|
||
```
|
||
|
||
`actorRef()`는 7건 잡히지만 전부 다른 타입(`AdminActor`, `NotificationAuditEvent`)의 동명 접근자다. 즉 `AccessContext`의 **세 필드 모두 어디에서도 읽히지 않는다**. 레코드는 생성만 되고 소비되지 않는다.
|
||
|
||
**감사 설비가 없어서가 아니다.** `NotificationAuditPort`가 있고 `LoggingNotificationAudit`이 전용 로거 `notification.audit`으로 구현하며, 같은 leaf의 `ProviderRuntimeRotator:26`가 실제로 주입받아 쓴다. 관리자 평면도 쓴다. 복호화 경로만 쓰지 않는다.
|
||
|
||
**실패 시나리오** — 사고 조사에서 "누가, 어떤 목적으로, 어떤 접촉점의 평문을 열었는가"를 묻는다. 프로덕션 reveal 호출자는 6곳(SES · SMTP · Twilio · APNs · FCM · Web Push 어댑터)이고 전부 `AccessContext.dispatch(profileId)`를 넘긴다 — 이 팩토리는 `auditRequired = false`로 고정한다(`AccessContext.java:18`). 그러나 `true`를 넘겨도 결과는 같다: 그 필드를 보는 코드가 없다. 이메일 주소·전화번호·디바이스 토큰의 평문 복호화가 **어떤 흔적도 남기지 않으며**, 타입 시그니처는 남는 것처럼 읽힌다.
|
||
|
||
호출자가 컨텍스트를 정직하게 채우고 있다는 점이 이 발견을 더 뾰족하게 만든다 — 배선은 다 되어 있고 종단만 비어 있다. cache-redis의 `RedisStartupProbe`(호출자 0)와 messaging의 `verifyJsonSchemaRuntimeGraph`(통과 불가)와 함께 이 저장소의 세 번째 "장치는 있고 회로가 닫히지 않은" 사례다.
|
||
|
||
**권고** — `AesGcmContactPointProtector`에 `NotificationAuditPort`를 주입하고 `reveal`에서 `record(...)`한다. 이벤트에는 `purposeCode`·`actorRef`·`keyId`·`type`만 넣고 평문과 지문은 넣지 않는다(`NotificationAuditPort` javadoc: "never carrying raw addresses"). `auditRequired`가 false인 dispatch 경로를 샘플링할지 여부는 그 다음 결정이다 — 지금은 필드가 정책을 표현하지만 정책을 집행하는 코드가 없다.
|
||
|
||
### 17.2 P2 — Thymeleaf 예외 메시지 삭제 가드가 프로덕션이 타지 않는 오버로드에만 있다
|
||
|
||
`ThymeleafStringTemplateEngine`은 같은 클래스에 `render` 두 개를 갖는다. 모드 없는 쪽은 예외를 잡아 메시지를 버리고, 그 이유를 명시한다:
|
||
|
||
```java
|
||
// ThymeleafStringTemplateEngine.java:84-100 (mode-less overload)
|
||
public String render(String source, Map<String, Object> variables) {
|
||
...
|
||
try {
|
||
return engine.process(source, contextFor(source, variables));
|
||
} catch (RuntimeException failure) {
|
||
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
|
||
// template expression contains the variable it failed on — which for this platform is a
|
||
// one-time code or a recipient name.
|
||
throw new TemplateRenderingException(NotificationFailureDescriptor.preDispatch(...));
|
||
}
|
||
}
|
||
```
|
||
|
||
모드 있는 쪽은 같은 호출을 **감싸지 않는다**:
|
||
|
||
```java
|
||
// :67-82 (mode-aware overload)
|
||
public String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
|
||
Objects.requireNonNull(mode, "mode");
|
||
String rendered = engineFor(mode).process(source, contextFor(source, variables)); // try 없음
|
||
return TemplateSlotPolicy.verifyRendered(mode, rendered);
|
||
}
|
||
```
|
||
|
||
probe로 확인한 `catch` 위치는 `:92` 하나뿐이고 `process(` 호출은 `:80`과 `:91` 두 곳이다.
|
||
|
||
**프로덕션은 모드 있는 쪽만 탄다.** 유일한 렌더러 `CanonicalNotificationRenderer`가 모든 슬롯을 모드와 함께 부른다:
|
||
|
||
```
|
||
$ grep -rn 'engine.render(' .../CanonicalNotificationRenderer.java
|
||
125: return engine.render(modeOf(slot), template.content().requireSlot(slot), variables);
|
||
133: .map(source -> engine.render(modeOf(slot), source, variables));
|
||
```
|
||
|
||
`render(String, Map)`은 main 소스 전체에서 **선언 3곳(인터페이스 + 두 구현)만 있고 호출자가 없다**.
|
||
|
||
그리고 이 엔진은 실제로 배선된다:
|
||
|
||
```java
|
||
// NotificationPlatformAutoConfiguration.java:74-75
|
||
case "placeholder" -> new PlaceholderTemplateEngine();
|
||
case "thymeleaf" -> new ThymeleafStringTemplateEngine();
|
||
// NotificationPlatformProviderConfig.java:73-75 (app-bootstrap)
|
||
return engine instanceof ThymeleafStringTemplateEngine thymeleaf
|
||
? new ThymeleafNotificationRenderer(channel, templates, validator, thymeleaf)
|
||
: new CanonicalNotificationRenderer(channel, templates, validator, engine);
|
||
```
|
||
|
||
`ThymeleafNotificationRenderer`는 `CanonicalNotificationRenderer`에 위임할 뿐이므로, `template-engine=thymeleaf` 배포에서 **가드 없는 경로가 유일한 경로다**.
|
||
|
||
**실패 시나리오** — 템플릿이 `${amount.formatted('%.2f')}`처럼 평가에 실패할 수 있는 식을 쓴다(변수는 존재하므로 `requireEveryReferencedVariable`은 통과한다 — 그 검사는 식의 **루트 식별자**만 본다, `:111-112`). Thymeleaf가 `TemplateProcessingException`을 던지고, 그 메시지는 실패한 식과 `StringTemplateResolver`가 템플릿 이름으로 쓰는 **소스 문자열 자체**를 포함한다. 아무도 잡지 않으므로 어댑터 밖으로 그대로 나가 상위 로거에 기록된다. 클래스가 막으려고 명시한 바로 그 노출이다 — 다만 막는 코드는 옆 오버로드에 있다.
|
||
|
||
`PlaceholderTemplateEngine`에는 이 문제가 없다. 식을 평가하지 않고, 자체 실패는 이미 리댁션된 `NotificationFailureDescriptor`로 던진다. 발견은 Thymeleaf 엔진에 한정된다.
|
||
|
||
**권고** — `:80`을 `:90-99`와 같은 try/catch로 감싼다. 두 오버로드가 같은 `process` 호출을 서로 다르게 다루는 상태 자체가, `default` 메서드 사건(`NotificationTemplateEngine` javadoc `:79-84`)에서 이 코드베이스가 이미 배운 형태다 — "the engine that skipped it looked complete because the interface compiled."
|
||
|
||
### 17.3 P3/기록 — `requireAllowedScheme`이 trim한 값으로 검사하고 원본을 반환한다
|
||
|
||
```java
|
||
// TemplateSlotPolicy.java:114-122
|
||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||
boolean allowed = ALLOWED_URI_SCHEMES.stream().anyMatch(s -> normalized.startsWith(s + ":"));
|
||
if (!allowed) { throw refuse(); }
|
||
return value; // ← trim 전 원본
|
||
```
|
||
|
||
`" https://example.com"`은 통과하고 선행 공백을 그대로 달고 나간다. 소비처는 `URI.create(...)`(`CanonicalNotificationRenderer:103,110,117`)이며 선행 공백은 `URI.create`에서 `IllegalArgumentException`이 된다 — 렌더 실패가 아니라 어댑터 밖 예외로 나타난다. `Locale.ROOT` 소문자화는 정확하다(터키어 `I` 문제 회피). 스킴 검사 자체는 우회되지 않으므로 보안 결함이 아니고, 실패 지점이 옮겨지는 것만 기록한다.
|
||
|
||
### 17.4 P3/기록 — `render(String, Map)`이 `requireEveryReferencedVariable`을 두 번 부른다
|
||
|
||
`:88`에서 한 번, `:91`의 `contextFor`가 다시 한 번(`:157`). 결과는 같고 비용만 두 배다. 위 §17.2대로 이 오버로드에 호출자가 없으므로 실행되지 않는다.
|
||
|
||
## 18. Sub-scope 04 완료 조건
|
||
|
||
- denominator 32 / 32 FULL_READ
|
||
- §8.1~§8.4 수행 — 도달성에서 P2 1건, 조건 형제 비교에서 P2 1건, 기록 2건
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
# Sub-scope 05 — `provider` + `core` + `platform/{provider,observation,reactor}` (38 files, 29 main + 9 test)
|
||
|
||
> 내부 상태: COMPLETE — **38 / 38 FULL_READ** · 근거 `evidence/raw/185-notification-provider-core-probes.txt` (`main_count=29`, `test_count=9`)
|
||
|
||
## 19. 무엇을 하는 코드인가
|
||
|
||
세 층이 한 sub-scope에 모인다.
|
||
|
||
**`core`** — 포크 프로젝트가 기여하는 단순 SPI(`NotificationProvider`)와 그 라우팅. `RoutingNotifier`는 이 저장소 전체에서 반복되는 **fail-closed at boot** 형태다: 채널 안 `providerId` 중복(`:47-54`)과 활성 provider 없는 라우트(`:87-95`) 둘 다 **생성자에서** `IllegalStateException`으로 죽고, 메시지가 고칠 프로퍼티 키를 그대로 적어 준다. 런타임에 바인딩 없는 라우트로 `notify`하면 `AdapterDisabledException` — 조용한 no-op이 아니다. 팬아웃 루프에 `try/catch`가 없는 이유도 적혀 있다: 각 provider가 이미 `FailOpenNotificationProvider`로 감싸여 있어 `send`가 `throws`를 선언하지 않는다.
|
||
|
||
**`provider`** — 어댑터 내부 프로토콜. "순수 prepare → 인가된 단 한 번의 전송"으로 갈라 놓은 `NotificationProviderAttemptClient`와, 그것을 한 컴파일된 바인딩·렌더러·클라이언트에 묶는 `NotificationProviderAttemptAdapter`(363 LOC). 비밀 자료는 `NotificationSecretMaterialHandle`로 **연산 범위 가변 사본**을 받아 `close()`에서 `Arrays.fill(...,0)`으로 자신을 지우고(`:59-70`), `toString`은 `material=<redacted>`를 낸다(`:72-79`). 어댑터는 try-with-resources로 받는다(`:227`).
|
||
|
||
**`platform/provider`** — 모든 HTTP provider가 공유하는 변환 규칙. `ProviderResults`가 "커밋된 본문 + 응답 없음 = ambiguous"를 한 번만 쓴다(`:26-36`). `EmailAttachments`가 resolve·verify·close를 한 곳에 묶고, 부분 실패 시 이미 연 것을 전부 닫은 뒤 전파한다(`:147-152`).
|
||
|
||
**`platform/observation`** — 감사·지표·헬스. `NotificationHealthSnapshot`이 노출 대상을 "provider states, credential generations and queue age — nothing else"로 한정하고 그 이유를 적는다: "A health endpoint is one of the least protected surfaces an application exposes."
|
||
|
||
**`platform/reactor`** — Reactor를 핵심 계약 밖에 두는 선택적 파사드. `boundedElastic`에서만 블로킹하고 스스로 `block()`을 부르지 않으며, `Mono` 취소가 **이미 커밋된 알림을 지우지 않는다** — "Undoing a durable acceptance because a subscriber went away would make the receipt meaningless."
|
||
|
||
## 20. Negative-space probes — sub-scope 05
|
||
|
||
### 20.1 (8.1) 도달성 — provider가 준 `Retry-After`는 실제로 쓰이는가
|
||
|
||
`ProviderResults.retryAfter`가 파싱한 값이 종단까지 도달하는지 추적했다. 도달한다:
|
||
|
||
```
|
||
$ grep -rn 'ProviderFailure::retryAfter' --include=*.java application-core/src/main
|
||
NotificationDispatchService.java:381: result.failure().flatMap(ProviderFailure::retryAfter),
|
||
```
|
||
|
||
그리고 `RetryBackoff.delay`가 소비한다:
|
||
|
||
```java
|
||
// RetryBackoff.java:43-45
|
||
Duration computed = Duration.ofMillis(Math.max(jittered, base.toMillis()));
|
||
Duration chosen = retryAfter.filter(hint -> hint.compareTo(computed) > 0).orElse(computed);
|
||
return chosen.compareTo(max) > 0 ? max : chosen;
|
||
```
|
||
|
||
힌트는 계산값보다 **길 때만** 채택되고, 그 뒤 설정된 `max`(기본 5분)로 **상한이 걸린다**. javadoc의 주장 — "A provider-supplied `Retry-After` always wins over the computed value, but never over the configured maximum: a provider asking for an hour must not silently extend a delivery deadline" — 이 코드와 정확히 일치한다. 악의적 provider가 `Retry-After: 999999999`로 배달을 수십 년 뒤로 미루는 경로는 **없다**. §17.1의 `AccessContext`와 대조되는, 회로가 닫힌 사례다.
|
||
|
||
### 20.2 (8.2) 조건 형제 비교 — 파서와 생성자의 음수 계약
|
||
|
||
두 지점이 같은 `Duration`을 서로 다른 계약으로 다룬다. 아래 §21.1.
|
||
|
||
### 20.3 (8.3) 중복 메커니즘 — 첨부 검증
|
||
|
||
`EmailAttachments.open`은 `guard.resolve`만 부르고 별도 `verify`를 부르지 않는다. javadoc은 "resolve, verify, close"를 약속한다. 드리프트가 아니라 위임이다 — 검증이 `AttachmentIntegrityGuard.resolve` 안에 있다:
|
||
|
||
```java
|
||
// AttachmentIntegrityGuard.java:34-43
|
||
ResolvedAttachment attachment = resolver.resolve(reference, context);
|
||
if (attachment.size() != reference.expectedSize()) { closeQuietly(...); throw integrityFailure(); }
|
||
if (!constantTimeEquals(attachment.digest(), reference.expectedDigest())) { closeQuietly(...); throw integrityFailure(); }
|
||
```
|
||
|
||
크기와 다이제스트를 **제출 시점에 고정된 참조**와 대조하고, 다이제스트 비교는 `MessageDigest.isEqual`로 상수 시간이다. 거부된 스트림은 예외 전에 닫힌다. 중복 아님, 드리프트 아님.
|
||
|
||
### 20.4 (8.4) 문서/카운트 드리프트 — 어떤 상태가 unhealthy인가
|
||
|
||
`NotificationHealthReporter.snapshot()`이 `healthy = false`로 넘어가는 조건은 넷이다:
|
||
|
||
| 조건 | 위치 |
|
||
|---|---|
|
||
| 감시 대상 프로파일이 레지스트리에 없음 (`UNREGISTERED`) | `:57-62` |
|
||
| 상태가 `AUTHENTICATION_FAILED` 또는 `DISABLED` | `:64-67` |
|
||
| provider는 있는데 라우팅된 채널이 하나도 없음 | `:78-80` |
|
||
| 서빙 상태가 선언된 임계치를 넘음 | `:83-85` |
|
||
|
||
세 번째 조건에는 이 저장소 특유의 자기고발이 붙어 있다: "A platform with providers but no route accepts every request and delivers none. It was reported healthy because every runtime was healthy — **which was true and beside the point**."
|
||
|
||
여기서 눈에 띄는 것은 **`DRAINING`이 목록에 없다**는 점이다. 로테이션 중 드레인은 정상 운영이므로 그 자체로는 옳다. 그러나 §13의 P2와 겹치면 부작용이 하나 더 생긴다 — 아래 §21.2.
|
||
|
||
## 21. Sub-scope 05 findings
|
||
|
||
### 21.1 P3 — 음수 `Retry-After` 헤더가 throttle 결과 대신 `IllegalArgumentException`을 만든다
|
||
|
||
파서는 `Long.parseLong`이 받아들이는 값을 그대로 `Duration`으로 만든다:
|
||
|
||
```java
|
||
// ProviderResults.java:90-99
|
||
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
|
||
return headerValue.flatMap(value -> {
|
||
try { return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); }
|
||
catch (NumberFormatException notSeconds) { return Optional.empty(); }
|
||
});
|
||
}
|
||
```
|
||
|
||
소비자는 음수를 거부한다:
|
||
|
||
```java
|
||
// ProviderFailure.java:29-34 (canonical constructor)
|
||
retryAfter.ifPresent(delay -> {
|
||
if (delay.isNegative()) { throw new IllegalArgumentException("retryAfter"); }
|
||
});
|
||
```
|
||
|
||
`ProviderResults.fromStatus`가 429를 만나면 파싱 결과를 그대로 `new ProviderFailure(...)`에 넣으므로(`:50-55`), `Retry-After: -1`을 보내는 provider는 **throttle 분류 대신 `IllegalArgumentException`을 만든다**. 이 예외는 `NotificationFailureDescriptor`를 달고 있지 않으므로 어댑터의 분류 체계 밖으로 나가고, `NotificationSchedulerWorker`의 catch-all이 `reason=IllegalArgumentException`으로 로그를 남긴 뒤 리스를 만료시킨다 — 회수가 재대사로 처리한다.
|
||
|
||
호출 경로 4개 모두 같다: `SesFailureClassifier:37,49` · `TwilioFailureClassifier:35,39` · `WebPushFailureClassifier:39`.
|
||
|
||
RFC 7231의 delta-seconds는 음이 아닌 정수이므로 준수하는 provider에서는 발생하지 않는다. 그래서 P3이다. 다만 이 값은 **원격이 통제하는 입력**이고, 파서가 `NumberFormatException`은 방어하면서 부호는 방어하지 않는 비대칭이 결함의 형태다. `Optional.of(...).filter(d -> !d.isNegative())` 한 줄이면 닫힌다.
|
||
|
||
같은 파서가 HTTP-date 형식(`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`)을 받으면 `Optional.empty()`가 되어 계산된 백오프로 떨어진다. javadoc이 "expressed in seconds"라고 범위를 명시하므로 드리프트가 아니다.
|
||
|
||
### 21.2 P3/기록 — §13의 2단계 우회는 헬스 신호도 함께 끈다
|
||
|
||
§13에서 확인한 `AUTHENTICATION_FAILED → markDraining() → resumeHealthy()` 시퀀스는 상태와 원인 코드만 지우는 것이 아니다. `NotificationHealthReporter`가 unhealthy로 보는 상태 목록(`:64-67`)에 `DRAINING`이 없으므로, 3단계에서 **헬스 엔드포인트가 즉시 정상으로 돌아온다** — 자격증명은 여전히 provider가 거부한 그것이다.
|
||
|
||
`NotificationHealthReporter`의 클래스 javadoc이 강조하는 바로 그 성질이 대상이다: "A provider whose credentials were rejected reports unhealthy even though the process is fine: **that is exactly the condition an operator needs paged on**, and it is invisible from process-level health." 관리자 평면의 두 호출이 그 페이징을 끄고, 첫 실패 시도가 다시 상태를 되돌릴 때까지 아무도 알지 못한다.
|
||
|
||
§13의 권고(관리자 경로는 조건부 드레인)를 적용하면 이 항목도 함께 닫힌다. 별도 결함으로 세지 않고 §13의 파급으로 기록한다.
|
||
|
||
## 22. Sub-scope 05 완료 조건
|
||
|
||
- denominator 38 / 38 FULL_READ (main 29 + test 9, probe가 카운트 확인)
|
||
- §8.1~§8.4 수행 — 도달성 확인 1건(회로 닫힘), 조건 형제 비교에서 P3 1건, 기록 1건
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
# Sub-scope 06 — `platform/provider/*` 8종 구현 (76 files, 60 main + 16 test)
|
||
|
||
> 내부 상태: COMPLETE — **76 / 76 FULL_READ** · 근거 `evidence/raw/186-notification-provider-impl-probes.txt` (`main_count=60`, `test_count=16`)
|
||
|
||
## 23. 무엇을 하는 코드인가
|
||
|
||
여덟 개 provider 구현(`apns` 4 · `fcm` 10 · `http` 6 · `ses` 11 · `smtp` 7 · `twilio` 9 · `webhook` 3 · `webpush` 10). 공통 골격이 일정하다 — properties(생성자 검증) · request mapper · failure classifier · adapter — 그리고 어느 것도 SDK를 끌어오지 않는다. `AwsSignatureV4Signer`의 javadoc이 이유를 적는다: SDK는 "its own HTTP client, retry policy and credential chain — three things this platform already owns and whose duplication would quietly move retry ownership out of the notification retry policy"를 함께 가져온다.
|
||
|
||
여기서도 자기고발 주석이 결함 이력을 남긴다:
|
||
|
||
- `SesRequestMapper` 클래스 javadoc — 첨부를 선언한 이메일이 `Simple` 콘텐츠로 전송되고 있었다. "the document was silently absent from the mail SES sent and **the attempt was still recorded as delivered**, which is a recipient told to read something that is not there." 지금은 첨부가 있으면 `Raw`(MIME)로 간다. 그리고 그 MIME은 **SMTP provider가 쓰는 같은 팩토리**로 만든다 — "two builders would be two places for those answers to drift apart."
|
||
- `SmtpMimeMessageFactory:225-229` — JavaMail이 첨부를 **두 번** 읽는다(전송 인코딩 결정용, 파트 기록용). 리졸버의 일회성 스트림은 두 번째 읽기에서 빈 값을 돌려줬다. "The part that went out announced a filename and carried no bytes, so the mail arrived with an empty attachment and **the attempt was still recorded as accepted**." 지금은 `ByteArrayInputStream` 공급자로 넘긴다.
|
||
- `ApnsRequestMapper` — "The capability declared a 4096-byte ceiling and **nothing compared anything to it**." 지금은 직렬화된 바이트에 대해 검사한다. `FcmBatchCoordinator`에도 같은 수정이 있다.
|
||
- `JdkNotificationHttpGateway.bodyWasLikelyCommitted:81-91` — 본문 커밋 여부를 `getMessage()`의 소문자 부분문자열("connection refused", "no route to host" 등)로 판정하고 있었다. "those strings come from the platform's C library and the JDK's own wording; they are localised on some platforms, they changed between JDK releases." 지금은 **타입**으로 판정하며, 기본값은 비관적인 쪽(커밋됨)이다. 원인 사슬 순회는 깊이 10으로 제한 — 순환 cause에 대한 방어까지 적혀 있다.
|
||
- `WebPushRequestMapper:107-112` — VAPID 서명 키를 "지금 활성인 키"가 아니라 "이 구독이 만들어질 때의 키"로 고른다. "VapidKeyRegistry already resolved the historical key per subscription — **nothing called it**."
|
||
|
||
암호 구현은 직접 작성했고 표준을 따른다. `AwsSignatureV4Signer`(SigV4 4단계 파생 키), `SnsSignatureVerifier`(SNS 정규 문자열 재구성 + 인증서 URL 제약), `TwilioSignatureValidator`(HMAC-SHA1 + `MessageDigest.isEqual` 상수시간 비교), `Rfc8291Aes128GcmEncryptor`(ECDH → HKDF ×2 → AES-128-GCM, 메시지마다 새 임시 키쌍), `WebhookSignatureStrategy`(타임스탬프를 서명 대상 안에 포함).
|
||
|
||
## 24. Negative-space probes — sub-scope 06
|
||
|
||
### 24.1 (8.1) 도달성 — SSRF 가드가 도달하는 호출처 전수
|
||
|
||
`NotificationEndpoints.requireExternallyRoutable`의 프로덕션 호출처는 **둘**뿐이다:
|
||
|
||
```
|
||
$ grep -rn 'requireExternallyRoutable' --include=*.java . | grep '/src/main/'
|
||
.../ses/SesProviderProperties.java:27: NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
|
||
.../webhook/WebhookSubscription.java:49: NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);
|
||
```
|
||
|
||
가드 자신의 javadoc이 지목하는 대상은 다른 둘이다:
|
||
|
||
> "**Web Push endpoints and webhook targets are supplied by clients**, which makes this a server-side request forgery primitive."
|
||
|
||
Web Push는 목록에 없다. §25.1.
|
||
|
||
### 24.2 (8.2) 조건 형제 비교 — 두 개의 "안전한 엔드포인트" 판정
|
||
|
||
같은 판정을 하는 코드가 두 벌이다:
|
||
|
||
| | 위치 | 검사 내용 |
|
||
|---|---|---|
|
||
| 강한 것 | `NotificationEndpoints.requireExternallyRoutable` | 스킴 · userinfo · 호스트 해석 · 내부 대역 전수 |
|
||
| 약한 것 | `NotificationEndpoints.requireSecureOrLoopback` | 스킴 + 루프백 호스트 이름 |
|
||
| **약한 것의 사본** | `WebPushSubscriptionValue.isSecureOrLoopback` (private) | 스킴 + 루프백 호스트 이름 |
|
||
|
||
세 번째가 문제다. `NotificationEndpoints`를 호출하지 않고 **같은 논리를 private으로 재구현**했으므로, 약한 가드의 이름으로 grep해도 이 호출처는 드러나지 않는다. §25.1.
|
||
|
||
### 24.3 (8.3) 중복 메커니즘 — MIME 조립
|
||
|
||
SES의 `Raw` 경로와 SMTP 경로가 같은 `SmtpMimeMessageFactory`를 쓴다. 명시적으로 중복을 없앤 사례이고 그 근거가 적혀 있다. 중복 아님.
|
||
|
||
### 24.4 (8.4) 문서/구현 드리프트 — 응답 본문 상한
|
||
|
||
`JdkNotificationHttpGateway`가 선언한 성질과 구현이 어긋난다. §25.2.
|
||
|
||
## 25. Sub-scope 06 findings
|
||
|
||
### 25.1 P2 — 클라이언트가 제공하는 Web Push 엔드포인트가 SSRF 가드를 지나지 않는다 (모듈 내 최고 영향도)
|
||
|
||
`WebPushSubscriptionValue`는 엔드포인트를 **스킴만** 검사한다:
|
||
|
||
```java
|
||
// WebPushSubscriptionValue.java:27-28
|
||
if (!isSecureOrLoopback(endpoint)) {
|
||
throw new IllegalArgumentException("endpoint must be https outside the loopback interface");
|
||
}
|
||
// :50-57 — NotificationEndpoints 를 호출하지 않는 private 사본
|
||
private static boolean isSecureOrLoopback(java.net.URI endpoint) {
|
||
String scheme = endpoint.getScheme() == null ? "" : endpoint.getScheme();
|
||
...
|
||
String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT);
|
||
```
|
||
|
||
그리고 그 엔드포인트는 매퍼가 그대로 POST 대상으로 쓴다:
|
||
|
||
```java
|
||
// WebPushRequestMapper.java:113-118
|
||
return new NotificationHttpRequest(
|
||
"POST", subscription.endpoint(), JdkNotificationHttpGateway.headers(headers),
|
||
encrypted.body(), properties.timeout());
|
||
```
|
||
|
||
이 정확한 결함은 **같은 저장소가 이웃 파일에서 이미 고쳤고, 고치면서 무엇이 남았는지까지 적었다**:
|
||
|
||
```java
|
||
// WebhookSubscription.java:29-33
|
||
// requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS
|
||
// URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address
|
||
// passed. The stronger guard was written for exactly this call site and then called from
|
||
// nowhere: it existed, its own tests were green, and the two sites it was written for kept the
|
||
// weaker check.
|
||
```
|
||
|
||
"the two sites it was written for" — 가드 자신의 javadoc은 그 둘을 **Web Push endpoints and webhook targets**로 지목한다. 수정은 webhook target과 SES endpoint에 적용됐다. 즉 두 개의 클라이언트 제공 표면 중 **하나만** 연결되었고, 나머지 하나는 약한 검사의 private 사본을 계속 들고 있다.
|
||
|
||
호출처 테스트도 그 잘못된 쌍을 그대로 고정했다:
|
||
|
||
```java
|
||
// EndpointGuardCallSiteTest.java:17-22
|
||
// It proved that for months while the function had no caller: both sites it was written for
|
||
// — a webhook target and an SES endpoint — kept calling requireSecureOrLoopback ...
|
||
```
|
||
|
||
이 테스트 클래스에 `WebPush`를 언급하는 줄은 **0개**다(probe §8.1). 클래스의 존재 이유가 "가드가 필요한 곳에서 실제로 도달되는가"인데, 그 "필요한 곳" 목록이 가드 javadoc의 목록과 다르다.
|
||
|
||
**실패 시나리오** — 포크 애플리케이션이 이 플랫폼이 전제하는 구독 등록 경로를 붙인다(브라우저의 `PushSubscription`을 받아 `WebPushSubscriptionValue`로 저장). 클라이언트가 엔드포인트를 `https://169.254.169.254/latest/meta-data/` 또는 `https://10.0.0.5:8500/v1/kv/...`로 보낸다. 스킴 검사를 통과하고, 암호화되어 저장되고, 해당 수신자에게 알림이 갈 때마다 배포 네트워크 **내부에서** 그 주소로 POST가 나간다. 응답 본문이 클라이언트에게 돌아가지는 않으므로 blind SSRF지만, URL과 일부 헤더(`topic`은 `content.options().topic()`에서 온다)를 공격자가 정한다. 실패 분류기가 상태 코드를 구분하므로 내부 호스트·포트 열거의 오라클도 된다.
|
||
|
||
**현재 저장소에서의 정확한 상태** — main 소스에서 `WebPushSubscriptionValue`를 생성하는 곳은 `AesGcmContactPointProtector.parseWebPush`(저장소에서 읽어올 때) 한 곳뿐이고, 요청 데이터로부터 만드는 인바운드 경로는 이 템플릿에 **동봉되어 있지 않다**. 그래서 P1이 아니라 P2다. 다만 레코드의 표준 생성자가 이 값의 **유일한 검증 지점**이고, 그것이 이 모듈의 계약이다 — 포크가 등록 엔드포인트를 붙이는 순간(그것이 이 모듈의 존재 이유다) 검증은 이미 통과되어 있다.
|
||
|
||
**권고** — `WebPushSubscriptionValue`의 private 사본을 지우고 `NotificationEndpoints.requireExternallyRoutable(endpoint, "web push endpoint", false)`를 호출한다. 루프백 허용은 `false`여야 한다 — 구독은 정의상 클라이언트가 이름 붙인 주소이고, `WebhookSubscription`이 `trusted=false`에 대해 내린 결정과 같은 근거다. 그리고 `EndpointGuardCallSiteTest`에 Web Push 케이스를 추가한다. 그 클래스가 존재하는 이유가 정확히 이 종류의 누락이다.
|
||
|
||
### 25.2 P2 — "상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다
|
||
|
||
```java
|
||
// JdkNotificationHttpGateway.java:116-128
|
||
/**
|
||
* A body handler that stops reading at the cap.
|
||
* ...
|
||
*/
|
||
private static HttpResponse.BodyHandler<byte[]> boundedBody(int maxBytes) {
|
||
return responseInfo ->
|
||
HttpResponse.BodySubscribers.mapping(
|
||
HttpResponse.BodySubscribers.ofByteArray(),
|
||
body -> body.length <= maxBytes ? body : java.util.Arrays.copyOf(body, maxBytes));
|
||
}
|
||
```
|
||
|
||
`BodySubscribers.mapping(upstream, finisher)`의 finisher는 **upstream이 완료된 뒤** 그 결과에 적용된다. upstream은 `ofByteArray()`이고, 그것은 무제한으로 요청하여 본문 **전체를 힙에 모은다**. 잘라내기는 그 다음이다.
|
||
|
||
호출부 주석이 막겠다고 선언한 것과 정확히 반대다:
|
||
|
||
```java
|
||
// :60-62
|
||
// Bounded, not ofByteArray(). A provider response is diagnostic — a status, some headers, an
|
||
// error document — and reading it without a cap makes the sender's heap a function of what
|
||
// the far end chooses to send. A chunked response with no end is a single-request outage.
|
||
HttpResponse<byte[]> response = client.send(builder.build(), boundedBody(MAX_RESPONSE_BYTES));
|
||
```
|
||
|
||
주석은 "not `ofByteArray()`"라고 쓰여 있고, 두 줄 아래 함수가 `ofByteArray()`를 upstream으로 쓴다.
|
||
|
||
**실패 시나리오** — provider(또는 그 자리에 들어온 무엇이든)가 응답으로 큰 본문을 빠르게 보낸다. 요청 타임아웃(`request.timeout()`)이 *시간*은 제한하므로 "끝나지 않는 chunked 응답"은 타임아웃에서 끊긴다 — 그러나 주석이 두 번째로 든 위험, 즉 **"보내는 쪽의 힙이 저쪽이 보내기로 한 양의 함수가 되는"** 상황은 그대로다. 10초 타임아웃 안에 2 GB를 받을 수 있는 링크에서는 2 GB가 전부 `byte[]`로 쌓인 뒤 64 KiB로 잘린다. 동시 발송이 많을수록 배수로 늘어난다. 이 leaf는 발송 워커가 virtual thread로 팬아웃하므로(`NotificationSchedulerWorker:62`) 동시 호출 수는 `maxGlobalConcurrency`만큼이다.
|
||
|
||
`Rfc8291Aes128GcmEncryptor`가 페이로드를 4 KiB로 제한하고 `ApnsRequestMapper`가 4,096바이트를 강제하는 등 **보내는 쪽**의 크기는 일관되게 통제된다. 통제되지 않는 것은 받는 쪽이며, 그것이 유일하게 원격이 정하는 크기다.
|
||
|
||
**권고** — `BodySubscribers.ofByteArray()` 대신 상한에서 구독을 취소하는 subscriber를 쓴다(`BodySubscribers.fromSubscriber(...)` 위에 카운터를 두고 초과 시 `subscription.cancel()`, 또는 `ofByteArrayConsumer`로 누적하며 상한에서 중단). 주석이 이미 옳은 설계를 서술하고 있으므로 바꿀 것은 구현뿐이다.
|
||
|
||
### 25.3 P3 — SigV4가 서명한 `host`에 포트가 없어, 기본 포트가 아닌 엔드포인트에서 서명이 어긋난다
|
||
|
||
```java
|
||
// SesRequestMapper.java
|
||
String host = properties.endpoint().getHost(); // 포트 없음
|
||
var signed = signer.sign("POST", PATH, "",
|
||
Map.of("host", host, "content-type", "application/json"), ...);
|
||
```
|
||
|
||
SigV4의 정규 요청은 실제로 전송되는 `Host` 헤더 값을 서명해야 하고, 기본이 아닌 포트는 그 값에 포함된다. 요청 자체는 `host` 헤더를 싣지 않으며(`JdkNotificationHttpGateway.RESTRICTED`가 거부하고 JDK가 URI에서 채운다), JDK는 `localhost:4566` 같은 값을 보낸다. 서명은 `localhost`에 대해 이루어졌다.
|
||
|
||
포트 443(실제 SES)에서는 두 값이 같으므로 프로덕션은 영향이 없다. 영향 범위는 이 저장소가 명시적으로 지원하는 로컬/컨트랙트 프로파일 — `EndpointGuardCallSiteTest:119`가 `http://localhost:4566`을 유효한 SES 엔드포인트로 확인한다 — 이며, 서명을 실제로 검증하는 에뮬레이터를 그 자리에 두면 `SignatureDoesNotMatch`가 된다. `host + (port == -1 || port == 443 ? "" : ":" + port)`로 닫힌다.
|
||
|
||
### 25.4 P3 — SigV4 서명 키 파생이 비밀을 지울 수 없는 `String`으로 승격시킨다
|
||
|
||
```java
|
||
// AwsSignatureV4Signer.java:117-119
|
||
byte[] key = ("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
|
||
.getBytes(StandardCharsets.UTF_8);
|
||
```
|
||
|
||
`secretAccessKey`는 `NotificationSecretMaterialHandle`이 제공하는 **지울 수 있는 가변 사본**이다. 그 핸들의 존재 이유가 "operation-scoped mutable secret copy that wipes itself on close"이고, `close()`가 `Arrays.fill(bytes, (byte) 0)`을 한다. 이 한 줄이 그 바이트를 불변 `String`으로 복사하며, 그 `String`은 GC가 가져갈 때까지 힙에 남고 어떤 `close()`도 지울 수 없다. 힙 덤프 한 장이면 회수된다.
|
||
|
||
기능적으로도 UTF-8 왕복이다 — AWS 비밀 키는 ASCII이므로 손상되지 않지만, 바이트를 문자로 해석할 이유 자체가 없다. `concat("AWS4".getBytes(US_ASCII), secretAccessKey)`가 같은 결과를 String 없이 만든다.
|
||
|
||
같은 패턴을 leaf 전체에서 찾았고(probe §8.3) 이 한 곳뿐이다. 나머지 비밀 사용처는 모두 `byte[]`를 그대로 `SecretKeySpec`에 넣는다.
|
||
|
||
### 25.5 P3/기록 — SNS SignatureVersion 1(SHA-1)을 발신자가 선택할 수 있고, v2를 요구할 설정이 없다
|
||
|
||
```java
|
||
// SnsSignatureVerifier.java:56, 66
|
||
if (!"1".equals(version) && !"2".equals(version)) { return false; }
|
||
...
|
||
Signature.getInstance("2".equals(version) ? "SHA256withRSA" : "SHA1withRSA");
|
||
```
|
||
|
||
이전 결함("필드가 없으면 기본 `1`로 떨어져 조용히 SHA-1로 내려갔고, 그 필드는 공격자가 정한다")은 고쳐졌다 — 이제 값이 정확히 `1` 또는 `2`여야 한다. 남은 것은 **여전히 `1`을 받아들인다**는 점이고, 어느 것을 쓸지는 봉투가 정한다. `SesProviderProperties`에 v2를 강제할 옵션이 없다(probe §8.4: `SignatureVersion` 참조는 검증기 한 줄뿐).
|
||
|
||
위조 가능성은 아니다 — 어느 쪽이든 AWS의 개인 키가 필요하고, 여기서 요구되는 것은 SHA-1 충돌이 아니라 프리이미지다. AWS가 v2를 별도로 제공하는 이유가 소비자가 SHA-1 수용을 끊을 수 있게 하는 것이므로, 그 선택지가 배포에 없다는 사실만 기록한다.
|
||
|
||
### 25.6 P3/기록 — `ApnsProviderProperties.allowedPushTypes`가 표현할 수 있는 질문이 하나뿐이다
|
||
|
||
`ApnsRequestMapper`가 push type을 상수 `"alert"`로 고정한 뒤 허용목록과 대조한다. 따라서 이 집합에 대해 물을 수 있는 것은 "`alert`을 포함하는가"뿐이고, `{"background","voip"}`로 설정하면 모든 발송이 설정 오류로 실패하며 `{"alert","background"}`는 `{"alert"}`와 동작이 같다. `apns-push-type`을 콘텐츠에서 유도하기 전까지는 검증만 있고 선택이 없는 설정 항목이다.
|
||
|
||
### 25.7 P3/기록 — 공개 `hkdf`가 32바이트를 넘는 요청을 조용히 0으로 채운다
|
||
|
||
`Rfc8291Aes128GcmEncryptor.hkdf`는 expand를 한 블록만 수행한다(`update(info); update((byte)1); doFinal()`). RFC 8291의 모든 용도가 ≤32바이트라 현재 호출은 전부 안전하지만, `public static`이므로 재사용 가능한 표면이다. `length > 32`이면 `Arrays.copyOf`가 뒤를 0으로 채운 값을 오류 없이 돌려준다. `if (length > 32) throw` 한 줄이면 닫힌다.
|
||
|
||
## 26. Sub-scope 06 완료 조건
|
||
|
||
- denominator 76 / 76 FULL_READ (main 60 + test 16, probe가 카운트 확인)
|
||
- §8.1~§8.4 수행 — 도달성에서 P2 1건, 문서/구현 드리프트에서 P2 1건, P3 2건, 기록 3건
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
# Sub-scope 07 — `slack/webhook` + `email/google` + testkit + 템플릿 리소스 (19 files, 6 main + 9 test + 4 resources)
|
||
|
||
> 내부 상태: COMPLETE — **19 / 19 FULL_READ** · 근거 `evidence/raw/187-notification-spi-testkit-probes.txt` (`main_count=6 test_count=9 resource_count=4`)
|
||
|
||
## 27. 무엇을 하는 코드인가
|
||
|
||
두 갈래다.
|
||
|
||
**단순 SPI 기여 2종** — `SlackWebhookProvider`와 `GoogleEmailProvider`. 각각 30~40 LOC이고 정책을 담지 않는다. fail-open과 PII-안전 로깅은 `FailOpenNotificationProvider`가 중앙에서 적용하고, SDK는 포크 프로젝트가 `SlackClient` / `GoogleEmailClient` 심(seam)으로 넣는다. 두 `@Configuration` 모두 `@ConditionalOnProperty(havingValue="true", matchIfMissing=false)`로 닫혀 있고, 심 빈이 `@Bean` 메서드의 필수 파라미터이므로 배선 누락은 컨텍스트 시작 실패가 된다 — provider 클래스에 `Objects.requireNonNull`이 없다는 점을 확인했으나, 지원되는 배선으로는 도달할 수 없다. 결함 아님.
|
||
|
||
**testkit 9종** — 이 leaf가 스스로에게 거는 게이트. 세 개가 서로 다른 질문을 맡는다:
|
||
|
||
- `ProviderAdapterContract` — "수용이란 무엇이고, 거절이란 무엇이며, **답이 오지 않으면 어떻게 되는가**". 서브클래스가 어댑터와 fault harness만 공급한다.
|
||
- `CrossProviderContractSuiteTest` — "모두가 같은 단어로 같은 것을 뜻하는가". 클래스 javadoc이 이유를 정확히 적는다: "Per-provider tests answer 'does this adapter map this provider's responses correctly'. This suite answers a different question... it is exactly what drifts when each adapter is only ever tested against its own expectations."
|
||
- `NotificationChaosSecurityTest` — 고장 주입으로만 드러나는 두 가지. 커밋 후 응답 손실(모든 어댑터가 `AMBIGUOUS`를 보고해야 함 — "this is the single case that cannot be discovered from a provider's documentation, only from cutting the connection")과 텔레메트리 비밀 누출.
|
||
|
||
`ProviderFaultHarness`는 목이 아니라 **실제 JDK HTTP 서버 소켓**을 띄운다: "the behaviour under test is what happens when a connection dies after the request body has been written — and a mock cannot reproduce that without also mocking away the thing being verified."
|
||
|
||
`PiiLeakScanner`는 패턴이 아니라 **테스트가 사용한 정확한 픽스처 값**을 찾는다: "it catches a token that looks like ordinary text, which is precisely the value a pattern-based scanner misses."
|
||
|
||
`NotificationPerformanceCertificationTest`는 처리량 수치를 게이트로 삼지 않는다 — "A throughput figure from a laptop is worthless as a gate, and a gate that varies with the machine is one people learn to re-run." 대신 규모와 무관하게 성립해야 하는 성질(식별자 순서·유일성, 동시성 상한, rate limit이 흡수가 아니라 거절)만 확인한다.
|
||
|
||
**리소스 4종** — 컨트랙트 이메일 템플릿 3개(html/subject/text)와 Slack 템플릿 1개. §sub-scope 02의 체크섬 고정 대상이다.
|
||
|
||
## 28. Negative-space probes — sub-scope 07
|
||
|
||
### 28.1 (8.1) 도달성 — 공유 계약을 실제로 상속하는 어댑터
|
||
|
||
```
|
||
$ grep -rn 'extends ProviderAdapterContract' adapter/outbound/notification/src/test
|
||
.../ses/SesNotificationProviderAdapterTest.java:50
|
||
.../twilio/TwilioSmsProviderAdapterTest.java:24
|
||
.../apns/ApnsNotificationProviderAdapterTest.java:31
|
||
```
|
||
|
||
**8종 중 3종.** 클래스 javadoc은 "Subclasses supply an adapter and a fault harness; the assertions are here **so that a new provider cannot be added without answering the same three questions**"라고 쓰지만, 상속을 강제하는 장치는 없다. 이 저장소는 같은 종류의 강제를 다른 곳에서는 만들어 두었다 — `EndpointGuardCallSiteTest`(가드가 호출처에서 실제로 도달하는가), `verifyNotificationApiSurface`(공개 타입 586개 스냅샷 고정). 여기에는 없다.
|
||
|
||
`ContractAdapters`가 크로스-프로바이더 스위트에 등록하는 것은 **5종**(ses · twilio · apns · webpush · webhook)이다. 빠진 둘은 fcm과 smtp이고, 그것은 harness의 구조적 한계로 설명된다 — 스위트는 HTTP 루프백 서버 위에서 돌고, SMTP는 JavaMail 릴레이로, FCM은 `FcmGateway` 심으로 나간다.
|
||
|
||
### 28.2 (8.2) 조건 형제 비교 — transport 실패를 ambiguous로 번역하는 어댑터
|
||
|
||
```
|
||
$ grep -rln 'ProviderResults.fromTransport' adapter/outbound/notification/src/main
|
||
.../platform/provider/apns/ApnsNotificationProviderAdapter.java
|
||
.../platform/provider/ses/SesNotificationProviderAdapter.java
|
||
.../platform/provider/smtp/SmtpNotificationProviderAdapter.java
|
||
.../platform/provider/twilio/TwilioSmsProviderAdapter.java
|
||
.../platform/provider/webhook/WebhookNotificationProviderAdapter.java
|
||
.../platform/provider/webpush/WebPushNotificationProviderAdapter.java
|
||
```
|
||
|
||
여섯. FCM이 없다. §29.1.
|
||
|
||
### 28.3 (8.3) 중복 메커니즘 — 두 개의 "모든 provider" 집합
|
||
|
||
`ProviderAdapterContract` 상속(3종)과 `ContractAdapters` 등록(5종)은 겹치되 같지 않고, 어느 쪽도 완전하지 않으며, 어느 쪽도 구조적으로 강제되지 않는다. 두 집합의 합집합에서 빠진 유일한 provider가 fcm이다 — 그리고 §29.1이 보이듯 그것이 계약을 만족하지 않는 provider다. 두 사실은 같은 사실이다.
|
||
|
||
### 28.4 (8.4) 테스트 레인 실행
|
||
|
||
```
|
||
$ ./gradlew :adapter:outbound:notification:test
|
||
> Task :adapter:outbound:notification:test
|
||
BUILD SUCCESSFUL in 5s
|
||
GRADLE_EXIT=0
|
||
```
|
||
|
||
전 레인 통과. 이 모듈의 발견 중 테스트가 검출한 것은 없다 — §9(순환 fallback)와 §13(2단계 우회)은 통과하는 테스트가 **다른 이유로** 통과하거나 합성을 확인하지 않아 생긴 것이고, §29.1은 테스트 대상 밖이다.
|
||
|
||
## 29. Sub-scope 07 findings
|
||
|
||
### 29.1 P2 — FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖에 있고, 그 FCM이 두 계약 집합 어디에도 없다
|
||
|
||
`ProviderResults`의 존재 이유가 클래스 javadoc에 쓰여 있다:
|
||
|
||
> "**Every HTTP provider adapter routes its transport failures through here**, so the rule that 'committed body plus no response equals ambiguous' is written once rather than re-derived per provider."
|
||
|
||
FCM 경로는 그것을 지나지 않는다. 세 계층 전부에 번역도 방어도 없다:
|
||
|
||
```java
|
||
// FcmGateway.java — 실패 계약이 선언되지 않은 심
|
||
@FunctionalInterface
|
||
public interface FcmGateway {
|
||
FcmBatchResult sendBatch(List<Map<String, Object>> messages);
|
||
}
|
||
|
||
// FcmBatchCoordinator.submit — try/catch 없음, fromTransport 없음
|
||
FcmBatchResult batch = gateway.sendBatch(messages);
|
||
if (batch.items().size() != submissions.size()) { throw new IllegalStateException(...); }
|
||
|
||
// FcmNotificationProviderAdapter — 그대로 통과
|
||
return coordinator.submit(submissions);
|
||
```
|
||
|
||
`NotificationHttpTransportException`도, `requestBodyCommitted` 비트도 이 경로에는 존재하지 않는다. 다른 여섯 어댑터가 모두 `catch (NotificationHttpTransportException) → ProviderResults.fromTransport(...)`로 `ProviderSubmissionResult.ambiguous(...)` + `ProviderExecutionEvidence.responseLost()`를 만드는 자리에서, FCM은 구현이 던지는 임의의 `RuntimeException`을 그대로 올려보낸다.
|
||
|
||
**실패 시나리오** — FCM 멀티캐스트 요청의 본문이 기록된 뒤 연결이 끊긴다. 배치가 `maxBatchSize`만큼의 수신자를 담고 있으므로 **한 번의 손실이 최대 배치 크기만큼의 배달에 동시에 영향을 준다**. 결과는 `ambiguous`가 아니라 분류되지 않은 예외다. `NotificationSchedulerWorker`의 catch-all이 `reason=<예외 타입>`으로 로그를 남기고(`:102-106`) 리스를 만료시킨다. `LeaseRecoveryService`가 미완 시도를 보고, `provablyNeverStarted`가 false이므로 재대사로 넘긴다. FCM의 `ProviderCapabilities`는 상태 질의를 `false`로 선언하므로(`FcmNotificationProviderAdapter:48-59`) `ReconciliationJobWorker`가 "질의 능력 없는 provider는 루프 돌지 않는다"는 규칙대로 작업을 종료하고 시도를 운영자에게 남긴다.
|
||
|
||
즉 **최종 방향은 안전하다** — 중복 발송이 아니라 운영자 대기다. 결함은 도착 경로다: 모델링된 AMBIGUOUS 경로 대신 미분류 예외로 도착하므로 (a) 시도 행에 기록되는 증거가 `responseLost()`가 아니라 없고, (b) 지표와 로그가 "ambiguous submission"이 아니라 예외 타입 이름을 말하며, (c) `ambiguousAttemptExists` 기반 라우팅 판단(`NotificationDispatchService:377-379`)이 이 경우를 보지 못한다.
|
||
|
||
**검증 공백이 같은 공백이다** — FCM은 `ProviderAdapterContract`를 상속하지 않고(§28.1), `ContractAdapters`에 등록되지 않았으며(§28.1), FCM 테스트 두 개(`FcmBatchAdapterTest` · `FcmExpiryTest`) 어디에도 `AMBIGUOUS` 문자열이 없다(§28.2 probe). 계약을 강제하는 두 장치가 모두 비껴간 provider가, 계약을 만족하지 않는 provider다. `ProviderAdapterContract`의 세 질문 중 세 번째 — "what happens when the answer never arrives" — 가 FCM에 대해서는 물어진 적이 없다.
|
||
|
||
**권고** — `FcmGateway`에 실패 계약을 선언한다(`NotificationHttpTransportException`을 던지도록 문서화하거나, `requestBodyCommitted`를 담은 FCM 전용 예외를 만든다). `FcmBatchCoordinator.submit`이 그것을 잡아 배치의 모든 항목에 대해 `ProviderResults.fromTransport(...)`를 돌려주게 한다 — 배치이므로 결과는 항목 수만큼의 `ambiguous`다. 그리고 `ContractAdapters`에 FCM을 등록한다. HTTP 루프백 harness로는 `FcmGateway` 심을 직접 고장 내는 편이 맞다.
|
||
|
||
### 29.2 P3 — 공유 provider 계약이 8종 중 3종에서만 상속되고, 강제 장치가 없다
|
||
|
||
§28.1·§28.3. `ProviderAdapterContract`의 javadoc이 약속하는 성질("a new provider cannot be added without answering the same three questions")을 지키는 장치가 없다. 이 저장소는 같은 실패 양식에 대해 `EndpointGuardCallSiteTest`와 `verifyNotificationApiSurface`라는 구조적 강제를 이미 두 번 만들었으므로, 형태는 이미 있다 — 적용되지 않았을 뿐이다.
|
||
|
||
세 번째 질문(응답이 오지 않을 때)의 실질 커버리지는 상속보다 넓다: smtp와 webhook은 자체 테스트에서 `AMBIGUOUS`를 확인하고, webpush는 크로스-프로바이더 스위트가 확인한다. 확인되지 않는 유일한 provider가 fcm이고 그것이 §29.1이다.
|
||
|
||
## 30. Sub-scope 07 완료 조건
|
||
|
||
- denominator 19 / 19 FULL_READ (main 6 + test 9 + resources 4, probe가 카운트 확인)
|
||
- §8.1~§8.4 수행 — 도달성·조건 형제 비교에서 P2 1건, P3 1건
|
||
- 테스트 레인 실행: `BUILD SUCCESSFUL`, `GRADLE_EXIT=0`
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
# 31. 모듈 종합 — `adapter-outbound-notification`
|
||
|
||
## 31.1 커버리지 원장 정산
|
||
|
||
| # | sub-scope | main | test | res | 합 | 상태 |
|
||
|---|---|---:|---:|---:|---:|---|
|
||
| 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 |
|
||
| | **합계** | **171** | **61** | **8** | **240** | **7 / 7** |
|
||
|
||
`FULL_READ 240 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`. 각 sub-scope의 분모는 해당 evidence 파일의 `OWNED FILES` 블록과 `*_count` 라인이 확인한다(`182`–`187`).
|
||
|
||
## 31.2 발견 종합 — P2 7건 · P3 4건 · 기록 8건
|
||
|
||
| 심각도 | § | 발견 | 위치 |
|
||
|---|---|---|---|
|
||
| **P2** | 9 | `SINGLE` 전용 가드가 먼저 던져 다중 타깃 검증 전체(순환 탐지 포함)가 도달 불가이고, 그것을 검증한다는 테스트가 `hasMessageContaining("strategy")`로 다른 가드에 걸려 통과 | `NotificationBindingCompiler:141,159-184` |
|
||
| **P2** | 13 | `resumeHealthy`가 `AUTHENTICATION_FAILED`를 거부한다는 보장이, 관리자 평면에 노출된 `DRAINING → HEALTHY` 2단계로 우회됨. 원인 코드도 소실 | `ProviderRuntime.markDraining` · `RegistryProviderRuntimeControl:30-39` |
|
||
| **P2** | 17.1 | "Every reveal is auditable"를 선언한 `AccessContext`의 세 필드를 읽는 코드가 저장소에 0개. 감사 싱크는 존재하고 다른 경로는 사용 중 | `AesGcmContactPointProtector:86-97` |
|
||
| **P2** | 17.2 | Thymeleaf 예외 메시지 삭제 가드가 프로덕션이 타지 않는 오버로드에만 있음 | `ThymeleafStringTemplateEngine:80` vs `:92` |
|
||
| **P2** | 25.1 | 클라이언트 제공 Web Push 엔드포인트가 SSRF 가드를 지나지 않음. 약한 검사의 private 사본을 사용 | `WebPushSubscriptionValue:27-28,50-57` |
|
||
| **P2** | 25.2 | "상한을 두고 읽는다"는 본문 핸들러가 `ofByteArray()`로 전부 읽은 뒤 자름 | `JdkNotificationHttpGateway:123-128` |
|
||
| **P2** | 29.1 | FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖. 배치라 한 번의 손실이 배치 크기만큼 영향 | `FcmBatchCoordinator` · `FcmGateway` |
|
||
| P3 | 21.1 | 음수 `Retry-After`가 throttle 결과 대신 `IllegalArgumentException` | `ProviderResults:90-99` |
|
||
| P3 | 25.3 | SigV4가 서명한 `host`에 포트 없음 — 기본 포트 아닌 엔드포인트에서 서명 불일치 | `SesRequestMapper` |
|
||
| P3 | 25.4 | SigV4 키 파생이 비밀을 지울 수 없는 `String`으로 승격 | `AwsSignatureV4Signer:117-119` |
|
||
| P3 | 29.2 | 공유 provider 계약이 8종 중 3종에서만 상속, 강제 장치 없음 | `ProviderAdapterContract` |
|
||
| 기록 | 9 · 13 · 17.3 · 17.4 · 21.2 · 25.5 · 25.6 · 25.7 | `indexOf` 순번 · javadoc 2 vs 코드 3 · trim 후 원본 반환 · 중복 검증 호출 · 헬스 신호 동반 소등 · SNS v1(SHA-1) 수용 · `allowedPushTypes` 무의미 · 공개 `hkdf` 32바이트 초과 시 0 패딩 | — |
|
||
|
||
## 31.3 이 모듈의 성격
|
||
|
||
240개 파일 중 **P1이 없다**. 지금까지 분석한 13개 모듈에서 P1이 나온 것은 httpclient(TLS 실패가 CONNECT로 오분류) 하나뿐이고, 이 모듈은 그보다 크면서 P1이 없다.
|
||
|
||
대신 이 모듈은 **자기고발 밀도**가 저장소 최고다. javadoc이 "이 코드는 전에 무엇을 잘못했고 그것이 무엇을 망가뜨렸는지"를 반복해서 적는다. 표본:
|
||
|
||
- "the one metric named 'queue depth' was the one that could not show a queue growing"
|
||
- "a `Semaphore` grows when you release more than you took... silently, in the direction of overloading the provider"
|
||
- "the limiter let more through than configured **at exactly the moment traffic rolls over**"
|
||
- "the mail arrived with an empty attachment and **the attempt was still recorded as accepted**"
|
||
- "A default that discards its own argument is not a fallback; it is the rule not applying, and the engine that skipped it **looked complete because the interface compiled**"
|
||
- "The stronger guard was written for exactly this call site and then **called from nowhere**: it existed, its own tests were green"
|
||
- "**Both existed as classes with no caller**... so the absence turned a deliberate design into a leak"
|
||
|
||
이 마지막 두 개가 이 모듈의 핵심 패턴이자 이 저장소 전체의 핵심 패턴이다 — **장치는 만들어졌고 회로가 닫히지 않았다**. 이 모듈은 그 패턴을 스스로 세 번 찾아내 고쳤고(배경 작업자 3종 배선, webhook SSRF 가드, VAPID 역사 키 조회), 그 과정을 문서화했다. 이번 분석의 P2 7건 중 **5건이 같은 패턴의 남은 사례**다:
|
||
|
||
| § | 만들어진 장치 | 닫히지 않은 회로 |
|
||
|---|---|---|
|
||
| 17.1 | `AccessContext` + `NotificationAuditPort` + `LoggingNotificationAudit` | 복호화 경로가 감사 포트를 부르지 않음 |
|
||
| 17.2 | Thymeleaf 메시지 삭제 try/catch | 프로덕션이 타는 오버로드에 없음 |
|
||
| 25.1 | `requireExternallyRoutable` | Web Push가 private 약한 사본을 씀 |
|
||
| 25.2 | `boundedBody` 핸들러 | 상한이 읽은 **뒤에** 적용됨 |
|
||
| 29.1 | `ProviderResults.fromTransport` | FCM 경로가 지나지 않음 |
|
||
|
||
나머지 2건(§9, §13)은 다른 패턴이다 — **테스트가 통과하지만 그 이름이 약속한 성질은 검증하지 않는다**. §9는 단언 문자열(`"strategy"`)이 네 개의 서로 다른 예외에 공통으로 들어 있어 어느 가드가 걸렸는지 식별하지 못하고, §13은 세 전이를 각각 새 객체에서 확인하고 합성을 확인하지 않는다. 두 경우 모두 테스트의 `@DisplayName`과 `.as()` 설명문이 깨진 성질을 정확히 서술하고 있다.
|
||
|
||
## 31.4 다른 모듈과의 대조
|
||
|
||
`build.gradle`의 `dependencyPolicy { absent ... }`(§sub-scope 01)는 messaging 모듈의 P2(§messaging 3·4 — 통과 불가능한 `verifyJsonSchemaRuntimeGraph`, 잘못된 lockfile 주장)에 대한 **해법이 같은 저장소 안에 이미 있다**는 것을 보여준다. 주석이 주장하고 그래프가 반박하는 상태를, 빌드가 확인할 수 있는 선언으로 바꾸는 방식이다.
|
||
|
||
반대로 §25.1은 이 모듈이 **자기 자신의 해법을 절반만 적용했다**는 사례다. `EndpointGuardCallSiteTest`는 "가드가 필요한 곳에서 도달되는가"를 묻는 정확한 장치인데, 그 "필요한 곳" 목록이 가드 javadoc이 지목한 목록과 다르다.
|
||
|
||
## 31.5 완료 게이트
|
||
|
||
- [x] denominator 240 / 240 FULL_READ, `STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`
|
||
- [x] 7개 sub-scope 각각 §8.1~§8.4 negative-space probe 수행
|
||
- [x] evidence `182`–`187` 생성, 각 파일에 `OWNED FILES` + revision + 실행 probe 포함
|
||
- [x] 실행 검증: `:adapter:outbound:notification:test` → `BUILD SUCCESSFUL`, `GRADLE_EXIT=0`
|
||
- [x] 거짓 양성 후보 검증 후 기각: `WebPushSubscriptionValue` 스킴 검사(→ 실제 결함으로 승격), `SlackWebhookProvider`/`GoogleEmailProvider` null 검사 부재(→ `@Bean` 필수 파라미터로 도달 불가), `EmailAttachments`의 verify 누락(→ `AttachmentIntegrityGuard.resolve` 내부에 존재), `RetryBackoff`의 무제한 `Retry-After`(→ `max`로 상한), `ReactorContextBridge`의 무조건 `remove()`(→ 풀 스레드 위생상 옳음), `ProviderRuntimeRegistry.replace`의 `compute` 내 예외(→ CHM이 매핑을 보존)
|
||
- [x] 소스 미변경 (`git status --short` = 0줄)
|
||
|
||
## Source anchors
|
||
|
||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **80개** (main 67 · test 10 · 기타 3).
|
||
|
||
```
|
||
src/adapter/outbound/notification/build.gradle
|
||
src/config/architecture/modules.json (adapter-outbound-notification 항목)
|
||
|
||
main:
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformMode.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationProviderAssembly.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationSecretRequirements.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/ProviderType.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/LeaseRecoveryService.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationBackgroundWorkers.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/NotificationSchedulerWorker.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntime.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ReconciliationJobWorker.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RegistryProviderDispatchGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/RuntimeDrainCoordinator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsProviderProperties.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/apns/ApnsRequestMapper.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/JdkNotificationHttpGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationEndpoints.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/NotificationHttpTransportException.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/AwsSignatureV4Signer.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesProviderProperties.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesRequestMapper.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SnsSignatureVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/smtp/SmtpMimeMessageFactory.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioCallbackAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioSignatureValidator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookNotificationProviderAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSignatureStrategy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webhook/WebhookSubscription.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/Rfc8291Aes128GcmEncryptor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/VapidKeyRegistry.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmCallbackPayloadProtection.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmContactPointProtector.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/AesGcmNotificationPayloadProtection.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/HmacProviderRequestIdHasher.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/security/ProviderCredentialManager.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/CanonicalNotificationRenderer.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/NotificationTemplateEngine.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/PlaceholderTemplateEngine.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotMode.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/TemplateSlotPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafNotificationRenderer.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/platform/template/ThymeleafStringTemplateEngine.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java
|
||
|
||
test:
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmBatchAdapterTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/fcm/FcmExpiryTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/http/EndpointGuardCallSiteTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/ContractAdapters.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/CrossProviderContractSuiteTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/NotificationChaosSecurityTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/NotificationPerformanceCertificationTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/PiiLeakScanner.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/ProviderAdapterContract.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/notification/platform/testkit/ProviderFaultHarness.java
|
||
|
||
기타:
|
||
docs/notification/api-surface-snapshot.txt
|
||
src/build.gradle
|
||
src/gradle/notification-api-surface.gradle
|
||
|
||
해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
|
||
evidence/raw/183-notification-catalog-dispatch-probes.txt
|
||
evidence/raw/181-notification-module-inventory.txt
|
||
evidence/raw/182-notification-activation-probes.txt
|
||
ProviderRuntimeState.java
|
||
evidence/raw/184-notification-template-security-probes.txt
|
||
AccessContext.java
|
||
evidence/raw/185-notification-provider-core-probes.txt
|
||
evidence/raw/186-notification-provider-impl-probes.txt
|
||
evidence/raw/187-notification-spi-testkit-probes.txt
|
||
|
||
```
|