Files
clean-architecture-backend-…/src/adapter/outbound/notification
DongHyeonkaandClaude Opus 5 1535481794 refactor(build,src): testkit 소스셋 이관과 빌드 게이트 정상화, 미추적 빌드 파일 추적
한 커밋인 이유: src/build.gradle 안에서 ca.testkit-publisher 플러그인 제거와
게이트 수정이 얽혀 있다. 플러그인 적용부만 빼면 web·websocket·persistence-jpa·
persistence-mongo·app-bootstrap 이 사라진 testkitPublisher() 와 *Testkit
컨피규레이션을 계속 참조해 설정 단계에서 빌드가 죽는다. 파일 단위로 나눌 수 없다.

1) testkit 소스셋 → Gradle 표준 java-test-fixtures 이관
   web, websocket, persistence-jpa, persistence-mongo, httpclient, graphql 과
   이들의 testkit 컨피규레이션을 소비하던 app-bootstrap.
   자체 제작 ca.testkit-publisher.gradle 77줄이 사라진다.

2) 실행되지 않거나 실패할 수 없던 빌드 게이트 정상화 (E등급)
   - strict-test-lane 의 실행 카운터가 skip 을 실행으로 세던 것 수정.
     전부 skip 인 레인은 이제 실패한다 (회귀 테스트 2건 추가)
   - public-path 스냅샷이 gitignore 된 src/.env 를 읽던 것을
     config/security.yml 의 바인딩 기본값으로 교체
   - verifyEnvKeys 가 build/ 산출물을 소스로 읽어 삭제된 키를 사용 중으로
     오판하던 것 수정 (입력 4,637 → 4,630 파일)
   - jpa-evidence 가 git 실패를 "워크트리 깨끗함"으로 읽던 것을 fail-closed 로
   - notification-evidence 의 Grade 열 탐지를 헤더 기준으로 교체 +
     표 부재 시 fail-closed
   - spring70CompatibilityTest 가 레인을 복제하며 잃은 fail-closed 복구
     (태스크명 유지 — 워크플로 3곳과 gate-matrix 린트 무손상)
   - 메시징 R2 스켈레톤 주변의 도달 불가 검증 45줄을 MSG-015 명시적 실패로 교체

3) git 에 없던 빌드 필수 파일 추적
   - src/gradle/libs.versions.toml — src/build.gradle 이 9곳에서 참조하는데
     추적되지 않아 깨끗한 체크아웃에서 설정이 실패했다
   - app-bootstrap config/*.yml 15개 — application.yml 이 전부 import 한다.
     하드코딩된 시크릿은 없고 값은 secret://environment/APP_* 참조다

4) 진행 중이던 구현 작업 반영 (redis/idempotency 구성, startup 검증,
   아키텍처 테스트 클래스, notification 콜백 레지스트리 등)

검증:
- 깨끗한 체크아웃에서 ./gradlew help 통과
- :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
  → 20개 클래스 174 tests, 실패 0, 스킵 0 (이전에는 0개 실행)

미해결: verifyOneTypePerFile 은 손대지 않았다(Checkstyle 로 교체 권고).
B/C/D 등급 100여 건과 CI 단계 분리는 별도 작업 —
docs/superpowers/plans/2026-09-16-ci-stage-separation.md 참고.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:53:25 +09:00
..

adapter:outbound:notification — R0 legacy truth

현재 구현 전체는 교체 전 호환성 기준선인 R0 legacy다. GoogleEmailClientSlackClient는 project-supplied seam일 뿐 실제 Google Mail 또는 Slack 연동이 아니며, provider/card qualification evidence도 없다.

알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트: dev.caskeleton.adapter.outbound.notification. :adapter:outbound:support 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.

허용/금지 의존 정책은 src/config/architecture/modules.jsonadapter-outbound-notification row가 SSOT다. 이 문서는 코드 주석에서 덜어낸 설계 결정의 근거와 canonical 구현 전 삭제 대상을 모아둔 참조용 기록이다.

Task 1 R0 truth table

현재 사실 증거와 한계
application contract raw NotificationPort.notify(Channel, route, Notification) mode, transaction, receipt, attempt certainty가 없는 R0 port
routing (channel, providerId) registry + route별 provider ID list fan-out RoutingNotifierTest; route list의 모든 provider를 순서대로 호출
provider failure 모든 provider를 중앙 FailOpenNotificationProvider로 감싸고 예외를 삼킴 global fail-open이며 application kind별 정책이 아님
unbound route AdapterDisabledException fail-fast NotificationAdapterTest; disabled sentinel은 없음
email seam google-email + GoogleEmailClient interface module 안 production client/SDK/credential/protocol 구현 0
Slack seam slack-webhook + SlackClient interface module 안 production client/SDK/credential/protocol 구현 0
configuration code는 app.notification.routes.*와 provider별 *.enabled를 읽음 checked-in application.yml/env registry의 app.notification.{slack,email}.provider selector와 drift
production consumer feature/application production consumer 0 main source에는 application contract 선언, adapter 구현과 bootstrap composition만 존재
evidence grade local fake/contract baseline real provider, durability, callback, security, load evidence 0; 모든 seam R0 legacy

selector drift는 이 기준선의 일부다. Task 1에서는 고치지 않는다. canonical graph가 준비되고 cutover evidence가 생기기 전까지 기존 key를 새 의미로 재사용하거나 legacy class에 production 동작을 추가하지 않는다.

모듈 개요

application-core 포트 뒤에 두는 선택형 알림 어댑터다. @ConditionalOnProperty 로 게이팅되고 기본 비활성이다. 이 모듈이 기본 제공하는 프로바이더는 email/google(GoogleEmailProvider / GoogleEmailClient)과 slack/webhook(SlackWebhookProvider / SlackClient)이며, 실제 연동 client 는 포킹 프로젝트가 채우는 seam 이다.

(channel, providerId) 복합 키 + fan-out

RoutingNotifier(channel, providerId) 복합 키로 프로바이더를 등록한다 — 채널 내 중복 providerId 는 생성 시점에 실패한다. 라우트당 providerId 목록을 주면 fan-out(모든 프로바이더 호출)이 된다. 각 프로바이더는 이미 FailOpenNotificationProvider 로 감싸져 있어 한 곳의 실패가 다른 곳을 막지 않고, 그래서 fan-out 루프에 try/catch 가 필요 없다. FailOpenNotificationProvider .sendthrows 를 선언하지 않는 건 이 루프를 try/catch 없이 예외-free 로 증명하기 위함이다.

중앙 fail-open 합성 + 라우팅 바인딩

NotificationConfig 가 모든 프로바이더를 FailOpenNotificationProvider 로 중앙에서 감싼다(→ :adapter:outbound:supportFailOpenDependencyLogger 로 WARN 로깅). 라우팅은 app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]. 프로바이더는 channel()+providerId() 로 키잉된 NotificationProvider 빈으로 기여한다(예: GoogleEmailProvider, SlackWebhookProvider). GoogleEmailClient/SlackClient 는 포크가 구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다.

Wave G deletion inventory

다음 surface는 canonical-only cutover와 retained evidence 검증이 끝난 뒤 한 묶음으로 제거한다. 그 전에는 동작을 확장하지 않고 R0 회귀 기준선으로만 유지한다.

  • application R0 contract: Channel, Notification, raw NotificationPort
  • router/decorator SPI: NotificationConfig, NotificationRoutesSettings, RoutingNotifier, NotificationProvider, FailOpenNotificationProvider
  • fake-only Google email seam: GoogleEmailClient, GoogleEmailProvider, GoogleEmailNotificationAdapterConfig
  • fake-only Slack webhook seam: SlackClient, SlackWebhookProvider, SlackNotificationAdapterConfig
  • legacy configuration/tests: app.notification.routes.*, app.notification.google-email.enabled, app.notification.slack-webhook.enabled, drifted APP_NOTIFICATION_EMAIL_PROVIDER/APP_NOTIFICATION_SLACK_PROVIDER, NotificationAdapterTest, RoutingNotifierTest와 bootstrap legacy gating cases

accepted 또는 indeterminate work를 inventory하지 않은 상태에서 이 목록을 삭제하거나 canonical provider로 자동 재전송하지 않는다.