docs(keycloak-session-store): import the session-storage lab as a new project

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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,703 @@
# 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=`<exception simple class>: <cause.getMessage()>`
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
```