merge: integrate notification production capability

# Conflicts:
#	docs/superpowers/plans/2026-07-28-notification-production-capability.md
#	src/adapter/outbound/persistence-jpa/build.gradle
#	src/adapter/outbound/persistence-jpa/gradle.lockfile
#	src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
#	src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java
This commit is contained in:
donghyeon-ka
2026-08-01 00:11:53 +09:00
77 changed files with 7051 additions and 68 deletions
+23
View File
@@ -74,6 +74,29 @@ Package root: `dev.caskeleton.application`.
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
writer-cutover command contracts.
- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort`
returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning
handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence
entity, compiled adapter binding and raw recipient/template payload types are forbidden here.
- Provider calls run outside database transactions. Dispatch and reconciliation use bounded
claim/authorize/finalize transactions with opaque claim/version/execution tokens; an
`INDETERMINATE` submission is terminal and must not be blindly retried.
- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce
and complaint facts may request technical suppression; consent/unsubscribe policy is outside this
capability.
- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile
registries are application-owned exact inputs; signed inventory/quiescence verification is
delegated to narrow verifier ports and the persistence operation must enforce locked durable
state/journal invariants.
- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking,
provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the
notification/persistence/bootstrap adapters.
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
writer-cutover command contracts.
@@ -27,6 +27,10 @@ public final class NotificationCapabilityCompatibilityValidator {
reasons,
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
addIf(
reasons,
policy.maxReconcileCalls() > 0 && !provider.reconciliationSupported(),
"PROVIDER_RECONCILIATION_UNSUPPORTED");
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
addIf(
reasons,
@@ -146,6 +146,20 @@ public interface NotificationDeliveryStorePort {
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition");
}
if (providerOutcome.retryDisposition() == RetryDisposition.PARK_BINDING) {
if (parkResult == NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED) {
throw new IllegalArgumentException("PARK_BINDING requires an admission park result");
}
boolean parked =
parkResult == NotificationAdmissionReadinessPort.ParkResult.PARKED
|| parkResult == NotificationAdmissionReadinessPort.ParkResult.ALREADY_PARKED;
TerminalState expected =
parked ? TerminalState.PARKED_BINDING : TerminalState.RETRY_SCHEDULED;
if (terminalState != expected) {
throw new IllegalArgumentException(
"terminal state must reflect the generation-guarded admission park result");
}
}
}
}
@@ -168,19 +182,46 @@ public interface NotificationDeliveryStorePort {
NotificationDeliveryId deliveryId,
String executionToken,
long expectedRowVersion,
String providerMessageReference,
NotificationRouteId routeId,
int routeRevision,
String bindingDigest,
int targetOrdinal,
String targetReference,
String providerCapabilityReference,
String providerBindingRevision,
String credentialGeneration,
String lookupReference,
ReconciliationLookupKind lookupKind,
Instant absoluteDeadline) {
public ReconciliationClaim {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
executionToken =
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
if (expectedRowVersion < 0) {
throw new IllegalArgumentException("reconciliation row version must be non-negative");
if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) {
throw new IllegalArgumentException(
"reconciliation row version, route revision and target ordinal are invalid");
}
providerMessageReference =
Objects.requireNonNull(routeId, "reconciliation route ID must be non-null");
if (bindingDigest == null || !bindingDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"reconciliation binding digest must be a lowercase SHA-256 digest");
}
targetReference =
NotificationIntentId.requireOpaque("reconciliation target reference", targetReference);
providerCapabilityReference =
NotificationIntentId.requireOpaque(
"provider message reference", providerMessageReference);
"reconciliation provider capability reference", providerCapabilityReference);
providerBindingRevision =
NotificationIntentId.requireOpaque(
"reconciliation provider binding revision", providerBindingRevision);
credentialGeneration =
NotificationIntentId.requireSlug(
"reconciliation credential generation", credentialGeneration);
lookupReference =
NotificationIntentId.requireOpaque(
"provider reconciliation lookup reference", lookupReference);
Objects.requireNonNull(lookupKind, "provider reconciliation lookup kind must be non-null");
Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null");
}
@@ -189,12 +230,34 @@ public interface NotificationDeliveryStorePort {
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
+ "expectedRowVersion="
+ expectedRowVersion
+ ", providerMessageReference=<redacted>, absoluteDeadline="
+ ", routeId="
+ routeId
+ ", routeRevision="
+ routeRevision
+ ", bindingDigest="
+ bindingDigest
+ ", targetOrdinal="
+ targetOrdinal
+ ", targetReference=<redacted>, providerCapabilityReference="
+ providerCapabilityReference
+ ", providerBindingRevision="
+ providerBindingRevision
+ ", credentialGeneration="
+ credentialGeneration
+ ", lookupReference=<redacted>, lookupKind="
+ lookupKind
+ ", absoluteDeadline="
+ absoluteDeadline
+ "]";
}
}
enum ReconciliationLookupKind {
PRE_SEND_CORRELATION,
CLIENT_OPERATION_KEY,
MESSAGE_REFERENCE
}
enum ReconciliationFinalizationResult {
APPLIED,
LATE_EXACT_APPLIED,
@@ -134,7 +134,7 @@ public final class NotificationDispatchUseCase
NotificationDeliveryStorePort.AttemptFinalization finalization =
new NotificationDeliveryStorePort.AttemptFinalization(
outcome, terminalState(outcome), fallbackEligible(outcome), parkResult);
outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult);
NotificationDeliveryStorePort.FinalizationResult result =
Objects.requireNonNull(
store.finalizeAttempt(attempt, finalization, clock.instant()),
@@ -143,7 +143,7 @@ public final class NotificationDispatchUseCase
}
private static NotificationDeliveryStorePort.TerminalState terminalState(
ProviderAttemptOutcome outcome) {
ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) {
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
}
@@ -152,7 +152,15 @@ public final class NotificationDispatchUseCase
}
return switch (outcome.retryDisposition()) {
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
case PARK_BINDING ->
switch (parkResult) {
case PARKED, ALREADY_PARKED ->
NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
case STALE_GENERATION -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
case NOT_REQUESTED ->
throw new IllegalStateException(
"PARK_BINDING outcome requires an admission park result");
};
case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE;
case NOT_APPLICABLE ->
throw new IllegalArgumentException(
@@ -1,6 +1,10 @@
package dev.caskeleton.application.notification;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@@ -10,6 +14,7 @@ public record NotificationFrozenPlan(
NotificationIntentId intentId,
NotificationKindPolicy policy,
Locale selectedLocale,
BindingSnapshot binding,
NotificationRecipientReference recipient,
NotificationTemplateParameters parameters,
String idempotencyScope,
@@ -24,33 +29,44 @@ public record NotificationFrozenPlan(
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
Objects.requireNonNull(policy, "notification kind policy must be non-null");
selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale);
Objects.requireNonNull(binding, "notification binding snapshot must be non-null");
Objects.requireNonNull(recipient, "notification recipient must be non-null");
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
idempotencyScope =
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
sourceOperationId =
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
Objects.requireNonNull(tenantReference, "tenant reference container must be non-null");
tenantReference = requireOptionalOpaque("tenant reference", tenantReference);
correlationReference =
NotificationIntentId.requireOpaque(
"notification correlation reference", correlationReference);
Objects.requireNonNull(causationReference, "causation reference container must be non-null");
causationReference = requireOptionalOpaque("causation reference", causationReference);
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
if (recipient.channel() != policy.channel()) {
throw new IllegalArgumentException("recipient channel must match notification kind channel");
}
if (!expiresAt.isAfter(notBefore)) {
throw new IllegalArgumentException("notification expiry must be after not-before");
if (binding.targets().size() != policy.maxTargetsPerRecipient()) {
throw new IllegalArgumentException(
"frozen binding target count must match the code-owned policy target bound");
}
Duration lifetime = Duration.between(notBefore, expiresAt);
if (lifetime.isZero()
|| lifetime.isNegative()
|| lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) {
throw new IllegalArgumentException(
"notification expiry must be after not-before and within the policy retry horizon");
}
}
public static NotificationFrozenPlan from(NotificationIntentDraft draft, Locale selectedLocale) {
public static NotificationFrozenPlan from(
NotificationIntentDraft draft, Locale selectedLocale, BindingSnapshot binding) {
Objects.requireNonNull(draft, "notification intent draft must be non-null");
return new NotificationFrozenPlan(
draft.intentId(),
draft.policy(),
selectedLocale,
binding,
draft.recipient(),
draft.parameters(),
draft.idempotencyScope(),
@@ -70,6 +86,11 @@ public record NotificationFrozenPlan(
return policy.routeId();
}
private static Optional<String> requireOptionalOpaque(String field, Optional<String> reference) {
Objects.requireNonNull(reference, field + " container must be non-null");
return reference.map(value -> NotificationIntentId.requireOpaque(field, value));
}
@Override
public String toString() {
return "NotificationFrozenPlan[intentId="
@@ -80,10 +101,116 @@ public record NotificationFrozenPlan(
+ policy.policyRevision()
+ ", selectedLocale="
+ selectedLocale.toLanguageTag()
+ ", routeRevision="
+ binding.routeRevision()
+ ", bindingDigest="
+ binding.bindingDigest()
+ ", rendererRevision="
+ binding.rendererRevision()
+ ", targets=<redacted>"
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
+ notBefore
+ ", expiresAt="
+ expiresAt
+ "]";
}
/** Provider-neutral immutable execution graph persisted with the logical intent. */
public record BindingSnapshot(
int routeRevision,
String bindingDigest,
String templateChecksum,
String rendererRevision,
List<FrozenTarget> targets,
boolean receiptRequired,
Duration perAttemptDeadline) {
private static final Duration MAXIMUM_ATTEMPT_DEADLINE = Duration.ofMinutes(5);
public BindingSnapshot {
if (routeRevision < 1 || routeRevision > 1_000_000) {
throw new IllegalArgumentException("frozen route revision must be in 1..1000000");
}
bindingDigest = requireDigest("notification binding digest", bindingDigest);
templateChecksum = requireDigest("notification template checksum", templateChecksum);
rendererRevision =
NotificationIntentId.requireSlug("notification renderer revision", rendererRevision);
Objects.requireNonNull(targets, "frozen notification targets must be non-null");
targets =
targets.stream()
.map(target -> Objects.requireNonNull(target, "frozen target must be non-null"))
.sorted(Comparator.comparingInt(FrozenTarget::ordinal))
.toList();
if (targets.isEmpty() || targets.size() > 16) {
throw new IllegalArgumentException(
"frozen notification targets must contain 1..16 entries");
}
if (new HashSet<>(targets.stream().map(FrozenTarget::targetReference).toList()).size()
!= targets.size()) {
throw new IllegalArgumentException(
"frozen notification targets contain duplicate references");
}
for (int index = 0; index < targets.size(); index++) {
if (targets.get(index).ordinal() != index) {
throw new IllegalArgumentException(
"frozen notification target ordinals must be contiguous from zero");
}
}
Objects.requireNonNull(
perAttemptDeadline, "notification per-attempt deadline must be non-null");
if (perAttemptDeadline.isZero()
|| perAttemptDeadline.isNegative()
|| perAttemptDeadline.compareTo(MAXIMUM_ATTEMPT_DEADLINE) > 0) {
throw new IllegalArgumentException(
"notification per-attempt deadline must be positive and at most five minutes");
}
}
private static String requireDigest(String field, String digest) {
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
}
return digest;
}
}
/** Opaque provider-leg identity; credentials, endpoints and SDK types are deliberately absent. */
public record FrozenTarget(
int ordinal,
String targetReference,
String providerCapabilityReference,
String providerBindingRevision,
String credentialGeneration) {
public FrozenTarget {
if (ordinal < 0 || ordinal > 15) {
throw new IllegalArgumentException("frozen notification target ordinal must be in 0..15");
}
targetReference =
NotificationIntentId.requireOpaque(
"frozen notification target reference", targetReference);
providerCapabilityReference =
NotificationIntentId.requireOpaque(
"frozen provider capability reference", providerCapabilityReference);
providerBindingRevision =
NotificationIntentId.requireOpaque(
"frozen provider binding revision", providerBindingRevision);
credentialGeneration =
NotificationIntentId.requireSlug(
"frozen provider credential generation", credentialGeneration);
}
@Override
public String toString() {
return "FrozenTarget[ordinal="
+ ordinal
+ ", targetReference=<redacted>, providerCapabilityReference="
+ providerCapabilityReference
+ ", providerBindingRevision="
+ providerBindingRevision
+ ", credentialGeneration="
+ credentialGeneration
+ "]";
}
}
}
@@ -8,11 +8,12 @@ public record NotificationMaintenanceCommand(
implements Command {
public NotificationMaintenanceCommand {
long total = (long) maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts;
if (maximumExpiredIntents < 0
|| maximumPayloadRedactions < 0
|| maximumExpiredReceipts < 0
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts < 1
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts > 100) {
|| total < 1
|| total > 100) {
throw new IllegalArgumentException(
"notification maintenance total mutation bound must be in 1..100");
}
@@ -5,7 +5,7 @@ public record NotificationMaintenanceResult(
int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
public NotificationMaintenanceResult {
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
if (expiredIntentCount < 0
|| redactedPayloadCount < 0
|| expiredReceiptCount < 0
@@ -11,7 +11,7 @@ public interface NotificationMaintenanceStorePort {
record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
public MutationResult {
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
if (expiredIntentCount < 0
|| redactedPayloadCount < 0
|| expiredReceiptCount < 0
@@ -88,6 +88,29 @@ class NotificationCapabilityCompatibilityValidatorTest {
.hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE");
}
@Test
void reconciliationPolicyRequiresProviderReconciliationCapability() {
NotificationKindPolicy policy = policy();
NotificationProviderCapabilityDescriptor withoutReconciliation =
new NotificationProviderCapabilityDescriptor(
"provider-capability-42",
NotificationChannel.EMAIL,
Set.of(NotificationMode.DURABLE_ASYNC),
true,
false,
true,
16,
1_000_000);
NotificationCapabilityCompatibilityValidator.Compatibility result =
new NotificationCapabilityCompatibilityValidator()
.validate(policy, withoutReconciliation, store(policy), Optional.of(ingress()), true);
assertThat(result.compatible()).isFalse();
assertThat(result.reasonCodes())
.containsExactly(new NotificationReasonCode("PROVIDER_RECONCILIATION_UNSUPPORTED"));
}
private static NotificationKindPolicy policy() {
return new NotificationKindPolicy(
new NotificationKindId("security-alert"),
@@ -151,6 +151,34 @@ class NotificationDispatchUseCaseTest {
assertThat(result.parkedCount()).isEqualTo(1);
}
@Test
void staleParkGenerationRequeuesTheLegInsteadOfStrandingItAsParked() {
List<String> trace = new ArrayList<>();
RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied());
NotificationDispatchResult result =
new NotificationDispatchUseCase(
store,
attempt ->
new ProviderAttemptOutcome(
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
new NotificationReasonCode("PROVIDER_AUTH_REJECTED"),
Optional.empty(),
"correlation-42",
Optional.empty()),
request -> NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION,
new TrackingTransactionPort(trace),
Clock.fixed(NOW, ZoneOffset.UTC))
.handle(new NotificationDispatchCommand(1));
assertThat(store.finalization.parkResult())
.isEqualTo(NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION);
assertThat(store.finalization.terminalState())
.isEqualTo(NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED);
assertThat(result.parkedCount()).isZero();
}
@Test
void exactLateResultIsCountedWithoutBlindProviderRetry() {
RecordingStore store =
@@ -251,7 +279,10 @@ class NotificationDispatchUseCaseTest {
Optional.empty(),
NOW,
NOW.plusSeconds(600));
return NotificationFrozenPlan.from(draft, java.util.Locale.ENGLISH);
return NotificationFrozenPlan.from(
draft,
java.util.Locale.ENGLISH,
NotificationTestFixtures.binding(draft.policy().channel()));
}
private static ProviderAttemptOutcome accepted() {
@@ -33,6 +33,17 @@ class NotificationMaintenanceUseCaseTest {
assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1));
assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() -> new NotificationMaintenanceCommand(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationMaintenanceStorePort.MutationResult(
Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() -> new NotificationMaintenanceResult(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
.isInstanceOf(IllegalArgumentException.class);
}
private static final class TrackingTransactions implements TransactionPort {
@@ -26,7 +26,10 @@ class NotificationPlanningBoundaryTest {
NotificationPlanPort planner =
requested ->
new NotificationPlanningResult.Planned(
NotificationFrozenPlan.from(requested, Locale.ENGLISH));
NotificationFrozenPlan.from(
requested,
Locale.ENGLISH,
NotificationTestFixtures.binding(requested.policy().channel())));
NotificationPlanningResult result = planner.plan(draft);
@@ -46,7 +49,9 @@ class NotificationPlanningBoundaryTest {
"recipient-ref-42",
"source-operation-42",
Instant.parse("2026-07-28T00:00:00Z"));
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.ENGLISH);
NotificationFrozenPlan plan =
NotificationFrozenPlan.from(
draft, Locale.ENGLISH, NotificationTestFixtures.binding(draft.policy().channel()));
AtomicReference<NotificationFrozenPlan> appended = new AtomicReference<>();
AtomicReference<NotificationFrozenPlan> attempted = new AtomicReference<>();
NotificationIntentAppendPort appendPort =
@@ -0,0 +1,26 @@
package dev.caskeleton.application.notification;
import java.time.Duration;
import java.util.List;
final class NotificationTestFixtures {
private NotificationTestFixtures() {}
static NotificationFrozenPlan.BindingSnapshot binding(NotificationChannel channel) {
String capability =
channel == NotificationChannel.EMAIL
? "aws-ses-v2-durable-single-local-sns-v1"
: "slack-web-api-durable-single-local-v1";
return new NotificationFrozenPlan.BindingSnapshot(
3,
"a".repeat(64),
"b".repeat(64),
"renderer-r3",
List.of(
new NotificationFrozenPlan.FrozenTarget(
0, "target-r3", capability, "provider-binding-r3", "credential-r3")),
channel == NotificationChannel.EMAIL,
Duration.ofSeconds(5));
}
}
@@ -193,14 +193,93 @@ class NotificationValueContractTest {
notBefore,
notBefore.plusSeconds(60));
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.forLanguageTag("en"));
NotificationFrozenPlan plan =
NotificationFrozenPlan.from(
draft,
Locale.forLanguageTag("en"),
NotificationTestFixtures.binding(draft.policy().channel()));
assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH);
assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC);
assertThat(plan.binding().targets()).isUnmodifiable();
assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary"));
assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada");
}
@Test
void publicFrozenPlanConstructorPreservesDraftContextAndRetryHorizonInvariants() {
NotificationKindPolicy policy = durablePolicy(Duration.ofHours(1));
Instant notBefore = Instant.parse("2026-07-28T00:00:00Z");
NotificationFrozenPlan valid =
new NotificationFrozenPlan(
new NotificationIntentId("intent-42"),
policy,
Locale.ENGLISH,
NotificationTestFixtures.binding(policy.channel()),
new EmailRecipientReference("recipient-ref-42"),
new NotificationTemplateParameters(
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
"password-reset",
"source-operation-42",
Optional.empty(),
"correlation-42",
Optional.empty(),
notBefore,
notBefore.plusSeconds(60));
assertThatThrownBy(
() ->
new NotificationFrozenPlan(
valid.intentId(),
policy,
valid.selectedLocale(),
valid.binding(),
valid.recipient(),
valid.parameters(),
valid.idempotencyScope(),
valid.sourceOperationId(),
Optional.of(" "),
valid.correlationReference(),
valid.causationReference(),
notBefore,
notBefore.plusSeconds(60)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationFrozenPlan(
valid.intentId(),
policy,
valid.selectedLocale(),
valid.binding(),
valid.recipient(),
valid.parameters(),
valid.idempotencyScope(),
valid.sourceOperationId(),
valid.tenantReference(),
valid.correlationReference(),
Optional.of("\n"),
notBefore,
notBefore.plusSeconds(60)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationFrozenPlan(
valid.intentId(),
policy,
valid.selectedLocale(),
valid.binding(),
valid.recipient(),
valid.parameters(),
valid.idempotencyScope(),
valid.sourceOperationId(),
valid.tenantReference(),
valid.correlationReference(),
valid.causationReference(),
notBefore,
notBefore.plus(Duration.ofHours(2))))
.isInstanceOf(IllegalArgumentException.class);
}
private static NotificationKindPolicy durablePolicy(Duration retryHorizon) {
return new NotificationKindPolicy(
new NotificationKindId("password-reset"),
@@ -81,7 +81,16 @@ class ReconcileNotificationDeliveriesUseCaseTest {
new NotificationDeliveryId("delivery-42"),
"reconcile-token-42",
3,
new NotificationRouteId("security-slack"),
3,
"a".repeat(64),
0,
"slack-primary",
"slack-web-api-durable-single-local-v1",
"slack-binding-r1",
"credential-r1",
"provider-message-42",
NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE,
NOW.plusSeconds(30)));
private RecordingStore(List<String> trace) {