1485 lines
77 KiB
Markdown
1485 lines
77 KiB
Markdown
# Notification 모듈 상세 코드·아키텍처 리뷰
|
|
|
|
- 기준 일자: 2026-08-14
|
|
- 기준 Git HEAD: `539e3eb58bed5db63e3a17f47eec213db2d2df79`
|
|
- notification 기준 source snapshot: `c1ee1d9dd916719e709bbea0b7cb46118bafc590`
|
|
- 대상 Gradle leaf:
|
|
- `:application-core`
|
|
- `:adapter:outbound:notification`
|
|
- `:adapter:outbound:persistence-jpa`
|
|
- `:adapter:inbound:web`
|
|
- `:app-bootstrap`
|
|
- 대상 문서/CI: `docs/notification`, `.github/workflows/notification-platform.yml`
|
|
- 판정: **CHANGES REQUIRED — 현재 상태를 production-ready Stable로 승격하면 안 됨**
|
|
- 변경 범위: 이 리뷰 문서만 추가했으며 production/test 코드는 수정하지 않았다.
|
|
|
|
> 리뷰 도중 HEAD가 `92744c5` → `c1ee1d9` → `539e3eb`로 이동했다. 최종 HEAD의
|
|
> `c1ee1d9..539e3eb` diff를 확인한 결과 notification application/adapter/bootstrap/JPA source는
|
|
> 변경되지 않았고, 마지막 병합은 주로 JPA persistence platform 추가였다. 새로 합쳐진
|
|
> `PostgreSqlWorkClaimExecutor`는 NTF-004의 원자적 claim 구현에 재사용할 수 있으므로 수정안에
|
|
> 반영했다.
|
|
|
|
## 1. 최종 결론
|
|
|
|
현재 notification 코드는 단순 알림 adapter가 아니다. 요청 수락, 예약, route, template, contact point,
|
|
provider runtime, retry/ambiguity, callback ledger, projection, suppression, admin, inbox까지 포함한 별도
|
|
delivery platform이다. provider-neutral port, 명시적인 evidence 모델, transaction 밖 provider call,
|
|
append-only event ledger라는 큰 설계 방향은 좋다.
|
|
|
|
그러나 타입과 단위 테스트가 존재하는 것과 실제 application runtime이 완성된 것은 별개다. 현재 기본
|
|
composition root에서는 provider runtime과 route가 모두 빈 상태이고, notification Flyway stream은 운영
|
|
migration에 연결되지 않는다. 예약 row는 claim 대상이 아니며, 멀티 replica lease는 원자성·fencing을
|
|
갖추지 못했다. crash recovery, reconciliation, pending projection, unmatched callback worker도 조립되지
|
|
않는다. 이 상태에서 platform을 켜면 요청은 받아 저장할 수 있어도 안전하게 전송·복구·재생할 수 없다.
|
|
|
|
즉시 적용할 운영 원칙은 다음과 같다.
|
|
|
|
1. `ca-skeleton.notification.platform.enabled`는 계속 기본 `false`로 유지한다.
|
|
2. NTF-001~NTF-012가 해결되기 전 `docs/notification/support-matrix.md`의 `Stable` 표기를 release
|
|
근거로 사용하지 않는다.
|
|
3. 설정만으로 platform을 활성화하지 말고, schema activation과 provider assembly가 모두 fail-closed로
|
|
검증된 뒤 worker를 시작한다.
|
|
4. 폴더 이동이나 패턴 도입보다 예약·lease·evidence·ledger의 데이터 정합성을 먼저 고친다.
|
|
5. notification provider를 실제 호출하는 검증 없이 “발송 가능”, PostgreSQL 검증 없이 “durable”,
|
|
restart 검증 없이 “recovery 지원”이라고 표현하지 않는다.
|
|
|
|
## 2. 검토 범위와 증거 경계
|
|
|
|
### 2.1 현재 규모
|
|
|
|
| 영역 | production Java 파일 | LOC | 비고 |
|
|
|---|---:|---:|---|
|
|
| 기존 + 신규 application notification 전체 | 372 | - | 기존 R1 100개 + 신규 platform 272개 |
|
|
| 신규 `application.notification.platform` | 272 | 8,588 | 대부분 public top-level type |
|
|
| outbound notification platform | 110 | 7,573 | provider/runtime/template/security |
|
|
| JPA notification platform | 36 | 4,266 | request/delivery/attempt/event/contact 등 |
|
|
| inbound callback | 8 | 480 | MVC + WebFlux |
|
|
| bootstrap notification | 8 | 1,241 | runtime config 한 파일이 506줄 |
|
|
|
|
이 규모에서는 “adapter 하나”로 취급해서는 안 된다. 다만 `src/config/architecture/modules.json`의 정확한
|
|
19개 leaf SSOT를 깨면서 31개 Gradle 모듈로 즉시 분해하는 것도 권장하지 않는다. 먼저 package DAG,
|
|
public API allowlist, adapter 내부 configuration facade로 경계를 강제한 뒤 실제 독립 배포·빌드 필요가
|
|
생길 때만 leaf 분리를 검토한다.
|
|
|
|
### 2.2 깊게 확인한 실행 흐름
|
|
|
|
```text
|
|
submit/schedule
|
|
-> fingerprint + durable plan writer
|
|
-> request/recipient JPA rows
|
|
-> scheduler claim/lease
|
|
-> render + contact reveal + provider runtime acquire
|
|
-> attempt pre-commit
|
|
-> provider call outside transaction
|
|
-> outcome commit
|
|
-> callback verify/normalize/ledger append
|
|
-> projector + suppression/evidence roll-up
|
|
-> recovery/reconciliation/admin
|
|
```
|
|
|
|
다음은 이번 리뷰에서 검증하지 못한 외부 증거다.
|
|
|
|
- 실제 APNs/FCM/SES/Twilio/SMTP/WebPush provider sandbox 호출
|
|
- notification migration을 적용한 실제 PostgreSQL CRUD 및 Hibernate schema validation
|
|
- 두 application replica가 경쟁하는 lease/fencing 검증
|
|
- provider call 직전·직후 process kill과 restart recovery
|
|
- 대량 callback burst와 DNS rebinding/metadata endpoint 공격 검증
|
|
|
|
따라서 provider별 SLA, 성능 한계, 실 provider 호환성을 이 리뷰가 승인하는 것은 아니다.
|
|
|
|
## 3. 유지할 설계
|
|
|
|
다음 방향은 리팩터링하면서 보존한다.
|
|
|
|
- application이 `NotificationProviderAdapter`, persistence, secret, attachment, callback port를 소유하고
|
|
outbound adapter가 구현하는 의존성 방향은 적절하다.
|
|
- `DeliveryStrategy`, `NotificationContent`, `ContactPointValue`, `RetryDecision`,
|
|
`ReconciliationResult`, `DispatchGuardOutcome`의 sealed hierarchy와 exhaustive switch는 Java 21을
|
|
잘 활용한다.
|
|
- `ProviderSubmissionResult`가 provider acceptance와 delivery를 구분하고 AMBIGUOUS를 일급 상태로
|
|
모델링한 점은 타당하다.
|
|
- provider call 전에 attempt를 commit하고, 외부 호출은 transaction 밖에서 수행하며, 결과를 다시
|
|
transaction으로 기록하는 큰 순서는 유지해야 한다.
|
|
- provider event를 append-only ledger에 저장하고 ordinal status 하나가 아니라 projector로 merge하는
|
|
방향은 out-of-order callback을 다루기에 적합하다.
|
|
- AES-GCM, AAD, lookup HMAC, redacted secret/contact representation을 분리하려는 의도는 좋다.
|
|
- provider별 failure classifier와 mapper는 Strategy로 유지한다. 상속 기반 거대한 Template Method로
|
|
합치지 않는다.
|
|
- HTTP redirect `NEVER`, callback raw-byte verification, WebFlux body buffer release, DB unique index,
|
|
guaranteed/exactly-once 표현 거부는 좋은 방어선이다.
|
|
|
|
## 4. 우선순위 요약
|
|
|
|
| ID | 우선순위 | 심각도 | 주제 | 완료 조건 요약 |
|
|
|---|---|---|---|---|
|
|
| NTF-001 | P0 | Critical | provider runtime/route/callback graph가 비어 있음 | full context에서 configured profile이 실제 wire call까지 수행 |
|
|
| NTF-002 | P0 | Critical | notification schema stream 미활성 + entity/schema 불일치 | 별도 history activation, migrate+validate+CRUD 통과 |
|
|
| NTF-003 | P0 | Critical | 예약 row가 영구 미발송 | due 전 0, due 시 정확히 1회 claim |
|
|
| NTF-004 | P0 | Critical | lease claim 비원자성·fencing 부재 | atomic CTE+generation, 2-worker disjoint claim |
|
|
| NTF-005 | P0 | Critical | recovery/reconciliation/projection worker dead code | kill/restart 후 중복 없이 복구·재생 |
|
|
| NTF-006 | P0 | High | 모든 RuntimeException을 AMBIGUOUS로 오분류 | pre-wire failure는 NOT_SUBMITTED, response-loss만 AMBIGUOUS |
|
|
| NTF-007 | P0 | High | projection의 engagement/suppression fact 유실 | restart 후 monotonic fact 보존 |
|
|
| NTF-008 | P0 | High | callback-before-outcome late binding 불가 | hash bind worker가 정확히 한 번 projection |
|
|
| NTF-009 | P0 | High | callback append/ack/dedupe transaction 결함 | concurrent duplicate 모두 204, row 1개 |
|
|
| NTF-010 | P0 | High | Web Push subscription persistence가 lossy | protect→DB→reveal→UA decrypt round-trip |
|
|
| NTF-011 | P0 | High | callback size/encryption envelope/DB bound 모순 | MVC/WebFlux/DB 동일 경계 계약 |
|
|
| NTF-012 | P0 | Critical | dynamic endpoint/SNS/body security 불충분 | SSRF/SNS adversarial suite 통과 |
|
|
| NTF-013 | P1 | High | fingerprint와 variables가 비정본·가변 | persisted canonical bytes와 hash 입력 동일 |
|
|
| NTF-014 | P1 | High | dedup/collapse/preferred order가 dead contract | submit→DB→wire E2E 동작 |
|
|
| NTF-015 | P1 | High | webhook/attachment/VAPID/TTL/payload wire 결함 | provider별 final wire contract test |
|
|
| NTF-016 | P1 | High | secret fail-fast/rotation/profile binding 부재 | versioned keyring + startup negative tests |
|
|
| NTF-017 | P1 | High | template slot별 escaping/URI 정책 부재 | HTML/TEXT/URI context security test |
|
|
| NTF-018 | P1 | High | 기존 R1과 신규 platform 정본 충돌 | ADR + type disposition + 단일 compatibility bridge |
|
|
| NTF-019 | P1 | High | mandatory UseCase fitness gate 우회 | 모든 entrypoint marker/capability/permission 보유 |
|
|
| NTF-020 | P1 | High | admin/routing business policy가 outbound에 있음 | application use case + 좁은 outbound control port |
|
|
| NTF-021 | P1 | High | runtime limiter/state/registry/credential 경쟁 | immutable atomic state와 concurrent tests |
|
|
| NTF-022 | P1 | Medium | package DAG/public surface/god config | package edge·cycle·public allowlist ArchUnit 통과 |
|
|
| NTF-023 | P1 | Medium | readiness/metrics/audit가 실제 상태를 반영하지 않음 | schema/provider/backlog/lag readiness와 bounded tags |
|
|
| NTF-024 | P1 | High | CI/release evidence가 실행 범위보다 강함 | 실 PostgreSQL/restart/callback/provider artifact 매핑 |
|
|
| NTF-025 | P2 | Medium | configuration/env surface가 template에 없음 | application.yml/env registry/reference 동기화 |
|
|
| NTF-026 | P1 | High | execution evidence certainty가 DB에서 소실 | value+certainty 전체 round-trip |
|
|
| NTF-027 | P1 | High | reconciliation event fingerprint 충돌 | SHA-256 canonical event identity |
|
|
|
|
## 5. 상세 발견 사항과 구현 명세
|
|
|
|
### NTF-001 — 설정된 provider가 production dispatch graph에 조립되지 않는다
|
|
|
|
**근거**
|
|
|
|
- `NotificationPlatformRuntimeConfig.java:274-299`는 빈 `ProviderRuntimeRegistry`,
|
|
`CapabilityReconciliationGateway(Map.of(), ...)`, `ConfiguredRoutePlanner(Map.of())`를 만든다.
|
|
- `NotificationPlatformSettings.java:17-24,97-145`는 provider profile map을 받지만 adapter/runtime 생성에
|
|
연결하지 않으며 unknown provider type도 실질적으로 조립 단계에서 거부되지 않는다.
|
|
- production source에서 `ProviderRuntimeRegistry.register(...)`와 provider별 runtime assembly 호출은
|
|
없다.
|
|
- SMTP의 `SmtpDispatch`, FCM의 `FcmGateway`, SES SNS의 `SnsCertificateProvider`는 production 구현이
|
|
없는 seam이다.
|
|
- `NotificationPlatformRegistriesConfig.java:35-68`은 이미 존재하는 callback/projector bean 목록만
|
|
map으로 바꾼다. 목록에 넣을 production bean factory가 없다.
|
|
- MVC는 `CallbackRequestFactory`와 `ExternalRequestUrlResolver`, WebFlux는 `CallbackRequestFactory`가
|
|
필요하지만 bootstrap에 해당 bean 조립이 없다.
|
|
- N1 `EmailNotifier`, `SmsNotifier` 등의 facade도 production 구현/bean이 없다.
|
|
|
|
**실패 모드**
|
|
|
|
platform을 enable하고 provider profile을 설정해도 request는 durable acceptance 뒤 eligible route를 찾지
|
|
못한다. 일부 runtime을 수동 구성해도 SMTP/FCM은 실제 transport가 없다. callback enabled context는
|
|
필수 bean 부재로 실패할 수 있다. 현재 `NotificationAutoConfigurationTest`는 codec/validator/JDK HTTP
|
|
gateway 조각만 검사하므로 전체 graph 공백을 드러내지 않는다.
|
|
|
|
**구현 결정: Abstract Factory + explicit contribution**
|
|
|
|
범용 plugin framework나 reflection은 필요 없다. 지원 provider 집합을 닫힌 enum으로 두고 provider별
|
|
assembler가 하나의 완결된 contribution을 반환하게 한다.
|
|
|
|
```java
|
|
enum ProviderType { APNS, FCM, SES, SMTP, TWILIO, WEB_PUSH, WEBHOOK }
|
|
|
|
interface ProviderRuntimeAssembler<P extends ProviderProfileSettings> {
|
|
ProviderType type();
|
|
AssembledProvider assemble(P profile, ProviderAssemblyDependencies dependencies);
|
|
}
|
|
|
|
record AssembledProvider(
|
|
ProviderRuntime runtime,
|
|
Channel channel,
|
|
Optional<ProviderCallbackAdapter> callback,
|
|
Optional<ProviderEventProjector> projector,
|
|
Optional<ReconciliationCapability> reconciliation) {}
|
|
```
|
|
|
|
1. string `type`을 `ProviderType`과 provider별 typed settings로 바꾼다.
|
|
2. profile 하나를 adapter, mapper, transport, credential generation, limiter, capability, callback,
|
|
projector, reconciliation까지 한 번에 조립한다.
|
|
3. 조립 결과로 runtime/profile map과 channel route를 불변 map으로 만든다.
|
|
4. 중복 profile, 한 channel의 모호한 primary route, 필수 secret/transport 누락, unknown type은 worker
|
|
시작 전에 boot failure로 만든다.
|
|
5. provider가 0개인 모드를 허용하려면 `INGEST_ONLY` 같은 별도 mode로 명시하고 readiness를 DOWN 또는
|
|
non-serving으로 표시한다.
|
|
6. callback MVC/WebFlux 공통 request factory와 trusted external URL resolver를 composition root에서
|
|
제공한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 실제 `CaSkeletonApplication` context + fake provider profile로 submit → claim → wire request → outcome.
|
|
- 각 Stable provider profile이 정확히 1 runtime과 1 adapter를 만든다.
|
|
- unknown type, duplicate profile, missing credential/transport, callback adapter 없는 callback-enabled
|
|
profile은 context startup이 실패한다.
|
|
- servlet/reactive callback enabled context가 각각 하나의 route만 등록한다.
|
|
|
|
### NTF-002 — notification Flyway stream이 활성화되지 않고 entity와 schema도 맞지 않는다
|
|
|
|
**근거**
|
|
|
|
- `V1__notification_platform_core.sql:3-5`는 notification migration이 opt-in stream이라고 명시한다.
|
|
- `PostgreSqlPersistenceConfig.java:55-58`은 기본 Flyway location을
|
|
`classpath:db/migration/postgresql`로 고정한다.
|
|
- fileserver에는 `FileserverSchemaActivation.java:49-65`, 전용 history table과 readiness card가 있지만
|
|
notification에는 동등한 activation/registry/readiness 항목이 없다.
|
|
- `NotificationRequestEntity.java:46-47`의 `template_locale`은 V1 request table
|
|
`V1__notification_platform_core.sql:11-29`에 존재하지 않는다.
|
|
- migration의 `metadata_json`, `routing_plan_json`, `normalized_payload_json`, `content_json`은 `jsonb`지만
|
|
entity는 JSON JDBC mapping annotation 없이 `String`으로 선언한 곳이 있다. 실제 Hibernate validate와
|
|
bind 계약을 입증하는 테스트가 없다.
|
|
- persistence-jpa notification 테스트는 isolated crypto 위주이며 migration/store PostgreSQL 통합
|
|
테스트가 없다.
|
|
|
|
**실패 모드**
|
|
|
|
빈 PostgreSQL에서 platform을 enable하면 table이 없거나, 운영자가 stream을 수동 적용해도
|
|
`template_locale`과 JSON type mismatch로 Hibernate validation/CRUD가 실패할 수 있다. scheduler는 즉시
|
|
시작되고 tick 예외를 log 후 반복하므로 readiness가 거짓 정상일 수 있다.
|
|
|
|
**구현 결정: optional schema capability + fail-closed activation**
|
|
|
|
1. `db/migration/jpa/notification-platform`에 전용 history table
|
|
`flyway_jpa_notification_history`를 부여한다. primary Flyway history에 같은 V1 번호를 섞지 않는다.
|
|
2. `jpa-notification-platform-v1` capability registry/readiness card와 operator apply/promote 절차를
|
|
추가한다.
|
|
3. `NotificationSchemaActivation.requireActive()`를 worker/provider bean보다 먼저 실행한다.
|
|
4. `template_locale`을 migration에 추가하거나 entity/record에서 제거해 단일 SSOT를 선택한다.
|
|
5. JSONB 필드는 `@JdbcTypeCode(SqlTypes.JSON)` 등 현재 Hibernate 7/8 호환 정책에 맞춘 명시적 mapping을
|
|
사용하거나 DB 타입을 text로 바꾼다. 문자열로 JSONB에 기대어 쓰지 않는다.
|
|
6. schema가 ACTIVE가 아니면 platform enabled context가 readiness 이전에 실패한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- Testcontainers PostgreSQL: empty DB → core migration → notification migration → capability promotion →
|
|
Hibernate `validate`.
|
|
- request/recipient/attempt/event/contact/template/inbox CRUD와 재기동.
|
|
- notification stream 미적용/미승격/잘못된 revision이면 boot failure.
|
|
- downgrade/repair가 아니라 forward-only upgrade와 checksum validation.
|
|
|
|
### NTF-003 — 예약 notification은 영구적으로 dispatch되지 않는다
|
|
|
|
**근거**
|
|
|
|
- `CanonicalNotificationPlanWriter.java:71-76`은 `scheduleAt`이 있으면 recipient를 `PENDING`으로 만든다.
|
|
- `RecipientDeliveryJpaRepository.java:24-34`의 claim query는 `READY_TO_DISPATCH`, `RETRY_WAITING`만
|
|
조회한다.
|
|
- production search에서 due `PENDING` → `READY_TO_DISPATCH` activation path는 없다.
|
|
- migration index는 PENDING을 포함하지만 query는 포함하지 않는다.
|
|
- 테스트는 예약 요청의 초기 PENDING만 확인하고 시간 경과 후 claim을 확인하지 않는다.
|
|
|
|
**실패 모드**
|
|
|
|
모든 `schedule(...)` 요청은 `next_dispatch_at`이 지나도 claim되지 않아 무기한 정체된다.
|
|
|
|
**구현 결정: due-time queue를 단일 상태로 단순화**
|
|
|
|
권장안은 scheduled row도 `READY_TO_DISPATCH`로 저장하고 `next_dispatch_at = max(scheduleAt,
|
|
notBefore)`만 미래로 두는 것이다. `PENDING`이 별도 UI 의미로 반드시 필요하면 NTF-004의 atomic claim
|
|
CTE가 due PENDING을 직접 DISPATCHING으로 전이하도록 한다. 별도 activation daemon을 추가하는 것은
|
|
현재 규모에서는 불필요하다.
|
|
|
|
claim 조건에는 다음을 한 문장으로 고정한다.
|
|
|
|
```sql
|
|
next_dispatch_at <= now
|
|
and delivery_state in ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING')
|
|
and (expires_at is null or expires_at > now)
|
|
and (lease_until is null or lease_until < now)
|
|
```
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- due 1ns 전 claim 0, due 순간 claim 1, 반복 poll/provider call 1회.
|
|
- 과거 schedule은 즉시 claim.
|
|
- `notBefore` 전 0회, `expiresAt` 이후 0회와 EXPIRED 전이.
|
|
- restart 및 두 worker 경쟁에서도 정확히 한 claim.
|
|
|
|
### NTF-004 — lease claim이 원자적이지 않고 fencing이 없다
|
|
|
|
**근거**
|
|
|
|
- `JpaRecipientLeaseStore.java:29-46`은 `selectClaimable` 뒤 별도 `markLeased`를 호출하며 enclosing
|
|
transaction이 없다.
|
|
- `RecipientDeliveryJpaRepository.java:24-49`도 `FOR UPDATE SKIP LOCKED` SELECT와 UPDATE가 별도
|
|
repository call이다. lock이 두 호출 사이에 유지된다는 보장이 없다.
|
|
- `markLeased`는 id만 조건으로 사용하고 이전 state/owner/version/token을 확인하지 않는다.
|
|
- renew는 `JpaRecipientLeaseStore.java:49-57`에서 동일한 broad update를 재사용하므로 stale worker가
|
|
새 lease를 덮을 수 있다.
|
|
- native update는 `RecipientDeliveryEntity.java:84-86`의 JPA `@Version`을 증가시키지 않아 stale managed
|
|
entity write가 lease/state를 덮을 수 있다.
|
|
- `NotificationDispatchService.load(lease)`는 owner/token/expiry를 재검증하지 않는다.
|
|
- 모든 replica의 worker ID는 `NotificationPlatformRuntimeConfig.java:500-503`의
|
|
`notification-worker-1`이다.
|
|
- scheduler는 batch 전체를 먼저 claim한 뒤 semaphore를 기다리므로 concurrency보다 큰 batch는 실행
|
|
전에 lease가 만료될 수 있다.
|
|
|
|
**실패 모드**
|
|
|
|
두 replica가 같은 row를 소유했다고 믿거나, 만료된 worker가 새 owner의 claim 이후 provider를 호출하고
|
|
결과를 기록할 수 있다. 이는 duplicate notification과 잘못된 outcome overwrite로 이어진다.
|
|
|
|
**구현 결정: atomic work claim + fencing token**
|
|
|
|
새 JPA 병합에 들어온 `PostgreSqlWorkClaimExecutor`의 fixed-statement registry와 단일 native query 구조를
|
|
재사용한다. 다만 현재 `WorkClaim`에는 generation이 없으므로 notification queue에는 fencing을 확장해야
|
|
한다.
|
|
|
|
```sql
|
|
with candidate as (
|
|
select id
|
|
from notification_recipient_delivery
|
|
where next_dispatch_at <= :now
|
|
and delivery_state in ('PENDING','READY_TO_DISPATCH','RETRY_WAITING')
|
|
and (lease_until is null or lease_until < :now)
|
|
order by next_dispatch_at, id
|
|
for update skip locked
|
|
limit :batchSize
|
|
)
|
|
update notification_recipient_delivery d
|
|
set lease_owner = :owner,
|
|
lease_generation = d.lease_generation + 1,
|
|
lease_until = :leaseUntil,
|
|
delivery_state = 'DISPATCHING',
|
|
version = d.version + 1,
|
|
updated_at = :now
|
|
from candidate c
|
|
where d.id = c.id
|
|
returning d.id, d.lease_generation, d.lease_until;
|
|
```
|
|
|
|
```java
|
|
record RecipientLease(
|
|
RecipientDeliveryId id,
|
|
String owner,
|
|
long generation,
|
|
Instant until) {}
|
|
```
|
|
|
|
1. worker ID는 instance UUID/pod identity + boot UUID로 만든다.
|
|
2. renew/release/begin-attempt/outcome update는 모두 `id + owner + generation + lease_until > now`를
|
|
조건으로 한다.
|
|
3. stale token으로 영향받은 row가 0이면 provider call을 시작하지 않거나 outcome write를 거부한다.
|
|
4. claim은 현재 available permit 수만큼만 수행한다.
|
|
5. native write와 JPA version을 일치시킨다.
|
|
6. transaction은 새 공통 `JpaTransactionExecutor` 또는 명시적 adapter transaction boundary에서 한
|
|
statement 전체를 감싼다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 실제 PostgreSQL의 두 connection barrier에서 두 worker claim 집합이 disjoint.
|
|
- stale owner/generation renew, release, begin-attempt, outcome update가 모두 0 rows.
|
|
- lease expiry 직전/직후 race와 process restart.
|
|
- batch=10, concurrency=1, 짧은 lease에서도 provider call 최대 1회.
|
|
- native claim 뒤 stale JPA entity flush가 state/version을 덮지 못함.
|
|
|
|
### NTF-005 — recovery, reconciliation, projection replay가 runtime에 연결되지 않는다
|
|
|
|
**근거**
|
|
|
|
- `LeaseRecoveryService`는 class만 있고 bean/scheduler/caller가 없다.
|
|
- 구현도 incomplete attempt만 순회하므로 provider attempt row를 쓰기 전에 crash한 DISPATCHING row는
|
|
복구하지 못한다.
|
|
- `ProviderEventLedger.pendingProjection(...)`, `unmatched(...)`는 persistence 구현만 있고 production
|
|
consumer가 없다.
|
|
- V3 migration은 `notification_reconciliation_job` table을 만들지만 대응 entity/store/worker가 없다.
|
|
- `RECONCILIATION_REQUIRED` recipient를 claim하는 worker가 없다.
|
|
- `NotificationSchedulerWorker`는 raw virtual thread handle을 보관·join하지 않고 `close()`가 polling
|
|
thread를 확실히 중지하지 않는다.
|
|
- scheduler catch는 lease를 남겨 recovery를 기대하지만 실제 recovery lifecycle이 없다.
|
|
|
|
**실패 모드**
|
|
|
|
crash 시 DISPATCHING row, pending/failed projection, unmatched callback, ambiguous attempt가 영구 정체된다.
|
|
shutdown 중 executor submission race도 lease를 남긴다.
|
|
|
|
**구현 결정: explicit recovery state machine + managed lifecycle**
|
|
|
|
다음 세 worker를 `SmartLifecycle`로 관리한다.
|
|
|
|
1. `DispatchRecoveryWorker`: 만료 lease를 case별로 복구한다.
|
|
- attempt 없음: provider call 전 crash가 증명되므로 safe requeue.
|
|
- attempt 존재 + `requestStarted=PROVEN false`: safe retry.
|
|
- body committed/unknown: reconciliation queue.
|
|
- response가 proven rejected: terminal/fallback policy.
|
|
2. `ReconciliationWorker`: due reconciliation job을 claim/fence하고 capability 결과를 적용한다.
|
|
3. `ProviderEventWorker`: pending/failed projection replay와 unmatched binding을 수행한다.
|
|
|
|
worker는 start/stop phase, interrupt, join timeout, jitter, batch/permit, lag metric을 공통 lifecycle support로
|
|
관리하되 하나의 범용 workflow engine으로 만들 필요는 없다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- attempt insert 전, insert 후/request 전, body commit 후/response 전, outcome commit 전 process kill.
|
|
- restart 후 safe case만 retry하고 ambiguous case는 provider resend 없이 reconciliation.
|
|
- unsupported reconciliation은 operator-visible 상태로 남고 busy loop하지 않음.
|
|
- pending/failed projection replay, callback-before-outcome binding, graceful shutdown 시 lease 반환.
|
|
|
|
### NTF-006 — 모든 RuntimeException을 body-committed AMBIGUOUS로 기록한다
|
|
|
|
**근거**
|
|
|
|
- `NotificationDispatchService.java:178-190`은 `gateway.submit()`의 모든 `RuntimeException`을
|
|
`ProviderExecutionEvidence.responseLost()`로 변환한다.
|
|
- `ProviderRuntime.acquireAttempt()`의 disabled/auth/rate/concurrency failure는 wire call 전 발생한다.
|
|
- contact reveal, payload mapping, expiry, size, configuration error도 network call 전에 발생할 수 있다.
|
|
- 이미 `ProviderSubmissionResult.notSubmitted(...)`와 `ProviderExecutionEvidence.notStarted()`가 있지만
|
|
이 경로에서는 사용하지 않는다.
|
|
- JDK HTTP gateway는 exception message substring으로 commitment phase를 추정한다.
|
|
|
|
**실패 모드**
|
|
|
|
provider byte를 하나도 쓰지 않은 limiter/profile/payload 오류가 “이미 전송됐을 수 있음”으로 기록되어
|
|
automatic retry/fallback이 영구 차단되고 불가능한 reconciliation에 들어간다.
|
|
|
|
**구현 결정: typed transport milestone result**
|
|
|
|
adapter의 실제 transport seam만 commitment evidence를 결정하게 한다.
|
|
|
|
```java
|
|
sealed interface ProviderCallOutcome {
|
|
record Completed(ProviderSubmissionResult result) implements ProviderCallOutcome {}
|
|
record FailedBeforeWrite(ProviderFailure failure) implements ProviderCallOutcome {}
|
|
record FailedAfterCommit(ProviderFailure failure) implements ProviderCallOutcome {}
|
|
}
|
|
```
|
|
|
|
1. runtime unavailable, limiter, mapping, validation은 `FailedBeforeWrite`/NOT_SUBMITTED.
|
|
2. body write 완료를 transport가 관측한 뒤 response를 잃은 경우만 AMBIGUOUS.
|
|
3. SDK hidden retry는 끄고 각 SDK의 milestone/attempt count를 명시적으로 매핑한다.
|
|
4. 예상 밖 programming error는 internal failure로 관측하고 body commitment를 추측하지 않는다.
|
|
5. generic `Either` library는 추가하지 않고 기존 Result/evidence 타입을 확장한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- disabled/auth failed/rate exhausted/concurrency exhausted/payload invalid: wire bytes 0,
|
|
NOT_SUBMITTED, ambiguous=false.
|
|
- connect failure와 request-body write 전 reset: NOT_SUBMITTED.
|
|
- body commit 후 socket reset: AMBIGUOUS.
|
|
- provider response 완료: accepted/rejected evidence가 정확히 기록됨.
|
|
|
|
### NTF-007 — projection snapshot이 engagement와 suppression 사실을 잃는다
|
|
|
|
**근거**
|
|
|
|
- `JpaDeliveryAttemptStore.java:130-144`는 DB row를 `DeliveryProjection`으로 복원할 때 persisted
|
|
submission/delivery/evidence는 읽지만 `EngagementFacts.NONE`, `SuppressionFacts.NONE`을 항상 넣는다.
|
|
- `save(...)`도 delivery outcome/evidence만 attempt/recipient에 roll-up한다.
|
|
- migration/entity에는 opened/clicked/complaint/hard-bounce/invalid-target fact를 보존할 컬럼이나 versioned
|
|
projection payload가 없다.
|
|
- `StandardDeliveryProjector.java:100-111`은 hard bounce가 있으면 later delivered를 무시하지만, reload 후
|
|
hard-bounce fact가 사라져 이 불변식이 깨진다.
|
|
- complaint/opened 사실도 다음 event/restart에서 사라진다.
|
|
|
|
**실패 모드**
|
|
|
|
hard bounce 뒤 late delivered가 적용되거나 complaint/read/open facts가 사라진다. suppression side effect가
|
|
재실행되거나 monotonic projection이 후퇴할 수 있다.
|
|
|
|
**구현 결정: durable projection snapshot + replay verifier**
|
|
|
|
권장안은 명시적 typed columns 또는 versioned canonical projection JSON을 optimistic version과 함께
|
|
저장하는 것이다. event 수가 작더라도 매번 전체 ledger fold만 수행하면 조회 비용과 side-effect exactly
|
|
once 문제가 커지므로 snapshot을 기본으로 하고, ledger replay verifier를 운영/테스트용으로 둔다.
|
|
|
|
저장할 최소 내용:
|
|
|
|
- submission outcome, delivery outcome, evidence level
|
|
- opened/clicked/displayed/read와 최초/최종 시각
|
|
- hard bounce, complaint, invalid target
|
|
- projection version/last applied event ID
|
|
- suppression side-effect applied marker 또는 독립 idempotency key
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- hard-bounce → transaction 종료 → restart → delivered: ignored.
|
|
- delivered → complaint → restart → late delivered: delivery+complaint 모두 보존.
|
|
- read → displayed: read가 후퇴하지 않음.
|
|
- 동일 event replay와 projector retry에서 side effect 1회.
|
|
- snapshot을 full ledger fold 결과와 비교하는 property test.
|
|
|
|
### NTF-008 — callback-before-outcome event를 나중에 attempt에 연결할 수 없다
|
|
|
|
**근거**
|
|
|
|
- `JpaProviderEventLedger.java:152-176`은 append 시 raw provider request ID로 즉시 attempt를 찾고, 없으면
|
|
`attempt_id=null`로 저장한다.
|
|
- raw provider request ID는 저장하지 않고 hash만 저장한다.
|
|
- `toRecord(...):190-214`는 provider request ID를 항상 empty로 복원한다.
|
|
- `ProviderEventEntity.java:40-42`의 `attempt_id`는 `updatable=false`다.
|
|
- ledger에는 bind API가 없고 `unmatched(...)` consumer도 없다.
|
|
- callback은 provider outcome commit보다 먼저 도착할 수 있으므로 이 race는 정상 운영 시나리오다.
|
|
|
|
**실패 모드**
|
|
|
|
callback-before-outcome event가 영구 unmatched로 남고 delivery/suppression projection에 반영되지 않는다.
|
|
|
|
**구현 결정: hash-based late binding**
|
|
|
|
1. ledger port에 `bindUnmatched(profileId, providerRequestIdHash, attemptId)` 또는 batch matcher CAS를
|
|
추가한다.
|
|
2. `attempt_id`는 payload identity가 아니라 후발 association이므로 update를 허용한다.
|
|
3. provider outcome이 request ID hash를 저장한 직후 matcher를 trigger하고, background worker가 race를
|
|
보완한다.
|
|
4. bind는 `attempt_id is null` 조건의 atomic update이며 성공한 event만 projection queue에 넣는다.
|
|
5. 원문 ID가 reconciliation에 필요하면 lookup hash와 별도로 목적별 AEAD ciphertext를 저장한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- callback append → attempt outcome/hash 저장 → matcher → projection 정확히 1회.
|
|
- matcher와 callback/outcome의 모든 순서 permutation.
|
|
- 동일 hash의 tenant/profile scope 충돌 방지.
|
|
- stale/잘못된 profile은 bind 0건, operator metric 증가.
|
|
|
|
### NTF-009 — callback append, acknowledgement, dedupe가 원자적이지 않다
|
|
|
|
**근거**
|
|
|
|
- `ProviderCallbackIngestionService.java:84-106`은 normalize/protect/append 뒤 새 event를 동기적으로
|
|
project한다. append 전체를 감싸는 application transaction이 없다.
|
|
- `JpaProviderEventLedger.java:66-79`는 event마다 find-before-insert 후 `saveAndFlush`한다. batch 중간
|
|
실패 시 앞 event만 commit될 수 있다.
|
|
- concurrent duplicate는 둘 다 pre-check를 통과한 뒤 unique violation loser가 500이 될 수 있다.
|
|
- MVC는 validation exception만 400으로 매핑하고 persistence unique race를 duplicate 204로 바꾸지 않는다.
|
|
- `ProviderEventProjectionService.java:56-59`의 no-projector `markFailed`는 transaction 밖이고,
|
|
`JpaProviderEventLedger.markFailed`는 명시적 save/update query도 없다.
|
|
- “durable append 후 빠른 2xx”라는 주석과 달리 synchronous projector failure가 callback response를
|
|
실패시킬 수 있다.
|
|
|
|
**구현 결정: transactional inbox append + asynchronous projector**
|
|
|
|
1. signature 검증·normalization 이후 batch를 하나의 write transaction에서 append한다.
|
|
2. PostgreSQL `INSERT ... ON CONFLICT DO NOTHING RETURNING` 또는 정확한 constraint 분류+reread로
|
|
concurrent duplicate를 정상 결과로 만든다.
|
|
3. durable append가 commit되면 HTTP 204를 반환한다. projection은 별도 worker가 수행한다.
|
|
4. projection status는 `PENDING -> APPLYING -> APPLIED|IGNORED|FAILED` CAS/update query로 전이한다.
|
|
5. callback URL의 `{provider}`와 profile에 등록된 provider ID, enabled 상태를 서명 검증 전에 결합한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 동일 callback 동시 N개: 모두 204, ledger row 1개, created 1/duplicate N-1.
|
|
- batch 3개 중 DB failure: 전부 rollback 또는 명시적 per-event 결과; 부분 성공을 숨기지 않음.
|
|
- projector throw: callback은 durable append 후 204, event는 FAILED/PENDING으로 replay 가능.
|
|
- wrong provider/profile/disabled profile은 ledger append와 cert fetch 전에 거부.
|
|
|
|
### NTF-010 — Web Push subscription이 persistence round-trip에서 손실된다
|
|
|
|
**근거**
|
|
|
|
- `WebPushSubscriptionValue`는 endpoint, p256dh, authSecret, vapidKeyId 네 필드를 갖는다.
|
|
- `WebPushSubscriptionValue.java:76-80`의 `normalized()`는 endpoint+p256dh만 포함한다.
|
|
- `AesGcmContactPointProtector.java:70-80`은 normalized 문자열만 암호화한다.
|
|
- reveal의 `parseWebPush(...):191-203`은 authSecret을 zero 16 bytes, vapidKeyId를 `restored`로 만든다.
|
|
- 주석은 별도 encrypted columns가 있다고 하지만 `notification_contact_point` schema와 entity에는 해당
|
|
컬럼이 없다.
|
|
- `WebPushRequestMapper.java:103-108`은 subscription의 vapidKeyId도 사용하지 않고 active key를 선택한다.
|
|
|
|
**실패 모드**
|
|
|
|
저장 후 복원된 subscription으로 RFC 8291 payload를 만들면 browser가 decrypt할 수 없고, VAPID rotation
|
|
후 구 subscription 서명 key도 선택하지 못한다.
|
|
|
|
**구현 결정: identity와 secret serialization 분리**
|
|
|
|
1. lookup fingerprint용 identity codec은 endpoint+p256dh만 사용할 수 있다.
|
|
2. encrypted payload codec은 versioned envelope로 네 필드를 모두 직렬화한다.
|
|
3. AEAD AAD에 contact type, codec version, tenant/contact ID를 포함한다.
|
|
4. `VapidKeyRegistry`가 subscription.vapidKeyId로 historical key pair를 선택한다.
|
|
5. 모든 `ContactPointValue` subtype이 같은 codec registry/Strategy를 사용한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 모든 contact subtype의 protect → JPA save → load → reveal equality.
|
|
- WebPush는 원 UA private key/auth secret으로 provider payload decrypt 성공.
|
|
- active VAPID key 변경 후 old subscription은 old key로 서명.
|
|
- unknown/retired key ID는 발송 전에 typed failure.
|
|
|
|
### NTF-011 — callback body bound, AES-GCM envelope, DB bound가 서로 모순된다
|
|
|
|
**근거**
|
|
|
|
- settings는 callback body를 최대 1,048,576 bytes까지 허용한다.
|
|
- MVC는 `@RequestBody byte[]`로 이미 전부 할당한 뒤 hard-coded 65,536을 검사한다.
|
|
- WebFlux는 configured max를 join 단계에서 적용한다.
|
|
- `AesGcmCallbackPayloadProtection.java:55-72`는 plaintext를 max로 자른 뒤 12-byte nonce와 16-byte
|
|
GCM tag를 더한다.
|
|
- V1 DB check는 ciphertext 전체를 65,536 bytes 이하로 제한한다.
|
|
- 정확히 65,536-byte plaintext는 65,564-byte ciphertext가 되어 DB check를 위반한다.
|
|
- callback ciphertext에는 key ID/version이 없어 rotation 뒤 historical payload decrypt 계약도 없다.
|
|
|
|
**구현 결정: 하나의 end-to-end payload envelope contract**
|
|
|
|
예를 들어 stored ciphertext cap을 65,536으로 유지한다면 retained plaintext는 최대 65,508이어야 한다.
|
|
request acceptance max와 retained diagnostic max를 분리해도 된다.
|
|
|
|
```java
|
|
record ProtectedCallbackPayload(
|
|
int version,
|
|
String keyId,
|
|
byte[] nonce,
|
|
byte[] ciphertext) {}
|
|
```
|
|
|
|
1. shared contract에 `maxRequestBytes`, `maxRetainedPlaintextBytes`, envelope overhead, DB cap을 정의한다.
|
|
2. servlet은 endpoint-specific filter/container limit로 deserialization 전에 차단한다.
|
|
3. WebFlux와 MVC가 같은 property/boundary semantics를 사용한다.
|
|
4. DB에 version/keyId/nonce/ciphertext를 분리하거나 self-describing envelope를 저장한다.
|
|
5. raw payload 복호화가 실제 필요 없다면 ciphertext 자체를 제거하고 bounded digest만 보존하는 선택도
|
|
검토한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- MVC/WebFlux/PostgreSQL 모두 max-1/max/max+1.
|
|
- Content-Length 없음, chunked body, cancellation, oversized stream.
|
|
- max request의 encryption/insert 성공.
|
|
- active key rotation 뒤 historical envelope decrypt 또는 의도한 삭제 정책.
|
|
|
|
### NTF-012 — dynamic endpoint, HTTP response, SNS callback security가 fail-closed가 아니다
|
|
|
|
**근거**
|
|
|
|
- `NotificationEndpoints.java:24-35`의 `requireSecureOrLoopback`은 HTTPS라는 이유만으로 private,
|
|
link-local, metadata 주소를 허용한다. dynamic webhook/WebPush endpoint에는 SSRF 방어가 되지 않는다.
|
|
- `JdkNotificationHttpGateway.java:59-63`은 `BodyHandlers.ofByteArray()`로 response body를 무제한 읽는다.
|
|
- 기존 HTTP client platform에는 dynamic target validation, DNS/response size policy가 있지만 notification
|
|
bootstrap은 이를 bridge하지 않는다.
|
|
- `SnsSignatureVerifier.java:49-75`는 host suffix만 검사한다. `evilamazonaws.com`류 label confusion,
|
|
non-default port/path/userinfo/query, private DNS/redirect를 충분히 제한하지 않는다.
|
|
- SignatureVersion `2` 이외는 SHA-1로 내려가며 unknown/missing version을 fail-closed로 거부하지 않는다.
|
|
- TopicArn/account/region/profile, timestamp/replaySkew가 signature policy에 결합되지 않는다.
|
|
- production `SnsCertificateProvider` 구현이 없다. 지금은 unwired지만 연결 순간 보안 결함이 활성화된다.
|
|
|
|
**구현 결정: 기존 dynamic HTTP platform 재사용 + provider-specific verifier**
|
|
|
|
1. bootstrap에서 notification `NotificationHttpGateway`를 기존 dynamic target gateway에 bridge한다.
|
|
2. 외부 endpoint는 DNS resolve/re-resolve, private/loopback/link-local/multicast/metadata deny, redirect deny,
|
|
scheme/port allowlist를 적용한다. loopback 허용은 명시적 test/local profile만 가능하게 한다.
|
|
3. response는 streaming cap을 적용하고 status+headers+bounded body/digest만 보존한다.
|
|
4. SNS cert URL은 AWS partition별 exact hostname grammar, expected path, default HTTPS port, no userinfo/query를
|
|
검사한다.
|
|
5. signature version은 정확히 지원 목록만, TopicArn/account/region과 timestamp skew는 profile에 bind한다.
|
|
6. X509 fetch cache는 time/size bounded이며 cert validity와 hostname/profile을 검증한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- `127.0.0.1`, RFC1918, link-local, IPv6 local, cloud metadata, DNS rebinding, redirect, mixed DNS answer 거부.
|
|
- attacker-owned suffix host, port/path/userinfo/query, SignatureVersion 3/missing, wrong TopicArn/profile,
|
|
stale timestamp는 cert fetch/ledger append 전에 거부.
|
|
- huge/chunked-infinite response가 byte cap에서 중단되고 OOM이 발생하지 않음.
|
|
- 정상 SNS v1/v2 fixture만 통과.
|
|
|
|
### NTF-013 — idempotency fingerprint가 저장된 요청의 정본 표현이 아니다
|
|
|
|
**근거**
|
|
|
|
- `NotificationPlan.java:23,51`은 `Map<String,Object>`를 받고 shallow `Map.copyOf`만 수행한다.
|
|
- `RequestFingerprint.java:47-50`은 strategy class simple name과 primary channel만 기록해 ordered fallback
|
|
tail/order를 누락한다.
|
|
- recipient의 `ChannelPreferenceOverride` preferred/blocked 전체가 fingerprint에 포함되지 않는다.
|
|
- dedup key/window는 포함하지만 `DeduplicationAction`은 누락된다.
|
|
- delimiter를 escape하지 않은 `key=value,`와 arbitrary value `toString()`을 사용한다.
|
|
- 중첩 mutable object가 fingerprint 계산 뒤 persistence encode 전에 바뀔 수 있다.
|
|
- persistence는 별도 JSON codec으로 variables를 저장하므로 hash 입력과 저장 byte가 다르다.
|
|
|
|
**실패 모드**
|
|
|
|
서로 다른 fallback/override/dedup 요청이 같은 idempotency fingerprint로 합쳐지거나, 동일 logical JSON이
|
|
map 순서/구현체에 따라 conflict가 난다. delimiter collision과 TOCTOU도 가능하다.
|
|
|
|
**구현 결정: typed immutable values + canonical plan encoder**
|
|
|
|
기존 R1의 닫힌 `NotificationTemplateValue` algebra를 승격하거나 새 sealed JSON-neutral value type을 만든다.
|
|
application-core에 Jackson `JsonNode`를 넣지는 않는다.
|
|
|
|
```java
|
|
sealed interface NotificationVariable
|
|
permits TextValue, NumberValue, BooleanValue, NullValue, ListValue, ObjectValue {}
|
|
|
|
record EncodedNotificationPlan(int version, byte[] bytes, String variablesPayload) {}
|
|
```
|
|
|
|
1. public DTO graph에서 arbitrary Object를 제거하고 depth/key/value/total byte bound를 둔다.
|
|
2. `CanonicalNotificationPlanEncoderPort`가 versioned length-framed bytes를 한 번 만든다.
|
|
3. fingerprint는 그 bytes에 SHA-256을 적용하고 persistence도 같은 encoded payload를 사용한다.
|
|
4. full fallback order, override, dedup action, collapse, schedule/expiry, metadata 의미 필드를 모두 포함한다.
|
|
5. secret/PII를 `toString()`에 노출하지 않는다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- `[EMAIL,SMS]` vs `[EMAIL,PUSH]`, 순서 변경, override/dedup action/collapse 변경은 다른 hash.
|
|
- delimiter/type/nested collection collision property/fuzz test.
|
|
- 동일 object map insertion order는 동일 hash.
|
|
- input nested object를 계산 중 변경해도 저장 payload와 hash가 어긋나지 않음.
|
|
- public DTO graph reflection test가 `Object`/`Map<?,Object>`를 재귀적으로 금지.
|
|
|
|
### NTF-014 — deduplication, collapse, recipient preferred order가 공개 계약만 있고 실행되지 않는다
|
|
|
|
**근거**
|
|
|
|
- `NotificationPlan`은 deduplication과 collapse를 받는다.
|
|
- `DeduplicationService`와 JPA store bean은 있지만 submission path에서 호출되지 않는다.
|
|
- `NotificationRequestRecord`/entity/schema는 dedup/collapse frozen semantics를 저장하지 않는다.
|
|
- `NotificationDispatchService.java:166-177`은 `ProviderSubmission.collapse`를 항상 empty로 만든다.
|
|
- APNs/FCM mapper와 capability는 collapse를 지원하지만 값이 도달하지 않는다.
|
|
- `ConfiguredRoutePlanner.java:41-68`은 blocked channel만 보고 `preferredOrder`를 무시한다.
|
|
|
|
**실패 모드**
|
|
|
|
caller가 dedup/drop/return-existing/collapse/preference를 요청해도 duplicate message가 전송되고 provider
|
|
collapse header/route 순서가 반영되지 않는다.
|
|
|
|
**구현 결정: acceptance transaction에 정책 고정**
|
|
|
|
1. multi-recipient dedup 의미를 확정한다. 정해지기 전에는 dedup plan을 single recipient로 제한하거나
|
|
명시적 validation failure로 거부한다.
|
|
2. candidate notification ID를 먼저 발급하고 request/recipient insert와 dedup claim을 같은 transaction에
|
|
넣는다.
|
|
3. `DROP`과 `RETURN_EXISTING`을 receipt/result에 명시적으로 표현한다.
|
|
4. collapse를 durable request/recipient plan에 저장해 retry/redrive에도 동일 값을 사용한다.
|
|
5. provider capability가 false면 조용히 무시하지 말고 pre-dispatch typed failure로 거부한다.
|
|
6. route는 preferred-order 교집합을 먼저 두고 나머지는 original strategy order를 유지한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 동일 tenant/recipient/category/key/window 동시 N건에서 durable notification/provider call 1회.
|
|
- DROP과 RETURN_EXISTING 결과 차이, window 경계.
|
|
- APNs/FCM final wire collapse key.
|
|
- preferred/blocked/fallback 조합의 deterministic route.
|
|
|
|
### NTF-015 — provider별 wire contract에 내용 누락과 expiry/limit 오류가 있다
|
|
|
|
**근거**
|
|
|
|
- `WebhookNotificationProviderAdapter.java:101-107` body는 attemptId와 contentDigest뿐이며 실제 rendered
|
|
content가 없다.
|
|
- `WebhookSubscription.signingKeyRef`는 무시되고 global callback-signing key가 사용된다.
|
|
- `EmailContent`는 attachment를 계약으로 받지만 SMTP adapter는 MIME factory에 `List.of()`를 넘기고
|
|
SES mapper도 attachment를 처리하지 않는다. `AttachmentResolver`는 production에 연결되지 않는다.
|
|
- `WebPushRequestMapper`는 이미 있는 `VapidKeyRegistry`를 사용하지 않고 active key를 사용한다.
|
|
- WebPush receipt capability가 선언되지만 production receipt flow에 연결되지 않는다.
|
|
- FCM remaining TTL이 이미 음수면 expired가 아니라 provider max TTL로 되살아날 수 있다.
|
|
- APNs/FCM capability가 4096-byte limit을 선언하지만 final UTF-8 wire payload size를 provider call 전에
|
|
강제하지 않는다.
|
|
- `ProviderSubmission`은 channel/profile/content compatibility 불변식을 강제하지 않아 webhook test가
|
|
다른 channel content를 넣어도 성립할 수 있다.
|
|
|
|
**구현 결정: provider별 versioned wire DTO + preflight validator**
|
|
|
|
1. webhook에 schema version, attempt/idempotency/expiry, channel, 허용된 rendered content/metadata를 담는
|
|
명시적 envelope를 만들고 정확한 canonical bytes를 key-ref별로 서명한다.
|
|
2. attachment는 resolver → digest/size/content-type 검증 → try-with-resources → MIME/SES raw message 순서로
|
|
처리한다. 지원하지 않으면 provider call 전 typed rejection.
|
|
3. WebPush는 subscription-specific VAPID key를 사용하고 receipt end-to-end 연결 전 capability를 false로
|
|
둔다.
|
|
4. FCM expiry는 no-expiry/max, remaining<=0/EXPIRED, future/min의 세 분기로 나눈다.
|
|
5. APNs/FCM/WebPush/Webhook은 final serialized bytes에 provider limit을 적용한다.
|
|
6. `ProviderSubmission` compact constructor에서 channel/content/profile compatibility를 검사한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- webhook body에 실제 title/body/data와 schema version이 있고 key ref별 signature가 다름.
|
|
- attachment byte/digest/name/content-type, 성공/실패 모두 resource close.
|
|
- resolver 미설정/oversize/digest mismatch는 provider call 0회.
|
|
- expired FCM은 call 0회, future TTL은 정확한 min.
|
|
- APNs/FCM 4096 경계 ±1 byte.
|
|
- mismatched channel/content/profile은 construction 또는 preflight에서 실패.
|
|
|
|
### NTF-016 — secret startup validation, rotation, profile binding이 완성되지 않았다
|
|
|
|
**근거**
|
|
|
|
- `NotificationPlatformSecretsConfig.java:17-23`은 strict startup validation을 설명하지만
|
|
`:41-70`은 blank 값을 건너뛰고 Base64 decode만 한다.
|
|
- AES 길이와 purpose별 material distinctness는 첫 protect 시 일부만 검사된다.
|
|
- key ID는 `contact-enc`, `payload-enc` 등 상수라 env material 교체 시 동일 ID 아래 ciphertext가
|
|
decrypt 불가능해진다.
|
|
- historical key map은 항상 empty다.
|
|
- callback payload에는 key ID가 없다.
|
|
- SES/Twilio/Webhook/WebPush는 profile의 credential/key ref 대신 global active key를 사용하는 경로가
|
|
있다.
|
|
- contact lookup HMAC을 callback/provider ID hashing에도 재사용해 purpose separation이 약해진다.
|
|
|
|
**구현 결정: versioned keyring + capability-aware startup validator**
|
|
|
|
```yaml
|
|
ca-skeleton:
|
|
notification:
|
|
platform:
|
|
secrets:
|
|
contact-encryption:
|
|
active-key-id: contact-2026-08
|
|
keys:
|
|
contact-2026-08: ${...}
|
|
contact-2026-01: ${...}
|
|
```
|
|
|
|
1. purpose별 active key ID와 historical key map을 바인딩한다.
|
|
2. enabled provider/callback/contact 기능별 required-purpose matrix를 startup에서 검사한다.
|
|
3. Base64, AES/HMAC 길이, distinct material, known ref, active/historical 중복을 검증한다.
|
|
4. adapter에는 global provider가 아니라 profile-bound credential handle/generation을 전달한다.
|
|
5. old generation은 in-flight drain 및 historical decrypt 기간 동안 유지한다.
|
|
6. callback fingerprint/provider request lookup에는 별도 SecretPurpose를 둔다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- missing/weak/duplicate/unknown ref는 full context boot failure.
|
|
- key rotation 전 row를 rotation 후 historical key로 decrypt.
|
|
- 두 provider profile의 wire auth가 서로 다름.
|
|
- rotation 중 old in-flight는 old generation, 새 attempt는 new generation을 사용하고 DB 기록과 일치.
|
|
|
|
### NTF-017 — template engine이 slot context를 구분하지 않는다
|
|
|
|
**근거**
|
|
|
|
- default `PlaceholderTemplateEngine.java:24-45`는 값을 raw string으로 치환한다.
|
|
- `NotificationPlatformRuntimeConfig.java:242-271`은 subject, plain text, HTML, SMS, push, URI slot에 같은
|
|
engine을 사용한다.
|
|
- HTML_BODY에서 caller value가 active markup이 될 수 있다.
|
|
- Thymeleaf를 HTML mode로 모든 slot에 쓰면 plain text/SMS의 `&` 등이 entity로 변할 수 있다.
|
|
- rendered deep link는 URI parse만 하고 scheme/host allowlist를 강제하지 않는다.
|
|
|
|
**구현 결정: context-aware rendering Strategy**
|
|
|
|
```java
|
|
enum TemplateSlotMode { SUBJECT, TEXT, HTML_TEXT, URI }
|
|
|
|
interface NotificationTemplateEngine {
|
|
String render(TemplateSlotMode mode, String source, NotificationVariables variables);
|
|
}
|
|
```
|
|
|
|
1. SUBJECT/TEXT/SMS는 literal text mode와 CR/LF/length 정책을 적용한다.
|
|
2. HTML body는 HTML text/attribute/URL context를 구분하거나 unescaped construct를 금지한다.
|
|
3. URI slot은 허용한 `https`/명시적 app scheme만 통과시킨다.
|
|
4. template publish 시 slot별 compile/validation을 수행하고 runtime cache는 bounded digest key로 둔다.
|
|
5. 범용 expression language는 허용하지 않는다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- HTML element/attribute/URL injection, quote breakout, script/event handler.
|
|
- plain text `a & b`가 `&`로 변하지 않음.
|
|
- subject CR/LF 거부.
|
|
- `javascript:`, `data:`, `file:` 거부, 허용 HTTPS/app scheme 통과.
|
|
|
|
### NTF-018 — 기존 R1과 신규 platform의 canonical ownership이 충돌한다
|
|
|
|
**근거**
|
|
|
|
- `application-core/CLAUDE.md:75-96`은 기존 `dev.caskeleton.application.notification`을 R1 canonical로
|
|
설명한다.
|
|
- `docs/notification/migration-guide.md:3-18`은 R0 router → 신규 platform만 설명하고 기존 R1 100개
|
|
production type의 처분을 다루지 않는다.
|
|
- outbound notification README는 현재 구현 전체를 R0처럼 기술한다.
|
|
- 기존/new `Channel`, plan, dispatch, receipt/evidence 모델이 중복되며 production bridge/import가 없다.
|
|
|
|
**실패 모드**
|
|
|
|
새 consumer가 어느 API를 사용해야 하는지 알 수 없고 두 모델이 별도 진화한다. R0 삭제 후에도 R1 graph가
|
|
고아로 남거나, platform이 R1 정책을 우회하는 이중 canonical이 된다.
|
|
|
|
**구현 결정: ADR + temporary Anti-Corruption Layer**
|
|
|
|
1. R0 → R1 → platform 버전/역할과 최종 canonical namespace를 ADR로 확정한다.
|
|
2. 기존 R1 100개 type을 `replace / bridge / retain / delete`로 전수 분류한다.
|
|
3. platform이 canonical이면 R1 public entrypoint에 deprecation/forRemoval과 신규 production consumer 금지
|
|
ArchUnit을 추가한다.
|
|
4. 필요한 변환은 `compatibility/r1`의 단 하나 ACL에만 둔다.
|
|
5. README, CLAUDE, migration guide, deletion inventory, public path snapshot을 같은 disposition 표와
|
|
동기화한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 허용 ACL 외 두 namespace 간 production dependency 0건.
|
|
- 모든 R1 public type disposition 목록.
|
|
- R0/R1/platform consumer와 삭제 조건 contract test.
|
|
- 중복 simple-name API 제거 또는 명시적 compatibility test.
|
|
|
|
### NTF-019 — 신규 orchestration이 mandatory UseCase fitness gate를 우회한다
|
|
|
|
**근거**
|
|
|
|
- `NotificationOrchestrator`는 submit/schedule/cancel/get을 한 interface에 섞는다.
|
|
- `NotificationSubmissionService`, `ProviderCallbackIngestionService`, `ReconciliationService`, dispatch/admin
|
|
orchestration이 `CommandUseCase`/`QueryUseCase`와 `@UseCaseCapability`를 사용하지 않는다.
|
|
- MVC controller는 inbound use case port가 아니라 concrete `ProviderCallbackIngestionService`를 주입한다.
|
|
- 기존 ArchUnit은 이미 marker를 구현한 type만 검사하므로 marker를 쓰지 않은 신규 entrypoint를 놓친다.
|
|
|
|
**실패 모드**
|
|
|
|
transaction mode, repository access, external outbound, idempotency, permission metadata가 자동 fitness gate를
|
|
우회한다. read/write를 한 class에 섞어 class-level capability도 정확히 선언할 수 없다.
|
|
|
|
**구현 결정: CQRS-style explicit inbound use cases**
|
|
|
|
submit, schedule, cancel, get, callback ingest, dispatch, reconcile, admin command/query를 각각 분리한다.
|
|
controller/worker는 해당 `port.in` interface만 의존한다. `NotificationOrchestrator`가 호환성 때문에
|
|
필요하면 deprecated facade로 두고 분리 use case에 위임한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 모든 public application orchestration entrypoint가 Command/Query marker와 capability를 보유.
|
|
- mutating/admin use case가 필요한 permission을 보유.
|
|
- controller/worker가 concrete application implementation을 참조하지 않음.
|
|
- marker 없는 위반 fixture가 ArchUnit에서 실제 실패.
|
|
|
|
### NTF-020 — admin과 route business policy가 outbound adapter에 있다
|
|
|
|
**근거**
|
|
|
|
- outbound `AdminAuthorizationGuard`, `DuplicateRiskGuard`가 actor/tenant/ambiguous redrive business rule을
|
|
구현한다.
|
|
- `NotificationAdminServiceImpl`은 authorization, idempotency, suppression, redrive, reconcile, transaction
|
|
orchestration과 provider state mutation을 한 class에서 수행한다.
|
|
- admin operation은 find → side effect → save라 concurrent 동일 operation ID에서 side effect가 중복될 수
|
|
있다.
|
|
- `ConfiguredRoutePlanner`가 delivery strategy/blocked channel eligibility를 해석한다.
|
|
- settings가 ambiguous fallback invariant를 소유한다.
|
|
|
|
**구현 결정: application command handler + atomic admin claim**
|
|
|
|
1. admin command별 application use case로 authorization/tenant/idempotency/duplicate-risk policy를 옮긴다.
|
|
2. outbound에는 좁은 `ProviderRuntimeControlPort`, catalog/status adapter만 남긴다.
|
|
3. admin store port를 `claim(operationId, commandFingerprint)`의
|
|
`Claimed/Replay/InProgress/Conflict` 결과로 바꾼다.
|
|
4. 외부/DB side effect 전에 durable claim하고 exact result/phase를 저장한다.
|
|
5. route business eligibility/preference/fallback은 application에 두고 adapter는 configured profile catalog만
|
|
제공한다.
|
|
6. 범용 command bus/workflow engine은 필요 없다. 작은 phase state machine이면 충분하다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 동시 N개 동일 operation ID에서 side effect 1회.
|
|
- 동일 ID/다른 command payload는 conflict.
|
|
- 중간 failure 재시도는 완료 item을 반복하지 않음.
|
|
- outbound notification package에서 actor authorization/TransactionPort orchestration이 사라짐.
|
|
|
|
### NTF-021 — runtime limiter/state/registry/credential compound operation이 원자적이지 않다
|
|
|
|
**근거**
|
|
|
|
- `ProviderAttemptLimiter`는 window CAS와 count reset을 별도 atomic으로 수행해 rollover race에서 증가를
|
|
잃을 수 있다.
|
|
- provider permit close는 idempotent guard가 없어 double-close가 semaphore 한도를 늘릴 수 있다.
|
|
- `ProviderRuntime`은 state와 reason을 별도 AtomicReference로 두어 모순 snapshot이 가능하다.
|
|
- markHealthy는 일부 state만 전이하지만 admin은 실제 적용되지 않아도 성공을 기록할 수 있다.
|
|
- `ProviderRuntimeRegistry.register`는 duplicate current generation을 조용히 교체한다.
|
|
- draining cleanup과 replace가 경쟁하면 generation을 잃을 수 있다.
|
|
- credential manager의 get/validate/put 경쟁은 낮은 generation이 높은 generation 뒤에 활성화될 수 있다.
|
|
|
|
**구현 결정: immutable atomic state + transition table**
|
|
|
|
```java
|
|
record RuntimeHealth(ProviderRuntimeState state, Optional<String> reason) {}
|
|
record RateWindow(long epochSecond, int used) {}
|
|
```
|
|
|
|
1. health는 하나의 `AtomicReference<RuntimeHealth>`와 명시적 operator/provider transition table로 관리한다.
|
|
2. limiter는 `(window,count)` 단일 CAS 또는 작은 synchronized token bucket을 사용하고 monotonic ticker를
|
|
주입한다.
|
|
3. permit은 `AtomicBoolean released`로 idempotent close.
|
|
4. 최초 registry register는 `putIfAbsent`로 duplicate fail; replace/drain/cleanup은 profile holder의
|
|
`compute`/lock으로 원자화한다.
|
|
5. credential generation은 strictly increasing CAS로 검증한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 100+ thread window boundary에서 configured rate 초과 없음.
|
|
- double close가 permit 수를 늘리지 않음.
|
|
- 모든 legal/illegal health transition과 reason 일관성.
|
|
- concurrent replace/cleanup/rotation에서 in-flight generation 보존, 최종 generation=max.
|
|
|
|
### NTF-022 — logical module/package DAG와 public API 경계가 강제되지 않는다
|
|
|
|
**근거**
|
|
|
|
- docs는 31 logical modules를 5개 leaf의 package로 매핑하지만 같은 Gradle project 내부 package edge와
|
|
cycle은 registry가 검사하지 않는다.
|
|
- application platform 272개, outbound 110개, persistence 36개 등 거의 모든 top-level type이 public이다.
|
|
- `PublicApiBoundaryTest`는 facade 직접 parameter만 보고 record 내부 `Map<String,Object>`와 visibility를
|
|
놓친다.
|
|
- `NotificationPlatformRuntimeConfig` 506줄, persistence config 220줄이 codec/security/template/policy/
|
|
worker/entity/repository/mapper 조립을 한곳에 모은다.
|
|
- bootstrap이 persistence entity/repository/mapper internals를 직접 import하므로 해당 type이 public이어야
|
|
한다.
|
|
- root controller/mapper ArchUnit 일부는 package 문자열 기준이라 현재 callback controller와
|
|
`NotificationRecordMapper`를 선택하지 못한다.
|
|
|
|
**구현 결정: package DAG + internal-by-default**
|
|
|
|
1. 아래 6절의 package 구조로 이동한다.
|
|
2. `api`와 `port` allowlist 외 type은 package-private 또는 `.internal`로 둔다.
|
|
3. ArchUnit에 exact allowed package edge, cycle 금지, public API allowlist를 추가한다.
|
|
4. controller는 `@RestController`, mapper는 suffix/annotation, entity는 JPA annotation처럼 semantic
|
|
predicate로 선택한다.
|
|
5. persistence leaf 내부 configuration facade 하나만 application port bean을 노출하고 bootstrap은
|
|
entity/repository/mapper를 직접 import하지 않는다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- package graph cycle 0, 허용되지 않은 edge fixture 실패.
|
|
- internal type 외부 접근 0.
|
|
- public path snapshot이 의도한 API/SPI만 승인.
|
|
- package 위치와 무관하게 bad controller/mapper/entity fixture 실패.
|
|
|
|
### NTF-023 — readiness, metrics, audit가 실제 serving 상태를 반영하지 않는다
|
|
|
|
**근거**
|
|
|
|
- health reporter가 queue를 빈 map으로 반환하고 actual oldest-due age/depth를 조회하지 않는다.
|
|
- scheduler `QUEUE_DEPTH`는 전체 backlog가 아니라 이번 claim 수에 가깝다.
|
|
- schema activation, provider route 0개, projection/reconciliation lag가 application readiness에 연결되지
|
|
않는다.
|
|
- tag guard는 key allowlist 위주이고 raw category/callback path/provider 같은 값 cardinality를 닫지 않는다.
|
|
- audit actorRef/attributes에 contact/credential/OTP가 들어가는 것을 타입/mandatory redactor가 막지 않는다.
|
|
|
|
**구현 결정: serving readiness + low-cardinality vocabulary**
|
|
|
|
enabled platform의 readiness는 최소 다음을 함께 확인한다.
|
|
|
|
- notification schema capability ACTIVE
|
|
- enabled profile마다 runtime+route+required callback/projector 존재
|
|
- dispatch oldest due age/depth와 stuck lease 수
|
|
- pending/failed/unmatched projection lag
|
|
- reconciliation due/oldest age
|
|
- secret/key generation load 상태
|
|
|
|
metric tag value는 enum/profile alias/bucket만 허용하고 tenant/recipient/category/path의 raw 값을 tag로 쓰지
|
|
않는다. audit builder는 sensitivity classifier/redactor를 반드시 통과하게 한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- schema 누락, route 0, provider unavailable, queue/projection lag SLO 초과 시 readiness DOWN.
|
|
- 임의 category/path 10,000개 입력 후 meter series 수가 상수 bound.
|
|
- email/phone/token/OTP/credential이 모든 logger/audit sink에 없음.
|
|
|
|
### NTF-024 — CI와 release gate가 실행한 것보다 강한 증거를 만든다
|
|
|
|
**근거**
|
|
|
|
- support matrix는 SMTP/SES/Twilio/FCM/APNs/WebPush를 Stable로 표시한다.
|
|
- PR workflow는 unit/architecture subset은 실행하지만 notification PostgreSQL migration, full configured
|
|
context, multi-replica lease, restart recovery가 없다.
|
|
- nightly job 이름은 ambiguity/restart recovery/callback burst지만 실제로는 일부 unit suite와 전체 test를
|
|
실행한다.
|
|
- provider sandbox job은 `continue-on-error`이며 실제 provider 호출 없이 echo 두 줄만 실행한다.
|
|
- release gate 일부는 docs file/문구 존재를 확인할 뿐 runtime artifact와 직접 연결하지 않는다.
|
|
- workflow path filter가 bootstrap notification/configuration surface 변경을 완전하게 포괄하지 않는다.
|
|
|
|
**구현 결정: evidence manifest**
|
|
|
|
support grade마다 필요한 executable artifact를 선언한다.
|
|
|
|
| 주장 | 필요한 최소 증거 |
|
|
|---|---|
|
|
| Durable | PostgreSQL migration+CRUD+restart |
|
|
| Multi-worker safe | real DB 2-worker claim/fencing race |
|
|
| Recoverable | process kill phase matrix |
|
|
| Callback supported | signature+burst+duplicate+late-bind E2E |
|
|
| Provider Stable | secret-protected real sandbox wire+correlation artifact |
|
|
|
|
1. 위 artifact가 없으면 grade를 `Contract implemented / runtime unqualified` 또는 Experimental로 낮춘다.
|
|
2. provider sandbox는 실제 test를 실행하고 immutable evidence artifact를 업로드한다.
|
|
3. `failOnNoDiscoveredTests`, skip reason, artifact digest를 gate에 포함한다.
|
|
4. workflow path에 application/bootstrap/settings/migration/docs 전체 notification surface를 포함한다.
|
|
|
|
### NTF-025 — template의 configuration/env surface에 신규 platform이 없다
|
|
|
|
**근거**
|
|
|
|
- `application.yml`과 local/sample 설정은 기존 `ca-skeleton.notification`/`app.notification` provider만
|
|
보여 주고 신규 `ca-skeleton.notification.platform` tree를 제공하지 않는다.
|
|
- configuration reference에 property 이름은 있으나 실제 env placeholder와 env-key registry가 동기화되지
|
|
않았다.
|
|
- Boot relaxed binding으로 직접 환경변수를 넣을 수는 있지만 템플릿 사용자가 필요한 profile/secret/
|
|
callback/schema activation 조합을 안전하게 발견할 수 없다.
|
|
|
|
**개선**
|
|
|
|
NTF-001/002/016 설계가 고정된 뒤 disabled-safe 기본 tree, 명시적 env placeholders, env validation registry,
|
|
configuration reference를 한 source에서 동기화한다. 지금 미완성 property를 먼저 문서화해 enable을
|
|
유도하지 않는다.
|
|
|
|
### NTF-026 — execution evidence certainty가 persistence에서 소실된다
|
|
|
|
**근거**
|
|
|
|
- `ProviderExecutionEvidence`는 각 milestone을 `EvidenceFact(value, certainty)`로 표현한다.
|
|
- docs도 requestStarted/bodyCommitted/responseReceived 각각의 certainty를 저장한다고 설명한다.
|
|
- `DispatchOutcomeRecorder.java:55-58`은 `.value()` boolean만 `DeliveryAttemptRecord`에 넣고 certainty를
|
|
버린다.
|
|
- V1 attempt table도 세 boolean만 저장하고 certainty/providerAcceptance fact를 저장하지 않는다.
|
|
|
|
**실패 모드**
|
|
|
|
restart 뒤 `PROVEN false`와 `UNKNOWN false`를 구분할 수 없어 safe retry와 reconciliation 판단이
|
|
불가능해진다.
|
|
|
|
**구현 결정**
|
|
|
|
각 milestone을 value+certainty 컬럼으로 저장하거나 versioned evidence JSON을 저장한다. NTF-005 recovery
|
|
state machine은 persisted certainty만 사용하고 추측하지 않는다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- UNKNOWN false와 PROVEN false가 서로 다른 DB representation으로 round-trip.
|
|
- every `ProviderExecutionEvidence` combination의 entity/record round-trip.
|
|
- restart 뒤 recovery decision이 원래 decision과 동일.
|
|
|
|
### NTF-027 — reconciliation synthetic event fingerprint가 hash가 아니다
|
|
|
|
**근거**
|
|
|
|
- `ReconciliationService.java:126-135`는 seed 문자열 bytes를 `BigInteger` hex로 바꾸고 앞 64자를
|
|
substring한다.
|
|
- attempt UUID prefix가 seed 앞부분을 차지하므로 같은 attempt의 서로 다른 event type/native type이 같은
|
|
64-char prefix를 만들 수 있다.
|
|
- ledger unique fingerprint에 걸려 later accepted/delivered 같은 별도 reconciliation event가 duplicate로
|
|
사라질 수 있다.
|
|
|
|
**구현 결정**
|
|
|
|
기존 `MessageDigestPort`를 사용해 provider profile, attempt ID, normalized event type, native type,
|
|
providerOccurredAt, stable native identity를 versioned length-framed encoding한 뒤 SHA-256한다.
|
|
|
|
**필수 인수 테스트**
|
|
|
|
- 같은 attempt의 accepted와 delivered는 다른 fingerprint.
|
|
- 동일 event replay는 같은 fingerprint.
|
|
- delimiter/Unicode/timezone representation collision 없음.
|
|
|
|
## 6. 권장 폴더 구조
|
|
|
|
아래 구조는 현재 19개 Gradle leaf를 유지한다. package 경계와 visibility를 먼저 강제하고, 실제 build-time
|
|
독립성이 필요해질 때만 registry/Gradle leaf를 늘린다.
|
|
|
|
### 6.1 application-core
|
|
|
|
```text
|
|
dev.caskeleton.application.notification/
|
|
api/ # 의도한 public DTO/value만
|
|
port/in/
|
|
submission/
|
|
query/
|
|
cancellation/
|
|
callback/
|
|
reconciliation/
|
|
admin/
|
|
port/out/
|
|
persistence/
|
|
provider/
|
|
security/
|
|
observation/
|
|
usecase/ # package-private implementation
|
|
submission/
|
|
dispatch/
|
|
callback/
|
|
reconciliation/
|
|
admin/
|
|
model/
|
|
request/
|
|
delivery/
|
|
event/
|
|
routing/
|
|
policy/
|
|
template/
|
|
contact/
|
|
compatibility/r1/ # 임시 ACL만, 신규 기능 금지
|
|
```
|
|
|
|
원칙:
|
|
|
|
- `api`와 `port`만 public allowlist에 넣는다.
|
|
- JPA/HTTP/provider SDK/Spring DTO는 들어오지 않는다.
|
|
- use case 구현과 policy implementation은 package-private를 기본으로 한다.
|
|
- submit/read/cancel/callback/admin을 한 orchestrator interface에 합치지 않는다.
|
|
|
|
### 6.2 adapter:outbound:notification
|
|
|
|
```text
|
|
dev.caskeleton.adapter.outbound.notification.platform/
|
|
configuration/ # ProviderRuntimeAssembler contribution만 노출
|
|
runtime/
|
|
dispatch/
|
|
lease/
|
|
lifecycle/
|
|
provider/
|
|
shared/http/
|
|
apns/internal/
|
|
fcm/internal/
|
|
ses/internal/
|
|
smtp/internal/
|
|
twilio/internal/
|
|
webpush/internal/
|
|
webhook/internal/
|
|
callback/
|
|
ses/
|
|
twilio/
|
|
template/
|
|
security/
|
|
observation/
|
|
```
|
|
|
|
원칙:
|
|
|
|
- provider package 외부에는 assembler/configuration facade와 application port 구현만 보인다.
|
|
- business eligibility/authorization/idempotency는 application으로 이동한다.
|
|
- 공통 HTTP/security code가 provider-specific rule을 삼키지 않는다.
|
|
- provider capability는 8개 positional boolean보다 `EnumSet<ProviderCapability>` + typed limits를
|
|
선호한다.
|
|
|
|
### 6.3 adapter:outbound:persistence-jpa
|
|
|
|
```text
|
|
dev.caskeleton.adapter.outbound.persistence.notification.platform/
|
|
configuration/NotificationPersistenceAdapters
|
|
request/
|
|
delivery/
|
|
attempt/
|
|
event/
|
|
contact/
|
|
template/
|
|
policy/
|
|
admin/
|
|
inbox/
|
|
```
|
|
|
|
원칙:
|
|
|
|
- entity/repository/mapper는 package-private/internal.
|
|
- bootstrap에는 `NotificationPersistenceAdapters` 한 facade만 노출한다.
|
|
- claim은 새 JPA platform의 registered fixed statement executor를 재사용하되 notification fencing을
|
|
추가한다.
|
|
- JSON/crypto schema mapping과 migration owner를 slice별 integration test로 고정한다.
|
|
|
|
### 6.4 adapter:inbound:web
|
|
|
|
```text
|
|
notification/platform/callback/
|
|
controller/ # MVC
|
|
servlet/
|
|
reactive/
|
|
shared/ # request factory/canonical URL contract
|
|
```
|
|
|
|
MVC/WebFlux는 같은 application inbound port, provider/profile validation, canonical external URL contract,
|
|
body size semantics를 사용한다. framework별 buffer/security configuration만 분리한다.
|
|
|
|
### 6.5 app-bootstrap
|
|
|
|
```text
|
|
notification/
|
|
NotificationPlatformConfiguration
|
|
NotificationProviderGraphConfiguration
|
|
NotificationDispatchRuntimeConfiguration
|
|
NotificationCallbackRuntimeConfiguration
|
|
NotificationObservationConfiguration
|
|
NotificationSchemaActivationConfiguration
|
|
```
|
|
|
|
각 configuration은 100~200줄 이하를 목표로 하되 줄 수 자체를 gate로 만들지는 않는다. 핵심은 bootstrap이
|
|
entity/repository/mapper를 알지 않고, provider graph가 worker 시작 전에 완성·검증된다는 점이다.
|
|
|
|
## 7. 디자인 패턴 적용 판단
|
|
|
|
패턴은 이름을 붙이기 위해 도입하지 않고 현재 실패 경계를 닫는 데만 사용한다.
|
|
|
|
| 문제 | 적용할 패턴 | 이유 | 피할 것 |
|
|
|---|---|---|---|
|
|
| provider별 조립 | Abstract Factory / Contribution | profile 하나의 adapter·runtime·callback graph를 원자적으로 구성 | reflection/범용 plugin framework |
|
|
| dispatch 단계 | Pipeline + typed Result | pre-wire/committed/response milestone을 명시 | 모든 예외를 catch해 AMBIGUOUS 추측 |
|
|
| lease/recovery | State Machine + Fencing Token | stale worker write를 데이터로 차단 | owner 문자열만 사용 |
|
|
| DB orchestration | Unit of Work | claim/attempt/ledger append 원자성 | repository별 암묵 transaction 의존 |
|
|
| callback | Transactional Inbox + Projector | 빠른 honest 2xx와 재생 가능성 | HTTP thread에서 synchronous projection |
|
|
| provider error/route/template | Strategy | provider/slot별 정책 차이를 닫힌 계약으로 표현 | 거대한 if/switch god service |
|
|
| R1 migration | Anti-Corruption Layer | 두 모델의 임시 변환을 한곳에 격리 | 양방향 자유 import |
|
|
| runtime health | Immutable State + transition table | state/reason 일관성 | enum마다 class를 만드는 과도한 State hierarchy |
|
|
| cross-cutting provider call | Decorator | metric/rate/circuit/auth를 순서대로 합성 | adapter 내부 곳곳의 중복 try/catch |
|
|
|
|
## 8. 구현 순서
|
|
|
|
### Wave 0 — 사실성 및 release 차단
|
|
|
|
1. platform default disabled 유지.
|
|
2. support matrix를 `Contract implemented / runtime unqualified`로 정정.
|
|
3. fake provider echo sandbox와 과장된 nightly 이름/문구 수정.
|
|
4. R0/R1/platform canonical ADR 작성.
|
|
|
|
완료 기준: 문서/CI가 현재 executable evidence보다 강한 주장을 하지 않는다.
|
|
|
|
### Wave 1 — boot graph와 schema
|
|
|
|
1. typed provider settings + assembler/contribution.
|
|
2. 실제 runtime/route/callback/projector/reconciliation registry 조립.
|
|
3. notification schema stream/history/readiness activation.
|
|
4. entity/migration/JSONB mapping 정합화.
|
|
5. full application context fake-provider smoke.
|
|
|
|
완료 기준: enabled context가 실제 provider 한 건을 보내거나, 구성 불완전 시 boot fail한다.
|
|
|
|
### Wave 2 — durable queue
|
|
|
|
1. scheduled due-time state 수정.
|
|
2. atomic CTE claim + lease generation/fencing.
|
|
3. unique worker identity, capacity-aware claim.
|
|
4. SmartLifecycle dispatch/recovery worker.
|
|
5. PostgreSQL two-worker/restart/expiry tests.
|
|
|
|
완료 기준: duplicate provider call을 만드는 stale worker race가 DB 조건으로 차단된다.
|
|
|
|
### Wave 3 — evidence와 ledger
|
|
|
|
1. evidence certainty persistence.
|
|
2. complete projection snapshot/version.
|
|
3. transactional callback append + conflict-safe duplicate.
|
|
4. late binding + pending/failed projection worker.
|
|
5. durable reconciliation queue/worker + SHA-256 fingerprint.
|
|
|
|
완료 기준: out-of-order/restart/replay 후에도 ledger fold와 snapshot이 같고 side effect가 1회다.
|
|
|
|
### Wave 4 — security와 provider correctness
|
|
|
|
1. WebPush versioned contact codec/VAPID keyring.
|
|
2. capability-aware secret keyring/startup validation.
|
|
3. callback payload envelope/body bound.
|
|
4. dynamic HTTP/SNS/response bound.
|
|
5. template slot modes/deep-link allowlist.
|
|
6. webhook/attachment/FCM/APNs wire contract.
|
|
|
|
완료 기준: security negative suite와 provider final-wire tests가 모두 통과한다.
|
|
|
|
### Wave 5 — API와 architecture
|
|
|
|
1. typed variables + canonical encoder/fingerprint.
|
|
2. dedup/collapse/preference E2E.
|
|
3. CQRS-style inbound use cases/capability marker.
|
|
4. admin/routing policy application 이동.
|
|
5. package 구조/internal visibility/ArchUnit/public snapshot.
|
|
|
|
완료 기준: dead public contract가 없고 새 consumer가 canonical API 하나만 사용한다.
|
|
|
|
### Wave 6 — qualification
|
|
|
|
1. real provider sandbox test와 correlation evidence.
|
|
2. PostgreSQL performance/lease/callback burst/chaos/restart lane.
|
|
3. readiness/metrics/audit cardinality/PII test.
|
|
4. evidence manifest 충족 provider만 Stable 승격.
|
|
|
|
## 9. 권장 테스트 구조와 명령
|
|
|
|
### 9.1 새 테스트 source set/fixture
|
|
|
|
```text
|
|
application-core:test
|
|
- request canonicalization property tests
|
|
- use-case/policy/state-machine unit tests
|
|
|
|
adapter:outbound:notification:test
|
|
- provider final-wire contract
|
|
- transport milestone/error classification
|
|
- template/security/concurrency tests
|
|
|
|
adapter:outbound:persistence-jpa:notificationPostgresqlIntegrationTest
|
|
- migration + Hibernate validate
|
|
- CRUD/projection/ledger
|
|
- claim/fencing/concurrent duplicate
|
|
|
|
app-bootstrap:test
|
|
- full configured context
|
|
- provider contribution/schema activation/readiness
|
|
|
|
notificationChaosTest
|
|
- process kill/restart
|
|
- response loss and callback burst
|
|
```
|
|
|
|
### 9.2 구현 중 focused 검증
|
|
|
|
```bash
|
|
cd src
|
|
./gradlew :application-core:test --console=plain
|
|
./gradlew :adapter:outbound:notification:test --console=plain
|
|
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
|
./gradlew :adapter:inbound:web:test --console=plain
|
|
./gradlew :app-bootstrap:test --tests '*Notification*' --console=plain
|
|
./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys --console=plain
|
|
```
|
|
|
|
### 9.3 release 전 필수 검증
|
|
|
|
```bash
|
|
cd src
|
|
./gradlew notificationPostgresqlIntegrationTest --console=plain
|
|
./gradlew notificationChaosTest --console=plain
|
|
./gradlew test --console=plain
|
|
./gradlew check --console=plain
|
|
```
|
|
|
|
task는 실제 source set을 만든 뒤 정확한 Gradle 이름으로 확정한다. 존재하지 않는 이름을 문서만 먼저
|
|
추가하지 않는다. 모든 lane은 `failOnNoDiscoveredTests`와 skip reason을 강제한다.
|
|
|
|
## 10. 완료 정의
|
|
|
|
다음 질문에 모두 코드·DB·실행 artifact로 “예”라고 답할 수 있을 때만 notification platform을 Stable로
|
|
판정한다.
|
|
|
|
- enabled profile이 full application context에서 실제 runtime/route/transport로 조립되는가?
|
|
- notification schema가 별도 history로 적용·승격되고 Hibernate validate를 통과하는가?
|
|
- 예약 요청이 due 시 정확히 한 번 claim되는가?
|
|
- 두 replica와 stale worker가 같은 notification을 provider에 중복 제출하지 못하는가?
|
|
- crash 지점별 recovery가 safe retry와 ambiguity를 evidence로 구분하는가?
|
|
- callback-before-outcome, duplicate, out-of-order, projector failure가 재생 가능한가?
|
|
- engagement/suppression/evidence certainty가 restart 뒤 보존되는가?
|
|
- WebPush/contact/callback ciphertext가 key rotation 뒤 복원되는가?
|
|
- SSRF, oversized request/response, malicious SNS URL/topic/replay가 provider/ledger 전에 거부되는가?
|
|
- fingerprint가 저장한 plan의 정확한 semantic bytes를 대표하는가?
|
|
- dedup/collapse/preference/attachment/webhook 공개 계약이 final wire까지 적용되는가?
|
|
- 모든 orchestration entrypoint가 repository의 UseCase/permission/architecture fitness gate를 통과하는가?
|
|
- support matrix의 각 Stable 주장이 실제 실행 evidence artifact에 연결되는가?
|
|
|
|
하나라도 아니면 해당 기능은 Stable이 아니라 `Experimental`, `Unwired`, 또는 `Contract-only`로 표시한다.
|
|
|
|
## 11. 이번 리뷰에서 실행한 검증
|
|
|
|
모든 최종 성공 결과는 HEAD `539e3eb58bed5db63e3a17f47eec213db2d2df79`에서 build 산출물을
|
|
재생성한 뒤 얻었다.
|
|
|
|
| 명령 | 결과 | 관측 범위 |
|
|
|---|---|---|
|
|
| `./gradlew :application-core:clean :application-core:test :application-core:jar :adapter:outbound:notification:clean :adapter:outbound:notification:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 3m 16s | application 98 suites/656 tests, notification 31 suites/176 tests, failure/skip 0 |
|
|
| `./gradlew :adapter:outbound:persistence-jpa:clean :adapter:outbound:persistence-jpa:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 46s | 78 suites/384 tests, failure/skip 0 |
|
|
| `./gradlew :adapter:inbound:web:clean :adapter:inbound:web:test --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 44s | 57 suites/333 tests, failure/skip 0 |
|
|
| `./gradlew :app-bootstrap:clean :app-bootstrap:test --tests '*NotificationAutoConfigurationTest' --tests '*NotificationArchitectureTest' --tests '*CleanArchitectureTest' verifyCleanArchitectureDependencies --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 3m 25s | 선택된 7 suites/83 tests와 module dependency gate, failure/skip 0 |
|
|
| `./gradlew verifyPublicPathSnapshot verifyEnvKeys --rerun-tasks --console=plain` | BUILD SUCCESSFUL, 1m 2s | public path unchanged, env registry gate OK |
|
|
| `git diff --no-index --check /dev/null docs/reviews/2026-08-14-notification-module-code-review.md 2>&1 \| wc -c` | `0` | untracked 신규 문서의 whitespace 오류 출력 없음 |
|
|
|
|
첫 통합 실행
|
|
`./gradlew :application-core:test :adapter:outbound:notification:test :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --rerun-tasks --console=plain`은
|
|
`application-core-0.0.1+539e3eb58bed.jar: zip END header not found` 때문에
|
|
`:adapter:outbound:persistence-jpa:compileTestJava`에서 101개 연쇄 symbol error로 실패했다. 리뷰 중 병렬
|
|
빌드가 같은 공유 build output을 사용한 뒤 남은 손상으로 판단했고, 위 표처럼 대상 build directory를
|
|
clean한 뒤 모듈별 순차 실행하여 모두 fresh 통과했다. 이 최초 실패를 notification assertion 실패로
|
|
분류하지는 않지만, 공유 workspace에서 병렬 Gradle build output을 격리해야 한다는 도구 운영상 주의점은
|
|
남는다.
|
|
|
|
다음 검증은 실행하지 못한 것이 아니라 **현재 repository에 해당 executable test lane이 존재하지 않아**
|
|
검증할 수 없었다.
|
|
|
|
- notification 전용 PostgreSQL migration/Hibernate validate/CRUD integration task
|
|
- 두 process/connection을 사용한 notification lease fencing task
|
|
- process kill/restart recovery 및 callback burst chaos task
|
|
- secret-protected real provider sandbox test
|
|
|
|
일반 unit/contract/ArchUnit 통과는 위 네 운영 계약을 대신하지 않는다. NTF-024의 핵심은 이 공백을
|
|
release evidence에 정직하게 반영하고 실제 lane으로 채우는 것이다.
|
|
|
|
### 11.1 최종 작업공간 상태 주의
|
|
|
|
위 검증을 마친 뒤, 이 리뷰 작업이 만들지 않은 messaging 모듈 병합 변경이 같은 worktree에 추가됐고
|
|
`src/config/spotbugs/exclude.xml`은 현재 unmerged(`UU`) 상태가 됐다. 최종 대조 시 Git HEAD는 여전히
|
|
`539e3eb58bed5db63e3a17f47eec213db2d2df79`였으며, notification 검토 대상 경로에는 HEAD 대비 staged 또는
|
|
unstaged 변경이 없었다. 따라서 notification source finding은 유지되지만, 다음 두 범위는 구분해야 한다.
|
|
|
|
- 위 표의 성공 결과는 messaging 병합 충돌이 나타나기 전, HEAD `539e3eb`의 notification 관련 graph에서
|
|
얻은 fresh evidence다.
|
|
- 현재 충돌이 남은 worktree 전체가 build/check를 통과한다는 뜻은 아니다. 충돌을 사용자 작업에서
|
|
해소한 뒤 `verifyCleanArchitectureDependencies`, notification focused test, 전체 `check`를 다시
|
|
실행해야 한다.
|
|
|
|
또한 이 문서의 “19개 leaf” 판단은 committed HEAD와 이 저장소의 현재 `AGENTS.md` 정책을 기준으로 한다.
|
|
진행 중인 messaging 병합이 `modules.json`에 다수 leaf를 추가하므로, 그 변경이 최종 정책이라면
|
|
`AGENTS.md`의 정확히 19개 leaf 계약과 registry/settings/build 검증을 함께 개정·승인해야 한다. 이번
|
|
notification read-only 리뷰에서는 그 별도 병합이나 충돌을 수정하지 않았다.
|