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>
31 KiB
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_READ8 /STRUCTURAL_ONLY0 /EXCLUDED0 /UNCLASSIFIED0 - 최초 분석 revision
a24ece9c→ 재검증 revision21234e38· 이 리프의 변경 파일 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.outbounddev.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는 사실상 세 가지다.
-
OutboundCorrelation- SLF4J MDC에서
correlation_id를 조회한다. - 값이 없거나 blank면
"unknown"을 반환한다.
- SLF4J MDC에서
-
FailOpenDependencyLogger- optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다.
- success는 DEBUG, failure는 WARN이다.
-
OutboundSupportConfigFailOpenDependencyLoggerdefault 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-coreapplication-coreshared-contract
그러나 현재 build.gradle과 fresh compileClasspath 결과를 보면 실제 project dependency는 0개다. 실제 compile dependency는 다음 외부 라이브러리뿐이다.
spring-boot-autoconfigure4.0.8slf4j-api2.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()의 규칙은 단순하다.
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:
MessagingConfigOutboundMessagePublisher
Notification:
NotificationConfigFailOpenNotificationProvider
반대로 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에 넣는다.
cause.getClass().getSimpleName() + ": " + cause.getMessage()
그리고 consumer SPI들은 exception message의 내용을 제한하지 않는다.
NotificationProvider.send(Notification)→ arbitraryExceptionGoogleEmailClient.send(Notification)→ arbitraryExceptionSlackClient.send(Notification)→ arbitraryExceptionMessageBroker.send(OutboundMessage)→ arbitraryException
특히 Notification contract는 recipient/body가 PII이며 logger에 전달하면 안 된다고 명시한다. 하지만 provider SDK/fork implementation이 recipient나 response/body 일부를 exception message에 넣는 것을 이 interface가 통제할 수 없다.
4.3 실행 재현
현재 compiled FailOpenDependencyLogger에 다음 exception을 전달하는 focused probe를 실행했다.
RuntimeException(
"provider rejected recipient secret@gmail.com body=secret-body-content")
실제 formatted WARN에는 다음 문자열이 그대로 남았다.
error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content"
probe source와 output은 각각:
evidence/raw/021a-support-logger-pii-probe.javaevidence/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)
가능한 방향은 두 가지다.
- 공통 logger가 raw
cause.getMessage()를 기록하지 않고 exception type + bounded/stable error classification만 기록한다. - 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을 분리한다.
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는 다음 구조다.
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 결과:
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 결과:
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.javaevidence/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 하나만 제공한다.
@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한다.
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가:
..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 loggerSlf4jOutboxRelayFailureReportAdapter: 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:
src/build.gradle 의 allowedProjectDependencies[...]가 SSOT
현재:
- root
AGENTS.md:src/config/architecture/modules.json가 SSOT - support
CLAUDE.md: 동일 src/build.gradle은 registry를 읽고allowedProjectDependenciesmap을 파생 생성함
즉 variable 자체는 아직 존재하지만 source-of-truth 위치 설명은 outdated다.
Drift 2 — CLAUDE.md 부재 주장
README:
이 모듈은 아직 별도 CLAUDE.md를 두지 않았다
현재:
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
검증 기준:
- 해당 leaf에서 support dependency 제거
- compile/test/runtime classpath 및 focused tests 실행
- app-bootstrap shipped composition/architecture tests 실행
- runtime bean graph 차이가 없는지 확인
현재 분석에서는 source를 수정하지 않았다.
10. 테스트 레인과 실제 증명 범위
10.1 support dedicated test
Fresh command:
./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 SUCCESSFULverifyCleanArchitectureDependencies: 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는 rawcause.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:
021aprobe와 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:
022afocused 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:
019raw 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