feat(notification): implement the notification delivery platform

Maps the 31-module plan onto the registry's 19 leaves as packages; the two
edges the registry forbids (provider->httpclient, inbox->messaging) are
replaced by application-owned ports. See docs/notification/module-mapping.md.

Acceptance is not delivery: ProviderSubmissionResult refuses to carry a
delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks
automatic retry and fallback until reconciliation resolves it.

Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature +
reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292),
SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate
HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception.

Dispatch commits the attempt row, calls the provider with no transaction open,
then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 13:57:27 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit 701ba67456
511 changed files with 30537 additions and 23 deletions
@@ -0,0 +1,26 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
/** Raised when an actor lacks the operator authority an action requires. */
public class AdminAccessDeniedException extends NotificationException {
private static final long serialVersionUID = 1L;
public AdminAccessDeniedException(NotificationAdminAuthority required) {
super(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.AUTHORIZATION));
this.required = required;
}
private final transient NotificationAdminAuthority required;
/** Authority that was missing. */
public NotificationAdminAuthority required() {
return required;
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.TenantId;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Who is performing an operator action, and what they are allowed to do. */
public record AdminActor(
String actorRef, Set<NotificationAdminAuthority> authorities, Optional<TenantId> tenantId) {
public AdminActor {
Objects.requireNonNull(actorRef, "actorRef");
authorities = Set.copyOf(Objects.requireNonNull(authorities, "authorities"));
Objects.requireNonNull(tenantId, "tenantId");
if (actorRef.isBlank()) {
throw new IllegalArgumentException("actorRef");
}
}
/** Whether this actor holds an authority. */
public boolean holds(NotificationAdminAuthority authority) {
return authorities.contains(authority);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.NotificationId;
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Outcome of one audited operator action. */
public record AdminOperationResult(
String operationId,
boolean dryRun,
int affected,
Optional<NotificationId> notificationId,
Optional<RecipientDeliveryId> recipientDeliveryId,
Optional<DeliveryAttemptId> newAttemptId,
List<String> reasonCodes) {
public AdminOperationResult {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
Objects.requireNonNull(newAttemptId, "newAttemptId");
reasonCodes = List.copyOf(Objects.requireNonNull(reasonCodes, "reasonCodes"));
if (operationId.isBlank()) {
throw new IllegalArgumentException("operationId");
}
if (affected < 0) {
throw new IllegalArgumentException("affected");
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.admin;
import java.util.Optional;
/** Idempotent record of operator actions, so a retried request cannot redrive twice. */
public interface AdminOperationStorePort {
/** Claim an operation id, or return the previous result for it. */
Optional<AdminOperationResult> findByOperationId(String operationId);
/** Store the result of an operation. */
AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action);
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
/**
* Raised when an operator tries to redrive an ambiguous attempt without accepting the duplicate
* risk. The platform cannot tell whether the first submission reached the user, so the decision has
* to be recorded as a human one.
*/
public class DuplicateRiskApprovalRequiredException extends NotificationException {
private static final long serialVersionUID = 1L;
public DuplicateRiskApprovalRequiredException() {
super(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.AMBIGUOUS_SUBMISSION));
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.admin;
/** Separate authorities for the operator plane; application authority never grants these. */
public enum NotificationAdminAuthority {
REDRIVE,
RECONCILE,
SUPPRESS,
PROVIDER_CONTROL,
CREDENTIAL_ROTATION,
PROJECTION_REPLAY
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.notification.platform.admin;
/**
* N4 operator plane.
*
* <p>Every operation requires a separate authority, an idempotent operation id, a reason and an
* audit record. It is deliberately not reachable from the application-facing APIs.
*/
public interface NotificationAdminService {
/** Re-run a delivery while preserving its logical identity. */
AdminOperationResult redrive(RedriveCommand command, AdminActor actor);
/** Force reconciliation of a bounded batch. */
AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor);
/** Add or remove a suppression entry. */
AdminOperationResult suppress(SuppressCommand command, AdminActor actor);
/** Change a provider runtime state. */
AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor);
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import java.util.List;
import java.util.Objects;
/** Operator-triggered reconciliation over a bounded batch of attempts. */
public record ReconcileCommand(
String operationId, List<DeliveryAttemptId> attemptIds, String reason, boolean dryRun) {
public static final int MAX_BATCH = 200;
public ReconcileCommand {
Objects.requireNonNull(operationId, "operationId");
attemptIds = List.copyOf(Objects.requireNonNull(attemptIds, "attemptIds"));
Objects.requireNonNull(reason, "reason");
if (attemptIds.isEmpty() || attemptIds.size() > MAX_BATCH) {
throw new IllegalArgumentException("attemptIds must be 1.." + MAX_BATCH);
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import java.util.Objects;
/**
* Re-run one previously failed delivery.
*
* <p>A redrive reuses the pinned template version and rendered digest: it re-executes the original
* notification rather than sending a new, possibly different message under an old identity.
*/
public record RedriveCommand(
String operationId,
DeliveryAttemptId attemptId,
String reason,
boolean approveDuplicateRisk,
boolean dryRun) {
public RedriveCommand {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(attemptId, "attemptId");
Objects.requireNonNull(reason, "reason");
if (operationId.isBlank() || reason.isBlank()) {
throw new IllegalArgumentException("operationId and reason must not be blank");
}
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.Objects;
/** Enable, disable or drain a provider profile. */
public record SetProviderStateCommand(
String operationId,
ProviderProfileId profileId,
ProviderRuntimeState desiredState,
String reason,
boolean dryRun) {
public SetProviderStateCommand {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(desiredState, "desiredState");
Objects.requireNonNull(reason, "reason");
if (reason.isBlank()) {
throw new IllegalArgumentException("reason");
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.policy.SuppressionReason;
import dev.caskeleton.application.notification.platform.policy.SuppressionScope;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Add or remove a suppression entry as an operator. */
public record SuppressCommand(
String operationId,
TenantId tenantId,
SuppressionScope scope,
SuppressionReason reason,
String targetFingerprint,
Optional<Instant> expiresAt,
boolean remove,
String reasonText,
boolean dryRun) {
public SuppressCommand {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(scope, "scope");
Objects.requireNonNull(reason, "reason");
Objects.requireNonNull(targetFingerprint, "targetFingerprint");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(reasonText, "reasonText");
if (targetFingerprint.length() != 64) {
throw new IllegalArgumentException("targetFingerprint must be a keyed SHA-256 hex digest");
}
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Objects;
/**
* Cancellation intent.
*
* <p>Cancellation stops future logical attempts. It never claims that an already accepted provider
* submission was withdrawn.
*/
public record CancelCommand(String reasonCode, boolean cancelAmbiguousFollowUps) {
private static final int MAX_REASON_LENGTH = 120;
public CancelCommand {
Objects.requireNonNull(reasonCode, "reasonCode");
if (reasonCode.isBlank() || reasonCode.length() > MAX_REASON_LENGTH) {
throw new IllegalArgumentException("reasonCode");
}
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.List;
import java.util.Objects;
/** Outcome of a cancellation request, including how much external effect stays unknown. */
public record CancelResult(
NotificationId notificationId,
int canceledRecipients,
int alreadyTerminalRecipients,
boolean externalSideEffectUncertain,
List<String> reasonCodes) {
public CancelResult {
Objects.requireNonNull(notificationId, "notificationId");
reasonCodes = List.copyOf(Objects.requireNonNull(reasonCodes, "reasonCodes"));
if (canceledRecipients < 0 || alreadyTerminalRecipients < 0) {
throw new IllegalArgumentException("counts must not be negative");
}
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.notification.platform.api;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Caller-supplied channel preference for one recipient. Preference selects among channels that are
* already eligible; it can never re-enable a suppressed channel.
*/
public record ChannelPreferenceOverride(
List<Channel> preferredOrder, Set<Channel> blockedChannels) {
public ChannelPreferenceOverride {
preferredOrder = List.copyOf(Objects.requireNonNull(preferredOrder, "preferredOrder"));
blockedChannels = Set.copyOf(Objects.requireNonNull(blockedChannels, "blockedChannels"));
if (preferredOrder.stream().anyMatch(blockedChannels::contains)) {
throw new IllegalArgumentException("a channel cannot be preferred and blocked");
}
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification.platform.api;
/** Scope a collapse key is unique within. */
public enum CollapseScope {
RECIPIENT,
TENANT_CATEGORY
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Objects;
/**
* Provider transport hint. Collapse replaces an undelivered message; it never cancels a
* notification that already reached the device and never guarantees a single user-visible
* notification.
*/
public record CollapseSpec(String key, CollapseScope scope) {
private static final int MAX_KEY_LENGTH = 64;
public CollapseSpec {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("key");
}
if (key.length() > MAX_KEY_LENGTH) {
throw new IllegalArgumentException("key exceeds " + MAX_KEY_LENGTH);
}
Objects.requireNonNull(scope, "scope");
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Objects;
import java.util.UUID;
/** Identity of a protected contact point. Never the raw address. */
public record ContactPointId(UUID value) {
public ContactPointId {
Objects.requireNonNull(value, "value");
}
@Override
public String toString() {
return "ContactPointId[redacted]";
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.api;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import java.util.Objects;
/** Binds a channel of a recipient to one protected contact point. */
public record ContactPointSelector(Channel channel, ContactPointId contactPointId) {
public ContactPointSelector {
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(contactPointId, "contactPointId");
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.api;
/** Caller-supplied correlation identity used for trace linking only. */
public record CorrelationId(String value) {
private static final int MAX_LENGTH = 160;
public CorrelationId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
}
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification.platform.api;
/** What to do when a later request falls inside an existing deduplication window. */
public enum DeduplicationAction {
RETURN_EXISTING,
DROP
}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.notification.platform.api;
import java.time.Duration;
import java.util.Objects;
/**
* Opt-in business deduplication. This is not idempotency: it answers "are these different requests
* the same user-facing notification for a while?".
*/
public record DeduplicationSpec(String dedupKey, Duration window, DeduplicationAction action) {
private static final int MAX_KEY_LENGTH = 200;
private static final Duration MAX_WINDOW = Duration.ofDays(7);
public DeduplicationSpec {
if (dedupKey == null || dedupKey.isBlank()) {
throw new IllegalArgumentException("dedupKey");
}
if (dedupKey.length() > MAX_KEY_LENGTH) {
throw new IllegalArgumentException("dedupKey exceeds " + MAX_KEY_LENGTH);
}
Objects.requireNonNull(window, "window");
Objects.requireNonNull(action, "action");
if (window.isZero() || window.isNegative() || window.compareTo(MAX_WINDOW) > 0) {
throw new IllegalArgumentException("window must be positive and at most " + MAX_WINDOW);
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Objects;
import java.util.UUID;
/** Identity of one physical provider attempt. */
public record DeliveryAttemptId(UUID value) {
public DeliveryAttemptId {
Objects.requireNonNull(value, "value");
}
@Override
public String toString() {
return "DeliveryAttemptId[redacted]";
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.api;
/** Caller-supplied API idempotency key, unique inside one tenant. */
public record IdempotencyKey(String value) {
private static final int MAX_LENGTH = 200;
public IdempotencyKey {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Objects;
import java.util.UUID;
/** Identity of one logical notification request. Internal UUIDv7. */
public record NotificationId(UUID value) {
public NotificationId {
Objects.requireNonNull(value, "value");
}
@Override
public String toString() {
return "NotificationId[redacted]";
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification.platform.api;
import java.time.Instant;
/**
* N2 advanced API.
*
* <p>{@code submit} and {@code schedule} return once the logical request and its recipient jobs are
* durably committed. Neither waits for a provider call.
*/
public interface NotificationOrchestrator {
/** Accept a plan for immediate dispatch. */
NotificationReceipt submit(NotificationPlan plan);
/** Accept a plan that must not be activated before {@code scheduleAt}. */
NotificationReceipt schedule(NotificationPlan plan, Instant scheduleAt);
/** Stop future logical attempts for a notification. */
CancelResult cancel(NotificationId notificationId, CancelCommand command);
/** Read the current projection of a notification. */
NotificationSnapshot get(NotificationId notificationId);
}
@@ -0,0 +1,85 @@
package dev.caskeleton.application.notification.platform.api;
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* N2 submission intent.
*
* <p>{@code variables} is the notification-wide normalized variable map. It is pinned into the
* request fingerprint together with the template coordinate, so a later template publish cannot
* change what an accepted notification means.
*/
public record NotificationPlan(
TenantId tenantId,
IdempotencyKey idempotencyKey,
String category,
TemplateSelection template,
Map<String, Object> variables,
List<RecipientSpec> recipients,
DeliveryStrategy deliveryStrategy,
Optional<Instant> notBefore,
Optional<Instant> expiresAt,
Optional<DeduplicationSpec> deduplication,
Optional<CollapseSpec> collapse,
CorrelationId correlationId,
Map<String, String> boundedMetadata) {
/** Hard limits from the design contract; a profile may lower them but never raise them. */
public static final int MAX_METADATA_ENTRIES = 16;
public static final int MAX_METADATA_KEY_BYTES = 64;
public static final int MAX_METADATA_VALUE_BYTES = 256;
public static final int MAX_RECIPIENTS = 1000;
private static final int MAX_CATEGORY_LENGTH = 120;
public NotificationPlan {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
Objects.requireNonNull(template, "template");
Objects.requireNonNull(deliveryStrategy, "deliveryStrategy");
Objects.requireNonNull(correlationId, "correlationId");
Objects.requireNonNull(notBefore, "notBefore");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(deduplication, "deduplication");
Objects.requireNonNull(collapse, "collapse");
variables = Map.copyOf(Objects.requireNonNull(variables, "variables"));
recipients = List.copyOf(Objects.requireNonNull(recipients, "recipients"));
boundedMetadata = Map.copyOf(Objects.requireNonNull(boundedMetadata, "boundedMetadata"));
if (category == null || category.isBlank() || category.length() > MAX_CATEGORY_LENGTH) {
throw new IllegalArgumentException("category");
}
if (recipients.isEmpty() || recipients.size() > MAX_RECIPIENTS) {
throw new IllegalArgumentException("recipients must be 1.." + MAX_RECIPIENTS);
}
if (boundedMetadata.size() > MAX_METADATA_ENTRIES) {
throw new IllegalArgumentException("boundedMetadata exceeds " + MAX_METADATA_ENTRIES);
}
boundedMetadata.forEach(NotificationPlan::requireBoundedMetadataEntry);
if (expiresAt.isPresent()
&& notBefore.isPresent()
&& !expiresAt.get().isAfter(notBefore.get())) {
throw new IllegalArgumentException("expiresAt must be after notBefore");
}
}
private static void requireBoundedMetadataEntry(String key, String value) {
Objects.requireNonNull(key, "metadata key");
Objects.requireNonNull(value, "metadata value");
if (key.getBytes(StandardCharsets.UTF_8).length > MAX_METADATA_KEY_BYTES) {
throw new IllegalArgumentException(
"metadata key exceeds " + MAX_METADATA_KEY_BYTES + " bytes");
}
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_METADATA_VALUE_BYTES) {
throw new IllegalArgumentException(
"metadata value exceeds " + MAX_METADATA_VALUE_BYTES + " bytes");
}
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.notification.platform.api;
import java.time.Instant;
import java.util.Objects;
/**
* Result of a durable acceptance.
*
* <p>There is deliberately no {@code delivered}, {@code sent} or {@code read} component: a receipt
* proves the logical request and its recipient jobs are committed, nothing about any provider.
*/
public record NotificationReceipt(
NotificationId notificationId, RequestStatus status, Instant acceptedAt) {
public NotificationReceipt {
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(acceptedAt, "acceptedAt");
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.application.notification.platform.api;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome;
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Read model of one logical notification: status plus the evidence held per recipient and attempt.
*/
public record NotificationSnapshot(
NotificationId notificationId,
TenantId tenantId,
RequestStatus status,
Instant acceptedAt,
Instant updatedAt,
List<RecipientSnapshot> recipients) {
public NotificationSnapshot {
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(acceptedAt, "acceptedAt");
Objects.requireNonNull(updatedAt, "updatedAt");
recipients = List.copyOf(Objects.requireNonNull(recipients, "recipients"));
}
/** Per-recipient projection. Contact point values are never exposed here. */
public record RecipientSnapshot(
RecipientDeliveryId recipientDeliveryId,
RecipientDeliveryState state,
SubmissionOutcome submissionOutcome,
DeliveryOutcome deliveryOutcome,
EvidenceLevel evidenceLevel,
boolean ambiguousAttemptExists,
boolean duplicateRisk,
int attemptCount,
List<AttemptSnapshot> attempts) {
public RecipientSnapshot {
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
Objects.requireNonNull(state, "state");
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
attempts = List.copyOf(Objects.requireNonNull(attempts, "attempts"));
}
}
/** Per-attempt projection. */
public record AttemptSnapshot(
DeliveryAttemptId attemptId,
int attemptNo,
Channel channel,
ProviderProfileId providerProfileId,
AttemptConfirmation confirmation,
SubmissionOutcome submissionOutcome,
DeliveryOutcome deliveryOutcome,
EvidenceLevel evidenceLevel,
Optional<String> failureCode,
Instant startedAt,
Optional<Instant> completedAt) {
public AttemptSnapshot {
Objects.requireNonNull(attemptId, "attemptId");
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(providerProfileId, "providerProfileId");
Objects.requireNonNull(confirmation, "confirmation");
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
Objects.requireNonNull(failureCode, "failureCode");
Objects.requireNonNull(startedAt, "startedAt");
Objects.requireNonNull(completedAt, "completedAt");
}
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.notification.platform.api;
/**
* Provider-assigned event identity. Absent for providers that do not emit one; the ledger then
* falls back to a deterministic fingerprint.
*/
public record ProviderEventId(String value) {
private static final int MAX_LENGTH = 300;
public ProviderEventId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
}
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.api;
/** Provider family identity, for example {@code ses}, {@code twilio}, {@code fcm}. */
public record ProviderId(String value) {
private static final int MAX_LENGTH = 80;
public ProviderId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
}
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.api;
/** Configured provider profile identity, for example {@code ses-primary}. */
public record ProviderProfileId(String value) {
private static final int MAX_LENGTH = 120;
public ProviderProfileId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Objects;
import java.util.UUID;
/** Identity of the logical delivery job for a single recipient. */
public record RecipientDeliveryId(UUID value) {
public RecipientDeliveryId {
Objects.requireNonNull(value, "value");
}
@Override
public String toString() {
return "RecipientDeliveryId[redacted]";
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.notification.platform.api;
import java.time.ZoneId;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/**
* One recipient of a logical notification.
*
* <p>{@code recipientRef} is the caller's stable reference. It is never a contact point value and
* is never used as a metric label.
*/
public record RecipientSpec(
String recipientRef,
Optional<Locale> locale,
Optional<ZoneId> timeZone,
List<ContactPointSelector> contactPoints,
Optional<ChannelPreferenceOverride> channelOverride) {
private static final int MAX_RECIPIENT_REF_LENGTH = 200;
public RecipientSpec {
if (recipientRef == null || recipientRef.isBlank()) {
throw new IllegalArgumentException("recipientRef");
}
if (recipientRef.length() > MAX_RECIPIENT_REF_LENGTH) {
throw new IllegalArgumentException("recipientRef exceeds " + MAX_RECIPIENT_REF_LENGTH);
}
Objects.requireNonNull(locale, "locale");
Objects.requireNonNull(timeZone, "timeZone");
contactPoints = List.copyOf(Objects.requireNonNull(contactPoints, "contactPoints"));
Objects.requireNonNull(channelOverride, "channelOverride");
if (contactPoints.isEmpty()) {
throw new IllegalArgumentException("contactPoints must not be empty");
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.api;
/**
* Projection of the logical request. This never expresses a provider status: a request may be
* {@code COMPLETED} while individual recipients hold only {@code PROVIDER_ACCEPTED} evidence.
*/
public enum RequestStatus {
CREATED,
VALIDATED,
SCHEDULED,
PROCESSING,
PARTIALLY_COMPLETED,
COMPLETED,
CANCELED,
EXPIRED,
FAILED
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Locale;
import java.util.Objects;
/**
* Exact template coordinate pinned at submit time. Retry and redrive reuse this selection instead
* of resolving the newest published version.
*/
public record TemplateSelection(String templateId, long version, Locale locale) {
private static final int MAX_TEMPLATE_ID_LENGTH = 160;
public TemplateSelection {
if (templateId == null || templateId.isBlank()) {
throw new IllegalArgumentException("templateId");
}
if (templateId.length() > MAX_TEMPLATE_ID_LENGTH) {
throw new IllegalArgumentException("templateId exceeds " + MAX_TEMPLATE_ID_LENGTH);
}
if (version <= 0) {
throw new IllegalArgumentException("version");
}
Objects.requireNonNull(locale, "locale");
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.api;
/** Tenant boundary every repository query must carry. */
public record TenantId(String value) {
private static final int MAX_LENGTH = 100;
public TenantId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException("value exceeds " + MAX_LENGTH + " characters");
}
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification.platform.api.content;
/** How an attachment reference is presented in the rendered message. */
public enum AttachmentDisposition {
ATTACHMENT,
INLINE
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.util.Objects;
/**
* Immutable reference to content owned by the file server or object storage capability. The
* notification platform never stores attachment bytes.
*/
public record AttachmentRef(
String contentReference,
String displayName,
String contentType,
long expectedSize,
String expectedDigest,
AttachmentDisposition disposition) {
public AttachmentRef {
Objects.requireNonNull(contentReference, "contentReference");
Objects.requireNonNull(displayName, "displayName");
Objects.requireNonNull(contentType, "contentType");
Objects.requireNonNull(expectedDigest, "expectedDigest");
Objects.requireNonNull(disposition, "disposition");
if (contentReference.isBlank()) {
throw new IllegalArgumentException("contentReference");
}
if (expectedSize < 0) {
throw new IllegalArgumentException("expectedSize");
}
if (expectedDigest.isBlank()) {
throw new IllegalArgumentException("expectedDigest");
}
if (displayName.indexOf('\r') >= 0 || displayName.indexOf('\n') >= 0) {
throw new IllegalArgumentException("displayName must not contain CR or LF");
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Rendered email body: text plus optional HTML for a multipart/alternative message. */
public record EmailContent(
String subject,
String textBody,
Optional<String> htmlBody,
List<AttachmentRef> attachments,
EmailOptions options)
implements NotificationContent {
private static final int MAX_ATTACHMENTS = 20;
public EmailContent {
Objects.requireNonNull(subject, "subject");
Objects.requireNonNull(textBody, "textBody");
Objects.requireNonNull(htmlBody, "htmlBody");
attachments = List.copyOf(Objects.requireNonNull(attachments, "attachments"));
Objects.requireNonNull(options, "options");
if (subject.isBlank()) {
throw new IllegalArgumentException("subject");
}
if (subject.indexOf('\r') >= 0 || subject.indexOf('\n') >= 0) {
throw new IllegalArgumentException("subject must not contain CR or LF");
}
if (attachments.size() > MAX_ATTACHMENTS) {
throw new IllegalArgumentException("too many attachments");
}
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.application.notification.platform.api.content;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Typed email transport options. Copy recipients and reply-to are carried as protected contact
* point references, never as raw addresses, and header values are rejected when they contain CR or
* LF.
*/
public record EmailOptions(
List<ContactPointId> cc,
List<ContactPointId> bcc,
Optional<ContactPointId> replyTo,
Map<String, String> approvedHeaders,
boolean listUnsubscribeEnabled) {
private static final int MAX_COPY_RECIPIENTS = 50;
private static final int MAX_HEADERS = 16;
public static final EmailOptions DEFAULT =
new EmailOptions(List.of(), List.of(), Optional.empty(), Map.of(), false);
public EmailOptions {
cc = List.copyOf(Objects.requireNonNull(cc, "cc"));
bcc = List.copyOf(Objects.requireNonNull(bcc, "bcc"));
Objects.requireNonNull(replyTo, "replyTo");
approvedHeaders = Map.copyOf(Objects.requireNonNull(approvedHeaders, "approvedHeaders"));
if (cc.size() + bcc.size() > MAX_COPY_RECIPIENTS) {
throw new IllegalArgumentException("too many copy recipients");
}
if (approvedHeaders.size() > MAX_HEADERS) {
throw new IllegalArgumentException("too many approved headers");
}
approvedHeaders.forEach(
(name, value) -> {
if (containsControlCharacter(name) || containsControlCharacter(value)) {
throw new IllegalArgumentException("header must not contain CR or LF");
}
});
}
private static boolean containsControlCharacter(String value) {
Objects.requireNonNull(value, "value");
return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0;
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
/** UI-independent semantic action attached to an in-app inbox item. */
public record InAppAction(String actionId, String label, Optional<URI> deepLink) {
public InAppAction {
Objects.requireNonNull(actionId, "actionId");
Objects.requireNonNull(label, "label");
Objects.requireNonNull(deepLink, "deepLink");
if (actionId.isBlank()) {
throw new IllegalArgumentException("actionId");
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Rendered in-app inbox content. The database row is the source of truth for this channel. */
public record InAppContent(
String title, String body, Optional<URI> deepLink, List<InAppAction> actions, String category)
implements NotificationContent {
private static final int MAX_ACTIONS = 8;
public InAppContent {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(deepLink, "deepLink");
actions = List.copyOf(Objects.requireNonNull(actions, "actions"));
Objects.requireNonNull(category, "category");
if (title.isBlank()) {
throw new IllegalArgumentException("title");
}
if (category.isBlank()) {
throw new IllegalArgumentException("category");
}
if (actions.size() > MAX_ACTIONS) {
throw new IllegalArgumentException("too many actions");
}
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.net.URI;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Rendered mobile push content. The data map is the one place where a string-to-string map is the
* platform-native shape; reserved provider keys are rejected by the provider adapters.
*/
public record MobilePushContent(
String title,
String body,
Optional<URI> deepLink,
Map<String, String> data,
PushPresentation presentation)
implements NotificationContent {
private static final int MAX_DATA_ENTRIES = 32;
public MobilePushContent {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(deepLink, "deepLink");
data = Map.copyOf(Objects.requireNonNull(data, "data"));
Objects.requireNonNull(presentation, "presentation");
if (title.isBlank()) {
throw new IllegalArgumentException("title");
}
if (data.size() > MAX_DATA_ENTRIES) {
throw new IllegalArgumentException("too many data entries");
}
}
/** Convenience factory that accepts a nullable deep link. */
public static MobilePushContent of(
String title, String body, URI deepLink, Map<String, String> data) {
return new MobilePushContent(
title, body, Optional.ofNullable(deepLink), data, PushPresentation.DEFAULT);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.notification.platform.api.content;
/**
* Channel content after rendering. Provider SDK objects, arbitrary option maps, raw credentials,
* unbounded headers and binary attachment bytes are deliberately absent from this hierarchy.
*/
public sealed interface NotificationContent
permits EmailContent, SmsContent, MobilePushContent, WebPushContent, InAppContent {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.util.Objects;
import java.util.Optional;
/** Presentation hints shared by mobile push providers. */
public record PushPresentation(Optional<String> sound, Optional<Integer> badge) {
public static final PushPresentation DEFAULT =
new PushPresentation(Optional.empty(), Optional.empty());
public PushPresentation {
Objects.requireNonNull(sound, "sound");
Objects.requireNonNull(badge, "badge");
badge.ifPresent(
value -> {
if (value < 0) {
throw new IllegalArgumentException("badge");
}
});
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.util.Objects;
/** Rendered SMS body. Segment and encoding analysis is performed by the SMS estimator, not here. */
public record SmsContent(String text, SmsOptions options) implements NotificationContent {
public SmsContent {
if (text == null || text.isBlank()) {
throw new IllegalArgumentException("text");
}
Objects.requireNonNull(options, "options");
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.util.Objects;
import java.util.Optional;
/**
* Typed SMS transport options. The sender is a configured profile reference; business code never
* supplies an arbitrary sender string.
*/
public record SmsOptions(Optional<String> senderProfileRef, boolean transactional) {
public static final SmsOptions DEFAULT = new SmsOptions(Optional.empty(), true);
public SmsOptions {
Objects.requireNonNull(senderProfileRef, "senderProfileRef");
senderProfileRef.ifPresent(
reference -> {
if (reference.isBlank()) {
throw new IllegalArgumentException("senderProfileRef");
}
});
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.net.URI;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Rendered Web Push content. The encrypted payload size is validated before the provider call. */
public record WebPushContent(
String title,
String body,
Optional<URI> deepLink,
Map<String, String> data,
WebPushOptions options)
implements NotificationContent {
private static final int MAX_DATA_ENTRIES = 32;
public WebPushContent {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(deepLink, "deepLink");
data = Map.copyOf(Objects.requireNonNull(data, "data"));
Objects.requireNonNull(options, "options");
if (title.isBlank()) {
throw new IllegalArgumentException("title");
}
if (data.size() > MAX_DATA_ENTRIES) {
throw new IllegalArgumentException("too many data entries");
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.notification.platform.api.content;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/** Typed RFC 8030 options. TTL is derived from the delivery expiry, never supplied here. */
public record WebPushOptions(Urgency urgency, Optional<String> topic) {
private static final int MAX_TOPIC_LENGTH = 32;
public static final WebPushOptions DEFAULT = new WebPushOptions(Urgency.NORMAL, Optional.empty());
/** RFC 8030 urgency values. */
public enum Urgency {
VERY_LOW,
LOW,
NORMAL,
HIGH;
/** Wire token used in the Urgency header. */
public String headerValue() {
return name().toLowerCase(Locale.ROOT).replace('_', '-');
}
}
public WebPushOptions {
Objects.requireNonNull(urgency, "urgency");
Objects.requireNonNull(topic, "topic");
topic.ifPresent(
value -> {
if (value.isBlank() || value.length() > MAX_TOPIC_LENGTH) {
throw new IllegalArgumentException("topic");
}
});
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.notification.platform.api.delivery;
/** How certain the platform is about the completion of one physical attempt. */
public enum AttemptConfirmation {
CONFIRMED,
REJECTED,
AMBIGUOUS
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.delivery;
/** What the platform knows about delivery after provider callbacks or reconciliation. */
public enum DeliveryOutcome {
UNKNOWN,
SENT,
DELIVERED,
UNDELIVERED,
BOUNCED,
EXPIRED
}
@@ -0,0 +1,44 @@
package dev.caskeleton.application.notification.platform.api.delivery;
import java.util.Objects;
/**
* The strongest evidence obtained so far.
*
* <p>The explicit {@code strength} rank exists so that no code has to reach for {@code ordinal()}.
* It compares two evidence levels <em>about the same attempt</em> and nothing else: merging
* provider events is done by channel-specific projectors with real transition tables, because
* "delivered then complaint" and "delivered then late sent" are not comparisons at all.
*/
public enum EvidenceLevel {
NONE(0),
PLATFORM_QUEUED(1),
PROVIDER_ACCEPTED(2),
NETWORK_OR_CARRIER_ACCEPTED(3),
DEVICE_DELIVERED(4),
USER_AGENT_DISPLAYED(5),
USER_READ(6);
private final int strength;
EvidenceLevel(int strength) {
this.strength = strength;
}
/** Rank of this level; only meaningful against another level of the same attempt. */
public int strength() {
return strength;
}
/** Whether this level is at least as strong as another. */
public boolean atLeast(EvidenceLevel other) {
Objects.requireNonNull(other, "other");
return strength >= other.strength;
}
/** The stronger of two levels, so evidence never moves backwards. */
public EvidenceLevel strongerOf(EvidenceLevel other) {
Objects.requireNonNull(other, "other");
return strength >= other.strength ? this : other;
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.notification.platform.api.delivery;
/** Lifecycle projection of one recipient delivery job. */
public enum RecipientDeliveryState {
PENDING,
READY_TO_DISPATCH,
SUPPRESSED,
DISPATCHING,
RETRY_WAITING,
RECONCILIATION_REQUIRED,
COMPLETED,
FAILED,
EXPIRED,
CANCELED
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification.platform.api.delivery;
/** What the platform knows about the submission itself, independent of any delivery evidence. */
public enum SubmissionOutcome {
NOT_SUBMITTED,
CONFIRMED_ACCEPTED,
CONFIRMED_REJECTED,
AMBIGUOUS
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** The submission may or may not have been accepted; automatic retry and fallback stay blocked. */
public class AmbiguousSubmissionException extends NotificationException {
private static final long serialVersionUID = 1L;
public AmbiguousSubmissionException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Attachment size or digest did not match the pinned reference. */
public class AttachmentIntegrityException extends NotificationException {
private static final long serialVersionUID = 1L;
public AttachmentIntegrityException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Attachment reference is not readable or not READY. */
public class AttachmentUnavailableException extends NotificationException {
private static final long serialVersionUID = 1L;
public AttachmentUnavailableException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** A stored provider event could not be projected. */
public class CallbackProjectionException extends NotificationException {
private static final long serialVersionUID = 1L;
public CallbackProjectionException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Callback failed signature, size, content-type or replay validation. */
public class CallbackValidationException extends NotificationException {
private static final long serialVersionUID = 1L;
public CallbackValidationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Retry-relevant classification of a failure. Provider status strings never reach this enum. */
public enum FailureCategory {
TRANSIENT_PROVIDER,
THROTTLED,
AUTHENTICATION,
AUTHORIZATION,
INVALID_RECIPIENT,
INVALID_PAYLOAD,
TEMPLATE_FAILURE,
PERMANENT_PROVIDER,
AMBIGUOUS_SUBMISSION,
CALLBACK_VALIDATION_FAILURE,
CAPACITY_REJECTED,
EXPIRED
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Same idempotency key reused with a different request fingerprint. */
public class IdempotencyConflictException extends NotificationException {
private static final long serialVersionUID = 1L;
public IdempotencyConflictException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** The contact point is not usable for this channel. */
public class InvalidContactPointException extends NotificationException {
private static final long serialVersionUID = 1L;
public InvalidContactPointException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Intake refused because the durable queue is at its bound. */
public class NotificationCapacityException extends NotificationException {
private static final long serialVersionUID = 1L;
public NotificationCapacityException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.notification.platform.api.error;
/**
* Root of the stable notification error hierarchy.
*
* <p>The message is the registered failure code and nothing else. Recipient addresses, message
* bodies, template variables and provider exception text never reach it; diagnostics that need more
* detail go to the audit trail with protected identifiers.
*/
public abstract class NotificationException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient NotificationFailureDescriptor descriptor;
protected NotificationException(NotificationFailureDescriptor descriptor) {
super(descriptor.code());
this.descriptor = descriptor;
}
/** Stable, low-cardinality metadata describing this failure. */
public NotificationFailureDescriptor descriptor() {
return descriptor;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** The delivery expiry passed before a new attempt could start. */
public class NotificationExpiredException extends NotificationException {
private static final long serialVersionUID = 1L;
public NotificationExpiredException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,80 @@
package dev.caskeleton.application.notification.platform.api.error;
import java.util.Set;
/**
* Bounded failure-code registry.
*
* <p>Codes are safe as metric tags precisely because this set is closed. An adapter that meets an
* unknown provider error maps it onto one of these codes and keeps the native status in the ledger.
*/
public final class NotificationFailureCode {
public static final String VALIDATION_FAILED = "VALIDATION_FAILED";
public static final String IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT";
public static final String TEMPLATE_NOT_FOUND = "TEMPLATE_NOT_FOUND";
public static final String TEMPLATE_RENDERING_FAILED = "TEMPLATE_RENDERING_FAILED";
public static final String TEMPLATE_VARIABLES_INVALID = "TEMPLATE_VARIABLES_INVALID";
public static final String CONTACT_POINT_INVALID = "CONTACT_POINT_INVALID";
public static final String CONTACT_POINT_SUPPRESSED = "CONTACT_POINT_SUPPRESSED";
public static final String NOTIFICATION_EXPIRED = "NOTIFICATION_EXPIRED";
public static final String NOTIFICATION_SUPPRESSED = "NOTIFICATION_SUPPRESSED";
public static final String PROVIDER_AUTHENTICATION_FAILED = "PROVIDER_AUTHENTICATION_FAILED";
public static final String PROVIDER_AUTHORIZATION_FAILED = "PROVIDER_AUTHORIZATION_FAILED";
public static final String PROVIDER_THROTTLED = "PROVIDER_THROTTLED";
public static final String PROVIDER_TRANSIENT_FAILURE = "PROVIDER_TRANSIENT_FAILURE";
public static final String PROVIDER_PERMANENT_FAILURE = "PROVIDER_PERMANENT_FAILURE";
public static final String PROVIDER_REJECTED = "PROVIDER_REJECTED";
public static final String PROVIDER_RESPONSE_LOST = "PROVIDER_RESPONSE_LOST";
public static final String PROVIDER_PAYLOAD_LIMIT = "PROVIDER_PAYLOAD_LIMIT";
public static final String PROVIDER_CONFIGURATION_INVALID = "PROVIDER_CONFIGURATION_INVALID";
public static final String PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE";
public static final String CALLBACK_SIGNATURE_INVALID = "CALLBACK_SIGNATURE_INVALID";
public static final String CALLBACK_PAYLOAD_REJECTED = "CALLBACK_PAYLOAD_REJECTED";
public static final String CALLBACK_PROJECTION_FAILED = "CALLBACK_PROJECTION_FAILED";
public static final String RECONCILIATION_FAILED = "RECONCILIATION_FAILED";
public static final String CAPACITY_REJECTED = "CAPACITY_REJECTED";
public static final String ATTACHMENT_UNAVAILABLE = "ATTACHMENT_UNAVAILABLE";
public static final String ATTACHMENT_INTEGRITY_FAILED = "ATTACHMENT_INTEGRITY_FAILED";
private static final Set<String> REGISTERED =
Set.of(
VALIDATION_FAILED,
IDEMPOTENCY_CONFLICT,
TEMPLATE_NOT_FOUND,
TEMPLATE_RENDERING_FAILED,
TEMPLATE_VARIABLES_INVALID,
CONTACT_POINT_INVALID,
CONTACT_POINT_SUPPRESSED,
NOTIFICATION_EXPIRED,
NOTIFICATION_SUPPRESSED,
PROVIDER_AUTHENTICATION_FAILED,
PROVIDER_AUTHORIZATION_FAILED,
PROVIDER_THROTTLED,
PROVIDER_TRANSIENT_FAILURE,
PROVIDER_PERMANENT_FAILURE,
PROVIDER_REJECTED,
PROVIDER_RESPONSE_LOST,
PROVIDER_PAYLOAD_LIMIT,
PROVIDER_CONFIGURATION_INVALID,
PROVIDER_UNAVAILABLE,
CALLBACK_SIGNATURE_INVALID,
CALLBACK_PAYLOAD_REJECTED,
CALLBACK_PROJECTION_FAILED,
RECONCILIATION_FAILED,
CAPACITY_REJECTED,
ATTACHMENT_UNAVAILABLE,
ATTACHMENT_INTEGRITY_FAILED);
private NotificationFailureCode() {}
/** Whether a code belongs to the closed registry. */
public static boolean isRegistered(String code) {
return REGISTERED.contains(code);
}
/** All registered codes. */
public static Set<String> registered() {
return REGISTERED;
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.application.notification.platform.api.error;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* Low-cardinality failure metadata that is safe to log, tag and audit.
*
* <p>{@code channel} and {@code providerId} are optional because failures such as an idempotency
* conflict happen before a route is chosen; modelling that as a sentinel value would push a fake
* provider into metrics.
*/
public record NotificationFailureDescriptor(
String code,
FailureCategory category,
boolean retryable,
boolean ambiguous,
Optional<Channel> channel,
Optional<ProviderId> providerId,
int attemptNumber,
Duration elapsed) {
public NotificationFailureDescriptor {
Objects.requireNonNull(code, "code");
Objects.requireNonNull(category, "category");
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(providerId, "providerId");
Objects.requireNonNull(elapsed, "elapsed");
if (!NotificationFailureCode.isRegistered(code)) {
throw new IllegalArgumentException("unregistered failure code");
}
if (attemptNumber < 0) {
throw new IllegalArgumentException("attemptNumber");
}
if (ambiguous && retryable) {
throw new IllegalArgumentException("an ambiguous failure is never automatically retryable");
}
}
/** Descriptor for a failure raised before any provider attempt. */
public static NotificationFailureDescriptor preDispatch(String code, FailureCategory category) {
return new NotificationFailureDescriptor(
code, category, false, false, Optional.empty(), Optional.empty(), 0, Duration.ZERO);
}
/** Descriptor for a submission whose result could not be determined. */
public static NotificationFailureDescriptor ambiguous(
String code, Channel channel, ProviderId providerId, int attemptNumber) {
return new NotificationFailureDescriptor(
code,
FailureCategory.AMBIGUOUS_SUBMISSION,
false,
true,
Optional.of(channel),
Optional.of(providerId),
attemptNumber,
Duration.ZERO);
}
/** Descriptor for a delivery blocked by suppression or injected eligibility. */
public static NotificationFailureDescriptor suppressed(String reasonCode, Channel channel) {
return new NotificationFailureDescriptor(
NotificationFailureCode.NOTIFICATION_SUPPRESSED,
FailureCategory.INVALID_RECIPIENT,
false,
false,
Optional.of(channel),
Optional.empty(),
0,
Duration.ZERO)
.withReasonSuffix(reasonCode);
}
/** Descriptor for a provider failure with a known category. */
public static NotificationFailureDescriptor provider(
String code,
FailureCategory category,
boolean retryable,
Channel channel,
ProviderId providerId,
int attemptNumber,
Duration elapsed) {
return new NotificationFailureDescriptor(
code,
category,
retryable,
false,
Optional.of(channel),
Optional.of(providerId),
attemptNumber,
elapsed);
}
/**
* The reason code is intentionally not concatenated into {@link #code()}: keeping the tag closed
* is what makes it safe for metrics. This accessor exists so that callers can express intent
* without widening the registry.
*/
private NotificationFailureDescriptor withReasonSuffix(String reasonCode) {
Objects.requireNonNull(reasonCode, "reasonCode");
return this;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Dispatch blocked by suppression or injected eligibility. */
public class NotificationSuppressedException extends NotificationException {
private static final long serialVersionUID = 1L;
public NotificationSuppressedException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Request rejected before any durable write. */
public class NotificationValidationException extends NotificationException {
private static final long serialVersionUID = 1L;
public NotificationValidationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.api.error;
/**
* Provider credentials are invalid; this opens the provider route rather than retrying one message.
*/
public class ProviderAuthenticationException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderAuthenticationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider credentials are valid but not permitted for this operation. */
public class ProviderAuthorizationException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderAuthorizationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider profile and target disagree, for example APNs environment mismatch. */
public class ProviderConfigurationException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderConfigurationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Payload or batch exceeded a provider limit before the call. */
public class ProviderPayloadLimitException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderPayloadLimitException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider failed in a way that repeating cannot fix. */
public class ProviderPermanentException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderPermanentException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider explicitly refused the submission. */
public class ProviderRejectedException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderRejectedException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider rate limit reached. */
public class ProviderThrottledException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderThrottledException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider failed in a way that is safe to retry. */
public class ProviderTransientException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderTransientException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider runtime refused a new attempt because it is unhealthy, disabled or draining. */
public class ProviderUnavailableException extends NotificationException {
private static final long serialVersionUID = 1L;
public ProviderUnavailableException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Provider status query failed. */
public class ReconciliationException extends NotificationException {
private static final long serialVersionUID = 1L;
public ReconciliationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** No template version matched the pinned selection and locale chain. */
public class TemplateNotFoundException extends NotificationException {
private static final long serialVersionUID = 1L;
public TemplateNotFoundException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Rendering failed for a template that exists. */
public class TemplateRenderingException extends NotificationException {
private static final long serialVersionUID = 1L;
public TemplateRenderingException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification.platform.api.error;
/** Template variables failed JSON Schema validation before any provider call. */
public class TemplateVariableValidationException extends NotificationException {
private static final long serialVersionUID = 1L;
public TemplateVariableValidationException(NotificationFailureDescriptor descriptor) {
super(descriptor);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.notification.platform.api.routing;
/**
* Stable delivery channels. Provider families are configured behind a channel, never in place of
* one.
*/
public enum Channel {
EMAIL,
SMS,
PUSH,
WEB_PUSH,
IN_APP,
WEBHOOK
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.notification.platform.api.routing;
/**
* Stable routing strategies.
*
* <p>{@code PARALLEL_MULTI_CHANNEL} and {@code FIRST_SUCCESS} are deliberately absent: both commit
* two irreversible provider submissions before either outcome is known, so they stay experimental
* and are never reachable through this sealed hierarchy.
*/
public sealed interface DeliveryStrategy permits ExplicitChannel, OrderedFallback {
/** First channel this strategy will attempt. */
Channel primaryChannel();
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.api.routing;
import java.util.Objects;
/** Send on exactly one channel; no automatic fallback exists for this strategy. */
public record ExplicitChannel(Channel channel) implements DeliveryStrategy {
public ExplicitChannel {
Objects.requireNonNull(channel, "channel");
}
@Override
public Channel primaryChannel() {
return channel;
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification.platform.api.routing;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/**
* Try the channels in order. Moving to the next channel is still gated by the routing decision
* engine, which refuses to fall back while an ambiguous attempt exists.
*/
public record OrderedFallback(List<Channel> channels) implements DeliveryStrategy {
public OrderedFallback {
channels = List.copyOf(Objects.requireNonNull(channels, "channels"));
if (channels.isEmpty() || new HashSet<>(channels).size() != channels.size()) {
throw new IllegalArgumentException("channels must be non-empty and distinct");
}
}
@Override
public Channel primaryChannel() {
return channels.get(0);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.notification.platform.callback;
import java.util.List;
import java.util.Objects;
/** Outcome of an append. Duplicates are a normal, expected mode, not an error. */
public record AppendEventResult(
List<ProviderEventRecord> newEvents, List<ProviderEventRecord> duplicates) {
public AppendEventResult {
newEvents = List.copyOf(Objects.requireNonNull(newEvents, "newEvents"));
duplicates = List.copyOf(Objects.requireNonNull(duplicates, "duplicates"));
}
/** Whether at least one event was newly stored. */
public boolean created() {
return !newEvents.isEmpty();
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification.platform.callback;
/** How many events an ingestion stored and how many were already known. */
public record CallbackIngestionResult(int appended, int duplicates) {
public CallbackIngestionResult {
if (appended < 0 || duplicates < 0) {
throw new IllegalArgumentException("counts must not be negative");
}
}
/** Whether the whole callback was a repeat of one already stored. */
public boolean duplicate() {
return appended == 0 && duplicates > 0;
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.notification.platform.callback;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Hard bounds every callback endpoint applies before doing any work. */
public record CallbackLimits(long maxBodyBytes, Set<String> allowedContentTypes) {
public static final long DEFAULT_MAX_BODY_BYTES = 65_536L;
public CallbackLimits {
allowedContentTypes =
Set.copyOf(Objects.requireNonNull(allowedContentTypes, "allowedContentTypes"));
if (maxBodyBytes < 1) {
throw new IllegalArgumentException("maxBodyBytes");
}
if (allowedContentTypes.isEmpty()) {
throw new IllegalArgumentException("allowedContentTypes must not be empty");
}
}
/** Whether a content type is accepted, ignoring parameters such as charset. */
public boolean acceptsContentType(String contentType) {
if (contentType == null) {
return false;
}
String mediaType = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
return allowedContentTypes.contains(mediaType);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.callback;
/** Bounded, encrypted retention of raw callback payloads. */
public interface CallbackPayloadProtectionPort {
/** Encrypt at most the configured number of bytes of the raw payload. */
byte[] protectRawPayload(byte[] rawBody);
/** Hex SHA-256 digest of the raw payload, used for fingerprinting and audit. */
String digest(byte[] rawBody);
/** Deterministic fingerprint for providers that do not supply an event id. */
String fingerprint(
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId,
NormalizedProviderEvent event,
String rawPayloadDigest);
}
@@ -0,0 +1,108 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* A provider callback as received.
*
* <p>The raw body is kept as bytes because several providers sign the exact octets; decoding first
* and re-encoding later is the most common cause of false signature failures.
*/
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record CallbackRequest(
ProviderId providerId,
ProviderProfileId providerProfileId,
String externalUrl,
String httpMethod,
Optional<String> contentType,
Map<String, List<String>> headers,
byte[] body,
Instant receivedAt) {
public CallbackRequest {
Objects.requireNonNull(providerId, "providerId");
Objects.requireNonNull(providerProfileId, "providerProfileId");
Objects.requireNonNull(externalUrl, "externalUrl");
Objects.requireNonNull(httpMethod, "httpMethod");
Objects.requireNonNull(contentType, "contentType");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(receivedAt, "receivedAt");
if (externalUrl.isBlank()) {
throw new IllegalArgumentException("externalUrl");
}
headers =
headers.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
entry -> entry.getKey().toLowerCase(Locale.ROOT),
entry -> List.copyOf(entry.getValue())));
body = body.clone();
}
@Override
public byte[] body() {
return body.clone();
}
/** First value of a header, matched case-insensitively. */
public Optional<String> header(String name) {
List<String> values = headers.get(name.toLowerCase(Locale.ROOT));
return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.get(0));
}
/** Size of the raw body. */
public int bodyLength() {
return body.length;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof CallbackRequest value)) {
return false;
}
return providerId.equals(value.providerId)
&& providerProfileId.equals(value.providerProfileId)
&& externalUrl.equals(value.externalUrl)
&& httpMethod.equals(value.httpMethod)
&& contentType.equals(value.contentType)
&& headers.equals(value.headers)
&& Arrays.equals(body, value.body)
&& receivedAt.equals(value.receivedAt);
}
@Override
public int hashCode() {
return Objects.hash(
providerId,
providerProfileId,
externalUrl,
httpMethod,
contentType,
headers,
Arrays.hashCode(body),
receivedAt);
}
@Override
public String toString() {
return "CallbackRequest[provider="
+ providerId
+ ", profile="
+ providerProfileId
+ ", bytes="
+ body.length
+ ", body=redacted]";
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.notification.platform.callback;
import java.util.Objects;
import java.util.Optional;
/** Signature verification outcome. A rejection never reaches the ledger. */
public record CallbackVerificationResult(
boolean valid, String reasonCode, Optional<VerifiedCallback> verifiedCallback) {
public CallbackVerificationResult {
Objects.requireNonNull(reasonCode, "reasonCode");
Objects.requireNonNull(verifiedCallback, "verifiedCallback");
if (valid && verifiedCallback.isEmpty()) {
throw new IllegalArgumentException("a valid result must carry the verified callback");
}
if (!valid && verifiedCallback.isPresent()) {
throw new IllegalArgumentException("an invalid result must not carry a verified callback");
}
}
/** Accepted signature. */
public static CallbackVerificationResult valid(VerifiedCallback callback) {
return new CallbackVerificationResult(true, "OK", Optional.of(callback));
}
/** Rejected signature. */
public static CallbackVerificationResult invalid(String reasonCode) {
return new CallbackVerificationResult(false, reasonCode, Optional.empty());
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import java.util.Optional;
/** Resolves the attempt an incoming provider event belongs to. */
public interface DeliveryAttemptResolverPort {
/** Look up by the attempt identity carried in the event. */
Optional<DeliveryAttemptSnapshot> byAttemptId(DeliveryAttemptId attemptId);
/**
* Look up by the provider request id.
*
* <p>Implementations index a keyed hash of the value, never the raw provider request id.
*/
Optional<DeliveryAttemptSnapshot> byProviderRequestId(
ProviderProfileId profileId, String providerRequestId);
}
@@ -0,0 +1,73 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.NotificationId;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome;
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Read-only view of an attempt used by projectors, reconciliation and admin operations. */
public record DeliveryAttemptSnapshot(
DeliveryAttemptId attemptId,
RecipientDeliveryId recipientDeliveryId,
NotificationId notificationId,
TenantId tenantId,
int attemptNo,
Channel channel,
ProviderId providerId,
ProviderProfileId providerProfileId,
Optional<String> providerRequestId,
ContactPointId contactPointId,
AttemptConfirmation confirmation,
SubmissionOutcome submissionOutcome,
DeliveryOutcome deliveryOutcome,
EvidenceLevel evidenceLevel,
long credentialGeneration,
String renderedContentDigest,
Instant startedAt,
Optional<Instant> completedAt,
Optional<Instant> expiresAt) {
public DeliveryAttemptSnapshot {
Objects.requireNonNull(attemptId, "attemptId");
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(providerId, "providerId");
Objects.requireNonNull(providerProfileId, "providerProfileId");
Objects.requireNonNull(providerRequestId, "providerRequestId");
Objects.requireNonNull(contactPointId, "contactPointId");
Objects.requireNonNull(confirmation, "confirmation");
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
Objects.requireNonNull(renderedContentDigest, "renderedContentDigest");
Objects.requireNonNull(startedAt, "startedAt");
Objects.requireNonNull(completedAt, "completedAt");
Objects.requireNonNull(expiresAt, "expiresAt");
if (attemptNo < 1) {
throw new IllegalArgumentException("attemptNo");
}
}
/** Current projection derived from the stored attempt columns. */
public DeliveryProjection projection() {
return new DeliveryProjection(
submissionOutcome,
deliveryOutcome,
evidenceLevel,
EngagementFacts.NONE,
SuppressionFacts.NONE);
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOutcome;
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
import java.util.Objects;
/** Current projected view of one attempt, rebuildable from the event ledger at any time. */
public record DeliveryProjection(
SubmissionOutcome submissionOutcome,
DeliveryOutcome deliveryOutcome,
EvidenceLevel evidenceLevel,
EngagementFacts engagementFacts,
SuppressionFacts suppressionFacts) {
public DeliveryProjection {
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
Objects.requireNonNull(evidenceLevel, "evidenceLevel");
Objects.requireNonNull(engagementFacts, "engagementFacts");
Objects.requireNonNull(suppressionFacts, "suppressionFacts");
}
/** Projection of an attempt that has produced no evidence yet. */
public static DeliveryProjection empty() {
return new DeliveryProjection(
SubmissionOutcome.NOT_SUBMITTED,
DeliveryOutcome.UNKNOWN,
EvidenceLevel.NONE,
EngagementFacts.NONE,
SuppressionFacts.NONE);
}
/** Projection of an accepted submission with no delivery evidence yet. */
public static DeliveryProjection accepted() {
return new DeliveryProjection(
SubmissionOutcome.CONFIRMED_ACCEPTED,
DeliveryOutcome.UNKNOWN,
EvidenceLevel.PROVIDER_ACCEPTED,
EngagementFacts.NONE,
SuppressionFacts.NONE);
}
/** Copy with a different delivery outcome and evidence level. */
public DeliveryProjection withDelivery(DeliveryOutcome outcome, EvidenceLevel evidence) {
return new DeliveryProjection(
submissionOutcome, outcome, evidence, engagementFacts, suppressionFacts);
}
/** Copy with a different evidence level only. */
public DeliveryProjection withEvidence(EvidenceLevel evidence) {
return new DeliveryProjection(
submissionOutcome, deliveryOutcome, evidence, engagementFacts, suppressionFacts);
}
/** Copy with a different submission outcome. */
public DeliveryProjection withSubmission(SubmissionOutcome outcome, EvidenceLevel evidence) {
return new DeliveryProjection(
outcome, deliveryOutcome, evidence, engagementFacts, suppressionFacts);
}
/** Copy with new engagement facts. */
public DeliveryProjection withEngagement(EngagementFacts facts) {
return new DeliveryProjection(
submissionOutcome, deliveryOutcome, evidenceLevel, facts, suppressionFacts);
}
/** Copy with new suppression facts. */
public DeliveryProjection withSuppression(SuppressionFacts facts) {
return new DeliveryProjection(
submissionOutcome, deliveryOutcome, evidenceLevel, engagementFacts, facts);
}
/**
* Whether the delivery outcome has reached a state that later, weaker events must not overwrite.
*/
public boolean hasTerminalDelivery() {
return switch (deliveryOutcome) {
case DELIVERED, BOUNCED, UNDELIVERED, EXPIRED -> true;
case UNKNOWN, SENT -> false;
};
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
/** Persists the projection produced from the ledger. */
public interface DeliveryProjectionStorePort {
/** Current stored projection of an attempt. */
DeliveryProjection load(DeliveryAttemptId attemptId);
/** Store a projection for an attempt and roll it up into the recipient and request. */
void save(DeliveryAttemptId attemptId, DeliveryProjection projection);
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.notification.platform.callback;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* Engagement is stored beside delivery, never instead of it.
*
* <p>An open or a click is telemetry: it is not proof of delivery reliability and never downgrades
* or upgrades the delivery outcome.
*/
public record EngagementFacts(
boolean opened, boolean clicked, boolean read, Optional<Instant> lastEngagedAt) {
public static final EngagementFacts NONE =
new EngagementFacts(false, false, false, Optional.empty());
public EngagementFacts {
Objects.requireNonNull(lastEngagedAt, "lastEngagedAt");
}
/** Merge that never removes a fact that was already observed. */
public EngagementFacts merge(EngagementFacts other, Instant at) {
Objects.requireNonNull(other, "other");
return new EngagementFacts(
opened || other.opened,
clicked || other.clicked,
read || other.read,
Optional.ofNullable(at));
}
/** Copy with the opened fact set. */
public EngagementFacts withOpened(Instant at) {
return new EngagementFacts(true, clicked, read, Optional.ofNullable(at));
}
/** Copy with the clicked fact set. */
public EngagementFacts withClicked(Instant at) {
return new EngagementFacts(opened, true, read, Optional.ofNullable(at));
}
/** Copy with the read fact set. */
public EngagementFacts withRead(Instant at) {
return new EngagementFacts(opened, clicked, true, Optional.ofNullable(at));
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.notification.platform.callback;
/**
* Stable event vocabulary.
*
* <p>Provider status strings are mapped onto this closed set and the native string is kept beside
* it, so a provider adding a status cannot force a change to the public contract.
*/
public enum NormalizedEventType {
PROVIDER_ACCEPTED,
PROVIDER_REJECTED,
SENT,
DELIVERY_CONFIRMED,
DELIVERY_DELAYED,
UNDELIVERED,
BOUNCED_HARD,
BOUNCED_SOFT,
COMPLAINT,
INVALID_RECIPIENT,
OPENED,
CLICKED,
DISPLAYED,
READ,
EXPIRED,
TEMPLATE_FAILURE,
UNKNOWN
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.notification.platform.callback;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Adapter output: a provider payload expressed in the stable vocabulary.
*
* <p>Unknown provider fields are preserved in the raw payload, not rejected: a webhook contract
* that grows a field must not start failing ingestion.
*/
public record NormalizedProviderEvent(
NormalizedEventType type,
String providerNativeType,
Optional<String> providerEventId,
Optional<String> providerRequestId,
Optional<Instant> providerOccurredAt,
Map<String, String> attributes) {
private static final int MAX_ATTRIBUTES = 32;
public NormalizedProviderEvent {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(providerNativeType, "providerNativeType");
Objects.requireNonNull(providerEventId, "providerEventId");
Objects.requireNonNull(providerRequestId, "providerRequestId");
Objects.requireNonNull(providerOccurredAt, "providerOccurredAt");
attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes"));
if (providerNativeType.isBlank()) {
throw new IllegalArgumentException("providerNativeType");
}
if (attributes.size() > MAX_ATTRIBUTES) {
throw new IllegalArgumentException("too many normalized attributes");
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.callback;
/**
* Applies the consequences a provider event has outside the ledger.
*
* <p>Kept behind a port so a projector stays a pure merge function: a projector that also wrote to
* the contact point store could not be replayed safely.
*/
public interface NotificationSideEffectPort {
/** Invalidate contact points and add suppression entries implied by an event. */
void apply(DeliveryAttemptSnapshot attempt, SuppressionFacts facts);
}

Some files were not shown because too many files have changed in this diff Show More