# adapter-outbound-support 상세 분석 ## SSOT identity — 2026-08-31 재검증 - registered leaf id: `adapter-outbound-support` - canonical state `analysisFile`: `analysis/04-adapter-outbound-support.md` (이 문서) — 이 leaf의 단일 SSOT - source path: `src/adapter/outbound/support` · Gradle `:adapter:outbound:support` - registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]` - registry `runtime_memberships`: `["app-bootstrap"]` - coverage ledger: `FULL_READ` **8** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0** - 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0** - 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) > 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다. > 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다. --- > 상태: COMPLETE > 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` > 분석 범위: `src/adapter/outbound/support` > Gradle path: `:adapter:outbound:support` ## 0. 커버리지와 숫자 지도 이 leaf는 크기가 작다. 그래서 대표 파일을 샘플링하지 않고 leaf-owned source/build/document를 전부 읽고, 실제 consumer와 composition-root wiring을 별도 cross-scope evidence로 추적했다. | file group | count | disposition | reason | |---|---:|---|---| | production Java | 4 | FULL_READ | leaf의 전체 production source | | test Java | 1 | FULL_READ | leaf의 전체 dedicated test source | | `build.gradle` | 1 | FULL_READ | 실제 compile dependency와 leaf build policy | | `README.md` | 1 | FULL_READ | 현재 코드와 대조해야 하는 설계 결정 문서 | | `CLAUDE.md` | 1 | FULL_READ | 현재 leaf-local 분석/경계 지침 | | resources | 0 | FULL_READ | main/test resource 없음 | | **합계** | **8** | **FULL_READ 8 / UNCLASSIFIED 0** | bounded scope complete denominator | 추가 측정: - production Java: 4 files / 약 97 LOC - test Java: 1 file / 약 89 LOC - production package directories: 2 - `dev.caskeleton.adapter.outbound` - `dev.caskeleton.adapter.outbound.support` - entities/tables/migrations: 없음 - configuration properties: 없음 - runtime membership registry: `app-bootstrap` - leaf-owned Spring configuration: `OutboundSupportConfig` Raw inventory는 `evidence/raw/015-adapter-outbound-support-inventory.txt`에 보존했다. ## 1. 모듈의 정체와 경계 `adapter-outbound-support`는 application port를 구현하는 하나의 기술 adapter라기보다 **여러 outbound adapter가 공유할 수 있는 기술적 보조 seam**이다. 현재 production surface는 사실상 세 가지다. 1. `OutboundCorrelation` - SLF4J MDC에서 `correlation_id`를 조회한다. - 값이 없거나 blank면 `"unknown"`을 반환한다. 2. `FailOpenDependencyLogger` - optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다. - success는 DEBUG, failure는 WARN이다. 3. `OutboundSupportConfig` - `FailOpenDependencyLogger` default bean을 제공한다. - `@ConditionalOnMissingBean`으로 fork/application이 같은 타입을 override할 수 있게 한다. `package-info.java`는 outbound adapter package의 개괄만 가진다. ### 1.1 허용 dependency와 실제 dependency는 다르다 `src/config/architecture/modules.json`은 support leaf가 다음 project dependency를 **허용**한다. - `domain-core` - `application-core` - `shared-contract` 그러나 현재 `build.gradle`과 fresh `compileClasspath` 결과를 보면 실제 project dependency는 **0개**다. 실제 compile dependency는 다음 외부 라이브러리뿐이다. - `spring-boot-autoconfigure` 4.0.8 - `slf4j-api` 2.0.18 즉 registry의 `allowed_dependencies`는 가능한 최대 경계를 나타내고, 현재 source graph가 그 edge를 모두 사용한다는 뜻이 아니다. support는 현 snapshot에서 domain/application/shared 타입과도 결합하지 않는다. 이 구분은 Clean Architecture 설명에서도 중요하다. “Core 쪽 dependency가 허용된다”와 “현재 adapter가 Core에 실제로 의존한다”는 별개의 사실이다. ## 2. `OutboundCorrelation`: MDC lookup을 한 곳으로 모은 작은 seam `OutboundCorrelation.current()`의 규칙은 단순하다. ```text MDC[correlation_id] != null && !blank -> 해당 값 otherwise -> "unknown" ``` `docs/registries/mdc-keys.yaml`의 `correlation_id` 계약은 다음을 선언한다. - source: inbound filter - type: ULID - required in: request/dependency/application - propagation: HTTP/async/message - metric tag 사용 금지(high cardinality) `OutboundCorrelation`은 이 중 **조회와 missing sentinel만 소유**한다. ULID 형식 검증이나 생성/전파를 여기서 하지 않는다. 따라서 이 utility가 임의 문자열을 그대로 반환한다고 해서 곧바로 contract 위반이라고 볼 근거는 없다. canonicalization/validation은 upstream context owner의 책임으로 보인다. ### Reachability scope 밖 production code에서 `OutboundCorrelation`을 직접 참조하는 파일은 현재 0개다. 하지만 dead code는 아니다. - `FailOpenDependencyLogger`가 같은 leaf 내부에서 production consumer다. - messaging/notification tests는 MDC key를 맞추기 위해 이 상수를 직접 사용한다. 따라서 “external production reference 0”만으로 dead 판정을 하면 오탐이다. 이번에 추가된 negative-space rule이 요구하는 바로 그 사례다. ## 3. `FailOpenDependencyLogger`: 진단을 business outcome과 분리하려는 계약 ### 3.1 성공과 실패 포맷 `logSuccess(...)`는 DEBUG로 다음 정보를 기록한다. - dependency_name - dependency_type - operation - outcome=`SUCCESS` - correlation_id `logFailure(...)`는 WARN으로 다음을 추가한다. - outcome=`FAILURE` - error=`: ` README와 javadoc은 WARN을 선택한 이유를 “optional fail-open dependency가 실패해도 core use case 자체는 성공했기 때문”이라고 설명한다. 이 logger 자체는 retry, recovery, fallback을 수행하지 않는다. **실패 정책을 결정하는 주체가 아니라 이미 결정된 fail-open outcome을 관측하는 기술 seam**이다. ### 3.2 실제 production consumer repository-wide production reference scan에서 support package를 직접 import하는 current production files는 네 개뿐이었다. Messaging: - `MessagingConfig` - `OutboundMessagePublisher` Notification: - `NotificationConfig` - `FailOpenNotificationProvider` 반대로 support README가 “공유 consumer”로 설명하는 `cache-redis`, `httpclient`는 Gradle dependency는 유지하지만 support production type을 직접 참조하지 않는다. 이 차이는 §8에서 별도로 다룬다. ## 4. Confirmed P1 — `cause.getMessage()` 때문에 PII-safe logging 계약이 성립하지 않는다 ### 4.1 문서와 테스트가 주장하는 계약 support source와 README는 다음 취지의 강한 주장을 한다. > logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다. `FailOpenDependencyLoggerTest`도 실패 로그에 email/body marker가 없음을 검사한다. 하지만 테스트 fixture의 exception은 단순히 `"connection refused"`다. 즉 PII marker는 logger에 들어가는 어떤 argument에도 존재하지 않는다. 이 테스트는 **payload object가 직접 argument로 전달되지 않는다는 것**만 확인할 뿐, exception message를 통한 leakage를 검사하지 않는다. ### 4.2 실제 logger input은 payload-free가 아니다 `logFailure`는 다음 값을 그대로 formatted message에 넣는다. ```java cause.getClass().getSimpleName() + ": " + cause.getMessage() ``` 그리고 consumer SPI들은 exception message의 내용을 제한하지 않는다. - `NotificationProvider.send(Notification)` → arbitrary `Exception` - `GoogleEmailClient.send(Notification)` → arbitrary `Exception` - `SlackClient.send(Notification)` → arbitrary `Exception` - `MessageBroker.send(OutboundMessage)` → arbitrary `Exception` 특히 `Notification` contract는 recipient/body가 PII이며 logger에 전달하면 안 된다고 명시한다. 하지만 provider SDK/fork implementation이 recipient나 response/body 일부를 exception message에 넣는 것을 이 interface가 통제할 수 없다. ### 4.3 실행 재현 현재 compiled `FailOpenDependencyLogger`에 다음 exception을 전달하는 focused probe를 실행했다. ```text RuntimeException( "provider rejected recipient secret@gmail.com body=secret-body-content") ``` 실제 formatted WARN에는 다음 문자열이 그대로 남았다. ```text error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content" ``` probe source와 output은 각각: - `evidence/raw/021a-support-logger-pii-probe.java` - `evidence/raw/021-support-logger-pii-probe.txt` 에 보존했다. ### 4.4 global masking도 이 보장을 복구하지 않는다 `app-bootstrap`의 `LogMaskingPatterns`는 방어 심층화로 다음과 같은 secret 형태를 mask한다. - password/secret/token/api-key 계열 key=value - Authorization credentials - standalone Bearer token 그러나 arbitrary email address나 free-form body PII를 일반적으로 제거하는 규칙은 없다. app-bootstrap README 자체도 regex masking을 **보증이 아니라 defence-in-depth**라고 설명한다. 따라서 현재 “logger signature 때문에 PII가 들어올 수 없다”는 1차 방어선 설명은 사실과 맞지 않는다. ### 4.5 영향과 수정 후보 우선순위: **P1 (security/privacy contract)** 가능한 방향은 두 가지다. 1. 공통 logger가 raw `cause.getMessage()`를 기록하지 않고 exception type + bounded/stable error classification만 기록한다. 2. raw cause message가 정말 필요한 일부 dependency만 별도의 sanitizer/classifier를 거쳐 명시적으로 허용한다. 어느 쪽이든 현재의 “arbitrary exception message를 공통 logger가 그대로 출력”하는 방식은 PII-safe라는 강한 계약과 양립하지 않는다. Regression test는 exception message 자체에 email/body/token marker를 넣어 formatted log에 남지 않는지 검증해야 한다. 현재 테스트처럼 payload object만 logger argument에서 제외하는 것으로는 부족하다. ## 5. Confirmed P1 — notification consumer는 diagnostic failure를 authoritative failure로 바꿀 수 있다 이 finding은 support logger의 consumer semantics를 추적하면서 발견했다. ### 5.1 messaging은 이미 이 문제를 구분한다 `OutboundMessagePublisher`는 broker call과 observation을 분리한다. ```text broker.send -> success/failure fact 결정 -> observeQuietly(logger...) ``` `observeQuietly`는 logger가 RuntimeException을 던져도 caller-visible broker outcome을 바꾸지 않는다. source comment에는 과거 버그도 직접 기록돼 있다. - send와 success log가 같은 try block에 있었음 - broker는 이미 메시지를 받았음 - success logger가 실패함 - 같은 catch가 이를 publish failure로 오인했음 현재 `OutboundMessagePublisherTest.aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure`는 logger가 DEBUG에서 실제로 예외를 던지도록 만들고도 publish가 예외 없이 끝나며 broker send가 1회 완료됐음을 검증한다. fresh focused test도 통과했다. ### 5.2 notification은 같은 shared logger를 다른 방식으로 사용한다 현재 `FailOpenNotificationProvider`는 다음 구조다. ```text try { delegate.send() logSuccess() } catch (Exception ex) { logFailure(ex) } ``` 여기서는 provider outcome과 diagnostic outcome이 분리되지 않는다. #### Case A — provider 성공 후 success logger 실패 `delegate.send()`가 성공한 뒤 `logSuccess()`가 RuntimeException을 던지면 같은 catch가 잡는다. 그 결과 이미 성공한 provider send에 대해 `logFailure()`까지 호출된다. focused probe 결과: ```text SUCCESS_PATH sends=1 debugCalls=1 warnCalls=1 ``` 실제 send는 1회 성공했지만 success observation failure 때문에 WARN failure observation이 추가 호출됐다. #### Case B — provider 실패 후 failure logger도 실패 provider failure가 catch된 뒤 `logFailure()`가 RuntimeException을 던지면 이를 흡수하는 바깥 경계가 없다. probe 결과: ```text FAILURE_PATH propagated=IllegalStateException:logger-warn-failed warnCalls=1 ``` 즉 클래스가 “provider failure를 swallow해 core use case를 실패시키지 않는다”고 선언해도 diagnostics failure가 caller까지 전파될 수 있다. probe source/output: - `evidence/raw/022a-notification-logger-failure-probe.java` - `evidence/raw/022-notification-logger-failure-probe.txt` ### 5.3 현재 notification test가 green인 이유 `NotificationAdapterTest`는 ordinary `ListAppender`를 사용한다. provider failure와 PII object가 log line에 직접 들어가지 않는 것은 검증하지만 logger/appender 자체가 실패하는 fixture는 없다. fresh `NotificationAdapterTest`는 정상 통과했다. 따라서 이 finding은 “기존 테스트 실패”가 아니라 **green test가 다루지 않는 failure seam**이다. 우선순위: **P1 (reliability / outcome correctness)** 수정 후보: - messaging과 동일하게 provider call과 observation을 분리하고 observation failure를 non-authoritative로 흡수한다. - 또는 `FailOpenDependencyLogger` 자체를 no-throw contract로 바꿔 모든 consumer를 보호한다. 후자는 shared behavior를 바꾸므로 messaging/notification뿐 아니라 future consumer까지 contract review가 필요하다. 어느 owner가 isolation을 가져갈지는 후속 Decision 후보로 남긴다. ## 6. `OutboundSupportConfig`: unconditional shared bean seam과 실제 runtime wiring `OutboundSupportConfig`는 `@Configuration`이며 `FailOpenDependencyLogger` bean 하나만 제공한다. ```text @ConditionalOnMissingBean FailOpenDependencyLogger failOpenDependencyLogger() ``` 별도 master property condition은 없다. 이는 support 자체를 optional capability로 취급하지 않고, 실제 provider/client capability의 on/off를 sibling adapter가 소유하게 하려는 구조다. ### 6.1 direct production reference 0이지만 unwired가 아니다 `OutboundSupportConfig`를 support 밖 production Java에서 명시적으로 참조하는 파일은 0개다. 그러나 실제 composition root `CaSkeletonApplication`은 다음 broad package를 component scan한다. ```text dev.caskeleton.adapter ``` `AUTO_CONFIGURED_PACKAGES` exclusion에는 messaging/notification/persistence 등은 들어가지만 support package는 포함되지 않는다. 따라서 support config는 broad component scan으로 도달한다. registry도 support runtime membership을 `app-bootstrap`으로 선언하고 `app-bootstrap/build.gradle`이 support project를 직접 `implementation`한다. 따라서 이 configuration은 현재 **active scanned path**다. ### 6.2 conditional sibling comparison support config 자체에는 `@ConditionalOnProperty`가 없고 `@ConditionalOnMissingBean`만 있다. 이것은 같은 optional adapter들의 master switch 누락으로 판정하지 않았다. 이유: - support는 provider/client를 생성하지 않는다. - logger bean 하나만 default로 제공한다. - actual messaging/notification/httpclient 등은 자기 capability root에서 activation을 소유한다. - support README와 config javadoc 모두 이 비대칭을 의도적으로 설명한다. `OptionalAdapterBeanGatingTest`도 support config를 함께 넣은 상태에서 optional adapters가 기본 disabled여도 context가 성공함을 검증한다. 다만 이 test는 `OutboundSupportConfig`를 `.withUserConfiguration(...)`으로 직접 공급하므로 full-app component-scan evidence 자체는 아니다. full app wiring은 `CaSkeletonApplication` source와 registry/build edge가 별도 근거다. ## 7. Build / ArchUnit enforcement ### 7.1 registry `modules.json`에서 support는 독립 leaf이며 runtime membership은 app-bootstrap이다. ### 7.2 Gradle dependency validation fresh `verifyCleanArchitectureDependencies`가 통과했다. 이 task는 registry의 allowed dependency와 실제 Gradle project edge를 비교한다. 중요한 한계는 이 검증이 **edge가 허용되는지**를 판단한다는 점이다. 사용되지 않는 allowed edge까지 제거해야 한다고 판단하지는 않는다. ### 7.3 outbound peer isolation `CleanArchitectureTest.OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT`는 outbound adapter family를 slice로 나누고 서로 직접 의존하지 못하게 한다. 유일한 shared-code 예외는 target package가: ```text ..adapter.outbound.support.. ``` 인 dependency다. 따라서 messaging → notification 같은 peer coupling은 금지하지만 messaging → support는 허용한다. fresh `CleanArchitectureTest --rerun-tasks`도 통과했다. 이 구조는 support 모듈이 단순 편의 library가 아니라 **outbound family에서 sanctioned shared dependency point**라는 점을 build-time fitness function으로 고정한다. ## 8. Negative-space probes 강화된 분석 규칙에 따라 네 가지 부재/중복/drift probe를 별도로 수행했다. ### 8.1 Public surface reachability Raw evidence: `016-adapter-outbound-support-public-reachability.txt` | public type | support 밖 current reference | 판정 | |---|---|---| | `FailOpenDependencyLogger` | messaging/notification production + tests | active shared surface | | `OutboundCorrelation` | external production 0, downstream tests 존재 | leaf-internal production utility, dead 아님 | | `OutboundSupportConfig` | external production direct ref 0, app-bootstrap test ref 존재 | component-scan active path | 결론적으로 현재 세 타입 중 confirmed dead public type은 없다. ### 8.2 Conditional sibling comparison Raw evidence: `017-adapter-outbound-support-conditional-wiring.txt` - support config: unconditional configuration + missing-bean override seam - app composition root: support package는 broad component scan에 포함 - optional provider adapter package들은 별도 conditional/auto-config ownership support의 unconditional nature는 현재 역할과 일치하며 conditional mismatch defect로 판정하지 않았다. ### 8.3 Duplicate / competing mechanism sweep Raw evidence: `018-adapter-outbound-support-duplicate-mechanisms.txt` 확인한 주요 후보: - `FailOpenDependencyLogger`: current fail-open shared logger - `Slf4jOutboxRelayFailureReportAdapter`: durable/outbox failure reporter - cache Redis 내부의 자체 logger들 - historical `OutboundHttpDependencyLogger` 현재 evidence로는 이들을 같은 runtime responsibility의 confirmed duplicate라고 볼 수 없다. - outbox reporter는 fail-closed durable relay의 typed report를 기록한다. - Redis logger는 lifecycle/config/SDK observability 역할이다. - HTTP dependency logger는 현재 source에서 제거됐다. 따라서 **현재 중복 fail-open dependency logger 구현 defect는 확인되지 않았다.** 다만 messaging과 notification이 동일 logger를 사용하면서 diagnostics-failure semantics가 다르다는 consumer-level inconsistency는 §5의 confirmed finding이다. ### 8.4 Documentation / measured-claim drift Raw evidence: `019-adapter-outbound-support-document-drift.txt` 여기서는 명확한 drift가 확인됐다. #### Drift 1 — dependency SSOT 위치 README: ```text src/build.gradle 의 allowedProjectDependencies[...]가 SSOT ``` 현재: - root `AGENTS.md`: `src/config/architecture/modules.json`가 SSOT - support `CLAUDE.md`: 동일 - `src/build.gradle`은 registry를 읽고 `allowedProjectDependencies` map을 **파생 생성**함 즉 variable 자체는 아직 존재하지만 source-of-truth 위치 설명은 outdated다. #### Drift 2 — CLAUDE.md 부재 주장 README: ```text 이 모듈은 아직 별도 CLAUDE.md를 두지 않았다 ``` 현재: ```text src/adapter/outbound/support/CLAUDE.md ``` 가 실제 존재한다. #### Drift 3 — 존재하지 않는 현재 비교 대상 README는 fail-open WARN logger와 `OutboundHttpDependencyLogger`를 현재 대비되는 구현처럼 설명한다. current repository exact search에서는 이 symbol이 support README 한 줄 외에 존재하지 않는다. Git history를 보면 해당 class는 초기 repository에 존재했으나 commit `5f10b791...`에서 httpclient 관련 old classes/tests와 함께 삭제됐다. support README는 initial commit 이후 이 변화에 맞춰 갱신되지 않았다. 우선순위: **P3 documentation maintenance** ## 9. Candidate unnecessary Gradle edges — cache/httpclient → support Raw evidence: `020-adapter-outbound-support-project-edge-usage.txt` 다음 네 leaf는 모두 support를 `implementation project(':adapter:outbound:support')`로 선언한다. - cache-redis - httpclient - messaging - notification 하지만 current production Java reference는: - messaging: 있음 - notification: 있음 - cache-redis: 0 - httpclient: 0 support leaf에는 resource도 없다. 따라서 cache/httpclient의 edge는 **현재 source에서 직접 필요성을 찾지 못한 candidate stale dependency**다. 다만 static textual reference만으로 Gradle edge가 100% 불필요하다고 단정하지 않는다. compile/runtime classpath presence 자체를 의도적으로 이용하는 plugin/reflection mechanism이 있는지 downstream leaf 전체 분석에서 다시 확인해야 한다. 우선순위: **P3 cleanup candidate** 검증 기준: 1. 해당 leaf에서 support dependency 제거 2. compile/test/runtime classpath 및 focused tests 실행 3. app-bootstrap shipped composition/architecture tests 실행 4. runtime bean graph 차이가 없는지 확인 현재 분석에서는 source를 수정하지 않았다. ## 10. 테스트 레인과 실제 증명 범위 ### 10.1 support dedicated test Fresh command: ```text ./gradlew :adapter:outbound:support:test --rerun-tasks --console=plain ``` 결과: BUILD SUCCESSFUL. 이 test가 실제로 증명하는 것: - normal Logback path에서 failure correlation id가 기록됨 - MDC 없을 때 unknown sentinel - ordinary exception fixture에서 payload marker가 log에 없음 - success DEBUG logging 증명하지 않는 것: - exception message에 PII가 있을 때의 안전성 - logger/appender 자체 실패 시 consumer behavior - full app component scan - downstream provider semantics ### 10.2 messaging consumer test Fresh `OutboundMessagePublisherTest` 통과. 이 class에는 logger가 success observation에서 실제 RuntimeException을 던지는 regression test가 있고, send outcome이 logger failure와 분리됨을 증명한다. ### 10.3 notification consumer test Fresh `NotificationAdapterTest` 통과. normal logger에서 provider failure를 swallow하고 direct Notification PII가 log line에 없음을 증명한다. throwing-logger case는 없다. ### 10.4 optional adapter gating Fresh `OptionalAdapterBeanGatingTest` 통과. support config와 여러 optional adapter configs를 ApplicationContextRunner에 함께 넣었을 때 disabled defaults가 실제 provider bean을 만들지 않는다는 것을 검증한다. full `CaSkeletonApplication` scan과 동일한 boot path는 아니다. ### 10.5 architecture suite / dependency registry - `CleanArchitectureTest --rerun-tasks`: BUILD SUCCESSFUL - `verifyCleanArchitectureDependencies`: BUILD SUCCESSFUL 이 둘은 source/package/project dependency constraint를 증명하며 diagnostics runtime failure나 PII behavior를 증명하지 않는다. ## 11. 역사적 형태 support README와 source는 initial repository부터 존재한다. 이후 architecture가 크게 성장하는 동안 support의 역할 설명 일부가 current implementation과 어긋났다. 특히 `OutboundHttpDependencyLogger`는 실제 historical class였다. 따라서 README의 해당 문장이 처음부터 허구였던 것은 아니다. 문제는 **class가 삭제된 이후 문서가 함께 이동하지 않았다는 것**이다. 또 messaging current source에는 logger failure를 business/publish failure와 분리하기 위해 `observeQuietly`가 도입된 과거 regression 설명이 남아 있다. 이 history는 notification consumer의 현재 shape와 비교할 때 중요한 evidence가 된다. 두 consumer가 같은 shared logger를 사용하지만 하나만 diagnostics를 non-authoritative로 격리한다. ## 12. Findings / improvement backlog ### P1 — arbitrary exception message가 PII-safe logging boundary를 우회한다 - **Observed fact:** `FailOpenDependencyLogger.logFailure`는 raw `cause.getMessage()`를 formatted WARN에 포함한다. - **Runtime evidence:** explicit email/body marker가 포함된 exception message가 실제 formatted log에 그대로 출력됐다. - **Contract conflict:** README/source/test는 payload/PII가 logger에 닿지 않는다고 주장한다. - **Why it matters:** fork/provider SDK exception message는 application이 통제하지 못하며 recipient/body/remote response를 포함할 수 있다. - **Verification:** `021a` probe와 raw output, 향후 dedicated regression test. - **Candidate:** raw cause message 제거 또는 explicit sanitizer/classifier. - **Tech-Log:** CASE + DECISION 후보. ### P1 — notification fail-open consumer가 logger failure를 격리하지 않는다 - **Observed fact:** `FailOpenNotificationProvider`는 send와 logSuccess를 동일 try에 두고 catch 안의 logFailure를 보호하지 않는다. - **Runtime evidence:** successful send 뒤 debug logger failure가 warn failure observation을 만들었고, provider failure 뒤 warn logger failure는 caller까지 전파됐다. - **Comparison:** messaging은 같은 shared logger를 `observeQuietly`로 이미 격리하고 regression test를 갖는다. - **Why it matters:** diagnostics가 business/provider outcome을 바꿔서는 안 된다는 non-authoritative observation 원칙이 consumer마다 달라진다. - **Verification:** `022a` focused probe; notification에 throwing-logger regression 추가. - **Candidate:** notification에서 observation isolation 또는 shared logger no-throw contract. - **Tech-Log:** CASE + DECISION 후보. ### P3 — support README가 current architecture registry/history와 drift - **Observed fact:** SSOT 위치, CLAUDE.md 존재 여부, HTTP logger 존재 여부가 current source와 불일치. - **Why it matters:** support module의 dependency policy와 비교 설계를 읽는 사람이 현재 architecture를 잘못 이해한다. - **Verification:** `019` raw search/history. - **Candidate:** README를 `modules.json`/current consumer topology에 맞춰 갱신. - **Tech-Log:** 보통 refactor/doc maintenance; 독립 CASE 우선순위는 낮음. ### P3 — cache-redis/httpclient의 support project dependency 필요성 재검증 - **Observed fact:** 두 leaf 모두 Gradle support dependency는 있지만 current production Java support reference는 0이고 support resource도 없다. - **Why it matters:** 불필요 edge는 classpath와 architecture narrative를 실제 필요보다 넓힌다. - **Verification:** dependency 제거 후 각 leaf focused test + app composition test. - **Status:** candidate only; downstream leaf exhaustive analysis 전에는 confirmed dead edge로 단정하지 않음. - **Tech-Log:** OPEN QUESTION / refactor candidate. ## 13. 확인한 것 / 확인하지 못한 것 ### 확인한 것 - leaf-owned source/test/build/README/CLAUDE 8개 전부 FULL_READ - current compile dependency graph - registry membership와 allowed project edges - app-bootstrap component-scan wiring - support public surface reachability - optional sibling activation shape - duplicate logger/correlation mechanism search - README named-claim drift와 relevant Git history - messaging/notification direct consumers - PII exception-message runtime probe - diagnostics-failure runtime probe - focused support/messaging/notification/gating tests - architecture/dependency verification ### 이 scope에서 exhaustive하지 않은 것 - messaging 전체 module - notification 전체 adapter module - cache-redis 전체 module - httpclient 전체 module - production Logback deployment/backend 장애 행동 - 실제 외부 provider SDK의 구체 exception message corpus 따라서 §4의 핵심은 “특정 SDK가 지금 반드시 PII를 exception에 넣는다”가 아니다. **공통 logger contract가 arbitrary exception message를 허용하고 실제로 그대로 출력하므로 PII-safe를 보장할 수 없다는 것**이다. §5 역시 실제 production disk-full 사고를 주장하지 않는다. 현재 compiled consumer에 throwing logger를 주었을 때 outcome semantics가 깨지는 코드 경로를 재현한 것이다. ## 14. 완료 판정 `adapter-outbound-support`는 다음 gate를 충족했다. - bounded denominator: 8 / unclassified 0 - production/test source 전부 FULL_READ - build dependency와 runtime membership 확인 - composition-root wiring 확인 - public contract와 logging semantics 추출 - success/failure consumer path 추적 - dedicated tests와 downstream focused tests 실행 - ArchUnit/project dependency enforcement 확인 - public reachability probe 수행 - conditional sibling probe 수행 - duplicate mechanism probe 수행 - documentation drift probe 수행 - raw evidence에 exact command/cwd/time/revision/exit/output 보존 - P1/P3 backlog와 limitations 분리 따라서 이 bounded scope를 `COMPLETE`로 판정할 수 있다. 프로젝트 전체는 아직 후속 outbound/persistence/messaging/inbound/bootstrap scopes와 마지막 `analysis/99-cross-scope.md`가 남아 있으므로 계속 `IN_PROGRESS`다. ## Source anchors 이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **12개** (main 4 · test 1 · 기타 7). ``` src/adapter/outbound/support/build.gradle src/config/architecture/modules.json (adapter-outbound-support 항목) main: src/main/java/dev/caskeleton/adapter/outbound/package-info.java src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java test: src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java 기타: AGENTS.md CLAUDE.md README.md docs/registries/mdc-keys.yaml src/app-bootstrap/build.gradle src/build.gradle src/config/architecture/modules.json 해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등: modules.json evidence/raw/015-adapter-outbound-support-inventory.txt evidence/raw/021a-support-logger-pii-probe.java evidence/raw/021-support-logger-pii-probe.txt evidence/raw/022a-notification-logger-failure-probe.java evidence/raw/022-notification-logger-failure-probe.txt 016-adapter-outbound-support-public-reachability.txt 017-adapter-outbound-support-conditional-wiring.txt 018-adapter-outbound-support-duplicate-mechanisms.txt 019-adapter-outbound-support-document-drift.txt 020-adapter-outbound-support-project-edge-usage.txt analysis/99-cross-scope.md ```