Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md
T

255 KiB

Notification Delivery Platform Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Java/Spring Backend Skeleton에 Request·Recipient·Attempt 수명주기, evidence 기반 Provider 결과, durable scheduling, callback ledger, SMTP·SES·Twilio·FCM·APNs·Web Push Stable Adapter, In-App Inbox, 보안·관측성·운영 검증을 갖춘 Notification Delivery Platform을 구현한다.

Architecture: notification-core-api가 N1 Typed API와 N2 Orchestration 계약을 소유하고, notification-provider-spi를 채널별 Adapter가 구현한다. 논리 요청은 NotificationRequest → RecipientDelivery → DeliveryAttempt로 분해하고 Provider callback은 append-only ProviderEvent 원장에 저장한 뒤 channel-specific projector가 SubmissionOutcome, DeliveryOutcome, EvidenceLevel, engagement·suppression fact를 갱신한다. submit() 성공은 durable acceptance만 뜻하며 AMBIGUOUS attempt의 자동 retry·fallback은 기본 차단한다.

Tech Stack: Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Boot dependency management, PostgreSQL 16, JPA, Flyway, Spring JavaMail, existing HTTP Client Platform, Thymeleaf reference renderer, JSON Schema 2020-12, AES-256-GCM, HMAC-SHA-256, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, WireMock, Toxiproxy.

Global Constraints

  • Root package는 io.backend.skeleton.notification이다.
  • 모듈 루트는 modules/notification이다.
  • Core 공개 비동기 타입은 CompletionStage이며 Reactor 타입은 notification-reactor에서만 제공한다.
  • notification-core-api, notification-content-api, notification-contact-api는 JPA, MVC, WebFlux, Provider SDK에 의존하지 않는다.
  • 일반 애플리케이션은 N1 Typed API를 기본으로 사용한다.
  • N2는 scheduling, cancel, ordered fallback, multi-recipient, opt-in dedup/collapse만 제공한다.
  • N3는 typed provider capability만 노출하며 raw SDK client를 반환하지 않는다.
  • N4 Admin Plane은 별도 authority, operation ID, reason, audit를 요구한다.
  • submit() 성공은 DB에 논리 요청과 recipient delivery가 commit됐음을 의미하며 최종 전달을 뜻하지 않는다.
  • SES MessageId, Twilio accepted/queued, FCM send success, APNs 2xx, Web Push 201을 DELIVERED로 매핑하지 않는다.
  • Provider 결과를 확정할 수 없으면 AMBIGUOUS로 저장한다.
  • AMBIGUOUS attempt가 있는 recipient에는 자동 retry와 cross-channel fallback을 기본 금지한다.
  • Callback은 signature 검증 후 append-only ledger에 저장하고 projector를 실행한다.
  • Callback 중복·역순·누락을 정상 failure mode로 처리한다.
  • FCM primary target은 FID이며 registration token은 legacy compatibility type이다.
  • Template ID, version, locale, normalized variables, rendered digest를 submit 시점에 고정한다.
  • Idempotency, deduplication, collapse는 별도 기능이다.
  • Stable scheduler는 PostgreSQL durable queue와 FOR UPDATE SKIP LOCKED를 사용한다.
  • Provider 호출은 DB transaction 밖에서 수행하고 호출 전에 Attempt row를 commit한다.
  • Contact Point 원문은 AES-256-GCM으로 암호화하고 equality lookup은 HMAC-SHA-256 fingerprint를 사용한다.
  • HMAC key와 encryption key를 분리한다.
  • metric label과 일반 로그에 recipient, 주소, token, notification ID, attempt ID, provider request ID, body를 기록하지 않는다.
  • Web Push endpoint, p256dh, auth secret은 secret 수준으로 보호한다.
  • Provider 인증 실패는 개별 메시지 retry가 아니라 Provider runtime health failure로 처리한다.
  • In-App Inbox의 PostgreSQL row가 source of truth이며 WebSocket은 commit 이후 신호만 보낸다.
  • Webhook extension은 기존 HTTP Client Platform을 재사용한다.
  • 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다.
  • 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다.
  • 테스트에 필요한 작은 fixture는 해당 test file 하단의 package-private type으로 작성한다. 공용 fixture만 notification-testkit으로 승격한다.

1. 확정 파일 구조

backend-skeleton/
├── settings.gradle.kts
├── build-logic/src/main/kotlin/notification-library-conventions.gradle.kts
├── modules/notification/
│   ├── notification-core-api/
│   ├── notification-content-api/
│   ├── notification-template-api/
│   ├── notification-template-thymeleaf/
│   ├── notification-contact-api/
│   ├── notification-policy/
│   ├── notification-provider-spi/
│   ├── notification-persistence-jpa/
│   ├── notification-dispatch-runtime/
│   ├── notification-callback-api/
│   ├── notification-callback-mvc/
│   ├── notification-callback-webflux/
│   ├── notification-email-api/
│   ├── notification-email-smtp/
│   ├── notification-email-ses/
│   ├── notification-sms-api/
│   ├── notification-sms-twilio/
│   ├── notification-push-api/
│   ├── notification-push-fcm/
│   ├── notification-push-apns/
│   ├── notification-webpush/
│   ├── notification-inbox-api/
│   ├── notification-inbox-jpa/
│   ├── notification-webhook-extension/
│   ├── notification-observability/
│   ├── notification-security/
│   ├── notification-admin-api/
│   ├── notification-admin-runtime/
│   ├── notification-reactor/
│   ├── notification-spring-boot-starter/
│   └── notification-testkit/
├── infra/notification/
│   ├── postgres/
│   ├── smtp/
│   ├── wiremock/
│   ├── toxiproxy/
│   └── tls/
├── docs/notification/
│   ├── support-matrix.md
│   ├── configuration-reference.md
│   ├── delivery-evidence.md
│   ├── callback-reconciliation.md
│   ├── provider-runbooks.md
│   ├── security-privacy.md
│   ├── operations.md
│   └── migration-guide.md
└── docs/superpowers/specs/2026-08-10-notification-platform-design.md

2. 핵심 패키지

io.backend.skeleton.notification.api
io.backend.skeleton.notification.api.content
io.backend.skeleton.notification.api.delivery
io.backend.skeleton.notification.api.error
io.backend.skeleton.notification.api.routing
io.backend.skeleton.notification.contact
io.backend.skeleton.notification.template
io.backend.skeleton.notification.policy
io.backend.skeleton.notification.provider
io.backend.skeleton.notification.persistence
io.backend.skeleton.notification.dispatch
io.backend.skeleton.notification.callback
io.backend.skeleton.notification.email
io.backend.skeleton.notification.sms
io.backend.skeleton.notification.push
io.backend.skeleton.notification.webpush
io.backend.skeleton.notification.inbox
io.backend.skeleton.notification.webhook
io.backend.skeleton.notification.observation
io.backend.skeleton.notification.security
io.backend.skeleton.notification.admin
io.backend.skeleton.notification.autoconfigure
io.backend.skeleton.notification.testkit

3. Module dependency map

Each module build file created in Task 1 must use the following direct project dependencies. Provider SDK and Spring web dependencies are added only in the owning adapter module.

notification-core-api
  → no project dependency

notification-content-api
  → notification-core-api

notification-contact-api
  → notification-core-api

notification-template-api
  → notification-core-api
  → notification-content-api

notification-template-thymeleaf
  → notification-template-api
  → notification-content-api

notification-provider-spi
  → notification-core-api
  → notification-content-api
  → notification-contact-api
  → notification-template-api

notification-policy
  → notification-core-api
  → notification-contact-api
  → notification-provider-spi

notification-callback-api
  → notification-core-api
  → notification-provider-spi

notification-security
  → notification-core-api
  → notification-contact-api

notification-observability
  → notification-core-api
  → notification-provider-spi

notification-persistence-jpa
  → notification-core-api
  → notification-contact-api
  → notification-template-api
  → notification-callback-api

notification-dispatch-runtime
  → notification-core-api
  → notification-content-api
  → notification-contact-api
  → notification-template-api
  → notification-policy
  → notification-provider-spi
  → notification-callback-api
  → notification-persistence-jpa
  → notification-security
  → notification-observability

notification-callback-mvc / notification-callback-webflux
  → notification-callback-api
  → notification-security

notification-email-api / notification-sms-api / notification-push-api
  → notification-core-api
  → notification-content-api
  → notification-contact-api

notification-email-smtp
  → notification-email-api
  → notification-provider-spi
  → notification-security

notification-email-ses
  → notification-email-api
  → notification-provider-spi
  → httpclient platform

notification-sms-twilio
  → notification-sms-api
  → notification-provider-spi
  → httpclient platform

notification-push-fcm / notification-push-apns
  → notification-push-api
  → notification-provider-spi
  → notification-security
  → httpclient platform

notification-webpush
  → notification-core-api
  → notification-content-api
  → notification-contact-api
  → notification-provider-spi
  → notification-security
  → httpclient platform

notification-inbox-api
  → notification-core-api
  → notification-content-api

notification-inbox-jpa
  → notification-inbox-api
  → notification-persistence-jpa
  → optional messaging outbox integration

notification-webhook-extension
  → notification-provider-spi
  → httpclient platform

notification-admin-api
  → notification-core-api

notification-admin-runtime
  → notification-admin-api
  → notification-dispatch-runtime
  → notification-persistence-jpa
  → notification-observability

notification-reactor
  → notification-core-api
  → notification-dispatch-runtime

notification-spring-boot-starter
  → notification-dispatch-runtime
  → optional callback/provider/admin modules

notification-testkit
  → test fixtures from every Stable adapter

No reverse dependency from an API module to a concrete adapter is allowed.


Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성

Files:

  • Modify: settings.gradle.kts
  • Create: build-logic/src/main/kotlin/notification-library-conventions.gradle.kts
  • Create: modules/notification/notification-core-api/build.gradle.kts
  • Create: modules/notification/notification-content-api/build.gradle.kts
  • Create: modules/notification/notification-template-api/build.gradle.kts
  • Create: modules/notification/notification-template-thymeleaf/build.gradle.kts
  • Create: modules/notification/notification-contact-api/build.gradle.kts
  • Create: modules/notification/notification-policy/build.gradle.kts
  • Create: modules/notification/notification-provider-spi/build.gradle.kts
  • Create: modules/notification/notification-persistence-jpa/build.gradle.kts
  • Create: modules/notification/notification-dispatch-runtime/build.gradle.kts
  • Create: modules/notification/notification-callback-api/build.gradle.kts
  • Create: modules/notification/notification-callback-mvc/build.gradle.kts
  • Create: modules/notification/notification-callback-webflux/build.gradle.kts
  • Create: modules/notification/notification-email-api/build.gradle.kts
  • Create: modules/notification/notification-email-smtp/build.gradle.kts
  • Create: modules/notification/notification-email-ses/build.gradle.kts
  • Create: modules/notification/notification-sms-api/build.gradle.kts
  • Create: modules/notification/notification-sms-twilio/build.gradle.kts
  • Create: modules/notification/notification-push-api/build.gradle.kts
  • Create: modules/notification/notification-push-fcm/build.gradle.kts
  • Create: modules/notification/notification-push-apns/build.gradle.kts
  • Create: modules/notification/notification-webpush/build.gradle.kts
  • Create: modules/notification/notification-inbox-api/build.gradle.kts
  • Create: modules/notification/notification-inbox-jpa/build.gradle.kts
  • Create: modules/notification/notification-webhook-extension/build.gradle.kts
  • Create: modules/notification/notification-observability/build.gradle.kts
  • Create: modules/notification/notification-security/build.gradle.kts
  • Create: modules/notification/notification-admin-api/build.gradle.kts
  • Create: modules/notification/notification-admin-runtime/build.gradle.kts
  • Create: modules/notification/notification-reactor/build.gradle.kts
  • Create: modules/notification/notification-spring-boot-starter/build.gradle.kts
  • Create: modules/notification/notification-testkit/build.gradle.kts
  • Test: modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/ModuleSmokeTest.java

Interfaces:

  • Consumes: 없음.
  • Produces: 후속 Task가 사용하는 31개 Gradle project path, Java 21 toolchain, JUnit Platform, dependency boundary.

Implementation requirements:

  • 모든 module은 공통 conventions plugin을 적용한다.

  • core/content/contact API build file에는 Spring, JPA, Provider SDK dependency를 추가하지 않는다.

  • Experimental 기능은 Stable module classpath에 자동 포함하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.api;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class ModuleSmokeTest {
    @Test
    void coreModuleRunsOnJava21() {
        assertThat(Runtime.version().feature()).isEqualTo(21);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-core-api:test --tests "*.ModuleSmokeTest"

Expected: FAIL: project path :modules:notification:notification-core-api가 존재하지 않는다.

  • Step 3: Write the minimal implementation

Files to implement:

  • settings.gradle.kts
  • build-logic/src/main/kotlin/notification-library-conventions.gradle.kts
  • modules/notification/notification-core-api/build.gradle.kts
  • modules/notification/notification-content-api/build.gradle.kts
  • modules/notification/notification-template-api/build.gradle.kts
  • modules/notification/notification-template-thymeleaf/build.gradle.kts
  • modules/notification/notification-contact-api/build.gradle.kts
  • modules/notification/notification-policy/build.gradle.kts
  • modules/notification/notification-provider-spi/build.gradle.kts
  • modules/notification/notification-persistence-jpa/build.gradle.kts
  • modules/notification/notification-dispatch-runtime/build.gradle.kts
  • modules/notification/notification-callback-api/build.gradle.kts
  • modules/notification/notification-callback-mvc/build.gradle.kts
  • modules/notification/notification-callback-webflux/build.gradle.kts
  • modules/notification/notification-email-api/build.gradle.kts
  • modules/notification/notification-email-smtp/build.gradle.kts
  • modules/notification/notification-email-ses/build.gradle.kts
  • modules/notification/notification-sms-api/build.gradle.kts
  • modules/notification/notification-sms-twilio/build.gradle.kts
  • modules/notification/notification-push-api/build.gradle.kts
  • modules/notification/notification-push-fcm/build.gradle.kts
  • modules/notification/notification-push-apns/build.gradle.kts
  • modules/notification/notification-webpush/build.gradle.kts
  • modules/notification/notification-inbox-api/build.gradle.kts
  • modules/notification/notification-inbox-jpa/build.gradle.kts
  • modules/notification/notification-webhook-extension/build.gradle.kts
  • modules/notification/notification-observability/build.gradle.kts
  • modules/notification/notification-security/build.gradle.kts
  • modules/notification/notification-admin-api/build.gradle.kts
  • modules/notification/notification-admin-runtime/build.gradle.kts
  • modules/notification/notification-reactor/build.gradle.kts
  • modules/notification/notification-spring-boot-starter/build.gradle.kts
  • modules/notification/notification-testkit/build.gradle.kts
// settings.gradle.kts
val notificationModules = listOf(
    "core-api", "content-api", "template-api", "template-thymeleaf",
    "contact-api", "policy", "provider-spi", "persistence-jpa",
    "dispatch-runtime", "callback-api", "callback-mvc", "callback-webflux",
    "email-api", "email-smtp", "email-ses", "sms-api", "sms-twilio",
    "push-api", "push-fcm", "push-apns", "webpush", "inbox-api",
    "inbox-jpa", "webhook-extension", "observability", "security",
    "admin-api", "admin-runtime", "reactor", "spring-boot-starter", "testkit"
)
notificationModules.forEach {
    include(":modules:notification:notification-$it")
}

// notification-library-conventions.gradle.kts
plugins {
    `java-library`
    id("java-test-fixtures")
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
tasks.withType<Test>().configureEach { useJUnitPlatform() }
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-core-api:test

Expected: PASS: ModuleSmokeTest 1개 통과, Java 21 toolchain 사용.

  • Step 5: Commit
git add 'settings.gradle.kts' 'build-logic/src/main/kotlin/notification-library-conventions.gradle.kts' 'modules/notification/notification-core-api/build.gradle.kts' 'modules/notification/notification-content-api/build.gradle.kts' 'modules/notification/notification-template-api/build.gradle.kts' 'modules/notification/notification-template-thymeleaf/build.gradle.kts' 'modules/notification/notification-contact-api/build.gradle.kts' 'modules/notification/notification-policy/build.gradle.kts' 'modules/notification/notification-provider-spi/build.gradle.kts' 'modules/notification/notification-persistence-jpa/build.gradle.kts' 'modules/notification/notification-dispatch-runtime/build.gradle.kts' 'modules/notification/notification-callback-api/build.gradle.kts' 'modules/notification/notification-callback-mvc/build.gradle.kts' 'modules/notification/notification-callback-webflux/build.gradle.kts' 'modules/notification/notification-email-api/build.gradle.kts' 'modules/notification/notification-email-smtp/build.gradle.kts' 'modules/notification/notification-email-ses/build.gradle.kts' 'modules/notification/notification-sms-api/build.gradle.kts' 'modules/notification/notification-sms-twilio/build.gradle.kts' 'modules/notification/notification-push-api/build.gradle.kts' 'modules/notification/notification-push-fcm/build.gradle.kts' 'modules/notification/notification-push-apns/build.gradle.kts' 'modules/notification/notification-webpush/build.gradle.kts' 'modules/notification/notification-inbox-api/build.gradle.kts' 'modules/notification/notification-inbox-jpa/build.gradle.kts' 'modules/notification/notification-webhook-extension/build.gradle.kts' 'modules/notification/notification-observability/build.gradle.kts' 'modules/notification/notification-security/build.gradle.kts' 'modules/notification/notification-admin-api/build.gradle.kts' 'modules/notification/notification-admin-runtime/build.gradle.kts' 'modules/notification/notification-reactor/build.gradle.kts' 'modules/notification/notification-spring-boot-starter/build.gradle.kts' 'modules/notification/notification-testkit/build.gradle.kts' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/ModuleSmokeTest.java'
git commit -m "build(notification): create delivery platform modules"

Task 2: 핵심 식별자와 증거 Enum 구현

Files:

  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientDeliveryId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeliveryAttemptId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderEventId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderProfileId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TenantId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CorrelationId.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/IdempotencyKey.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RequestStatus.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/SubmissionOutcome.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/DeliveryOutcome.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/AttemptConfirmation.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/EvidenceLevel.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/RecipientDeliveryState.java
  • Test: modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/delivery/EvidenceModelTest.java

Interfaces:

  • Consumes: Task 1의 core-api module.
  • Produces: NotificationId, RecipientDeliveryId, DeliveryAttemptId, SubmissionOutcome, DeliveryOutcome, AttemptConfirmation, EvidenceLevel, RecipientDeliveryState.

Implementation requirements:

  • EvidenceLevel ordinal로 Provider event merge를 구현하지 않는다.

  • Notification ID는 UUIDv7 generator를 후속 runtime Task에서 주입한다.

  • DELIVERED=true, EXACTLY_ONCE 같은 boolean 보장 타입을 만들지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.api.delivery;

import org.junit.jupiter.api.Test;
import java.util.UUID;
import io.backend.skeleton.notification.api.NotificationId;
import static org.assertj.core.api.Assertions.*;

class EvidenceModelTest {
    @Test
    void providerAcceptanceIsNotDelivery() {
        assertThat(EvidenceLevel.PROVIDER_ACCEPTED)
            .isNotEqualTo(EvidenceLevel.DEVICE_DELIVERED);
        assertThat(SubmissionOutcome.CONFIRMED_ACCEPTED.name())
            .doesNotContain("DELIVERED");
    }

    @Test
    void idsRejectNull() {
        assertThatThrownBy(() -> new NotificationId(null))
            .isInstanceOf(NullPointerException.class);
    }

    @Test
    void ambiguousIsFirstClassOutcome() {
        assertThat(SubmissionOutcome.values())
            .contains(SubmissionOutcome.AMBIGUOUS);
        assertThat(AttemptConfirmation.values())
            .contains(AttemptConfirmation.AMBIGUOUS);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-core-api:test --tests "*.EvidenceModelTest"

Expected: FAIL: NotificationId와 evidence enum이 정의되지 않았다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationId.java
  • RecipientDeliveryId.java
  • DeliveryAttemptId.java
  • ProviderEventId.java
  • ContactPointId.java
  • ProviderProfileId.java
  • ProviderId.java
  • TenantId.java
  • CorrelationId.java
  • IdempotencyKey.java
  • RequestStatus.java
  • SubmissionOutcome.java
  • DeliveryOutcome.java
  • AttemptConfirmation.java
  • EvidenceLevel.java
  • RecipientDeliveryState.java
public record NotificationId(UUID value) {
    public NotificationId {
        java.util.Objects.requireNonNull(value, "value");
    }
}

public record TenantId(String value) {
    public TenantId {
        if (value == null || value.isBlank()) throw new IllegalArgumentException("value");
    }
}

public enum RequestStatus {
    CREATED, VALIDATED, SCHEDULED, PROCESSING,
    PARTIALLY_COMPLETED, COMPLETED, CANCELED, EXPIRED, FAILED
}

public enum SubmissionOutcome {
    NOT_SUBMITTED, CONFIRMED_ACCEPTED, CONFIRMED_REJECTED, AMBIGUOUS
}

public enum DeliveryOutcome {
    UNKNOWN, SENT, DELIVERED, UNDELIVERED, BOUNCED, EXPIRED
}

public enum AttemptConfirmation {
    CONFIRMED, REJECTED, AMBIGUOUS
}

public enum EvidenceLevel {
    NONE,
    PLATFORM_QUEUED,
    PROVIDER_ACCEPTED,
    NETWORK_OR_CARRIER_ACCEPTED,
    DEVICE_DELIVERED,
    USER_AGENT_DISPLAYED,
    USER_READ
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-core-api:test

Expected: PASS: evidence 단계가 분리되고 null ID가 거부된다.

  • Step 5: Commit
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientDeliveryId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeliveryAttemptId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderEventId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderProfileId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ProviderId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TenantId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CorrelationId.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/IdempotencyKey.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RequestStatus.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/SubmissionOutcome.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/DeliveryOutcome.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/AttemptConfirmation.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/EvidenceLevel.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/delivery/RecipientDeliveryState.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/delivery/EvidenceModelTest.java'
git commit -m "feat(notification): add identity and evidence model"

Task 3: 채널별 NotificationContent sealed hierarchy 구현

Files:

  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/NotificationContent.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailContent.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsContent.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/MobilePushContent.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushContent.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppContent.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentRef.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailOptions.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsOptions.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/PushPresentation.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushOptions.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppAction.java
  • Create: modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentDisposition.java
  • Test: modules/notification/notification-content-api/src/test/java/io/backend/skeleton/notification/api/content/NotificationContentContractTest.java

Interfaces:

  • Consumes: Task 1 module structure.
  • Produces: Provider SDK와 raw credential이 없는 typed content records와 attachment reference.

Implementation requirements:

  • Email, SMS, Push 고유 필드를 하나의 거대 record로 합치지 않는다.

  • Map은 push data처럼 문자열 key/value가 표준인 위치에만 제한 사용한다.

  • attachment는 fileserver/objectstorage reference만 보유한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.api.content;

import org.junit.jupiter.api.Test;
import java.net.URI;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;

class NotificationContentContractTest {
    @Test
    void pushDataRejectsNullKeyAndValue() {
        assertThatThrownBy(() -> new MobilePushContent(
            "title", "body", URI.create("https://app.example/item/1"),
            Map.of("key", (String) null), PushPresentation.DEFAULT))
            .isInstanceOf(NullPointerException.class);
    }

    @Test
    void attachmentCarriesReferenceNotBytes() {
        var ref = new AttachmentRef("file:01", "report.pdf",
            "application/pdf", 1024, "sha256:abc", AttachmentDisposition.ATTACHMENT);
        assertThat(ref.contentReference()).isEqualTo("file:01");
        assertThat(ref.getClass().getRecordComponents())
            .noneMatch(c -> c.getType().equals(byte[].class));
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-content-api:test --tests "*.NotificationContentContractTest"

Expected: FAIL: content records와 AttachmentRef가 존재하지 않는다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationContent.java
  • EmailContent.java
  • SmsContent.java
  • MobilePushContent.java
  • WebPushContent.java
  • InAppContent.java
  • AttachmentRef.java
  • EmailOptions.java
  • SmsOptions.java
  • PushPresentation.java
  • WebPushOptions.java
  • InAppAction.java
  • AttachmentDisposition.java
public sealed interface NotificationContent
    permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent {}

public record PushPresentation(
    java.util.Optional<String> sound,
    java.util.Optional<Integer> badge
) {
    public static final PushPresentation DEFAULT =
        new PushPresentation(java.util.Optional.empty(), java.util.Optional.empty());
}

public record SmsContent(String text, SmsOptions options)
    implements NotificationContent {
    public SmsContent {
        if (text == null || text.isBlank()) throw new IllegalArgumentException("text");
        java.util.Objects.requireNonNull(options, "options");
    }
}

public record AttachmentRef(
    String contentReference,
    String displayName,
    String contentType,
    long expectedSize,
    String expectedDigest,
    AttachmentDisposition disposition
) {
    public AttachmentRef {
        if (expectedSize < 0) throw new IllegalArgumentException("expectedSize");
        java.util.Objects.requireNonNull(contentReference, "contentReference");
        java.util.Objects.requireNonNull(expectedDigest, "expectedDigest");
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-content-api:test

Expected: PASS: 각 채널 content가 typed record이고 attachment bytes가 공개 모델에 없다.

  • Step 5: Commit
git add 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/NotificationContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/MobilePushContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppContent.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentRef.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/EmailOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/SmsOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/PushPresentation.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/WebPushOptions.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/InAppAction.java' 'modules/notification/notification-content-api/src/main/java/io/backend/skeleton/notification/api/content/AttachmentDisposition.java' 'modules/notification/notification-content-api/src/test/java/io/backend/skeleton/notification/api/content/NotificationContentContractTest.java'
git commit -m "feat(notification): add typed channel content model"

Task 4: NotificationPlan과 Routing Strategy 구현

Files:

  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationPlan.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientSpec.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TemplateSelection.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationSpec.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationAction.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseSpec.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseScope.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointSelector.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ChannelPreferenceOverride.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/DeliveryStrategy.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/ExplicitChannel.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/OrderedFallback.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/Channel.java
  • Test: modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/NotificationPlanTest.java

Interfaces:

  • Consumes: Task 2 IDs와 Task 3 content API.
  • Produces: NotificationPlan, RecipientSpec, DeliveryStrategy, Stable ExplicitChannel·OrderedFallback.

Implementation requirements:

  • Parallel first-success는 이 Task의 public sealed hierarchy에 넣지 않는다.

  • metadata key/value hard limit validation을 NotificationPlan constructor에 적용한다.

  • recipient 목록은 immutable copy로 보존한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.api;

import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import io.backend.skeleton.notification.api.routing.*;
import static org.assertj.core.api.Assertions.*;

class NotificationPlanTest {
    @Test
    void orderedFallbackRequiresDistinctChannels() {
        assertThatThrownBy(() -> new OrderedFallback(
            List.of(Channel.PUSH, Channel.PUSH)))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void expiryMustBeAfterNotBefore() {
        var now = Instant.parse("2026-08-10T00:00:00Z");
        assertThatThrownBy(() -> NotificationPlanFixture.plan(now.plusSeconds(60), now))
            .isInstanceOf(IllegalArgumentException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-core-api:test --tests "*.NotificationPlanTest"

Expected: FAIL: NotificationPlan과 routing types가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationPlan.java
  • RecipientSpec.java
  • TemplateSelection.java
  • DeduplicationSpec.java
  • DeduplicationAction.java
  • CollapseSpec.java
  • CollapseScope.java
  • ContactPointSelector.java
  • ChannelPreferenceOverride.java
  • DeliveryStrategy.java
  • ExplicitChannel.java
  • OrderedFallback.java
  • Channel.java
public record TemplateSelection(String templateId, long version, java.util.Locale locale) {
    public TemplateSelection {
        if (templateId == null || templateId.isBlank()) throw new IllegalArgumentException("templateId");
        if (version <= 0) throw new IllegalArgumentException("version");
        java.util.Objects.requireNonNull(locale, "locale");
    }
}

public record DeduplicationSpec(
    String dedupKey,
    java.time.Duration window,
    DeduplicationAction action
) {}

public record CollapseSpec(String key, CollapseScope scope) {}

public sealed interface DeliveryStrategy permits ExplicitChannel, OrderedFallback {}

public record ExplicitChannel(Channel channel) implements DeliveryStrategy {
    public ExplicitChannel { java.util.Objects.requireNonNull(channel, "channel"); }
}

public record OrderedFallback(java.util.List<Channel> channels)
    implements DeliveryStrategy {
    public OrderedFallback {
        channels = java.util.List.copyOf(channels);
        if (channels.isEmpty() || new java.util.HashSet<>(channels).size() != channels.size()) {
            throw new IllegalArgumentException("channels must be non-empty and distinct");
        }
    }
}

// NotificationPlan compact constructor
if (expiresAt.isPresent() && notBefore.isPresent()
    && !expiresAt.get().isAfter(notBefore.get())) {
    throw new IllegalArgumentException("expiresAt must be after notBefore");
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-core-api:test

Expected: PASS: Stable routing strategy와 시간 불변 조건이 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationPlan.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/RecipientSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/TemplateSelection.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/DeduplicationAction.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseSpec.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CollapseScope.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ContactPointSelector.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/ChannelPreferenceOverride.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/DeliveryStrategy.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/ExplicitChannel.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/OrderedFallback.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/routing/Channel.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/NotificationPlanTest.java'
git commit -m "feat(notification): add notification plan and routing strategies"

Task 5: N1 Typed Facade와 N2 Orchestrator 계약 구현

Files:

  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationReceipt.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationOrchestrator.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelResult.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelCommand.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationSnapshot.java
  • Create: modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotification.java
  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotification.java
  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEstimate.java
  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEncoding.java
  • Create: modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotification.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotification.java
  • Create: modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotifier.java
  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotifier.java
  • Create: modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotifier.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotifier.java
  • Test: modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/PublicApiBoundaryTest.java

Interfaces:

  • Consumes: Task 2 IDs, Task 4 NotificationPlan.
  • Produces: N1 channel facade, N2 submit/schedule/cancel/get, durable acceptance만 표현하는 NotificationReceipt.

Implementation requirements:

  • N1 facade 구현은 후속 starter/runtime가 제공한다.

  • N1 메서드는 provider response를 대기하지 않는다.

  • public API에서 Provider SDK와 raw map request를 사용하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.api;

import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.*;

class PublicApiBoundaryTest {
    @Test
    void receiptContainsNoDeliveryBoolean() {
        assertThat(NotificationReceipt.class.getRecordComponents())
            .extracting(c -> c.getName())
            .doesNotContain("delivered", "sent", "read");
    }

    @Test
    void orchestratorReturnsDurableReceipt() throws Exception {
        Method method = NotificationOrchestrator.class
            .getMethod("submit", NotificationPlan.class);
        assertThat(method.getReturnType()).isEqualTo(NotificationReceipt.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-core-api:test --tests "*.PublicApiBoundaryTest"

Expected: FAIL: public facade와 receipt가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationReceipt.java
  • NotificationOrchestrator.java
  • CancelResult.java
  • CancelCommand.java
  • NotificationSnapshot.java
  • EmailNotification.java
  • SmsNotification.java
  • SmsEstimate.java
  • SmsEncoding.java
  • MobilePushNotification.java
  • WebPushNotification.java
  • EmailNotifier.java
  • SmsNotifier.java
  • MobilePushNotifier.java
  • WebPushNotifier.java
public record NotificationReceipt(
    NotificationId notificationId,
    RequestStatus status,
    java.time.Instant acceptedAt
) {
    public NotificationReceipt {
        java.util.Objects.requireNonNull(notificationId, "notificationId");
        java.util.Objects.requireNonNull(status, "status");
        java.util.Objects.requireNonNull(acceptedAt, "acceptedAt");
    }
}

public record SmsEstimate(
    SmsEncoding encoding,
    int segmentCount,
    int encodedLength,
    boolean exceedsRecommendedLimit
) {}

public interface NotificationOrchestrator {
    NotificationReceipt submit(NotificationPlan plan);
    NotificationReceipt schedule(NotificationPlan plan, java.time.Instant scheduleAt);
    CancelResult cancel(NotificationId notificationId, CancelCommand command);
    NotificationSnapshot get(NotificationId notificationId);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-core-api:test

Expected: PASS: receipt가 final delivery를 표현하지 않고 public API가 typed contract다.

  • Step 5: Commit
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationReceipt.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationOrchestrator.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelResult.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/CancelCommand.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/NotificationSnapshot.java' 'modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotification.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotification.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEstimate.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsEncoding.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotification.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotification.java' 'modules/notification/notification-email-api/src/main/java/io/backend/skeleton/notification/email/EmailNotifier.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsNotifier.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/MobilePushNotifier.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotifier.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/PublicApiBoundaryTest.java'
git commit -m "feat(notification): add typed public notification APIs"

Task 6: Contact Point 타입과 FCM FID 우선 모델 구현

Files:

  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointValue.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/EmailAddress.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/PhoneNumber.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/MobilePushTarget.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/FcmInstallationId.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/LegacyFcmRegistrationToken.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsDeviceToken.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsEnvironment.java
  • Create: modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointStatus.java
  • Test: modules/notification/notification-contact-api/src/test/java/io/backend/skeleton/notification/contact/ContactPointTypeTest.java

Interfaces:

  • Consumes: Task 1 contact-api module.
  • Produces: Typed contact values, FID primary와 legacy token의 명시적 분리, APNs environment.

Implementation requirements:

  • Email local-part case 정책은 보존하고 domain만 IDNA/lowercase normalize한다.

  • PhoneNumber E.164 구체 검증은 Task 30에서 강화한다.

  • Contact Point type에 provider credential을 넣지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.contact;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ContactPointTypeTest {
    @Test
    void fcmInstallationAndLegacyTokenAreDifferentTypes() {
        assertThat(FcmInstallationId.class)
            .isNotEqualTo(LegacyFcmRegistrationToken.class);
    }

    @Test
    void apnsTokenRequiresEnvironment() {
        assertThatThrownBy(() -> new ApnsDeviceToken("token", null))
            .isInstanceOf(NullPointerException.class);
    }

    @Test
    void emailIsNormalizedWithoutChangingOriginalDisplay() {
        var address = EmailAddress.parse("User@Example.COM");
        assertThat(address.normalized()).isEqualTo("User@example.com");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-contact-api:test --tests "*.ContactPointTypeTest"

Expected: FAIL: typed Contact Point가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ContactPointValue.java
  • EmailAddress.java
  • PhoneNumber.java
  • MobilePushTarget.java
  • FcmInstallationId.java
  • LegacyFcmRegistrationToken.java
  • ApnsDeviceToken.java
  • ApnsEnvironment.java
  • ContactPointStatus.java
public sealed interface MobilePushTarget extends ContactPointValue
    permits FcmInstallationId, LegacyFcmRegistrationToken, ApnsDeviceToken {}

public record FcmInstallationId(String value) implements MobilePushTarget {
    public FcmInstallationId {
        if (value == null || value.isBlank()) throw new IllegalArgumentException("value");
    }
}

public record LegacyFcmRegistrationToken(String value) implements MobilePushTarget {}

public record ApnsDeviceToken(String value, ApnsEnvironment environment)
    implements MobilePushTarget {
    public ApnsDeviceToken {
        java.util.Objects.requireNonNull(environment, "environment");
        if (value == null || value.isBlank()) throw new IllegalArgumentException("value");
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-contact-api:test

Expected: PASS: FID·legacy token·APNs token이 독립 타입이고 생명주기 enum이 존재한다.

  • Step 5: Commit
git add 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointValue.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/EmailAddress.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/PhoneNumber.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/MobilePushTarget.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/FcmInstallationId.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/LegacyFcmRegistrationToken.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsDeviceToken.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ApnsEnvironment.java' 'modules/notification/notification-contact-api/src/main/java/io/backend/skeleton/notification/contact/ContactPointStatus.java' 'modules/notification/notification-contact-api/src/test/java/io/backend/skeleton/notification/contact/ContactPointTypeTest.java'
git commit -m "feat(notification): add protected contact point value types"

Task 7: Contact Point AES-GCM 보호와 HMAC lookup 구현

Files:

  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretMaterialProvider.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ContactPointProtector.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AesGcmContactPointProtector.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProtectedContactPoint.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretKeyMaterial.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretPurpose.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AccessContext.java
  • Test: modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/AesGcmContactPointProtectorTest.java

Interfaces:

  • Consumes: Task 6 ContactPointValue.
  • Produces: SecretMaterialProvider, AES-256-GCM encrypted value, separate HMAC-SHA-256 fingerprint.

Implementation requirements:

  • AES key는 정확히 256-bit인지 startup에서 검증한다.

  • GCM nonce 재사용을 금지하고 SecureRandom으로 매번 생성한다.

  • exception message에 plaintext를 포함하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.security;

import org.junit.jupiter.api.Test;
import io.backend.skeleton.notification.contact.EmailAddress;
import static org.assertj.core.api.Assertions.*;

class AesGcmContactPointProtectorTest {
    @Test
    void encryptsRoundTripAndProducesStableLookupFingerprint() {
        var protector = SecurityFixture.protector();
        var value = EmailAddress.parse("user@example.com");
        var first = protector.protect(value);
        var second = protector.protect(value);

        assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext());
        assertThat(first.lookupHmac()).isEqualTo(second.lookupHmac());
        assertThat(protector.reveal(first, SecurityFixture.access())).isEqualTo(value);
    }

    @Test
    void encryptionAndHmacKeysAreDifferent() {
        assertThatThrownBy(SecurityFixture::protectorWithSameKeys)
            .isInstanceOf(IllegalArgumentException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-security:test --tests "*.AesGcmContactPointProtectorTest"

Expected: FAIL: security protector classes가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • SecretMaterialProvider.java
  • ContactPointProtector.java
  • AesGcmContactPointProtector.java
  • ProtectedContactPoint.java
  • SecretKeyMaterial.java
  • SecretPurpose.java
  • AccessContext.java
public record ProtectedContactPoint(
    String type,
    String keyId,
    byte[] nonce,
    byte[] ciphertext,
    String lookupHmac
) {
    public ProtectedContactPoint {
        nonce = nonce.clone();
        ciphertext = ciphertext.clone();
    }
}

public final class AesGcmContactPointProtector implements ContactPointProtector {
    private static final String CIPHER = "AES/GCM/NoPadding";
    private static final String HMAC = "HmacSHA256";

    public ProtectedContactPoint protect(ContactPointValue value) {
        var encryption = keys.activeKey(SecretPurpose.CONTACT_ENCRYPTION);
        var lookup = keys.activeKey(SecretPurpose.CONTACT_LOOKUP_HMAC);
        if (encryption.keyId().equals(lookup.keyId())) {
            throw new IllegalArgumentException("encryption and HMAC keys must differ");
        }
        // SecureRandom 96-bit nonce, canonical type+value as AAD, 128-bit GCM tag.
        return encryptAndFingerprint(value, encryption, lookup);
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-security:test

Expected: PASS: 동일 값은 같은 HMAC lookup을 가지지만 nonce로 암호문이 달라지고 복호화된다.

  • Step 5: Commit
git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretMaterialProvider.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ContactPointProtector.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AesGcmContactPointProtector.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProtectedContactPoint.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretKeyMaterial.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SecretPurpose.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/AccessContext.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/AesGcmContactPointProtectorTest.java'
git commit -m "feat(notification): protect contact points at rest"

Task 8: Template Registry와 immutable version 구현

Files:

  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateVersion.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateRegistry.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateStatus.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/VariableSchema.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateContentDefinition.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVersionConflictException.java
  • Test: modules/notification/notification-template-api/src/test/java/io/backend/skeleton/notification/template/TemplateRegistryContractTest.java

Interfaces:

  • Consumes: Task 4 Channel과 TemplateSelection.
  • Produces: Immutable NotificationTemplateVersion, explicit TemplateSelection, publish/disable registry contract.

Implementation requirements:

  • Template ID/version/locale 조합을 unique하게 관리한다.

  • disable은 기존 Notification의 retry/redrive용 조회를 삭제하지 않는다.

  • Java class name을 template ID로 사용하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.template;

import org.junit.jupiter.api.Test;
import java.util.Locale;
import static org.assertj.core.api.Assertions.*;

class TemplateRegistryContractTest {
    @Test
    void publishedVersionCannotBeOverwritten() {
        var registry = new InMemoryTemplateRegistry();
        var template = TemplateFixture.version(1, Locale.KOREAN);
        registry.publish(template);
        assertThatThrownBy(() -> registry.publish(template.withChangedContent("changed")))
            .isInstanceOf(TemplateVersionConflictException.class);
    }

    @Test
    void selectionPinsExactVersion() {
        var selection = new TemplateSelection("password-reset", 3, Locale.KOREAN);
        assertThat(selection.version()).isEqualTo(3);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-template-api:test --tests "*.TemplateRegistryContractTest"

Expected: FAIL: template registry contract가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationTemplateVersion.java
  • TemplateRegistry.java
  • TemplateStatus.java
  • VariableSchema.java
  • TemplateContentDefinition.java
  • TemplateVersionConflictException.java
public interface TemplateRegistry {
    NotificationTemplateVersion get(TemplateSelection selection);
    void publish(NotificationTemplateVersion version);
    void disable(String templateId, long version);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-template-api:test

Expected: PASS: publish된 버전은 덮어쓰지 못하고 selection이 version을 고정한다.

  • Step 5: Commit
git add 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateVersion.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateRegistry.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateStatus.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/VariableSchema.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateContentDefinition.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVersionConflictException.java' 'modules/notification/notification-template-api/src/test/java/io/backend/skeleton/notification/template/TemplateRegistryContractTest.java'
git commit -m "feat(notification): add immutable template registry contract"

Task 9: JSON Schema 변수 검증과 Thymeleaf Reference Renderer 구현

Files:

  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateRenderer.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderedNotificationContent.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderCommand.java
  • Create: modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVariableValidationException.java
  • Create: modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/JsonSchemaVariableValidator.java
  • Create: modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRenderer.java
  • Test: modules/notification/notification-template-thymeleaf/src/test/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRendererTest.java

Interfaces:

  • Consumes: Task 3 content records, Task 8 template registry.
  • Produces: NotificationTemplateRenderer, JSON Schema 2020-12 validation, deterministic rendered digest, locale fallback.

Implementation requirements:

  • Renderer error message에 secret-classified variable value를 포함하지 않는다.

  • Template resolver가 최신 version을 자동 선택하지 않고 exact selection을 사용한다.

  • Thymeleaf type은 template-api에 노출하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.template.thymeleaf;

import org.junit.jupiter.api.Test;
import java.util.Locale;
import static org.assertj.core.api.Assertions.*;

class ThymeleafNotificationRendererTest {
    @Test
    void rejectsMissingRequiredVariableBeforeProviderCall() {
        var renderer = RendererFixture.rendererWithRequiredVariable("code");
        assertThatThrownBy(() -> renderer.render(RendererFixture.command(java.util.Map.of())))
            .isInstanceOf(TemplateVariableValidationException.class);
    }

    @Test
    void sameVersionAndVariablesProduceSameDigest() {
        var renderer = RendererFixture.renderer();
        var command = RendererFixture.command(java.util.Map.of("name", "동현"));
        assertThat(renderer.render(command).contentDigest())
            .isEqualTo(renderer.render(command).contentDigest());
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-template-thymeleaf:test --tests "*.ThymeleafNotificationRendererTest"

Expected: FAIL: renderer와 schema validator가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationTemplateRenderer.java
  • RenderedNotificationContent.java
  • RenderCommand.java
  • TemplateVariableValidationException.java
  • JsonSchemaVariableValidator.java
  • ThymeleafNotificationRenderer.java
public interface NotificationTemplateRenderer {
    Channel channel();
    RenderedNotificationContent render(RenderCommand command);
}

public record RenderedNotificationContent(
    NotificationContent content,
    String contentDigest,
    TemplateSelection templateSelection,
    java.util.Locale resolvedLocale
) {}

// Renderer algorithm:
// 1. JSON Schema 2020-12 validation.
// 2. exact locale → language locale → template fallback → platform default.
// 3. deterministic canonical UTF-8 rendering.
// 4. SHA-256 digest over channel, template ID/version, locale and rendered fields.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-template-thymeleaf:test

Expected: PASS: invalid variables are rejected and rendering digest is deterministic.

  • Step 5: Commit
git add 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/NotificationTemplateRenderer.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderedNotificationContent.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/RenderCommand.java' 'modules/notification/notification-template-api/src/main/java/io/backend/skeleton/notification/template/TemplateVariableValidationException.java' 'modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/JsonSchemaVariableValidator.java' 'modules/notification/notification-template-thymeleaf/src/main/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRenderer.java' 'modules/notification/notification-template-thymeleaf/src/test/java/io/backend/skeleton/notification/template/thymeleaf/ThymeleafNotificationRendererTest.java'
git commit -m "feat(notification): add template validation and rendering"

Task 10: Provider SPI, Capability, 실행 증거 모델 구현

Files:

  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/NotificationProviderAdapter.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmission.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmissionResult.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderCapabilities.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderExecutionEvidence.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceCertainty.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderProfileSnapshot.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderFailure.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/TraceContext.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceFact.java
  • Test: modules/notification/notification-provider-spi/src/test/java/io/backend/skeleton/notification/provider/ProviderSpiContractTest.java

Interfaces:

  • Consumes: Tasks 2, 3, 6, 9의 ID·content·contact·rendered content.
  • Produces: NotificationProviderAdapter.submit, capability model, CONFIRMED/REJECTED/AMBIGUOUS result와 execution evidence.

Implementation requirements:

  • Provider native SDK object를 ProviderSubmission에 넣지 않는다.

  • Adapter가 모르는 실행 사실은 UNKNOWN certainty로 기록한다.

  • capability가 false인 기능을 runtime이 요청하면 startup 또는 dispatch 전에 거부한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.provider;

import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.concurrent.CompletionStage;
import static org.assertj.core.api.Assertions.*;

class ProviderSpiContractTest {
    @Test
    void submitIsAsyncAndReturnsEvidenceResult() throws Exception {
        Method method = NotificationProviderAdapter.class
            .getMethod("submit", ProviderSubmission.class);
        assertThat(method.getReturnType()).isEqualTo(CompletionStage.class);
    }

    @Test
    void ambiguousResultDoesNotClaimAcceptance() {
        var result = ProviderResultFixture.ambiguous();
        assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
        assertThat(result.evidenceLevel()).isNotEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-provider-spi:test --tests "*.ProviderSpiContractTest"

Expected: FAIL: provider SPI가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationProviderAdapter.java
  • ProviderSubmission.java
  • ProviderSubmissionResult.java
  • ProviderCapabilities.java
  • ProviderExecutionEvidence.java
  • EvidenceCertainty.java
  • ProviderProfileSnapshot.java
  • ProviderFailure.java
  • TraceContext.java
  • EvidenceFact.java
public interface NotificationProviderAdapter {
    ProviderId providerId();
    java.util.Set<Channel> channels();
    ProviderCapabilities capabilities();
    java.util.concurrent.CompletionStage<ProviderSubmissionResult> submit(
        ProviderSubmission submission);
}

public record ProviderExecutionEvidence(
    EvidenceFact requestStarted,
    EvidenceFact requestBodyCommitted,
    EvidenceFact responseReceived,
    EvidenceFact providerAcceptance
) {}

public record EvidenceFact(boolean value, EvidenceCertainty certainty) {}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-provider-spi:test

Expected: PASS: provider SPI가 CompletionStage와 명시적 evidence를 사용한다.

  • Step 5: Commit
git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/NotificationProviderAdapter.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmission.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderSubmissionResult.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderCapabilities.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderExecutionEvidence.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceCertainty.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderProfileSnapshot.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ProviderFailure.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/TraceContext.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/EvidenceFact.java' 'modules/notification/notification-provider-spi/src/test/java/io/backend/skeleton/notification/provider/ProviderSpiContractTest.java'
git commit -m "feat(notification): add provider adapter SPI and evidence contract"

Task 11: 안정 오류 계층과 FailureCategory 구현

Files:

  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationException.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationFailureDescriptor.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/FailureCategory.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/AmbiguousSubmissionException.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/IdempotencyConflictException.java
  • Create: modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationSuppressedException.java
  • Test: modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/error/NotificationExceptionTest.java

Interfaces:

  • Consumes: Task 2 evidence enum과 Task 4 Channel.
  • Produces: Driver/SDK exception을 숨기는 안정 예외, low-cardinality failure descriptor.

Implementation requirements:

  • Provider SDK exception을 public cause type으로 계약하지 않는다.

  • cause는 내부 diagnostic에 보존할 수 있지만 message/body/address를 exception message에 합치지 않는다.

  • failure code는 bounded registry로 관리한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.api.error;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationExceptionTest {
    @Test
    void ambiguousDescriptorIsNonRetryableByDefault() {
        var descriptor = NotificationFailureDescriptor.ambiguous(
            "PROVIDER_RESPONSE_LOST", Channel.EMAIL, new ProviderId("ses"), 1);
        assertThat(descriptor.ambiguous()).isTrue();
        assertThat(descriptor.retryable()).isFalse();
    }

    @Test
    void exceptionMessageDoesNotContainRecipient() {
        var exception = new NotificationSuppressedException(
            NotificationFailureDescriptor.suppressed("USER_OPT_OUT", Channel.SMS));
        assertThat(exception.getMessage()).doesNotContain("+821012345678");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-core-api:test --tests "*.NotificationExceptionTest"

Expected: FAIL: error hierarchy가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationException.java
  • NotificationFailureDescriptor.java
  • FailureCategory.java
  • AmbiguousSubmissionException.java
  • IdempotencyConflictException.java
  • NotificationSuppressedException.java
public enum FailureCategory {
    TRANSIENT_PROVIDER, THROTTLED, AUTHENTICATION, AUTHORIZATION,
    INVALID_RECIPIENT, INVALID_PAYLOAD, TEMPLATE_FAILURE,
    PERMANENT_PROVIDER, AMBIGUOUS_SUBMISSION,
    CALLBACK_VALIDATION_FAILURE, CAPACITY_REJECTED, EXPIRED
}

public record NotificationFailureDescriptor(
    String code,
    FailureCategory category,
    boolean retryable,
    boolean ambiguous,
    Channel channel,
    ProviderId providerId,
    int attemptNumber,
    java.time.Duration elapsed
) {}

public abstract class NotificationException extends RuntimeException {
    private final NotificationFailureDescriptor descriptor;
    protected NotificationException(NotificationFailureDescriptor descriptor) {
        super(descriptor.code());
        this.descriptor = descriptor;
    }
    public NotificationFailureDescriptor descriptor() { return descriptor; }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-core-api:test

Expected: PASS: ambiguity와 retryability가 descriptor에 분리되고 exception message가 sanitized된다.

  • Step 5: Commit
git add 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationFailureDescriptor.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/FailureCategory.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/AmbiguousSubmissionException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/IdempotencyConflictException.java' 'modules/notification/notification-core-api/src/main/java/io/backend/skeleton/notification/api/error/NotificationSuppressedException.java' 'modules/notification/notification-core-api/src/test/java/io/backend/skeleton/notification/api/error/NotificationExceptionTest.java'
git commit -m "feat(notification): add stable failure taxonomy"

Task 12: Flyway Notification 영속 스키마 구현

Files:

  • Create: modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V1__notification_core.sql
  • Create: modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V2__notification_contact_template_policy.sql
  • Create: modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V3__notification_inbox_admin.sql
  • Test: modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationFlywayMigrationTest.java

Interfaces:

  • Consumes: Tasks 2, 8, 11의 type names.
  • Produces: PostgreSQL tables, unique constraints, SKIP LOCKED indexes, ProviderEvent dedup indexes.

Implementation requirements:

  • enum은 PostgreSQL native enum이 아니라 varchar + application validation으로 시작한다.

  • Contact Point ciphertext는 bytea, lookup HMAC은 fixed char로 저장한다.

  • ProviderEvent raw payload에는 row-level size guard를 application과 DB check에 함께 둔다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.persistence;

import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.*;

class NotificationFlywayMigrationTest extends PostgreSqlNotificationTest {
    @Test
    void createsIdempotencyAndProviderEventUniqueness() {
        JdbcTemplate jdbc = jdbc();
        assertThat(indexNames(jdbc, "notification_request"))
            .contains("uk_notification_request_idempotency");
        assertThat(indexNames(jdbc, "notification_provider_event"))
            .contains("uk_notification_provider_event_id", "uk_notification_provider_event_fingerprint");
    }

    @Test
    void createsDispatchPartialIndex() {
        assertThat(indexDefinition(jdbc(), "ix_notification_recipient_dispatch"))
            .contains("next_dispatch_at")
            .contains("READY_TO_DISPATCH")
            .contains("RETRY_WAITING");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-persistence-jpa:test --tests "*.NotificationFlywayMigrationTest"

Expected: FAIL: Flyway migration이 없어서 notification table과 index가 존재하지 않는다.

  • Step 3: Write the minimal implementation

Files to implement:

  • V1__notification_core.sql
  • V2__notification_contact_template_policy.sql
  • V3__notification_inbox_admin.sql
CREATE TABLE notification_request (
    id uuid PRIMARY KEY,
    tenant_id varchar(100) NOT NULL,
    idempotency_key varchar(200) NOT NULL,
    request_fingerprint char(64) NOT NULL,
    category varchar(120) NOT NULL,
    template_id varchar(160) NOT NULL,
    template_version bigint NOT NULL,
    strategy_type varchar(40) NOT NULL,
    schedule_at timestamptz,
    not_before timestamptz,
    expires_at timestamptz,
    request_status varchar(40) NOT NULL,
    correlation_id varchar(160),
    metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,
    created_at timestamptz NOT NULL,
    updated_at timestamptz NOT NULL,
    version bigint NOT NULL DEFAULT 0,
    CONSTRAINT uk_notification_request_idempotency
        UNIQUE (tenant_id, idempotency_key)
);

CREATE TABLE notification_recipient_delivery (
    id uuid PRIMARY KEY,
    notification_id uuid NOT NULL REFERENCES notification_request(id),
    recipient_ref varchar(200) NOT NULL,
    routing_plan_json jsonb NOT NULL,
    route_cursor integer NOT NULL DEFAULT 0,
    delivery_state varchar(40) NOT NULL,
    submission_outcome varchar(40) NOT NULL,
    delivery_outcome varchar(40) NOT NULL,
    evidence_level varchar(50) NOT NULL,
    ambiguous_attempt_exists boolean NOT NULL DEFAULT false,
    duplicate_risk boolean NOT NULL DEFAULT false,
    next_dispatch_at timestamptz,
    lease_owner varchar(120),
    lease_until timestamptz,
    attempt_count integer NOT NULL DEFAULT 0,
    created_at timestamptz NOT NULL,
    updated_at timestamptz NOT NULL,
    version bigint NOT NULL DEFAULT 0
);
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-persistence-jpa:test

Expected: PASS: PostgreSQL 16에서 모든 migration과 index assertion이 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V1__notification_core.sql' 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V2__notification_contact_template_policy.sql' 'modules/notification/notification-persistence-jpa/src/main/resources/db/migration/notification/V3__notification_inbox_admin.sql' 'modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationFlywayMigrationTest.java'
git commit -m "feat(notification): add durable notification schema"

Task 13: JPA Entity, Repository, Tenant Guard 구현

Files:

  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestEntity.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryEntity.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/DeliveryAttemptEntity.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/ProviderEventEntity.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestRepository.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryRepository.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/TenantBoundRepositoryGuard.java
  • Test: modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationRepositoryTest.java

Interfaces:

  • Consumes: Task 12 schema, Tasks 2·13 state names.
  • Produces: Optimistic-lock JPA entities and tenant-scoped repositories.

Implementation requirements:

  • Entity setter를 public으로 열지 않고 domain transition method를 사용한다.

  • repository가 tenant 없는 contact/request lookup을 일반 API로 제공하지 않는다.

  • JPA entity를 Core public API에서 반환하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.persistence;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationRepositoryTest extends PostgreSqlNotificationTest {
    @Test
    void requestLookupRequiresTenant() {
        var saved = fixture().request("tenant-a", "idem-1");
        repository().save(saved);
        assertThat(repository().findByTenantAndIdempotencyKey("tenant-b", "idem-1"))
            .isEmpty();
    }

    @Test
    void optimisticVersionRejectsConcurrentProjectionUpdate() {
        var delivery = fixture().recipient();
        recipientRepository().saveAndFlush(delivery);
        var first = recipientRepository().findById(delivery.id()).orElseThrow();
        var second = recipientRepository().findById(delivery.id()).orElseThrow();
        first.markSuppressed();
        recipientRepository().saveAndFlush(first);
        second.markDispatching();
        assertThatThrownBy(() -> recipientRepository().saveAndFlush(second))
            .isInstanceOf(org.springframework.orm.ObjectOptimisticLockingFailureException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-persistence-jpa:test --tests "*.NotificationRepositoryTest"

Expected: FAIL: JPA entity와 repository가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationRequestEntity.java
  • RecipientDeliveryEntity.java
  • DeliveryAttemptEntity.java
  • ProviderEventEntity.java
  • NotificationRequestRepository.java
  • RecipientDeliveryRepository.java
  • TenantBoundRepositoryGuard.java
@Entity
@Table(name = "notification_request")
public class NotificationRequestEntity {
    @Id private UUID id;
    @Column(name = "tenant_id", nullable = false) private String tenantId;
    @Column(name = "idempotency_key", nullable = false) private String idempotencyKey;
    @Column(name = "request_fingerprint", nullable = false) private String requestFingerprint;
    @Version private long version;
    // package-private no-arg constructor; static factory enforces invariants.
}

public interface NotificationRequestRepository
    extends JpaRepository<NotificationRequestEntity, UUID> {
    Optional<NotificationRequestEntity> findByTenantIdAndIdempotencyKey(
        String tenantId, String idempotencyKey);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-persistence-jpa:test

Expected: PASS: tenant-scoped lookup과 optimistic lock 테스트가 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/DeliveryAttemptEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/ProviderEventEntity.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/NotificationRequestRepository.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientDeliveryRepository.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/TenantBoundRepositoryGuard.java' 'modules/notification/notification-persistence-jpa/src/test/java/io/backend/skeleton/notification/persistence/NotificationRepositoryTest.java'
git commit -m "feat(notification): add notification JPA repositories"

Task 14: ProviderEvent 원장과 Channel Projector 기반 구현

Files:

  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventRecord.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventProjector.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryProjection.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProjectionResult.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryAttemptSnapshot.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/EngagementFacts.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/SuppressionFacts.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventLedger.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedProviderEvent.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/AppendEventResult.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventSource.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaProviderEventLedger.java
  • Test: modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProjectionMergeContractTest.java

Interfaces:

  • Consumes: Tasks 2 evidence, Task 13 persistence repositories.
  • Produces: Append-only provider event ledger, idempotent projector, out-of-order merge contract.

Implementation requirements:

  • single numeric status priority를 사용하지 않는다.

  • event raw payload를 수정하지 않는다.

  • projector 재실행은 동일 projection을 생성해야 한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.callback;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ProjectionMergeContractTest {
    @Test
    void sentAfterDeliveredDoesNotDowngrade() {
        var projector = CallbackFixture.twilioProjector();
        var delivered = projector.project(
            CallbackFixture.attempt(), CallbackFixture.event("delivered"),
            DeliveryProjection.empty());
        var lateSent = projector.project(
            CallbackFixture.attempt(), CallbackFixture.event("sent"),
            delivered.projection());
        assertThat(lateSent.projection().deliveryOutcome())
            .isEqualTo(DeliveryOutcome.DELIVERED);
    }

    @Test
    void complaintAddsFactWithoutRemovingDelivery() {
        var result = CallbackFixture.emailProjector().project(
            CallbackFixture.attempt(), CallbackFixture.event("complaint"),
            CallbackFixture.deliveredProjection());
        assertThat(result.projection().deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED);
        assertThat(result.projection().suppressionFacts().complained()).isTrue();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-callback-api:test --tests "*.ProjectionMergeContractTest"

Expected: FAIL: event ledger와 projector contract가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ProviderEventRecord.java
  • ProviderEventProjector.java
  • DeliveryProjection.java
  • ProjectionResult.java
  • DeliveryAttemptSnapshot.java
  • EngagementFacts.java
  • SuppressionFacts.java
  • ProviderEventLedger.java
  • VerifiedProviderEvent.java
  • AppendEventResult.java
  • ProviderEventSource.java
  • JpaProviderEventLedger.java
public interface ProviderEventProjector {
    ProviderId providerId();
    ProjectionResult project(
        DeliveryAttemptSnapshot attempt,
        ProviderEventRecord event,
        DeliveryProjection current);
}

public record DeliveryProjection(
    SubmissionOutcome submissionOutcome,
    DeliveryOutcome deliveryOutcome,
    EvidenceLevel evidenceLevel,
    EngagementFacts engagementFacts,
    SuppressionFacts suppressionFacts
) {}

public interface ProviderEventLedger {
    AppendEventResult append(VerifiedProviderEvent event);
    java.util.List<ProviderEventRecord> pendingProjection(int limit);
    void markApplied(ProviderEventId eventId, ProjectionResult result);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-callback-api:test :modules:notification:notification-persistence-jpa:test

Expected: PASS: 역순 event가 downgrade되지 않고 event append가 idempotent하다.

  • Step 5: Commit
git add 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventRecord.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventProjector.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryProjection.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProjectionResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/DeliveryAttemptSnapshot.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/EngagementFacts.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/SuppressionFacts.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventLedger.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedProviderEvent.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/AppendEventResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderEventSource.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaProviderEventLedger.java' 'modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProjectionMergeContractTest.java'
git commit -m "feat(notification): add provider event ledger and projectors"

Task 15: Idempotent Submit Application Service 구현

Files:

  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionService.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RequestFingerprint.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/CanonicalNotificationPlanWriter.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionServiceTest.java

Interfaces:

  • Consumes: Tasks 4·5 plan/API, Task 8 exact template, Task 13 repositories.
  • Produces: Transactional submit, canonical fingerprint, unique conflict convergence, durable receipt.

Implementation requirements:

  • fingerprint에 recipient, template version, variables digest, schedule/expiry, routing을 포함한다.

  • Provider 호출을 submit transaction에 포함하지 않는다.

  • Receipt acceptedAt은 DB에 저장한 createdAt과 동일하다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;

class NotificationSubmissionServiceTest extends PostgreSqlNotificationTest {
    @Test
    void concurrentSameRequestReturnsOneNotificationId() throws Exception {
        var service = service();
        var plan = fixture().plan("idem-1");
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var a = executor.submit(() -> service.submit(plan));
            var b = executor.submit(() -> service.submit(plan));
            assertThat(a.get().notificationId()).isEqualTo(b.get().notificationId());
        }
        assertThat(requestCount()).isEqualTo(1);
    }

    @Test
    void sameKeyDifferentFingerprintFails() {
        service().submit(fixture().plan("idem-2"));
        assertThatThrownBy(() -> service().submit(fixture().changedPlan("idem-2")))
            .isInstanceOf(IdempotencyConflictException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationSubmissionServiceTest"

Expected: FAIL: submission service가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationSubmissionService.java
  • RequestFingerprint.java
  • CanonicalNotificationPlanWriter.java
@Transactional
public NotificationReceipt submit(NotificationPlan plan) {
    var fingerprint = fingerprint.of(plan);
    var existing = requests.findByTenantIdAndIdempotencyKey(
        plan.tenantId().value(), plan.idempotencyKey().value());
    if (existing.isPresent()) return compareAndReturn(existing.get(), fingerprint);

    try {
        var aggregate = factory.create(plan, fingerprint, clock.instant());
        requests.saveAndFlush(aggregate.request());
        recipients.saveAll(aggregate.recipients());
        return aggregate.receipt();
    } catch (DataIntegrityViolationException duplicate) {
        var winner = requests.findByTenantIdAndIdempotencyKey(
            plan.tenantId().value(), plan.idempotencyKey().value()).orElseThrow();
        return compareAndReturn(winner, fingerprint);
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: 동시 submit이 하나로 수렴하고 fingerprint conflict가 거부된다.

  • Step 5: Commit
git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionService.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RequestFingerprint.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/CanonicalNotificationPlanWriter.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSubmissionServiceTest.java'
git commit -m "feat(notification): add idempotent durable submission"

Task 16: PostgreSQL Durable Scheduler와 Lease Claim 구현

Files:

  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientLeaseRepository.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationScheduler.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RecipientLease.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/LeaseRecoveryService.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSchedulerConcurrencyTest.java

Interfaces:

  • Consumes: Task 12 dispatch index, Task 13 repositories, Task 15 recipient rows.
  • Produces: SKIP LOCKED claim, lease expiry, restart recovery, bounded batch scheduler.

Implementation requirements:

  • lease duration, batch size, poll interval은 bounded validated property다.

  • scheduler thread가 Provider 호출을 직접 수행하지 않고 dispatcher queue에 전달한다.

  • shutdown 시작 후 새 lease를 획득하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;

class NotificationSchedulerConcurrencyTest extends PostgreSqlNotificationTest {
    @Test
    void twoWorkersNeverClaimSameRecipient() throws Exception {
        insertReadyRecipients(100);
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var first = executor.submit(() -> scheduler("worker-a").claim(60));
            var second = executor.submit(() -> scheduler("worker-b").claim(60));
            var all = new java.util.HashSet<>(first.get());
            assertThat(all.addAll(second.get())).isTrue();
            assertThat(all).hasSize(100);
        }
    }

    @Test
    void expiredLeaseIsRecovered() {
        var id = insertExpiredLeasedRecipient();
        assertThat(scheduler("worker-b").claim(1)).containsExactly(id);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationSchedulerConcurrencyTest"

Expected: FAIL: scheduler와 lease SQL이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • RecipientLeaseRepository.java
  • NotificationScheduler.java
  • RecipientLease.java
  • LeaseRecoveryService.java
// RecipientLeaseRepository native query
SELECT id
  FROM notification_recipient_delivery
 WHERE next_dispatch_at <= :now
   AND delivery_state IN ('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 :limit

@Transactional
public java.util.List<RecipientDeliveryId> claim(String workerId, int limit) {
    var ids = repository.selectClaimable(clock.instant(), limit);
    repository.markLeased(ids, workerId, clock.instant().plus(leaseDuration));
    return ids.stream().map(RecipientDeliveryId::new).toList();
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: worker 간 중복 claim이 없고 만료 lease가 복구된다.

  • Step 5: Commit
git add 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/RecipientLeaseRepository.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationScheduler.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RecipientLease.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/LeaseRecoveryService.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationSchedulerConcurrencyTest.java'
git commit -m "feat(notification): add durable scheduler and lease recovery"

Task 17: Suppression·Preference·Consent Primitive 구현

Files:

  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionEntry.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionReason.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationEligibilityPolicy.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/PreferenceRecord.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/ConsentRecord.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionId.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionScope.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionSource.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/EligibilityResult.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationContext.java
  • Create: modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaSuppressionRepository.java
  • Test: modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationEligibilityTest.java

Interfaces:

  • Consumes: Task 6 Contact Point, Task 12 policy tables.
  • Produces: Distinct suppression/preference/consent models and final dispatch eligibility evaluation.

Implementation requirements:

  • 법률·광고 분류를 Core enum으로 만들지 않는다.

  • submit와 dispatch 직전 모두 평가하되 dispatch 결과를 최종으로 사용한다.

  • suppression 변경은 audit 대상이다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.policy;

import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.*;

class NotificationEligibilityTest {
    @Test
    void mandatorySuppressionOverridesPreference() {
        var engine = PolicyFixture.engine(
            PolicyFixture.preference(Channel.EMAIL),
            PolicyFixture.suppression(SuppressionReason.COMPLAINT));
        assertThat(engine.evaluate(PolicyFixture.context()).allowed()).isFalse();
    }

    @Test
    void expiredTemporarySuppressionDoesNotBlock() {
        var suppression = PolicyFixture.temporarySuppression(
            Instant.parse("2026-08-09T00:00:00Z"));
        assertThat(PolicyFixture.engine(suppression)
            .evaluate(PolicyFixture.contextAt("2026-08-10T00:00:00Z")).allowed()).isTrue();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-policy:test --tests "*.NotificationEligibilityTest"

Expected: FAIL: policy primitive가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • SuppressionEntry.java
  • SuppressionReason.java
  • NotificationEligibilityPolicy.java
  • PreferenceRecord.java
  • ConsentRecord.java
  • SuppressionId.java
  • SuppressionScope.java
  • SuppressionSource.java
  • EligibilityResult.java
  • NotificationContext.java
  • JpaSuppressionRepository.java
public interface NotificationEligibilityPolicy {
    EligibilityResult evaluate(NotificationContext context);
}

public enum SuppressionReason {
    USER_OPT_OUT, HARD_BOUNCE, COMPLAINT, INVALID_TOKEN, INVALID_PHONE,
    ADMIN_BLOCK, PROVIDER_BLOCK, TEMPORARY_SUPPRESSION
}

public record EligibilityResult(
    boolean allowed,
    java.util.List<String> reasonCodes
) {}

// Composite order: mandatory internal suppression → provider suppression
// → injected application eligibility. Preference selects channel only after eligibility.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-policy:test :modules:notification:notification-persistence-jpa:test

Expected: PASS: suppression이 preference보다 우선하고 expiry가 적용된다.

  • Step 5: Commit
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionEntry.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionReason.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationEligibilityPolicy.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/PreferenceRecord.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/ConsentRecord.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionId.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionScope.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/SuppressionSource.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/EligibilityResult.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationContext.java' 'modules/notification/notification-persistence-jpa/src/main/java/io/backend/skeleton/notification/persistence/JpaSuppressionRepository.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationEligibilityTest.java'
git commit -m "feat(notification): add suppression preference and consent primitives"

Task 18: Routing·Ordered Fallback Decision Engine 구현

Files:

  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecisionEngine.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecision.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RouteCandidate.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingContext.java
  • Test: modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/RoutingDecisionEngineTest.java

Interfaces:

  • Consumes: Task 4 strategies, Task 11 failure category, Task 17 eligibility.
  • Produces: Explicit/ordered routing, invalid-recipient fallback, ambiguous fallback hard block.

Implementation requirements:

  • Provider accepted 이후 fallback은 기본 금지한다.

  • route candidate는 active Contact Point와 enabled Provider Profile을 모두 요구한다.

  • Parallel first-success를 Stable engine에 넣지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.policy;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class RoutingDecisionEngineTest {
    @Test
    void ambiguousAttemptBlocksAutomaticFallback() {
        var decision = engine().next(PolicyFixture.ambiguousPushThenSms());
        assertThat(decision.fallbackAllowed()).isFalse();
        assertThat(decision.reconciliationRequired()).isTrue();
        assertThat(decision.duplicateRisk()).isTrue();
    }

    @Test
    void invalidPushTargetFallsBackToSms() {
        var decision = engine().next(PolicyFixture.invalidPushThenSms());
        assertThat(decision.selected().orElseThrow().channel()).isEqualTo(Channel.SMS);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-policy:test --tests "*.RoutingDecisionEngineTest"

Expected: FAIL: routing decision engine이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • RoutingDecisionEngine.java
  • RoutingDecision.java
  • RouteCandidate.java
  • RoutingContext.java
public RoutingDecision next(RoutingContext context) {
    if (context.ambiguousAttemptExists()) {
        return RoutingDecision.reconcile("AMBIGUOUS_ATTEMPT", true);
    }
    if (context.lastFailure() == FailureCategory.INVALID_RECIPIENT) {
        return selectNextEligibleRoute(context);
    }
    if (context.lastSubmission() == SubmissionOutcome.CONFIRMED_ACCEPTED) {
        return RoutingDecision.stop("PROVIDER_ALREADY_ACCEPTED");
    }
    return selectCurrentOrNextRoute(context);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-policy:test

Expected: PASS: ambiguous fallback이 차단되고 invalid recipient만 안전하게 다음 채널로 이동한다.

  • Step 5: Commit
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecisionEngine.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingDecision.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RouteCandidate.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RoutingContext.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/RoutingDecisionEngineTest.java'
git commit -m "feat(notification): add safe routing and fallback engine"

Task 19: Retry Decision Engine과 Expiry·Budget Guard 구현

Files:

  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryContext.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryDecision.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationRetryPolicy.java
  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryBudget.java
  • Test: modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationRetryPolicyTest.java

Interfaces:

  • Consumes: Tasks 10·11 provider evidence/error, Task 18 routing.
  • Produces: RetryAfter/Reconcile/Fallback/Stop decision, jittered backoff, expiry and budget enforcement.

Implementation requirements:

  • Retry budget은 provider profile별 원 요청 대비 추가 시도를 제한한다.

  • Retry-After는 maxBackoff와 expiresAt을 초과하지 않는 범위에서 존중한다.

  • jitter가 deterministic test Clock/RandomSource로 주입 가능해야 한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.policy;

import org.junit.jupiter.api.Test;
import java.time.*;
import static org.assertj.core.api.Assertions.*;

class NotificationRetryPolicyTest {
    @Test
    void authenticationFailureStopsAndOpensProvider() {
        var decision = policy().decide(PolicyFixture.authFailure());
        assertThat(decision).isInstanceOf(RetryDecision.Stop.class);
    }

    @Test
    void ambiguousWithoutReconcileOrProviderIdempotencyStops() {
        var decision = policy().decide(PolicyFixture.ambiguousUnsafe());
        assertThat(decision).isInstanceOf(RetryDecision.Stop.class);
    }

    @Test
    void nextBackoffBeyondExpiryExpiresDelivery() {
        var decision = policy().decide(PolicyFixture.transientWithExpiry(
            Instant.parse("2026-08-10T00:00:01Z")));
        assertThat(((RetryDecision.Stop) decision).reasonCode()).isEqualTo("EXPIRED");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-policy:test --tests "*.NotificationRetryPolicyTest"

Expected: FAIL: retry policy가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • RetryContext.java
  • RetryDecision.java
  • NotificationRetryPolicy.java
  • RetryBudget.java
public sealed interface RetryDecision {
    record RetryAfter(java.time.Duration delay) implements RetryDecision {}
    record Reconcile(java.time.Instant at) implements RetryDecision {}
    record Fallback(String reasonCode) implements RetryDecision {}
    record Stop(String reasonCode) implements RetryDecision {}
}

public RetryDecision decide(RetryContext context) {
    if (context.failureCategory() == FailureCategory.AUTHENTICATION
        || context.failureCategory() == FailureCategory.AUTHORIZATION) {
        return new RetryDecision.Stop("PROVIDER_CONFIGURATION_FAILURE");
    }
    if (context.confirmation() == AttemptConfirmation.AMBIGUOUS) {
        if (context.statusQuerySupported()) return new RetryDecision.Reconcile(context.nextReconcileAt());
        if (!context.providerIdempotency()) return new RetryDecision.Stop("AMBIGUOUS_UNSAFE_TO_RETRY");
    }
    var delay = backoff.delay(context.attemptNumber());
    if (!context.budget().canConsume() || !context.canFinishBeforeExpiry(delay)) {
        return new RetryDecision.Stop("EXPIRED_OR_BUDGET_EXHAUSTED");
    }
    return new RetryDecision.RetryAfter(delay);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-policy:test

Expected: PASS: auth/ambiguity/expiry/budget 규칙이 정확히 판정된다.

  • Step 5: Commit
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryContext.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryDecision.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/NotificationRetryPolicy.java' 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/RetryBudget.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/NotificationRetryPolicyTest.java'
git commit -m "feat(notification): add evidence-aware retry policy"

Task 20: Provider Runtime Registry, Health, Rate·Concurrency Guard 구현

Files:

  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistry.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntime.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeState.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderAttemptLimiter.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistryTest.java

Interfaces:

  • Consumes: Task 10 adapter capability, Task 19 retry policy.
  • Produces: Immutable generation runtime, provider health gate, per-attempt rate and concurrency permits.

Implementation requirements:

  • Rate limiter는 실제 provider attempt마다 token을 소비한다.

  • backoff 중 concurrency permit을 점유하지 않는다.

  • credential generation을 DeliveryAttempt에 기록한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ProviderRuntimeRegistryTest {
    @Test
    void authenticationFailureRejectsNewAttemptsWithoutConsumingPermit() {
        var runtime = RuntimeFixture.runtime();
        runtime.markAuthenticationFailed("INVALID_CREDENTIAL");
        assertThatThrownBy(runtime::acquireAttempt)
            .isInstanceOf(ProviderUnavailableException.class);
        assertThat(runtime.activeAttempts()).isZero();
    }

    @Test
    void replacementKeepsOldRuntimeDraining() {
        var registry = RuntimeFixture.registryWithGeneration(1);
        var old = registry.current("ses-primary");
        registry.replace(RuntimeFixture.runtime(2));
        assertThat(registry.current("ses-primary").generation()).isEqualTo(2);
        assertThat(old.state()).isEqualTo(ProviderRuntimeState.DRAINING);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ProviderRuntimeRegistryTest"

Expected: FAIL: runtime registry와 limiter가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ProviderRuntimeRegistry.java
  • ProviderRuntime.java
  • ProviderRuntimeState.java
  • ProviderAttemptLimiter.java
public enum ProviderRuntimeState {
    HEALTHY, DEGRADED, THROTTLED, AUTHENTICATION_FAILED, DISABLED, DRAINING
}

public final class ProviderRuntime {
    private final long generation;
    private final NotificationProviderAdapter adapter;
    private final ProviderAttemptLimiter limiter;
    private final java.util.concurrent.atomic.AtomicReference<ProviderRuntimeState> state;

    public AttemptPermit acquireAttempt() {
        var current = state.get();
        if (current == ProviderRuntimeState.AUTHENTICATION_FAILED
            || current == ProviderRuntimeState.DISABLED
            || current == ProviderRuntimeState.DRAINING) {
            throw new ProviderUnavailableException(current.name());
        }
        return limiter.acquire();
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: auth failure가 fail-fast하고 runtime replacement가 generation drain을 수행한다.

  • Step 5: Commit
git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistry.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntime.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeState.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderAttemptLimiter.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRegistryTest.java'
git commit -m "feat(notification): add provider runtime isolation and limits"

Task 21: Dispatch Orchestrator와 Ambiguous Completion 기록 구현

Files:

  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationDispatcher.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DeliveryAttemptFactory.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchOutcomeRecorder.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchPipeline.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationDispatcherAmbiguityTest.java

Interfaces:

  • Consumes: Tasks 16 scheduler, 17 eligibility, 18 routing, 19 retry, 20 runtime registry.
  • Produces: Attempt-before-call transaction, provider call outside transaction, confirmed/rejected/ambiguous outcome recording.

Implementation requirements:

  • Provider call을 @Transactional method 내부에서 실행하지 않는다.

  • process crash 후 DISPATCHING attempt는 lease recovery/reconciliation 대상이다.

  • Outcome record와 next action은 idempotent하게 재실행 가능해야 한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationDispatcherAmbiguityTest extends PostgreSqlNotificationTest {
    @Test
    void providerAcceptsThenResponseIsLostRecordsAmbiguousAndBlocksFallback() {
        var provider = ProviderFixture.acceptThenResetConnection();
        dispatcher(provider).dispatch(fixture().leasedRecipient());

        var attempt = lastAttempt();
        var recipient = loadRecipient(attempt.recipientDeliveryId());
        assertThat(attempt.submissionOutcome()).isEqualTo(SubmissionOutcome.AMBIGUOUS);
        assertThat(attempt.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
        assertThat(recipient.ambiguousAttemptExists()).isTrue();
        assertThat(recipient.deliveryState()).isEqualTo(RecipientDeliveryState.RECONCILIATION_REQUIRED);
        assertThat(provider.calls()).isEqualTo(1);
    }

    @Test
    void attemptRowExistsBeforeProviderInvocation() {
        dispatcher(ProviderFixture.assertAttemptExists(repository()))
            .dispatch(fixture().leasedRecipient());
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.NotificationDispatcherAmbiguityTest"

Expected: FAIL: dispatcher와 outcome recorder가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationDispatcher.java
  • DeliveryAttemptFactory.java
  • DispatchOutcomeRecorder.java
  • DispatchPipeline.java
public void dispatch(RecipientLease lease) {
    var snapshot = loader.load(lease.recipientDeliveryId());
    guards.verifyNotExpiredAndEligible(snapshot);
    var route = routing.next(snapshot.routingContext());
    var attempt = attempts.createAndCommit(snapshot, route.selected().orElseThrow());

    ProviderSubmissionResult result;
    try (var permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) {
        result = runtimes.current(attempt.providerProfileId())
            .adapter().submit(submissionFactory.from(attempt)).toCompletableFuture().join();
    } catch (ProviderTransportException error) {
        result = evidenceClassifier.classify(error);
    }
    recorder.record(attempt.id(), result);
    nextAction.apply(attempt.id(), retryPolicy.decide(contextFactory.from(attempt.id())));
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: response loss가 AMBIGUOUS로 저장되고 provider call 전에 Attempt row가 존재한다.

  • Step 5: Commit
git add 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/NotificationDispatcher.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DeliveryAttemptFactory.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchOutcomeRecorder.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/DispatchPipeline.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/NotificationDispatcherAmbiguityTest.java'
git commit -m "feat(notification): implement evidence-aware dispatch pipeline"

Task 22: Callback Verification·Normalization·Ingestion Core 구현

Files:

  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackAdapter.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackRequest.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackVerificationResult.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/NormalizedProviderEvent.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedCallback.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackIngestionResult.java
  • Create: modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionService.java
  • Test: modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionServiceTest.java

Interfaces:

  • Consumes: Task 14 event ledger/projector, Task 11 callback error category.
  • Produces: Signature-first callback service, bounded raw append, duplicate no-op, unknown field tolerance.

Implementation requirements:

  • Signature 검증에 필요한 raw bytes와 external URL을 decoding 전에 보존한다.

  • unknown JSON field로 parsing을 실패시키지 않는다.

  • Callback raw body hard limit을 Provider profile보다 크게 설정하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.callback;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ProviderCallbackIngestionServiceTest {
    @Test
    void invalidSignatureNeverChangesProjection() {
        var service = CallbackFixture.serviceWithInvalidSignature();
        assertThatThrownBy(() -> service.ingest(CallbackFixture.request()))
            .isInstanceOf(CallbackValidationException.class);
        assertThat(CallbackFixture.ledger().count()).isZero();
        assertThat(CallbackFixture.projectionWrites()).isZero();
    }

    @Test
    void duplicateEventIsAcknowledgedWithoutSecondProjection() {
        var service = CallbackFixture.service();
        var request = CallbackFixture.request("event-1");
        service.ingest(request);
        var duplicate = service.ingest(request);
        assertThat(duplicate.duplicate()).isTrue();
        assertThat(CallbackFixture.projectionWrites()).isEqualTo(1);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-callback-api:test --tests "*.ProviderCallbackIngestionServiceTest"

Expected: FAIL: callback ingestion core가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ProviderCallbackAdapter.java
  • CallbackRequest.java
  • CallbackVerificationResult.java
  • NormalizedProviderEvent.java
  • VerifiedCallback.java
  • CallbackIngestionResult.java
  • ProviderCallbackIngestionService.java
public CallbackIngestionResult ingest(CallbackRequest request) {
    callbackLimits.validate(request.contentType(), request.body().length);
    var adapter = adapters.require(request.providerProfileId());
    var verification = adapter.verify(request);
    if (!verification.valid()) {
        securityAudit.signatureRejected(request.providerProfileId(), verification.reasonCode());
        throw new CallbackValidationException(verification.reasonCode());
    }
    var normalized = adapter.normalize(verification.verifiedCallback());
    var append = ledger.appendAll(normalized);
    append.newEvents().forEach(projectorService::project);
    return new CallbackIngestionResult(append.newEvents().size(), append.duplicates().size());
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-callback-api:test

Expected: PASS: invalid signature는 상태를 바꾸지 않고 duplicate callback은 한 번만 projection된다.

  • Step 5: Commit
git add 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackAdapter.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackRequest.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackVerificationResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/NormalizedProviderEvent.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/VerifiedCallback.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/CallbackIngestionResult.java' 'modules/notification/notification-callback-api/src/main/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionService.java' 'modules/notification/notification-callback-api/src/test/java/io/backend/skeleton/notification/callback/ProviderCallbackIngestionServiceTest.java'
git commit -m "feat(notification): add secure callback ingestion core"

Task 23: Spring MVC Provider Callback Endpoint 구현

Files:

  • Create: modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcController.java
  • Create: modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/ExternalRequestUrlResolver.java
  • Create: modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/CallbackMvcSecurityConfiguration.java
  • Test: modules/notification/notification-callback-mvc/src/test/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcControllerTest.java

Interfaces:

  • Consumes: Task 22 CallbackIngestionService.
  • Produces: Bounded MVC endpoint, raw body preservation, profile path binding, fast 2xx response.

Implementation requirements:

  • Forwarded header를 무조건 신뢰하지 않고 trusted proxy 설정과 결합한다.

  • callback endpoint는 일반 user session security chain과 분리한다.

  • raw body를 일반 access log에 기록하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.callback.mvc;

import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class NotificationCallbackMvcControllerTest extends CallbackMvcTestBase {
    @Test
    void rejectsOversizedBodyBeforeIngestion() throws Exception {
        mockMvc.perform(post("/internal/notification/callbacks/twilio/twilio-primary")
                .contentType("application/x-www-form-urlencoded")
                .content(new byte[65537]))
            .andExpect(status().isPayloadTooLarge());
        verifyNoIngestion();
    }

    @Test
    void passesExternallyVisibleUrlForSignatureVerification() throws Exception {
        mockMvc.perform(post("/internal/notification/callbacks/twilio/twilio-primary")
                .header("Forwarded", "proto=https;host=callback.example.com")
                .contentType("application/x-www-form-urlencoded")
                .content("MessageSid=SM1&MessageStatus=delivered"))
            .andExpect(status().isNoContent());
        verifyExternalUrl("https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-callback-mvc:test --tests "*.NotificationCallbackMvcControllerTest"

Expected: FAIL: MVC callback controller가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationCallbackMvcController.java
  • ExternalRequestUrlResolver.java
  • CallbackMvcSecurityConfiguration.java
@RestController
@RequestMapping("/internal/notification/callbacks")
final class NotificationCallbackMvcController {
    @PostMapping(path = "/{provider}/{profile}")
    ResponseEntity<Void> callback(
        @PathVariable String provider,
        @PathVariable String profile,
        HttpServletRequest request,
        @RequestBody byte[] body) {
        var callback = requestFactory.create(provider, profile, request, body);
        ingestion.ingest(callback);
        return ResponseEntity.noContent().build();
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-callback-mvc:test

Expected: PASS: body limit, external URL, profile binding, 204 response가 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcController.java' 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/ExternalRequestUrlResolver.java' 'modules/notification/notification-callback-mvc/src/main/java/io/backend/skeleton/notification/callback/mvc/CallbackMvcSecurityConfiguration.java' 'modules/notification/notification-callback-mvc/src/test/java/io/backend/skeleton/notification/callback/mvc/NotificationCallbackMvcControllerTest.java'
git commit -m "feat(notification): add MVC callback endpoints"

Task 24: Spring WebFlux Provider Callback Endpoint 구현

Files:

  • Create: modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandler.java
  • Create: modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/CallbackWebFluxRouter.java
  • Create: modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/BoundedCallbackBodyReader.java
  • Test: modules/notification/notification-callback-webflux/src/test/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandlerTest.java

Interfaces:

  • Consumes: Task 22 CallbackIngestionService.
  • Produces: WebFlux raw bytes reader with buffer release, bounded size, boundedElastic ingestion bridge.

Implementation requirements:

  • blocking JPA ingestion을 Netty event-loop에서 실행하지 않는다.

  • MVC와 WebFlux가 같은 CallbackRequest canonicalization을 사용한다.

  • body-to-string 변환 전 signature verification용 bytes를 유지한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.callback.webflux;

import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.*;

class NotificationCallbackWebFluxHandlerTest extends CallbackWebFluxTestBase {
    @Test
    void releasesBuffersWhenBodyLimitIsExceeded() {
        StepVerifier.create(client().post()
                .uri("/internal/notification/callbacks/ses/ses-primary")
                .bodyValue(new byte[65537])
                .exchangeToMono(response -> response.releaseBody().thenReturn(response.statusCode())))
            .expectNextMatches(status -> status.value() == 413)
            .verifyComplete();
        assertThat(leakDetector().activeBuffers()).isZero();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-callback-webflux:test --tests "*.NotificationCallbackWebFluxHandlerTest"

Expected: FAIL: WebFlux callback router와 bounded reader가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationCallbackWebFluxHandler.java
  • CallbackWebFluxRouter.java
  • BoundedCallbackBodyReader.java
public Mono<ServerResponse> handle(ServerRequest request) {
    return bodyReader.read(request.exchange().getRequest())
        .publishOn(Schedulers.boundedElastic())
        .map(bytes -> callbackFactory.create(request, bytes))
        .doOnNext(ingestion::ingest)
        .then(ServerResponse.noContent().build());
}

// BoundedCallbackBodyReader accumulates at most configured bytes and
// releases every DataBuffer on success, error and cancellation.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-callback-webflux:test

Expected: PASS: body limit 초과와 cancellation 경로에서 DataBuffer leak가 없다.

  • Step 5: Commit
git add 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandler.java' 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/CallbackWebFluxRouter.java' 'modules/notification/notification-callback-webflux/src/main/java/io/backend/skeleton/notification/callback/webflux/BoundedCallbackBodyReader.java' 'modules/notification/notification-callback-webflux/src/test/java/io/backend/skeleton/notification/callback/webflux/NotificationCallbackWebFluxHandlerTest.java'
git commit -m "feat(notification): add WebFlux callback endpoints"

Task 25: Provider Reconciliation Framework 구현

Files:

  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationCapability.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationResult.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationScheduler.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationService.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ReconciliationServiceTest.java

Interfaces:

  • Consumes: Task 14 ledger/projector, Task 20 runtime registry, Task 21 attempt persistence.
  • Produces: Capability-aware reconcile, synthetic event append, bounded retries and correction audit.

Implementation requirements:

  • Reconciliation query도 provider rate/concurrency limit을 사용한다.

  • synthetic event가 기존 강한 evidence를 downgrade하지 않는다.

  • max reconciliation age와 attempts를 bounded property로 둔다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ReconciliationServiceTest extends PostgreSqlNotificationTest {
    @Test
    void confirmedQueryResultIsAppendedAsSyntheticEvent() {
        var attempt = insertAmbiguousAttempt();
        service(ProviderFixture.reconcileDelivered()).reconcile(attempt.id());
        var event = lastProviderEvent();
        assertThat(event.source()).isEqualTo(ProviderEventSource.RECONCILIATION);
        assertThat(loadRecipient(attempt.recipientDeliveryId()).deliveryOutcome())
            .isEqualTo(DeliveryOutcome.DELIVERED);
    }

    @Test
    void unsupportedProviderLeavesAttemptUnknown() {
        var attempt = insertAmbiguousAttempt();
        service(ProviderFixture.noReconciliation()).reconcile(attempt.id());
        assertThat(loadAttempt(attempt.id()).submissionOutcome())
            .isEqualTo(SubmissionOutcome.AMBIGUOUS);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ReconciliationServiceTest"

Expected: FAIL: reconciliation capability와 service가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ReconciliationCapability.java
  • ReconciliationResult.java
  • ReconciliationScheduler.java
  • ReconciliationService.java
public interface ReconciliationCapability {
    boolean supports(ProviderProfileSnapshot profile);
    CompletionStage<ReconciliationResult> reconcile(DeliveryAttemptSnapshot attempt);
}

public void reconcile(DeliveryAttemptId attemptId) {
    var attempt = attempts.load(attemptId);
    var runtime = runtimes.current(attempt.providerProfileId());
    var capability = runtime.reconciliationCapability();
    var result = capability.reconcile(attempt).toCompletableFuture().join();
    switch (result) {
        case ReconciliationResult.Confirmed confirmed ->
            ledger.append(confirmed.asSyntheticEvent(ProviderEventSource.RECONCILIATION));
        case ReconciliationResult.StillUnknown unknown -> schedule(attemptId, unknown.nextCheckAt());
        case ReconciliationResult.Unsupported ignored -> markUnsupported(attemptId);
        case ReconciliationResult.Failed failed -> handleFailure(attemptId, failed);
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: confirmed result는 ledger를 통하고 unsupported result는 추정하지 않는다.

  • Step 5: Commit
git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationCapability.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ReconciliationResult.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationScheduler.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ReconciliationService.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ReconciliationServiceTest.java'
git commit -m "feat(notification): add provider reconciliation framework"

Task 26: Attachment Reference Resolver와 Integrity Guard 구현

Files:

  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentResolver.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ResolvedAttachment.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentAccessContext.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuard.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuardTest.java

Interfaces:

  • Consumes: Task 3 AttachmentRef, existing fileserver/objectstorage contracts.
  • Produces: READY/authorization/size/digest checked immutable attachment stream, no byte persistence.

Implementation requirements:

  • attachment stream은 try-with-resources로 닫는다.

  • signed URL을 persistence/log에 보존하지 않는다.

  • Provider retry마다 immutable source를 다시 열 수 있어야 한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class AttachmentIntegrityGuardTest {
    @Test
    void rejectsFileThatIsNotReadyBeforeProviderCall() {
        var resolver = AttachmentFixture.notReadyResolver();
        assertThatThrownBy(() -> guard(resolver).resolve(AttachmentFixture.ref()))
            .isInstanceOf(AttachmentUnavailableException.class);
        assertThat(AttachmentFixture.providerCalls()).isZero();
    }

    @Test
    void rejectsDigestMismatch() {
        assertThatThrownBy(() -> guard(AttachmentFixture.digestMismatchResolver())
            .resolve(AttachmentFixture.ref()))
            .isInstanceOf(AttachmentIntegrityException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.AttachmentIntegrityGuardTest"

Expected: FAIL: attachment resolver와 guard가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • AttachmentResolver.java
  • ResolvedAttachment.java
  • AttachmentAccessContext.java
  • AttachmentIntegrityGuard.java
public interface AttachmentResolver {
    ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context);
}

public record ResolvedAttachment(
    java.io.InputStream content,
    long size,
    String digest,
    String contentType,
    String displayName
) implements AutoCloseable {
    @Override public void close() throws java.io.IOException { content.close(); }
}

public ResolvedAttachment resolve(AttachmentRef ref) {
    var attachment = resolver.resolve(ref, accessContext);
    if (attachment.size() != ref.expectedSize()) throw new AttachmentIntegrityException("SIZE_MISMATCH");
    if (!constantTimeEquals(attachment.digest(), ref.expectedDigest()))
        throw new AttachmentIntegrityException("DIGEST_MISMATCH");
    return attachment;
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: READY·authorization·size·digest가 provider call 전에 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentResolver.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/ResolvedAttachment.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/AttachmentAccessContext.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuard.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/AttachmentIntegrityGuardTest.java'
git commit -m "feat(notification): add safe attachment reference resolution"

Task 27: SMTP Provider Adapter와 MIME 구현

Files:

  • Create: modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapter.java
  • Create: modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpMimeMessageFactory.java
  • Create: modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpFailureClassifier.java
  • Create: modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpProviderProperties.java
  • Test: modules/notification/notification-email-smtp/src/test/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapterTest.java

Interfaces:

  • Consumes: Task 10 provider SPI, Task 26 attachment resolver, Task 30 Email content contract.
  • Produces: SMTP submit, multipart MIME, bounded timeouts, 4yz/5yz and ambiguous final-response classification.

Implementation requirements:

  • SMTP connection/read/write timeout을 모두 유한값으로 강제한다.

  • header CRLF injection을 MIME 생성 전 거부한다.

  • JavaMail exception 원문에 recipient가 포함되면 logging sanitizer로 제거한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.email.smtp;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class SmtpNotificationProviderAdapterTest extends SmtpTestServerBase {
    @Test
    void final250MeansProviderAcceptedNotDelivered() {
        smtpServer().respondAfterData(250, "queued");
        var result = adapter().submit(fixture().emailSubmission()).toCompletableFuture().join();
        assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.CONFIRMED_ACCEPTED);
        assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
        assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
    }

    @Test
    void connectionLossAfterDataIsAmbiguous() {
        smtpServer().acceptDataThenCloseWithoutResponse();
        var result = adapter().submit(fixture().emailSubmission()).toCompletableFuture().join();
        assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-email-smtp:test --tests "*.SmtpNotificationProviderAdapterTest"

Expected: FAIL: SMTP adapter와 MIME factory가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • SmtpNotificationProviderAdapter.java
  • SmtpMimeMessageFactory.java
  • SmtpFailureClassifier.java
  • SmtpProviderProperties.java
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
    return CompletableFuture.supplyAsync(() -> {
        try (var attachments = attachmentScope.open(submission)) {
            var message = mimeFactory.create(submission, attachments);
            javaMailSender.send(message);
            return results.accepted(EvidenceLevel.PROVIDER_ACCEPTED);
        } catch (MailSendException error) {
            return classifier.classify(error);
        }
    }, smtpExecutor);
}

// SmtpFailureClassifier maps 4yz to TRANSIENT_PROVIDER, 5yz to permanent or
// invalid recipient, and loss after DATA commitment to AMBIGUOUS_SUBMISSION.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-email-smtp:test

Expected: PASS: SMTP acceptance, MIME, transient/permanent, ambiguous response가 분리된다.

  • Step 5: Commit
git add 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapter.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpMimeMessageFactory.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpFailureClassifier.java' 'modules/notification/notification-email-smtp/src/main/java/io/backend/skeleton/notification/email/smtp/SmtpProviderProperties.java' 'modules/notification/notification-email-smtp/src/test/java/io/backend/skeleton/notification/email/smtp/SmtpNotificationProviderAdapterTest.java'
git commit -m "feat(notification): add stable SMTP email adapter"

Task 28: Amazon SES Submit Adapter 구현

Files:

  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapter.java
  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesRequestMapper.java
  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesFailureClassifier.java
  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesProviderProperties.java
  • Test: modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapterTest.java

Interfaces:

  • Consumes: Task 10 provider SPI, Task 20 runtime, existing httpclient platform.
  • Produces: SES MessageId mapping, acceptance-only evidence, HTTP ambiguity and error classification.

Implementation requirements:

  • SES retry owner는 notification policy이며 HTTP client profile의 blind retry를 비활성화한다.

  • configuration set은 approved N3 option으로만 전달한다.

  • SES MessageId를 NotificationId로 사용하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.email.ses;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class SesNotificationProviderAdapterTest extends SesWireMockTestBase {
    @Test
    void messageIdIsAcceptedEvidenceOnly() {
        stubSesSuccess("ses-message-1");
        var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
        assertThat(result.providerRequestId()).contains("ses-message-1");
        assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
        assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
    }

    @Test
    void responseLossAfterServerAcceptsIsAmbiguous() {
        stubAcceptThenReset();
        var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
        assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-email-ses:test --tests "*.SesNotificationProviderAdapterTest"

Expected: FAIL: SES adapter가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • SesNotificationProviderAdapter.java
  • SesRequestMapper.java
  • SesFailureClassifier.java
  • SesProviderProperties.java
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
    var request = mapper.map(submission);
    return httpClient.send(request).handle((response, error) -> {
        if (error != null) return classifier.fromTransport(error);
        if (response.statusCode().is2xxSuccessful()) {
            var messageId = response.body().messageId();
            return ProviderSubmissionResult.accepted(
                messageId, EvidenceLevel.PROVIDER_ACCEPTED,
                response.executionEvidence());
        }
        return classifier.fromResponse(response);
    });
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-email-ses:test

Expected: PASS: SES synchronous success와 response-loss ambiguity가 정확히 매핑된다.

  • Step 5: Commit
git add 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapter.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesRequestMapper.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesFailureClassifier.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesProviderProperties.java' 'modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesNotificationProviderAdapterTest.java'
git commit -m "feat(notification): add stable SES submission adapter"

Task 29: SES Event Ingestion·Projection·Suppression 구현

Files:

  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesCallbackAdapter.java
  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesEventNormalizer.java
  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesDeliveryProjector.java
  • Create: modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesSuppressionUpdater.java
  • Test: modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesEventProjectionTest.java

Interfaces:

  • Consumes: Tasks 14·22 ledger/callback core, Task 17 suppression.
  • Produces: SES delivery, delay, bounce, complaint, reject, rendering failure event mapping.

Implementation requirements:

  • Open/click event는 reliability delivery state와 분리한다.

  • duplicate SES event는 ledger unique constraint로 no-op이다.

  • soft bounce와 hard bounce를 같은 suppression reason으로 처리하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.email.ses;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class SesEventProjectionTest {
    @Test
    void deliveryThenComplaintPreservesDeliveryAndAddsSuppression() {
        ingest(fixture().deliveryEvent("m-1"));
        ingest(fixture().complaintEvent("m-1"));
        var projection = projection("m-1");
        assertThat(projection.deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED);
        assertThat(projection.suppressionFacts().complained()).isTrue();
        assertThat(suppressionReason("m-1")).isEqualTo(SuppressionReason.COMPLAINT);
    }

    @Test
    void hardBounceInvalidatesEmailAndSuppresses() {
        ingest(fixture().hardBounce("m-2"));
        assertThat(contactStatus("m-2")).isEqualTo(ContactPointStatus.INVALID);
        assertThat(suppressionReason("m-2")).isEqualTo(SuppressionReason.HARD_BOUNCE);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-email-ses:test --tests "*.SesEventProjectionTest"

Expected: FAIL: SES event normalizer/projector가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • SesCallbackAdapter.java
  • SesEventNormalizer.java
  • SesDeliveryProjector.java
  • SesSuppressionUpdater.java
public NormalizedProviderEvent normalize(SesEvent event) {
    return switch (event.type()) {
        case "Delivery" -> normalized("DELIVERY_CONFIRMED", DeliveryOutcome.DELIVERED,
            EvidenceLevel.NETWORK_OR_CARRIER_ACCEPTED);
        case "Bounce" -> normalizeBounce(event);
        case "Complaint" -> normalizedFact("COMPLAINT");
        case "DeliveryDelay" -> normalizedFact("DELIVERY_DELAYED");
        case "Reject" -> normalizedFailure(FailureCategory.PERMANENT_PROVIDER);
        case "RenderingFailure" -> normalizedFailure(FailureCategory.TEMPLATE_FAILURE);
        default -> normalizedUnknown(event.type());
    };
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-email-ses:test

Expected: PASS: SES delivery와 complaint/bounce facts가 독립적으로 반영된다.

  • Step 5: Commit
git add 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesCallbackAdapter.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesEventNormalizer.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesDeliveryProjector.java' 'modules/notification/notification-email-ses/src/main/java/io/backend/skeleton/notification/email/ses/SesSuppressionUpdater.java' 'modules/notification/notification-email-ses/src/test/java/io/backend/skeleton/notification/email/ses/SesEventProjectionTest.java'
git commit -m "feat(notification): process SES delivery evidence and suppression"

Task 30: E.164 PhoneNumber와 GSM-7/UCS-2 Segment Estimator 구현

Files:

  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/E164PhoneNumberParser.java
  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/GsmAlphabet.java
  • Create: modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsSegmentEstimator.java
  • Test: modules/notification/notification-sms-api/src/test/java/io/backend/skeleton/notification/sms/SmsSegmentEstimatorTest.java

Interfaces:

  • Consumes: Task 5 SmsEstimate·SmsEncoding and Task 6 PhoneNumber base type.
  • Produces: Strict E.164 parser, GSM-7 extension-aware length, 160/153 and 70/67 segment calculation.

Implementation requirements:

  • Java String length만으로 segment를 계산하지 않는다.

  • GSM extension table 문자는 2 septet으로 계산한다.

  • estimate는 비용 단가를 하드코딩하지 않고 segment count만 제공한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.sms;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class SmsSegmentEstimatorTest {
    @Test
    void gsm7BoundaryUsesOneThenTwoSegments() {
        assertThat(estimator().estimate("a".repeat(160)).segmentCount()).isEqualTo(1);
        assertThat(estimator().estimate("a".repeat(161)).segmentCount()).isEqualTo(2);
    }

    @Test
    void unicodeBoundaryUsesUcs2() {
        assertThat(estimator().estimate("가".repeat(70)).segmentCount()).isEqualTo(1);
        assertThat(estimator().estimate("가".repeat(71)).segmentCount()).isEqualTo(2);
        assertThat(estimator().estimate("hello🙂").encoding()).isEqualTo(SmsEncoding.UCS_2);
    }

    @Test
    void parsesE164AndRejectsLocalNumber() {
        assertThat(parser().parse("+821012345678").e164()).isEqualTo("+821012345678");
        assertThatThrownBy(() -> parser().parse("01012345678"))
            .isInstanceOf(InvalidPhoneNumberException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-sms-api:test --tests "*.SmsSegmentEstimatorTest"

Expected: FAIL: SMS parser와 estimator가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • E164PhoneNumberParser.java
  • GsmAlphabet.java
  • SmsSegmentEstimator.java
public SmsEstimate estimate(String text) {
    var gsmUnits = GsmAlphabet.encodedSeptets(text);
    if (gsmUnits.isPresent()) {
        int units = gsmUnits.getAsInt();
        int segments = units <= 160 ? 1 : divideCeiling(units, 153);
        return new SmsEstimate(SmsEncoding.GSM_7, segments, units, segments > 3);
    }
    int units = text.codePoints().map(cp -> Character.charCount(cp)).sum();
    int segments = units <= 70 ? 1 : divideCeiling(units, 67);
    return new SmsEstimate(SmsEncoding.UCS_2, segments, units, segments > 3);
}

public PhoneNumber parse(String value) {
    if (!value.matches("\+[1-9][0-9]{1,14}")) {
        throw new InvalidPhoneNumberException("INVALID_E164");
    }
    return new PhoneNumber(value);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-sms-api:test

Expected: PASS: E.164와 SMS segment 경계가 정확히 계산된다.

  • Step 5: Commit
git add 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/E164PhoneNumberParser.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/GsmAlphabet.java' 'modules/notification/notification-sms-api/src/main/java/io/backend/skeleton/notification/sms/SmsSegmentEstimator.java' 'modules/notification/notification-sms-api/src/test/java/io/backend/skeleton/notification/sms/SmsSegmentEstimatorTest.java'
git commit -m "feat(notification): add E164 and SMS segment estimation"

Task 31: Twilio SMS Submit Adapter 구현

Files:

  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapter.java
  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioRequestMapper.java
  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioFailureClassifier.java
  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioProviderProperties.java
  • Test: modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapterTest.java

Interfaces:

  • Consumes: Task 10 provider SPI, Task 20 runtime, Task 30 E.164 and estimate, existing httpclient.
  • Produces: Twilio API submit, accepted/queued evidence, invalid number, 429 and response-loss classification.

Implementation requirements:

  • Twilio status callback URL은 profile에서 고정한다.

  • phone number와 message body를 HTTP client log에 남기지 않는다.

  • httpclient 자동 retry는 끄고 Notification retry policy가 소유한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.sms.twilio;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class TwilioSmsProviderAdapterTest extends TwilioWireMockTestBase {
    @Test
    void acceptedStatusIsProviderAcceptedOnly() {
        stubCreateMessage("SM1", "accepted");
        var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
        assertThat(result.providerRequestId()).contains("SM1");
        assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
        assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
    }

    @Test
    void provider429IsThrottled() {
        stubRateLimited(10);
        var result = adapter().submit(fixture().submission()).toCompletableFuture().join();
        assertThat(result.failure().orElseThrow().category())
            .isEqualTo(FailureCategory.THROTTLED);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-sms-twilio:test --tests "*.TwilioSmsProviderAdapterTest"

Expected: FAIL: Twilio SMS adapter가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • TwilioSmsProviderAdapter.java
  • TwilioRequestMapper.java
  • TwilioFailureClassifier.java
  • TwilioProviderProperties.java
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
    var request = mapper.map(submission);
    return twilioClient.createMessage(request).handle((response, error) -> {
        if (error != null) return classifier.transport(error);
        return switch (response.status()) {
            case "accepted", "queued", "sending" ->
                results.accepted(response.sid(), EvidenceLevel.PROVIDER_ACCEPTED, response.status());
            case "failed" -> classifier.failed(response.errorCode());
            default -> classifier.unexpected(response.status());
        };
    });
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-sms-twilio:test

Expected: PASS: Twilio accepted와 throttle/error가 stable result로 변환된다.

  • Step 5: Commit
git add 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapter.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioRequestMapper.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioFailureClassifier.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioProviderProperties.java' 'modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioSmsProviderAdapterTest.java'
git commit -m "feat(notification): add stable Twilio SMS submission adapter"

Task 32: Twilio Callback·역순 Projection·Reconciliation 구현

Files:

  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAdapter.java
  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioStatusNormalizer.java
  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioDeliveryProjector.java
  • Create: modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioReconciliationCapability.java
  • Test: modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAndReconciliationTest.java

Interfaces:

  • Consumes: Tasks 22 callback core, 25 reconciliation, 31 Twilio provider profile.
  • Produces: X-Twilio-Signature validation, status normalization, reverse-order merge, status polling.

Implementation requirements:

  • callback receivedAt 순서가 아니라 event semantics를 사용한다.

  • query polling은 provider QPS와 max reconciliation age를 준수한다.

  • opt-out provider event를 internal suppression으로 연결한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.sms.twilio;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class TwilioCallbackAndReconciliationTest extends TwilioCallbackTestBase {
    @Test
    void deliveredBeforeSentNeverDowngrades() {
        ingest(callback("SM1", "delivered"));
        ingest(callback("SM1", "sent"));
        assertThat(projection("SM1").deliveryOutcome()).isEqualTo(DeliveryOutcome.DELIVERED);
    }

    @Test
    void missingCallbackIsCorrectedByPolling() {
        insertAcceptedAttempt("SM2");
        stubStatusQuery("SM2", "undelivered");
        reconcile("SM2");
        assertThat(projection("SM2").deliveryOutcome()).isEqualTo(DeliveryOutcome.UNDELIVERED);
        assertThat(lastEventSource()).isEqualTo(ProviderEventSource.RECONCILIATION);
    }

    @Test
    void invalidSignatureIsRejected() {
        assertThatThrownBy(() -> ingest(callbackWithInvalidSignature()))
            .isInstanceOf(CallbackValidationException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-sms-twilio:test --tests "*.TwilioCallbackAndReconciliationTest"

Expected: FAIL: Twilio callback adapter/projector/reconcile가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • TwilioCallbackAdapter.java
  • TwilioStatusNormalizer.java
  • TwilioDeliveryProjector.java
  • TwilioReconciliationCapability.java
public NormalizedProviderEvent normalize(TwilioStatusCallback callback) {
    return switch (callback.messageStatus()) {
        case "accepted", "queued", "sending" -> accepted(callback);
        case "sent" -> sent(callback, EvidenceLevel.NETWORK_OR_CARRIER_ACCEPTED);
        case "delivered" -> delivered(callback, EvidenceLevel.DEVICE_DELIVERED);
        case "undelivered" -> undelivered(callback);
        case "failed" -> failed(callback);
        default -> unknown(callback);
    };
}

// Projector transition table ignores sent after delivered and retains terminal facts.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-sms-twilio:test

Expected: PASS: signature, reverse-order callback, missing callback reconciliation이 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAdapter.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioStatusNormalizer.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioDeliveryProjector.java' 'modules/notification/notification-sms-twilio/src/main/java/io/backend/skeleton/notification/sms/twilio/TwilioReconciliationCapability.java' 'modules/notification/notification-sms-twilio/src/test/java/io/backend/skeleton/notification/sms/twilio/TwilioCallbackAndReconciliationTest.java'
git commit -m "feat(notification): add Twilio status callback and reconciliation"

Task 33: Mobile Push 공통 API와 Application Receipt 구현

Files:

  • Create: modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceipt.java
  • Create: modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceiptService.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceImpl.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceTest.java

Interfaces:

  • Consumes: Task 3 PushPresentation, Task 5 MobilePushNotification, Task 6 push targets, Task 14 event ledger.
  • Produces: Typed push API, authenticated displayed/read receipt, receipt dedup and evidence promotion.

Implementation requirements:

  • Provider acceptance 없이 앱 receipt만으로 임의 Attempt를 생성하지 않는다.

  • receipt ID, attempt ID, authenticated user/app binding을 검증한다.

  • client clock은 참고값이고 server receivedAt을 감사 기준으로 보존한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ApplicationReceiptServiceTest extends PostgreSqlNotificationTest {
    @Test
    void readReceiptPromotesEvidenceAndIsIdempotent() {
        var receipt = fixture().readReceipt("receipt-1");
        service().record(receipt, fixture().authenticatedApp());
        service().record(receipt, fixture().authenticatedApp());
        assertThat(projection(receipt.attemptId()).evidenceLevel()).isEqualTo(EvidenceLevel.USER_READ);
        assertThat(receiptEventCount("receipt-1")).isEqualTo(1);
    }

    @Test
    void receiptForAnotherUserIsRejected() {
        assertThatThrownBy(() -> service().record(
            fixture().readReceiptForUser("user-b"), fixture().authenticatedUser("user-a")))
            .isInstanceOf(ReceiptAuthorizationException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ApplicationReceiptServiceTest"

Expected: FAIL: app receipt API와 implementation이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ApplicationReceipt.java
  • ApplicationReceiptService.java
  • ApplicationReceiptServiceImpl.java
public interface ApplicationReceiptService {
    ReceiptResult record(ApplicationReceipt receipt, ApplicationIdentity identity);
}

@Transactional
public ReceiptResult record(ApplicationReceipt receipt, ApplicationIdentity identity) {
    authorization.verify(identity, receipt);
    var append = ledger.append(receiptEventFactory.from(receipt));
    if (append.created()) projector.project(append.eventId());
    return new ReceiptResult(append.created(), receipt.attemptId());
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: authenticated app receipt가 idempotent하게 evidence를 승격한다.

  • Step 5: Commit
git add 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceipt.java' 'modules/notification/notification-push-api/src/main/java/io/backend/skeleton/notification/push/ApplicationReceiptService.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceImpl.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ApplicationReceiptServiceTest.java'
git commit -m "feat(notification): add mobile push and app receipt contracts"

Task 34: FCM FID·Legacy Target Mapper와 Recipient별 Batch Adapter 구현

Files:

  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmNotificationProviderAdapter.java
  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmTargetMapper.java
  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmBatchCoordinator.java
  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmProviderProperties.java
  • Test: modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmBatchAdapterTest.java

Interfaces:

  • Consumes: Tasks 6 FID/legacy types, 10 provider SPI, 33 push API.
  • Produces: FID-primary mapping, legacy compatibility, max-500 batch, input-index partial result mapping.

Implementation requirements:

  • Batch transport 호출 하나를 하나의 RecipientDelivery로 축소하지 않는다.

  • FCM success evidence는 PROVIDER_ACCEPTED까지만 설정한다.

  • topic/condition은 N3 experimental interface에 분리한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.push.fcm;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class FcmBatchAdapterTest {
    @Test
    void mapsPartialBatchResultToEachRecipientAttempt() {
        var submissions = fixture().fiveSubmissions();
        var gateway = fixture().gatewayWithResults(true, false, true, false, true);
        var results = coordinator(gateway).submit(submissions).toCompletableFuture().join();
        assertThat(results).hasSize(5);
        assertThat(results.get(0).confirmation()).isEqualTo(AttemptConfirmation.CONFIRMED);
        assertThat(results.get(1).confirmation()).isEqualTo(AttemptConfirmation.REJECTED);
    }

    @Test
    void rejectsBatchAbove500() {
        assertThatThrownBy(() -> coordinator(fixture().gateway()).submit(fixture().submissions(501)))
            .isInstanceOf(ProviderPayloadLimitException.class);
    }

    @Test
    void fidAndLegacyTokenUseDistinctWireTargetKinds() {
        assertThat(mapper().map(new FcmInstallationId("fid-1")).kind()).isEqualTo("FID");
        assertThat(mapper().map(new LegacyFcmRegistrationToken("token-1")).kind()).isEqualTo("LEGACY_TOKEN");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-push-fcm:test --tests "*.FcmBatchAdapterTest"

Expected: FAIL: FCM adapter/batch coordinator가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • FcmNotificationProviderAdapter.java
  • FcmTargetMapper.java
  • FcmBatchCoordinator.java
  • FcmProviderProperties.java
public CompletionStage<java.util.List<ProviderSubmissionResult>> submitBatch(
    java.util.List<ProviderSubmission> submissions) {
    if (submissions.size() > properties.maxBatchSize()) {
        throw new ProviderPayloadLimitException("FCM_BATCH_MAX_500");
    }
    var request = mapper.mapBatch(submissions);
    return gateway.sendBatch(request).thenApply(response ->
        java.util.stream.IntStream.range(0, submissions.size())
            .mapToObj(i -> resultMapper.map(submissions.get(i), response.result(i)))
            .toList());
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-push-fcm:test

Expected: PASS: FID/legacy target와 recipient별 partial batch result가 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmNotificationProviderAdapter.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmTargetMapper.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmBatchCoordinator.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmProviderProperties.java' 'modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmBatchAdapterTest.java'
git commit -m "feat(notification): add FCM FID-first batch adapter"

Task 35: FCM 오류 분류·Target Invalidation·TTL/Collapse 구현

Files:

  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmFailureClassifier.java
  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmContactPointUpdater.java
  • Create: modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmMessageMapper.java
  • Test: modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmFailureAndLifecycleTest.java

Interfaces:

  • Consumes: Task 17 policy, Task 34 FCM adapter.
  • Produces: UNREGISTERED invalidation, quota/unavailable retry mapping, provider TTL and collapse mapping.

Implementation requirements:

  • UNREGISTERED를 transient로 retry하지 않는다.

  • credential failure는 Provider runtime auth failure로 승격한다.

  • FCM delivery order를 보장한다고 문서화하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.push.fcm;

import org.junit.jupiter.api.Test;
import java.time.*;
import static org.assertj.core.api.Assertions.*;

class FcmFailureAndLifecycleTest extends FcmTestBase {
    @Test
    void unregisteredInvalidatesContactAndDoesNotRetry() {
        var result = classifier().classify(fixture().error("UNREGISTERED"));
        updater().apply(fixture().contactId(), result);
        assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT);
        assertThat(contactStatus()).isEqualTo(ContactPointStatus.INVALID);
        assertThat(retryPolicy().decide(fixture().context(result)))
            .isInstanceOf(RetryDecision.Fallback.class);
    }

    @Test
    void ttlIsCappedByExpiry() {
        var now = Instant.parse("2026-08-10T00:00:00Z");
        var message = mapper(now).map(fixture().submissionExpiringAt(now.plusSeconds(90)));
        assertThat(message.ttl()).isEqualTo(Duration.ofSeconds(90));
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-push-fcm:test --tests "*.FcmFailureAndLifecycleTest"

Expected: FAIL: FCM failure classifier와 lifecycle updater가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • FcmFailureClassifier.java
  • FcmContactPointUpdater.java
  • FcmMessageMapper.java
public ProviderSubmissionResult classify(FcmError error) {
    return switch (error.code()) {
        case "UNREGISTERED" -> rejected(FailureCategory.INVALID_RECIPIENT, false);
        case "QUOTA_EXCEEDED" -> rejected(FailureCategory.THROTTLED, true);
        case "UNAVAILABLE" -> rejected(FailureCategory.TRANSIENT_PROVIDER, true);
        case "INVALID_ARGUMENT" -> rejected(FailureCategory.INVALID_PAYLOAD, false);
        case "THIRD_PARTY_AUTH_ERROR" -> rejected(FailureCategory.AUTHENTICATION, false);
        default -> rejected(FailureCategory.PERMANENT_PROVIDER, false);
    };
}

// Message mapper computes TTL = min(expiresAt-now, provider max TTL) and maps
// collapse key only when CollapseSpec is present.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-push-fcm:test

Expected: PASS: FCM target invalidation, retry classification, TTL/collapse mapping이 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmFailureClassifier.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmContactPointUpdater.java' 'modules/notification/notification-push-fcm/src/main/java/io/backend/skeleton/notification/push/fcm/FcmMessageMapper.java' 'modules/notification/notification-push-fcm/src/test/java/io/backend/skeleton/notification/push/fcm/FcmFailureAndLifecycleTest.java'
git commit -m "feat(notification): add FCM lifecycle and failure semantics"

Task 36: APNs HTTP/2 Adapter와 Environment·Topic Guard 구현

Files:

  • Create: modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapter.java
  • Create: modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsRequestMapper.java
  • Create: modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsFailureClassifier.java
  • Create: modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsProviderProperties.java
  • Test: modules/notification/notification-push-apns/src/test/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapterTest.java

Interfaces:

  • Consumes: Task 6 APNs token/environment, Task 10 provider SPI, existing httpclient HTTP/2 profile.
  • Produces: APNs headers, acceptance-only evidence, environment/topic mismatch, invalid token classification.

Implementation requirements:

  • APNs 2xx를 delivered로 매핑하지 않는다.

  • push type은 allowlist된 typed option만 허용한다.

  • sandbox/production Contact Point namespace를 분리한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.push.apns;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class ApnsNotificationProviderAdapterTest extends ApnsHttp2TestBase {
    @Test
    void http200IsProviderAcceptedNotDelivered() {
        stubApnsSuccess("apns-request-1");
        var result = adapter().submit(fixture().productionSubmission()).toCompletableFuture().join();
        assertThat(result.providerRequestId()).contains("apns-request-1");
        assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
        assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
    }

    @Test
    void sandboxTokenCannotUseProductionProfile() {
        assertThatThrownBy(() -> adapter().submit(fixture().sandboxTokenOnProduction()))
            .isInstanceOf(ProviderConfigurationException.class);
    }

    @Test
    void invalidTokenInvalidatesContactPoint() {
        stubApnsError(410, "Unregistered");
        var result = adapter().submit(fixture().productionSubmission()).toCompletableFuture().join();
        assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-push-apns:test --tests "*.ApnsNotificationProviderAdapterTest"

Expected: FAIL: APNs adapter가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ApnsNotificationProviderAdapter.java
  • ApnsRequestMapper.java
  • ApnsFailureClassifier.java
  • ApnsProviderProperties.java
public ApnsRequest map(ProviderSubmission submission) {
    var target = requireApnsTarget(submission.contactPoint());
    if (target.environment() != properties.environment()) {
        throw new ProviderConfigurationException("APNS_ENVIRONMENT_MISMATCH");
    }
    return new ApnsRequest(
        target.value(),
        properties.topic(),
        approvedPushType(submission),
        expirationEpoch(submission.expiresAt()),
        approvedPriority(submission),
        collapseId(submission),
        payload(submission));
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-push-apns:test

Expected: PASS: APNs 2xx, environment, invalid token, header mapping이 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapter.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsRequestMapper.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsFailureClassifier.java' 'modules/notification/notification-push-apns/src/main/java/io/backend/skeleton/notification/push/apns/ApnsProviderProperties.java' 'modules/notification/notification-push-apns/src/test/java/io/backend/skeleton/notification/push/apns/ApnsNotificationProviderAdapterTest.java'
git commit -m "feat(notification): add stable APNs adapter"

Task 37: Web Push RFC 8291 Encryption과 VAPID 구현

Files:

  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushSubscription.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushPayloadEncryptor.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/Rfc8291Aes128GcmEncryptor.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidJwtSigner.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidKeyRegistry.java
  • Test: modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushCryptoTest.java

Interfaces:

  • Consumes: Task 7 secret provider, Task 10 provider SPI.
  • Produces: Encrypted subscription, RFC 8291 AES128GCM payload, RFC 8292 ES256 VAPID JWT.

Implementation requirements:

  • Web Push endpoint와 key material을 log에 출력하지 않는다.

  • VAPID private key는 SecretMaterialProvider에서만 획득한다.

  • VAPID key rotation은 subscription migration이 필요하다는 상태를 노출한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.webpush;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class WebPushCryptoTest {
    @Test
    void encryptsPayloadUsingSubscriptionKeys() {
        var encrypted = encryptor().encrypt(
            fixture().subscription(), "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8));
        assertThat(encrypted.contentEncoding()).isEqualTo("aes128gcm");
        assertThat(encrypted.body()).doesNotContainSequence("hello".getBytes());
        assertThat(fixture().decrypt(encrypted)).isEqualTo("hello".getBytes());
    }

    @Test
    void vapidAudienceUsesEndpointOrigin() {
        var jwt = signer().sign(fixture().subscription(), fixture().key());
        assertThat(fixture().claims(jwt).audience()).isEqualTo("https://push.example.com");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-webpush:test --tests "*.WebPushCryptoTest"

Expected: FAIL: Web Push crypto와 VAPID가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • WebPushSubscription.java
  • WebPushPayloadEncryptor.java
  • Rfc8291Aes128GcmEncryptor.java
  • VapidJwtSigner.java
  • VapidKeyRegistry.java
public record WebPushSubscription(
    ContactPointId id,
    EncryptedValue endpoint,
    EncryptedValue p256dh,
    EncryptedValue authSecret,
    String vapidKeyId
) {}

public EncryptedWebPushPayload encrypt(WebPushKeyMaterial subscription, byte[] plaintext) {
    // RFC 8291: P-256 ECDH, auth secret, HKDF, random salt, aes128gcm record.
    var sharedSecret = ecdh.derive(ephemeralKeyPair(), subscription.p256dh());
    var keyAndNonce = hkdf.derive(sharedSecret, subscription.authSecret(), randomSalt());
    return aes128gcm.encrypt(plaintext, keyAndNonce);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-webpush:test

Expected: PASS: RFC encryption round-trip과 VAPID audience가 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushSubscription.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushPayloadEncryptor.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/Rfc8291Aes128GcmEncryptor.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidJwtSigner.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/VapidKeyRegistry.java' 'modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushCryptoTest.java'
git commit -m "feat(notification): add Web Push encryption and VAPID"

Task 38: Web Push RFC 8030 Transport·TTL·Subscription Invalidation 구현

Files:

  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotificationProviderAdapter.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushRequestMapper.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushFailureClassifier.java
  • Create: modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushReceiptCapability.java
  • Test: modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushProviderAdapterTest.java

Interfaces:

  • Consumes: Task 37 crypto/VAPID, existing HTTP Client Dynamic/Trusted policy.
  • Produces: TTL-required send, urgency/topic, 201 acceptance, 404/410 invalidation, optional receipt capability.

Implementation requirements:

  • Web Push endpoint를 arbitrary dynamic URL로 일반 공개하지 않는다.

  • HTTP redirect를 따르지 않는다.

  • receipt 지원 여부를 ProviderCapabilities로 확인한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.webpush;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class WebPushProviderAdapterTest extends WebPushWireMockTestBase {
    @Test
    void ttlHeaderIsRequiredAndAcceptanceIsNotDelivery() {
        stubPushService(201);
        var result = adapter().submit(fixture().submissionWithTtl(60)).toCompletableFuture().join();
        verifyHeader("TTL", "60");
        assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
        assertThat(result.deliveryOutcome()).isEqualTo(DeliveryOutcome.UNKNOWN);
    }

    @Test
    void expiredSubscriptionIsInvalidated() {
        stubPushService(404);
        var result = adapter().submit(fixture().submissionWithTtl(60)).toCompletableFuture().join();
        assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.INVALID_RECIPIENT);
    }

    @Test
    void missingExpiryCannotCreateWebPushAttempt() {
        assertThatThrownBy(() -> adapter().submit(fixture().submissionWithoutExpiry()))
            .isInstanceOf(ProviderConfigurationException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-webpush:test --tests "*.WebPushProviderAdapterTest"

Expected: FAIL: Web Push transport adapter가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • WebPushNotificationProviderAdapter.java
  • WebPushRequestMapper.java
  • WebPushFailureClassifier.java
  • WebPushReceiptCapability.java
public WebPushRequest map(ProviderSubmission submission) {
    var ttl = java.time.Duration.between(clock.instant(), submission.expiresAt());
    if (ttl.isNegative() || ttl.isZero()) throw new NotificationExpiredException("WEBPUSH_EXPIRED");
    var encrypted = encryptor.encrypt(subscriptionKeys(submission), payload(submission));
    return new WebPushRequest(
        endpoint(submission), ttl.toSeconds(), urgency(submission), topic(submission),
        vapidSigner.authorization(endpoint(submission)), encrypted);
}

// 201 = PROVIDER_ACCEPTED, 404 and provider-documented 410 = INVALID_RECIPIENT.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-webpush:test

Expected: PASS: TTL, acceptance evidence, subscription invalidation이 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushNotificationProviderAdapter.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushRequestMapper.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushFailureClassifier.java' 'modules/notification/notification-webpush/src/main/java/io/backend/skeleton/notification/webpush/WebPushReceiptCapability.java' 'modules/notification/notification-webpush/src/test/java/io/backend/skeleton/notification/webpush/WebPushProviderAdapterTest.java'
git commit -m "feat(notification): add stable Web Push transport"

Task 39: In-App Inbox API·JPA Persistence·Cursor Pagination 구현

Files:

  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/NotificationInbox.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItem.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxCursor.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemId.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPrincipal.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPage.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxQuery.java
  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxMutationResult.java
  • Create: modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxItemEntity.java
  • Create: modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInbox.java
  • Create: modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxUnreadCounter.java
  • Test: modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInboxTest.java

Interfaces:

  • Consumes: Task 12 inbox schema, Task 3 InAppContent.
  • Produces: Tenant/user-bound cursor pagination, idempotent seen/read/archive, consistent unread count.

Implementation requirements:

  • WebSocket availability를 Inbox transaction에 포함하지 않는다.

  • unread count cache를 source of truth로 사용하지 않는다.

  • bulk mark-read는 bounded batch와 cursor cutoff를 사용한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.inbox.jpa;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class JpaNotificationInboxTest extends PostgreSqlNotificationTest {
    @Test
    void cursorPaginationIsStableWithSameTimestamp() {
        insertInboxItemsWithSameTimestamp(25);
        var first = inbox().list(query().limit(10));
        var second = inbox().list(query().after(first.nextCursor()).limit(10));
        assertThat(first.items()).doesNotContainAnyElementsOf(second.items());
        assertThat(first.items()).hasSize(10);
        assertThat(second.items()).hasSize(10);
    }

    @Test
    void concurrentMarkReadIsIdempotentAndUnreadCountStaysCorrect() {
        var id = insertUnreadItem();
        runConcurrently(20, () -> inbox().markRead(id, principal()));
        assertThat(inbox().unreadCount(principal())).isZero();
        assertThat(readAuditCount(id)).isEqualTo(1);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-inbox-jpa:test --tests "*.JpaNotificationInboxTest"

Expected: FAIL: Inbox API/JPA implementation이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationInbox.java
  • InboxItem.java
  • InboxCursor.java
  • InboxItemId.java
  • InboxPrincipal.java
  • InboxPage.java
  • InboxQuery.java
  • InboxMutationResult.java
  • InboxItemEntity.java
  • JpaNotificationInbox.java
  • InboxUnreadCounter.java
public interface NotificationInbox {
    InboxPage list(InboxQuery query);
    InboxMutationResult markSeen(InboxItemId id, InboxPrincipal principal);
    InboxMutationResult markRead(InboxItemId id, InboxPrincipal principal);
    InboxMutationResult archive(InboxItemId id, InboxPrincipal principal);
    long unreadCount(InboxPrincipal principal);
}

// Query ordering: created_at DESC, id DESC.
// Mark-read SQL updates only rows owned by tenant/user and only when read_at IS NULL.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-inbox-jpa:test

Expected: PASS: cursor pagination, concurrent read, unread count, tenant guard가 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/NotificationInbox.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItem.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxCursor.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemId.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPrincipal.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxPage.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxQuery.java' 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxMutationResult.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxItemEntity.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInbox.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxUnreadCounter.java' 'modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/JpaNotificationInboxTest.java'
git commit -m "feat(notification): add durable in-app inbox"

Task 40: Inbox Commit Event와 WebSocket Signal Integration 구현

Files:

  • Create: modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemCreated.java
  • Create: modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxCommitEventPublisher.java
  • Create: modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxOutboxRecordFactory.java
  • Test: modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/InboxSignalDurabilityTest.java

Interfaces:

  • Consumes: Task 39 Inbox, existing messaging/outbox and websocket capability.
  • Produces: DB commit source-of-truth, post-commit/outbox signal, WebSocket failure isolation.

Implementation requirements:

  • messaging Outbox가 존재하면 재사용하고 없으면 local post-commit adapter를 선택한다.

  • signal payload에 message body와 contact point를 넣지 않는다.

  • WebSocket signal은 unread count의 source of truth가 아니다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.inbox.jpa;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class InboxSignalDurabilityTest extends PostgreSqlNotificationTest {
    @Test
    void websocketFailureDoesNotRollbackInboxItem() {
        var publisher = fixture().failingSignalPublisher();
        var service = inboxService(publisher);
        var itemId = service.create(fixture().command());
        assertThat(inboxRepository().findById(itemId.value())).isPresent();
        assertThat(signalRetryQueue()).isNotEmpty();
    }

    @Test
    void signalIsNotPublishedBeforeCommit() {
        fixture().transactionThatRollsBack(() -> inboxService().create(fixture().command()));
        assertThat(publishedSignals()).isEmpty();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-inbox-jpa:test --tests "*.InboxSignalDurabilityTest"

Expected: FAIL: commit event publisher가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • InboxItemCreated.java
  • InboxCommitEventPublisher.java
  • InboxOutboxRecordFactory.java
@Transactional
public InboxItemId create(CreateInboxItem command) {
    var entity = repository.save(factory.create(command));
    outboxRepository.save(outboxFactory.inboxCreated(entity));
    return new InboxItemId(entity.id());
}

// Relay publishes InboxItemCreated after DB commit. WebSocket adapter consumes
// the event; delivery failure retries the signal without altering inbox data.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-inbox-jpa:test

Expected: PASS: Inbox write와 signal failure가 분리되고 rollback transaction은 signal을 만들지 않는다.

  • Step 5: Commit
git add 'modules/notification/notification-inbox-api/src/main/java/io/backend/skeleton/notification/inbox/InboxItemCreated.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxCommitEventPublisher.java' 'modules/notification/notification-inbox-jpa/src/main/java/io/backend/skeleton/notification/inbox/jpa/InboxOutboxRecordFactory.java' 'modules/notification/notification-inbox-jpa/src/test/java/io/backend/skeleton/notification/inbox/jpa/InboxSignalDurabilityTest.java'
git commit -m "feat(notification): integrate inbox commit signals"

Task 41: Opt-in Deduplication과 Provider Collapse Mapping 구현

Files:

  • Create: modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/DeduplicationService.java
  • Create: modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/CollapseCapability.java
  • Test: modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/DeduplicationAndCollapseTest.java

Interfaces:

  • Consumes: Task 4 DeduplicationSpec·CollapseSpec, Task 15 submit, Tasks 34·36·38 provider adapters.
  • Produces: Recipient/window-scoped dedup and provider-specific collapse hints without delivery guarantee.

Implementation requirements:

  • Deduplication은 명시적 spec이 없으면 실행하지 않는다.

  • collapse key는 provider length/character limit을 사전 검증한다.

  • collapse가 이미 전달된 알림을 취소한다고 표현하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.policy;

import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.assertj.core.api.Assertions.*;

class DeduplicationAndCollapseTest extends PostgreSqlNotificationTest {
    @Test
    void dedupSuppressesSecondLogicalNotificationWithinWindow() {
        var spec = new DeduplicationSpec("order-1-delay", Duration.ofMinutes(30),
            DeduplicationAction.RETURN_EXISTING);
        var first = service().evaluate(fixture().recipient("user-1"), spec);
        var second = service().evaluate(fixture().recipient("user-1"), spec);
        assertThat(second.existingNotificationId()).contains(first.notificationId());
    }

    @Test
    void collapseDoesNotReportExactlyOnceOrDelivered() {
        var mapped = collapseCapability().map(new CollapseSpec("feed:user-1", CollapseScope.RECIPIENT));
        assertThat(mapped.guaranteesSingleUserNotification()).isFalse();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-policy:test --tests "*.DeduplicationAndCollapseTest"

Expected: FAIL: dedup/collapse types와 service가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • DeduplicationService.java
  • CollapseCapability.java
@Transactional
public DeduplicationResult evaluate(RecipientIdentity recipient, DeduplicationSpec spec) {
    var bucket = bucketClock.bucket(clock.instant(), spec.window());
    return repository.insertOrFind(
        recipient.tenantId(), recipient.recipientRef(), spec.dedupKey(), bucket);
}

public interface CollapseCapability {
    ProviderCollapseMapping map(CollapseSpec spec);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-policy:test

Expected: PASS: dedup은 logical notification에 적용되고 collapse는 provider transport hint로만 동작한다.

  • Step 5: Commit
git add 'modules/notification/notification-policy/src/main/java/io/backend/skeleton/notification/policy/DeduplicationService.java' 'modules/notification/notification-provider-spi/src/main/java/io/backend/skeleton/notification/provider/CollapseCapability.java' 'modules/notification/notification-policy/src/test/java/io/backend/skeleton/notification/policy/DeduplicationAndCollapseTest.java'
git commit -m "feat(notification): add deduplication and collapse capabilities"

Task 42: Webhook Notification Extension을 HTTP Client Platform 위에 구현

Files:

  • Create: modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotification.java
  • Create: modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapter.java
  • Create: modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSignatureStrategy.java
  • Create: modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSubscription.java
  • Test: modules/notification/notification-webhook-extension/src/test/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapterTest.java

Interfaces:

  • Consumes: Task 10 provider SPI and existing HTTP Client H1/H3 gateways.
  • Produces: Webhook extension with trusted/dynamic target separation, request signing and HTTP execution evidence mapping.

Implementation requirements:

  • HTTP retry, TLS, SSRF, redirect, timeout을 새로 구현하지 않는다.

  • webhook target과 signing secret을 사용자 입력으로 한 요청에서 동시에 받지 않는다.

  • response body는 bounded diagnostic metadata만 보존한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.webhook;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class WebhookNotificationProviderAdapterTest extends HttpClientFixtureBase {
    @Test
    void dynamicTargetNeverInheritsTrustedCredential() {
        adapter().submit(fixture().dynamicSubmission()).toCompletableFuture().join();
        assertThat(recordedRequest().headers()).doesNotContainKeys("Authorization", "Cookie");
    }

    @Test
    void sentNoResponseMapsToAmbiguous() {
        server().acceptBodyThenReset();
        var result = adapter().submit(fixture().trustedSubmission()).toCompletableFuture().join();
        assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-webhook-extension:test --tests "*.WebhookNotificationProviderAdapterTest"

Expected: FAIL: webhook extension이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • WebhookNotification.java
  • WebhookNotificationProviderAdapter.java
  • WebhookSignatureStrategy.java
  • WebhookSubscription.java
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
    var webhook = mapper.map(submission);
    var call = webhook.subscription().trusted()
        ? trustedGateway.exchange(webhook.profileName(), webhook.operation(), RESPONSE)
        : dynamicGateway.exchange(webhook.dynamicPolicy(), webhook.target(), webhook.operation(), RESPONSE);
    return call.handle((result, error) -> evidenceMapper.map(result, error));
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-webhook-extension:test

Expected: PASS: credential isolation과 HTTP ambiguity mapping이 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotification.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapter.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSignatureStrategy.java' 'modules/notification/notification-webhook-extension/src/main/java/io/backend/skeleton/notification/webhook/WebhookSubscription.java' 'modules/notification/notification-webhook-extension/src/test/java/io/backend/skeleton/notification/webhook/WebhookNotificationProviderAdapterTest.java'
git commit -m "feat(notification): add webhook delivery extension"

Task 43: Notification Metrics·Tracing·Audit 구현

Files:

  • Create: modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationObservationConvention.java
  • Create: modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationMetrics.java
  • Create: modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationAuditLogger.java
  • Create: modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/CardinalityGuard.java
  • Test: modules/notification/notification-observability/src/test/java/io/backend/skeleton/notification/observation/NotificationObservabilityTest.java

Interfaces:

  • Consumes: Tasks 15 submit, 21 dispatcher, 22 callback, 25 reconciliation.
  • Produces: Logical/recipient/attempt observations, bounded tags, callback trace links and audited admin changes.

Implementation requirements:

  • templateId는 registry에 등록된 bounded ID일 때만 tag로 허용한다.

  • raw provider status는 normalized bounded status로 변환한다.

  • audit record에도 Contact Point 원문을 쓰지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.observation;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationObservabilityTest {
    @Test
    void metricTagsNeverContainHighCardinalityIdentifiers() {
        var observation = fixture().dispatchObservation();
        assertThat(observation.lowCardinalityTags().keySet())
            .doesNotContain("notificationId", "recipientId", "attemptId",
                "providerRequestId", "email", "phone", "token");
    }

    @Test
    void callbackUsesTraceLinkInsteadOfLongRunningChildSpan() {
        var trace = fixture().callbackTrace();
        assertThat(trace.links()).contains(fixture().originalDispatchContext());
        assertThat(trace.parent()).isEmpty();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-observability:test --tests "*.NotificationObservabilityTest"

Expected: FAIL: observation convention과 cardinality guard가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationObservationConvention.java
  • NotificationMetrics.java
  • NotificationAuditLogger.java
  • CardinalityGuard.java
public final class CardinalityGuard {
    private static final java.util.Set<String> ALLOWED_TAGS = java.util.Set.of(
        "channel", "provider", "templateId", "notificationCategory",
        "status", "failureCategory", "attemptBucket", "sizeBucket");

    public void validate(java.util.Map<String, String> tags) {
        if (!ALLOWED_TAGS.containsAll(tags.keySet())) {
            throw new IllegalMetricTagException(tags.keySet());
        }
    }
}

// NotificationMetrics exposes requested, suppressed, render, dispatch,
// accepted, delivery, retry, fallback, ambiguous, callback, reconcile and queue metrics.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-observability:test

Expected: PASS: high-cardinality/PII tag가 차단되고 callback trace link가 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationObservationConvention.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationMetrics.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/NotificationAuditLogger.java' 'modules/notification/notification-observability/src/main/java/io/backend/skeleton/notification/observation/CardinalityGuard.java' 'modules/notification/notification-observability/src/test/java/io/backend/skeleton/notification/observation/NotificationObservabilityTest.java'
git commit -m "feat(notification): add metrics tracing and audit"

Task 44: PII·Secret Redaction과 구조 경계 ArchUnit 구현

Files:

  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/NotificationRedactor.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SensitiveValueClassifier.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SafeDiagnosticContext.java
  • Test: modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationRedactionTest.java
  • Test: modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationArchitectureTest.java

Interfaces:

  • Consumes: Task 7 protector, Task 43 observability.
  • Produces: Central redaction, safe diagnostic context, provider SDK dependency boundary and raw secret leak tests.

Implementation requirements:

  • exception/logging interceptor 모두 NotificationRedactor를 사용한다.

  • 일반 SHA-256 주소 fingerprint를 저장하지 않고 keyed HMAC을 사용한다.

  • test output과 assertion failure에도 secret fixture 원문을 출력하지 않도록 custom representation을 사용한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.security;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationRedactionTest {
    @Test
    void redactsAllContactAndCredentialKinds() {
        var input = "user@example.com +821012345678 fcm-token webpush-endpoint bearer-secret";
        var redacted = redactor().redact(input, fixture().classifiedValues());
        assertThat(redacted)
            .doesNotContain("user@example.com", "+821012345678", "fcm-token",
                "webpush-endpoint", "bearer-secret");
    }

    @Test
    void safeContextAcceptsOnlyBoundedFields() {
        assertThatThrownBy(() -> SafeDiagnosticContext.builder()
            .put("providerRequestId", "SM123").build())
            .isInstanceOf(UnsafeDiagnosticFieldException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-security:test --tests "*.NotificationRedactionTest"

Expected: FAIL: redactor와 architecture rule이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationRedactor.java
  • SensitiveValueClassifier.java
  • SafeDiagnosticContext.java
  • NotificationArchitectureTest.java
public final class SafeDiagnosticContext {
    private static final java.util.Set<String> ALLOWED = java.util.Set.of(
        "channel", "provider", "operation", "result", "failureCategory",
        "templateId", "attemptBucket");
    // Builder rejects keys outside ALLOWED and normalizes values to bounded enums/IDs.
}

// ArchUnit rules:
// core/content/contact packages must not depend on Firebase, Twilio, AWS SDK,
// Jakarta Mail, JPA, MVC or WebFlux packages.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-security:test

Expected: PASS: contact/credential redaction과 architecture dependency guard가 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/NotificationRedactor.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SensitiveValueClassifier.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/SafeDiagnosticContext.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationRedactionTest.java' 'modules/notification/notification-security/src/test/java/io/backend/skeleton/notification/security/NotificationArchitectureTest.java'
git commit -m "test(notification): enforce PII and architecture boundaries"

Task 45: N4 Admin Redrive·Reconcile·Suppression·Provider Control 구현

Files:

  • Create: modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminService.java
  • Create: modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/AdminActor.java
  • Create: modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/RedriveCommand.java
  • Create: modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminServiceImpl.java
  • Create: modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/AdminAuthorizationGuard.java
  • Test: modules/notification/notification-admin-runtime/src/test/java/io/backend/skeleton/notification/admin/NotificationAdminServiceTest.java

Interfaces:

  • Consumes: Tasks 17 suppression, 25 reconciliation, 20 runtime, 43 audit.
  • Produces: Audited N4 operations, dry-run, bounded batch, duplicate-risk approval, original identity preservation.

Implementation requirements:

  • Admin operation ID는 idempotent해야 한다.

  • dry-run은 DB/Provider 상태를 변경하지 않는다.

  • bulk redrive는 rate limit과 max batch size를 적용한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.admin;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationAdminServiceTest extends PostgreSqlNotificationTest {
    @Test
    void applicationAuthorityCannotRedrive() {
        assertThatThrownBy(() -> service().redrive(
            fixture().redriveCommand(), fixture().applicationActor()))
            .isInstanceOf(AdminAccessDeniedException.class);
    }

    @Test
    void redrivePreservesLogicalIdsAndCreatesNewAttempt() {
        var original = insertFailedAttempt();
        var result = service().redrive(fixture().approvedRedrive(original.id()), fixture().adminActor());
        assertThat(result.notificationId()).isEqualTo(original.notificationId());
        assertThat(result.recipientDeliveryId()).isEqualTo(original.recipientDeliveryId());
        assertThat(result.newAttemptId()).isNotEqualTo(original.id());
        assertThat(auditCount(result.operationId())).isEqualTo(1);
    }

    @Test
    void ambiguousRedriveRequiresDuplicateRiskApproval() {
        var ambiguous = insertAmbiguousAttempt();
        assertThatThrownBy(() -> service().redrive(
            fixture().redriveWithoutRiskApproval(ambiguous.id()), fixture().adminActor()))
            .isInstanceOf(DuplicateRiskApprovalRequiredException.class);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-admin-runtime:test --tests "*.NotificationAdminServiceTest"

Expected: FAIL: admin API/runtime가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationAdminService.java
  • AdminActor.java
  • RedriveCommand.java
  • NotificationAdminServiceImpl.java
  • AdminAuthorizationGuard.java
public interface NotificationAdminService {
    AdminOperationResult redrive(RedriveCommand command, AdminActor actor);
    AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor);
    AdminOperationResult suppress(SuppressCommand command, AdminActor actor);
    AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor);
}

@Transactional
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
    authorization.require(actor, NotificationAdminAuthority.REDRIVE);
    var original = attempts.load(command.attemptId());
    duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
    var operation = operations.begin(command.operationId(), actor, command.reason());
    var newAttempt = redriveFactory.create(original, operation.id());
    audit.recordRedrive(operation, original, newAttempt);
    return result(newAttempt, operation);
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-admin-runtime:test

Expected: PASS: 권한, identity 보존, duplicate-risk approval, audit가 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminService.java' 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/AdminActor.java' 'modules/notification/notification-admin-api/src/main/java/io/backend/skeleton/notification/admin/RedriveCommand.java' 'modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/NotificationAdminServiceImpl.java' 'modules/notification/notification-admin-runtime/src/main/java/io/backend/skeleton/notification/admin/AdminAuthorizationGuard.java' 'modules/notification/notification-admin-runtime/src/test/java/io/backend/skeleton/notification/admin/NotificationAdminServiceTest.java'
git commit -m "feat(notification): add audited notification admin plane"

Task 46: Credential·Certificate Rotation과 Runtime Generation Drain 구현

Files:

  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProviderCredentialManager.java
  • Create: modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/CredentialGeneration.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotator.java
  • Create: modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RuntimeDrainCoordinator.java
  • Test: modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotationTest.java

Interfaces:

  • Consumes: Task 7 secret provider, Task 20 runtime registry, Task 45 admin control.
  • Produces: Immutable runtime generation replacement, in-flight drain, attempt generation audit, rollback.

Implementation requirements:

  • Contact Point encryption key rotation은 별도 background re-encryption job이다.

  • VAPID key rotation은 restricted subscription migration을 요구하므로 generic rotation으로 처리하지 않는다.

  • credential material을 audit에 기록하지 않고 generation/key ID만 기록한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.dispatch;

import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.assertj.core.api.Assertions.*;

class ProviderRuntimeRotationTest {
    @Test
    void newAttemptUsesNewGenerationWhileOldAttemptDrains() {
        var oldPermit = registry().current("apns-main").acquireAttempt();
        rotator().rotate("apns-main", fixture().credentialGeneration(2));
        var newPermit = registry().current("apns-main").acquireAttempt();
        assertThat(oldPermit.generation()).isEqualTo(1);
        assertThat(newPermit.generation()).isEqualTo(2);
        oldPermit.close();
        assertThat(registry().drainingGenerations("apns-main")).isEmpty();
    }

    @Test
    void failedNewCredentialKeepsOldRuntimeActive() {
        assertThatThrownBy(() -> rotator().rotate(
            "ses-primary", fixture().invalidCredentialGeneration(2)))
            .isInstanceOf(CredentialValidationException.class);
        assertThat(registry().current("ses-primary").generation()).isEqualTo(1);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test --tests "*.ProviderRuntimeRotationTest"

Expected: FAIL: credential manager/rotator가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ProviderCredentialManager.java
  • CredentialGeneration.java
  • ProviderRuntimeRotator.java
  • RuntimeDrainCoordinator.java
public void rotate(ProviderProfileId profileId, CredentialGeneration generation) {
    var candidate = runtimeFactory.create(profileId, generation);
    credentialProbe.validate(candidate);
    var previous = registry.swap(profileId, candidate);
    previous.markDraining();
    drainCoordinator.drain(previous, properties.drainTimeout());
}

// If validation fails before swap, previous runtime remains current.
// If drain timeout expires, cancel/close according to provider-specific safe shutdown contract.
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-dispatch-runtime:test

Expected: PASS: new generation cutover, old drain, failed candidate rollback이 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/ProviderCredentialManager.java' 'modules/notification/notification-security/src/main/java/io/backend/skeleton/notification/security/CredentialGeneration.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotator.java' 'modules/notification/notification-dispatch-runtime/src/main/java/io/backend/skeleton/notification/dispatch/RuntimeDrainCoordinator.java' 'modules/notification/notification-dispatch-runtime/src/test/java/io/backend/skeleton/notification/dispatch/ProviderRuntimeRotationTest.java'
git commit -m "feat(notification): add credential rotation and runtime draining"

Task 47: Spring Boot Starter·Properties Validation·Actuator 구현

Files:

  • Create: modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProperties.java
  • Create: modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCoreAutoConfiguration.java
  • Create: modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProviderAutoConfiguration.java
  • Create: modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCallbackAutoConfiguration.java
  • Create: modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationHealthEndpoint.java
  • Create: modules/notification/notification-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  • Test: modules/notification/notification-spring-boot-starter/src/test/java/io/backend/skeleton/notification/autoconfigure/NotificationAutoConfigurationTest.java

Interfaces:

  • Consumes: All Core/Adapter runtime contracts from Tasks 1-46.
  • Produces: Conditional beans, startup guardrails, MVC/WebFlux selection, non-sensitive health endpoints.

Implementation requirements:

  • Health endpoint는 provider state/generation/queue age만 노출하고 secret/address를 제외한다.

  • MVC와 WebFlux callback auto-config가 동시에 endpoint를 등록하지 않는다.

  • Production trust-all, unbounded queue, ambiguous fallback enable을 거부한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.autoconfigure;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.*;

class NotificationAutoConfigurationTest {
    private final ApplicationContextRunner runner = new ApplicationContextRunner()
        .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
            NotificationCoreAutoConfiguration.class,
            NotificationProviderAutoConfiguration.class));

    @Test
    void productionWebPushWithoutVapidKeyFailsStartup() {
        runner.withPropertyValues(
                "notification.providers.webpush.type=WEB_PUSH",
                "notification.providers.webpush.enabled=true",
                "notification.providers.webpush.environment=PRODUCTION")
            .run(context -> assertThat(context.getStartupFailure())
                .hasMessageContaining("VAPID"));
    }

    @Test
    void adminBeansAreAbsentByDefault() {
        runner.run(context -> assertThat(context)
            .doesNotHaveBean(NotificationAdminService.class));
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-spring-boot-starter:test --tests "*.NotificationAutoConfigurationTest"

Expected: FAIL: starter properties와 auto-configuration이 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationProperties.java
  • NotificationCoreAutoConfiguration.java
  • NotificationProviderAutoConfiguration.java
  • NotificationCallbackAutoConfiguration.java
  • NotificationHealthEndpoint.java
  • AutoConfiguration.imports
@ConfigurationProperties("notification")
public record NotificationProperties(
    DispatchProperties dispatch,
    java.util.Map<String, ProviderProperties> providers,
    CallbackProperties callbacks,
    SecurityProperties security
) {
    public NotificationProperties {
        validateBounded(dispatch.claimBatchSize(), 1, 1000, "claimBatchSize");
        providers.forEach((id, profile) -> profile.validate(id));
    }
}

@AutoConfiguration
@EnableConfigurationProperties(NotificationProperties.class)
public class NotificationCoreAutoConfiguration {}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-spring-boot-starter:test

Expected: PASS: missing secrets/invalid bounds가 startup 실패하고 Admin은 opt-in이다.

  • Step 5: Commit
git add 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProperties.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCoreAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationProviderAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationCallbackAutoConfiguration.java' 'modules/notification/notification-spring-boot-starter/src/main/java/io/backend/skeleton/notification/autoconfigure/NotificationHealthEndpoint.java' 'modules/notification/notification-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/notification/notification-spring-boot-starter/src/test/java/io/backend/skeleton/notification/autoconfigure/NotificationAutoConfigurationTest.java'
git commit -m "feat(notification): add Spring Boot starter and health endpoints"

Task 48: Reactor Facade와 Cancellation·Context 계약 구현

Files:

  • Create: modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactiveNotificationOrchestrator.java
  • Create: modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestrator.java
  • Create: modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorContextBridge.java
  • Test: modules/notification/notification-reactor/src/test/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestratorTest.java

Interfaces:

  • Consumes: Task 5 synchronous durable API, CompletionStage core.
  • Produces: Mono facade, context propagation, cancellation semantics without cancelling already committed submit.

Implementation requirements:

  • .block()을 facade 내부에서 사용하지 않는다.

  • JPA blocking operation은 boundedElastic/virtual-thread adapter 경계에서 실행한다.

  • submit cancellation은 이미 commit된 논리 요청을 자동 취소하지 않는다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.reactor;

import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.*;

class ReactorNotificationOrchestratorTest {
    @Test
    void propagatesCorrelationContext() {
        StepVerifier.create(reactive().submit(fixture().plan())
                .contextWrite(context -> context.put("correlationId", "corr-1")))
            .assertNext(receipt -> assertThat(recordedCorrelationId()).isEqualTo("corr-1"))
            .verifyComplete();
    }

    @Test
    void cancellationAfterCommitDoesNotDeleteNotification() {
        var disposable = reactive().submit(fixture().slowReturnAfterCommit()).subscribe();
        fixture().awaitCommit();
        disposable.dispose();
        assertThat(fixture().requestCount()).isEqualTo(1);
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-reactor:test --tests "*.ReactorNotificationOrchestratorTest"

Expected: FAIL: Reactor facade가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ReactiveNotificationOrchestrator.java
  • ReactorNotificationOrchestrator.java
  • ReactorContextBridge.java
public interface ReactiveNotificationOrchestrator {
    reactor.core.publisher.Mono<NotificationReceipt> submit(NotificationPlan plan);
    reactor.core.publisher.Mono<NotificationReceipt> schedule(NotificationPlan plan, java.time.Instant at);
    reactor.core.publisher.Mono<NotificationSnapshot> get(NotificationId id);
}

public Mono<NotificationReceipt> submit(NotificationPlan plan) {
    return Mono.deferContextual(context -> Mono.fromCompletionStage(
        contextBridge.withContext(context, () -> asyncCore.submit(plan))));
}
  • Step 4: Run test to verify it passes

Run: ./gradlew :modules:notification:notification-reactor:test

Expected: PASS: Reactor context와 durable commit cancellation semantics가 검증된다.

  • Step 5: Commit
git add 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactiveNotificationOrchestrator.java' 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestrator.java' 'modules/notification/notification-reactor/src/main/java/io/backend/skeleton/notification/reactor/ReactorContextBridge.java' 'modules/notification/notification-reactor/src/test/java/io/backend/skeleton/notification/reactor/ReactorNotificationOrchestratorTest.java'
git commit -m "feat(notification): add Reactor notification facade"

Task 49: 공통 Provider Contract Testkit·Chaos·Security Suite 구현

Files:

  • Create: modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderAdapterContract.java
  • Create: modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderFaultHarness.java
  • Create: modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/CallbackContract.java
  • Create: modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/PiiLeakScanner.java
  • Create: modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/CrossProviderContractSuiteTest.java
  • Create: infra/notification/toxiproxy/docker-compose.yml
  • Test: modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationChaosSecurityTest.java

Interfaces:

  • Consumes: Tasks 21-38 provider adapters and callback core.
  • Produces: Reusable confirmed/rejected/ambiguous contract, callback duplicate/order contract, Toxiproxy fault and PII scanner.

Implementation requirements:

  • 각 abstract test method 본문은 fixture를 호출하고 실제 assertion을 수행한다.

  • 외부 Provider sandbox 없이 PR suite가 결정적으로 실행된다.

  • Toxiproxy suite는 nightly/release job에도 연결한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.testkit;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class NotificationChaosSecurityTest {
    @Test
    void acceptedThenResponseLossIsAmbiguousForEveryApplicableAdapter() {
        for (var fixture : ProviderFixtures.responseLossCapableAdapters()) {
            var result = fixture.submitAfterServerAcceptsThenDropsResponse();
            assertThat(result.confirmation())
                .as(fixture.providerId())
                .isEqualTo(AttemptConfirmation.AMBIGUOUS);
        }
    }

    @Test
    void logsMetricsAndTracesContainNoSensitiveFixtureValues() {
        ProviderFixtures.runAllFailurePaths();
        assertThat(PiiLeakScanner.scan(capturedTelemetry(), ProviderFixtures.secrets()))
            .isEmpty();
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-testkit:test --tests "*.NotificationChaosSecurityTest"

Expected: FAIL: cross-provider contract testkit과 fault harness가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • ProviderAdapterContract.java
  • ProviderFaultHarness.java
  • CallbackContract.java
  • PiiLeakScanner.java
  • CrossProviderContractSuiteTest.java
  • infra/notification/toxiproxy/docker-compose.yml
public abstract class ProviderAdapterContract {
    protected abstract NotificationProviderAdapter adapter();
    protected abstract ProviderFixture fixture();

    @Test
    void confirmedAcceptanceMapsOnlyToProviderAccepted() {
        var result = adapter().submit(fixture().confirmedSubmission())
            .toCompletableFuture().join();
        org.assertj.core.api.Assertions.assertThat(result.confirmation())
            .isEqualTo(AttemptConfirmation.CONFIRMED);
        org.assertj.core.api.Assertions.assertThat(result.evidenceLevel())
            .isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
        org.assertj.core.api.Assertions.assertThat(result.deliveryOutcome())
            .isEqualTo(DeliveryOutcome.UNKNOWN);
    }

    @Test
    void explicitRejectionIsNotAmbiguous() {
        var result = adapter().submit(fixture().rejectedSubmission())
            .toCompletableFuture().join();
        org.assertj.core.api.Assertions.assertThat(result.confirmation())
            .isEqualTo(AttemptConfirmation.REJECTED);
        org.assertj.core.api.Assertions.assertThat(result.submissionOutcome())
            .isEqualTo(SubmissionOutcome.CONFIRMED_REJECTED);
    }

    @Test
    void responseLossAfterCommitIsAmbiguous() {
        var result = adapter().submit(fixture().acceptedThenResponseLostSubmission())
            .toCompletableFuture().join();
        org.assertj.core.api.Assertions.assertThat(result.confirmation())
            .isEqualTo(AttemptConfirmation.AMBIGUOUS);
        org.assertj.core.api.Assertions.assertThat(result.submissionOutcome())
            .isEqualTo(SubmissionOutcome.AMBIGUOUS);
    }
}
  • Step 4: Run test to verify it passes

Run: ./gradlew notificationContractTest notificationChaosTest notificationSecurityTest

Expected: PASS: Stable Adapter 공통 contract, ambiguity fault, PII leak scan이 통과한다.

  • Step 5: Commit
git add 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderAdapterContract.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/ProviderFaultHarness.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/CallbackContract.java' 'modules/notification/notification-testkit/src/main/java/io/backend/skeleton/notification/testkit/PiiLeakScanner.java' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/CrossProviderContractSuiteTest.java' 'infra/notification/toxiproxy/docker-compose.yml' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationChaosSecurityTest.java'
git commit -m "test(notification): add cross-provider chaos and security gates"

Task 50: 성능 인증·호환성 Matrix·문서·Release Gate 완성

Files:

  • Create: modules/notification/notification-testkit/src/jmh/java/io/backend/skeleton/notification/testkit/NotificationFanoutBenchmark.java
  • Create: modules/notification/notification-testkit/src/performanceTest/java/io/backend/skeleton/notification/testkit/NotificationPerformanceCertificationTest.java
  • Create: .github/workflows/notification-platform.yml
  • Create: docs/notification/support-matrix.md
  • Create: docs/notification/configuration-reference.md
  • Create: docs/notification/delivery-evidence.md
  • Create: docs/notification/callback-reconciliation.md
  • Create: docs/notification/provider-runbooks.md
  • Create: docs/notification/security-privacy.md
  • Create: docs/notification/operations.md
  • Create: docs/notification/migration-guide.md
  • Create: docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md
  • Create: docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md
  • Create: docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md
  • Create: docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md
  • Test: modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationReleaseGateTest.java

Interfaces:

  • Consumes: Tasks 1-49의 모든 코드·테스트·설정.
  • Produces: PR/nightly/release workflow, Spring compatibility, bounded resource certification, support docs and ADRs.

Implementation requirements:

  • 성능 threshold는 측정 후 versioned certification profile에 숫자로 고정한다.

  • Support Matrix는 channel별 최대 evidence와 비지원 보장을 명시한다.

  • Release workflow는 실제 Provider sandbox smoke test를 secret-protected optional gate로 분리한다.

  • Step 1: Write the failing test

package io.backend.skeleton.notification.testkit;

import org.junit.jupiter.api.Test;
import java.nio.file.*;
import static org.assertj.core.api.Assertions.*;

class NotificationReleaseGateTest {
    @Test
    void requiredDocumentationAndAdrsExist() {
        assertThat(Path.of("docs/notification/delivery-evidence.md")).exists();
        assertThat(Path.of("docs/notification/callback-reconciliation.md")).exists();
        assertThat(Path.of("docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md")).exists();
    }

    @Test
    void supportMatrixDoesNotClaimGuaranteedDelivery() throws Exception {
        var text = Files.readString(Path.of("docs/notification/support-matrix.md"));
        assertThat(text).doesNotContain("exactly once notification", "guaranteed read");
        assertThat(text).contains("PROVIDER_ACCEPTED", "AMBIGUOUS", "FCM_FID");
    }
}
  • Step 2: Run test to verify it fails

Run: ./gradlew :modules:notification:notification-testkit:test --tests "*.NotificationReleaseGateTest"

Expected: FAIL: performance suite, workflow, docs and ADRs가 없다.

  • Step 3: Write the minimal implementation

Files to implement:

  • NotificationFanoutBenchmark.java
  • NotificationPerformanceCertificationTest.java
  • .github/workflows/notification-platform.yml
  • docs/notification/support-matrix.md
  • docs/notification/configuration-reference.md
  • docs/notification/delivery-evidence.md
  • docs/notification/callback-reconciliation.md
  • docs/notification/provider-runbooks.md
  • docs/notification/security-privacy.md
  • docs/notification/operations.md
  • docs/notification/migration-guide.md
  • docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md
  • docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md
  • docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md
  • docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md
// CI jobs in notification-platform.yml
// pr: compile, unit, ArchUnit, PostgreSQL contract, provider fixtures, PII scan
// compatibility: Spring 6.2 latest patch and Spring 7.0 latest patch
// nightly: Toxiproxy ambiguity, process-kill recovery, callback burst
// release: performance certification, credential rotation, full support matrix

@Test
void scheduledBurstStaysWithinResourceBounds() {
    var result = harness().runScheduledBurst(100_000);
    assertThat(result.maxHeapBytes()).isLessThan(properties().heapBudgetBytes());
    assertThat(result.maxDbLockWait()).isLessThan(properties().maxDbLockWait());
    assertThat(result.retryAmplification()).isLessThanOrEqualTo(properties().maxRetryAmplification());
    assertThat(result.maxQueueAge()).isLessThan(properties().maxQueueAge());
}
  • Step 4: Run test to verify it passes

Run: ./gradlew clean notificationContractTest notificationChaosTest notificationSecurityTest notificationPerformanceTest notificationCompatibilityTest

Expected: PASS: 전체 build와 contract/chaos/security/performance/compatibility gate가 0 failure로 종료된다.

  • Step 5: Commit
git add 'modules/notification/notification-testkit/src/jmh/java/io/backend/skeleton/notification/testkit/NotificationFanoutBenchmark.java' 'modules/notification/notification-testkit/src/performanceTest/java/io/backend/skeleton/notification/testkit/NotificationPerformanceCertificationTest.java' '.github/workflows/notification-platform.yml' 'docs/notification/support-matrix.md' 'docs/notification/configuration-reference.md' 'docs/notification/delivery-evidence.md' 'docs/notification/callback-reconciliation.md' 'docs/notification/provider-runbooks.md' 'docs/notification/security-privacy.md' 'docs/notification/operations.md' 'docs/notification/migration-guide.md' 'docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md' 'docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md' 'docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md' 'docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md' 'modules/notification/notification-testkit/src/test/java/io/backend/skeleton/notification/testkit/NotificationReleaseGateTest.java'
git commit -m "docs(notification): complete release gates and runbooks"

4. Final execution order and review gates

Tasks 1-10   Core public contracts and provider SPI
Tasks 11-20  Error, persistence, submission, scheduler and policy runtime
Tasks 21-30  Dispatch, callback, reconciliation, attachments, Email and SMS foundation
Tasks 31-40  Twilio, Mobile Push, Web Push and In-App Inbox
Tasks 41-48  Dedup/collapse, Webhook, observability, security, Admin, rotation, starter and Reactor
Tasks 49-50  Cross-provider verification, performance, compatibility and release documentation

Every task requires two review gates before moving forward:

  1. Specification review: public signatures, evidence semantics, persistence and failure behavior match the design.
  2. Quality review: tests prove red/green behavior, no PII leaks, resource lifecycle and module boundaries are correct.

Do not weaken an earlier contract while executing subsequent tasks. When an Adapter cannot prove a common evidence level, keep the lower evidence and expose the difference through ProviderCapabilities.