feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가

This commit is contained in:
DongHyeonka
2026-08-15 13:01:58 +09:00
parent ac874e49e6
commit 2f5d2fc219
909 changed files with 62510 additions and 6354 deletions
+9
View File
@@ -72,6 +72,15 @@ Package root: `dev.caskeleton.application`.
| `messaging.event.ValidatedIntegrationEvent` | Stable semantic identities plus immutable exact encoded bytes and hashes, ready for a later durable append boundary. |
| `messaging.event.IntegrationEventEncoderPort` | Framework-free local draft-to-validated-event boundary implemented by an outbound adapter. |
## Notification canonical namespace (NOTIF-ADR-005)
`..notification.platform..` is canonical. The R1 types directly under
`dev.caskeleton.application.notification` remain and accept no new production consumers; their
per-type disposition is in `docs/notification/module-mapping.md`. Production dependencies between
the two namespaces are zero, enforced by
`NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER`; the only permitted exception is
`dev.caskeleton.application.notification.compatibility.r1`.
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
@@ -0,0 +1,43 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.TenantId;
import java.util.Objects;
import java.util.Optional;
/**
* Operator authority check.
*
* <p>It lives in application-core. It decides who may act on what — business policy by any
* definition — and it sat in the outbound adapter, which is the layer that talks to providers. An
* authorization rule there is a rule no application test can reach and no architecture rule about
* application policy can see.
*
* <p>Application authority never grants an operator authority. The two planes are separated so that
* a compromised application credential cannot redrive a message or lift a suppression — the actions
* whose whole purpose is to override the platform's own safety decisions.
*/
public final class AdminAuthorizationGuard {
/** Require an authority, or refuse. */
public void require(AdminActor actor, NotificationAdminAuthority authority) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(authority, "authority");
if (!actor.holds(authority)) {
throw new AdminAccessDeniedException(authority);
}
}
/**
* Require that the actor may act on a tenant.
*
* <p>An actor with no tenant is a global operator; one bound to a tenant may only act inside it.
*/
public void requireTenant(AdminActor actor, TenantId tenantId) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(tenantId, "tenantId");
Optional<TenantId> scope = actor.tenantId();
if (scope.isPresent() && !scope.get().equals(tenantId)) {
throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.notification.platform.admin;
import java.util.Objects;
import java.util.Optional;
/**
* What an admin operation id claim decided.
*
* <p>Four outcomes, because collapsing them loses the distinction that matters. A replay must
* return the earlier result without acting; an in-progress claim must not act and must not pretend
* the work is done; and the same id with a different command is a mistake worth reporting rather
* than an idempotent repeat.
*
* @param outcome what the caller may do
* @param existing the earlier result, when there is one
*/
public record AdminOperationClaim(Outcome outcome, Optional<AdminOperationResult> existing) {
/** The four answers a claim can give. */
public enum Outcome {
/** This caller holds the operation and may proceed. */
CLAIMED,
/** The operation already completed; {@code existing} is what it produced. */
REPLAY,
/** Another caller holds it right now. Nothing has been decided yet. */
IN_PROGRESS,
/** The same id was presented with a different command. */
CONFLICT
}
/** Validates the claim. */
public AdminOperationClaim {
Objects.requireNonNull(outcome, "outcome");
Objects.requireNonNull(existing, "existing");
if (outcome == Outcome.REPLAY && existing.isEmpty()) {
throw new IllegalArgumentException("a replay must carry the result it is replaying");
}
}
/**
* The caller may run the operation.
*
* @return the claim
*/
public static AdminOperationClaim claimed() {
return new AdminOperationClaim(Outcome.CLAIMED, Optional.empty());
}
/**
* The operation already completed.
*
* @param existing what it produced
* @return the claim
*/
public static AdminOperationClaim replay(AdminOperationResult existing) {
return new AdminOperationClaim(Outcome.REPLAY, Optional.of(existing));
}
/**
* Another caller holds the operation.
*
* @return the claim
*/
public static AdminOperationClaim inProgress() {
return new AdminOperationClaim(Outcome.IN_PROGRESS, Optional.empty());
}
/**
* The id was reused with a different command.
*
* @return the claim
*/
public static AdminOperationClaim conflict() {
return new AdminOperationClaim(Outcome.CONFLICT, Optional.empty());
}
/**
* Whether the caller may perform the operation.
*
* @return true only for a fresh claim
*/
public boolean mayProceed() {
return outcome == Outcome.CLAIMED;
}
}
@@ -10,4 +10,24 @@ public interface AdminOperationStorePort {
/** Store the result of an operation. */
AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action);
/**
* Claims an operation id before anything happens, atomically.
*
* <p>The admin path was find, then side effect, then save. Two callers presenting the same
* operation id both read "not found", both ran the redrive, and both saved — the idempotency key
* was checked but never held, so it prevented a repeat and not a race. A destructive operation
* run twice concurrently is the exact failure the id exists to stop.
*
* <p>The fingerprint is part of the claim: the same id with a different command is not a replay,
* it is a conflict, and answering it with the earlier result would silently execute neither.
*
* @param operationId the caller's idempotency key
* @param commandFingerprint a digest of the command this id was presented with
* @param actor who is asking
* @param action what they are asking for
* @return whether this caller may proceed, and what the earlier one did if not
*/
AdminOperationClaim claim(
String operationId, String commandFingerprint, AdminActor actor, String action);
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import java.util.Objects;
/**
* Blocks an unapproved redrive of an ambiguous attempt.
*
* <p>Moved out of the outbound adapter: whether a human must approve a re-send is a decision about
* the product, not about a provider protocol.
*
* <p>The platform cannot tell whether the first submission reached the user, so re-sending is a
* decision with a real cost that only a human can accept. Requiring the approval flag makes that
* acceptance an explicit, audited act rather than a default.
*/
public final class DuplicateRiskGuard {
/** Verify the operator accepted the duplicate risk when one exists. */
public void verify(DeliveryAttemptSnapshot attempt, boolean approved) {
Objects.requireNonNull(attempt, "attempt");
boolean risky =
attempt.confirmation() == AttemptConfirmation.AMBIGUOUS
|| attempt.submissionOutcome()
== dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
.CONFIRMED_ACCEPTED;
if (risky && !approved) {
throw new DuplicateRiskApprovalRequiredException();
}
}
}
@@ -0,0 +1,336 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import dev.caskeleton.application.notification.platform.policy.SuppressionEntry;
import dev.caskeleton.application.notification.platform.policy.SuppressionId;
import dev.caskeleton.application.notification.platform.policy.SuppressionSource;
import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* N4 operator plane.
*
* <p>It lived in the outbound notification adapter, where it performed operator authorization,
* tenant scoping, duplicate-risk approval, idempotency and transaction orchestration — every one of
* them a decision about what the product allows, in the layer whose job is speaking provider
* protocols. What remains out there is {@link ProviderRuntimeControlPort}: two operations that turn
* an application request into a runtime transition.
*
* <p>Four properties hold for every operation: a separate authority, an idempotent operation id, a
* recorded reason, and an audit row. The idempotency matters more than it looks — an operator
* retrying a redrive after a timeout must not send the message twice, which is exactly the failure
* the operation is trying to repair.
*
* <p>A dry run reads and reports but writes nothing, so an operator can see the blast radius of a
* bulk action before committing to it.
*/
public final class NotificationAdminApplicationService implements NotificationAdminService {
private final AdminAuthorizationGuard authorization;
private final DuplicateRiskGuard duplicateRiskGuard;
private final DeliveryAttemptStorePort attempts;
private final RecipientDeliveryStorePort recipients;
private final ReconciliationService reconciliation;
private final SuppressionStorePort suppressions;
private final ProviderRuntimeControlPort runtimes;
private final AdminOperationStorePort operations;
private final NotificationAuditPort audit;
private final TransactionPort transactions;
private final dev.caskeleton.application.notification.platform.dispatch
.NotificationIdGeneratorPort
ids;
private final Clock clock;
public NotificationAdminApplicationService(
AdminAuthorizationGuard authorization,
DuplicateRiskGuard duplicateRiskGuard,
DeliveryAttemptStorePort attempts,
RecipientDeliveryStorePort recipients,
ReconciliationService reconciliation,
SuppressionStorePort suppressions,
ProviderRuntimeControlPort runtimes,
AdminOperationStorePort operations,
NotificationAuditPort audit,
TransactionPort transactions,
dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort ids,
Clock clock) {
this.authorization = Objects.requireNonNull(authorization, "authorization");
this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard");
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.recipients = Objects.requireNonNull(recipients, "recipients");
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
this.suppressions = Objects.requireNonNull(suppressions, "suppressions");
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
this.operations = Objects.requireNonNull(operations, "operations");
this.audit = Objects.requireNonNull(audit, "audit");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.ids = Objects.requireNonNull(ids, "ids");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.REDRIVE);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
DeliveryAttemptSnapshot original =
attempts
.snapshot(command.attemptId())
.orElseThrow(() -> new IllegalStateException("delivery attempt is not available"));
authorization.requireTenant(actor, original.tenantId());
duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.of(original.notificationId()),
Optional.of(original.recipientDeliveryId()),
Optional.empty(),
List.of("DRY_RUN"));
}
return transactions.inWrite(
() -> {
// The logical identities are preserved and only the attempt is new, so the history stays
// one story rather than becoming two unrelated notifications.
recipients.transition(
original.recipientDeliveryId(),
RecipientDeliveryState.READY_TO_DISPATCH,
Optional.of(clock.instant()));
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
1,
Optional.of(original.notificationId()),
Optional.of(original.recipientDeliveryId()),
Optional.empty(),
List.of(command.reason()));
audit.record(
new NotificationAuditEvent(
"ADMIN_REDRIVE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of(
"provider", original.providerId().value(),
"channel", original.channel().name())));
return operations.save(result, actor, "ADMIN_REDRIVE");
});
}
@Override
public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.RECONCILE);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
command.attemptIds().size(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
List<String> reasons = new ArrayList<>();
int reconciled = 0;
for (DeliveryAttemptId attemptId : command.attemptIds()) {
reconciliation.reconcile(attemptId);
reconciled++;
}
reasons.add(command.reason());
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
reconciled,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.copyOf(reasons));
audit.record(
new NotificationAuditEvent(
"ADMIN_RECONCILE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of()));
return operations.save(result, actor, "ADMIN_RECONCILE");
}
@Override
public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
authorization.requireTenant(actor, command.tenantId());
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
return transactions.inWrite(
() -> {
int affected;
if (command.remove()) {
// Removal is by fingerprint match rather than by id, because an operator lifting a
// suppression knows the target, not the row identifier the platform assigned.
affected =
suppressions
.activeFor(
command.tenantId(), command.targetFingerprint(), clock.instant())
.stream()
.map(entry -> suppressions.remove(command.tenantId(), entry.id()))
.filter(Optional::isPresent)
.count()
> 0
? 1
: 0;
} else {
suppressions.upsert(
new SuppressionEntry(
// ids.nextId(), not UUID.randomUUID(). The identifier contract requires the
// generator port, and while this class lived in the outbound adapter the rule
// that says so did not apply to it — a random v4 id here is unordered, so the
// suppression table's index degrades the way the platform's own UUIDv7 policy
// exists to prevent.
new SuppressionId(ids.nextId()),
command.tenantId(),
command.scope(),
command.reason(),
command.targetFingerprint(),
Optional.empty(),
clock.instant(),
command.expiresAt(),
SuppressionSource.ADMIN));
affected = 1;
}
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
affected,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(command.reasonText()));
audit.record(
new NotificationAuditEvent(
command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED",
actor.actorRef(),
Optional.of(command.reason().name()),
Optional.of(command.operationId()),
clock.instant(),
Map.of()));
return operations.save(
result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD");
});
}
@Override
public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
boolean applied =
runtimes.setState(command.profileId(), command.desiredState(), command.reason());
if (!applied) {
// The transition was refused — an authentication failure is not cleared by declaring the
// provider healthy, and a healthy runtime is not degraded on request. Recording success for a
// state change that did not happen tells an operator the provider is serving when it is not.
throw new ProviderConfigurationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHORIZATION));
}
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(command.reason()));
audit.record(
new NotificationAuditEvent(
"ADMIN_PROVIDER_STATE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of(
"providerProfile", command.profileId().value(),
"status", command.desiredState().name())));
return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
}
/** Current state of a provider runtime, for the health endpoint. */
public ProviderRuntimeState providerState(
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) {
return runtimes.state(profileId);
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.notification.platform.admin;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
/**
* The only thing the admin plane needs from a provider runtime.
*
* <p>Two operations, because that is all the admin service ever used of {@code
* ProviderRuntimeRegistry} — and depending on the registry meant the admin service had to live in
* the outbound adapter beside it. That is how operator authorization, tenant scoping,
* duplicate-risk approval and transaction orchestration ended up in the layer whose job is talking
* to providers.
*
* <p>Narrow on purpose. A port that exposed the registry would let the next admin operation reach
* for rotation, draining or the limiter, and the boundary would erode from the application side
* instead of the adapter side.
*/
public interface ProviderRuntimeControlPort {
/**
* Move a provider profile to an operator-requested state.
*
* @param profileId which profile
* @param desiredState the state the operator asked for
* @param reason why, recorded on the runtime
* @return whether the runtime accepted the transition; false means it refused, and an
* authentication failure is not cleared by declaring a provider healthy
*/
boolean setState(ProviderProfileId profileId, ProviderRuntimeState desiredState, String reason);
/**
* Current state of a profile.
*
* @param profileId which profile
* @return its state
*/
ProviderRuntimeState state(ProviderProfileId profileId);
}
@@ -1,6 +1,12 @@
package dev.caskeleton.application.notification.platform.callback;
package dev.caskeleton.application.notification.platform.api;
/** How many events an ingestion stored and how many were already known. */
/**
* How many events an ingestion stored and how many were already known.
*
* <p>It sits in {@code api} because the inbound port returns it. A port declaring a type owned by
* the package that implements the port is a cycle: neither package can then be read, tested, or
* moved without the other.
*/
public record CallbackIngestionResult(int appended, int duplicates) {
public CallbackIngestionResult {
@@ -1,7 +1,5 @@
package dev.caskeleton.application.notification.platform.callback;
package dev.caskeleton.application.notification.platform.api;
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;
@@ -15,6 +13,11 @@ import java.util.Optional;
*
* <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.
*
* <p>It lives in {@code api} rather than {@code callback} because the inbound port declares it.
* With it in {@code callback}, the port package that exists to be the boundary depended on the
* package implementing behind that boundary, and the two could no longer be reasoned about
* separately.
*/
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record CallbackRequest(
@@ -0,0 +1,60 @@
package dev.caskeleton.application.notification.platform.api;
import java.util.Arrays;
import java.util.Objects;
/**
* One plan, encoded once.
*
* <p>The fingerprint used to be taken over a canonical string built by {@code RequestFingerprint},
* while the stored variables payload was produced separately by a JSON codec. Two encodings of the
* same request, written in different places, with nothing forcing them to agree — so the bytes that
* decided whether a request was a duplicate were not the bytes the request was stored as.
*
* <p>These bytes are the request. The fingerprint is their SHA-256 and the persisted payload is
* {@link #variablesPayload()} taken from the same pass, so the two cannot drift apart.
*
* @param version the encoding version, inside the bytes as well as here, so a future change to the
* field set produces different fingerprints by construction
* @param bytes the canonical, length-framed encoding
* @param variablesPayload the variables as they will be stored
*/
@SuppressWarnings("ArrayRecordComponent") // copied on construction and on every read
public record EncodedNotificationPlan(int version, byte[] bytes, String variablesPayload) {
public EncodedNotificationPlan {
Objects.requireNonNull(bytes, "bytes");
Objects.requireNonNull(variablesPayload, "variablesPayload");
if (version < 1) {
throw new IllegalArgumentException("version");
}
bytes = bytes.clone();
}
@Override
public byte[] bytes() {
return bytes.clone();
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
return other instanceof EncodedNotificationPlan encoded
&& version == encoded.version
&& Arrays.equals(bytes, encoded.bytes)
&& variablesPayload.equals(encoded.variablesPayload);
}
@Override
public int hashCode() {
return Objects.hash(version, Arrays.hashCode(bytes), variablesPayload);
}
@Override
public String toString() {
// The length, never the content: the encoding contains every variable the caller supplied.
return "EncodedNotificationPlan[version=" + version + ", bytes=" + bytes.length + "]";
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.notification.platform.api;
/**
* What the platform did with a submission.
*
* <p>The receipt used to say only "accepted", whatever happened. Deduplication has two distinct
* outcomes and a caller has to be able to tell them apart: {@code DROP} means this request will
* never be sent, {@code RETURN_EXISTING} means an earlier notification covers it and the id in the
* receipt is that earlier one. Reporting both as an ordinary acceptance tells a caller their
* notification is on its way when it is not.
*/
public enum NotificationAcceptance {
/** A new request, stored and queued. */
ACCEPTED,
/** Suppressed by a deduplication window; nothing will be sent for this submission. */
DROPPED_AS_DUPLICATE,
/** An earlier notification in the same window covers this one; its id is in the receipt. */
CONVERGED_ON_EXISTING
}
@@ -14,13 +14,18 @@ import java.util.Optional;
* <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.
*
* <p>Values are {@link NotificationVariable}, not {@code Object}. With {@code Object} the
* fingerprint had to render values through {@code toString()} — so {@code "1"} and {@code 1} hashed
* alike and two different requests could collapse onto one idempotency key — and the plan could not
* copy a nested mutable value it did not recognise.
*/
public record NotificationPlan(
TenantId tenantId,
IdempotencyKey idempotencyKey,
String category,
TemplateSelection template,
Map<String, Object> variables,
Map<String, NotificationVariable> variables,
List<RecipientSpec> recipients,
DeliveryStrategy deliveryStrategy,
Optional<Instant> notBefore,
@@ -48,6 +53,11 @@ public record NotificationPlan(
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(deduplication, "deduplication");
Objects.requireNonNull(collapse, "collapse");
// Map.copyOf is enough now: NotificationVariable is a closed algebra of immutable records, so
// there is no nested mutable object left for a caller to change after the fingerprint has been
// computed and before the payload is stored. The map used to be Map<String, Object>, where a
// deep copy could only guess at the shapes it was copying and had to leave an unrecognised
// mutable type alone.
variables = Map.copyOf(Objects.requireNonNull(variables, "variables"));
recipients = List.copyOf(Objects.requireNonNull(recipients, "recipients"));
boundedMetadata = Map.copyOf(Objects.requireNonNull(boundedMetadata, "boundedMetadata"));
@@ -10,11 +10,38 @@ import java.util.Objects;
* proves the logical request and its recipient jobs are committed, nothing about any provider.
*/
public record NotificationReceipt(
NotificationId notificationId, RequestStatus status, Instant acceptedAt) {
NotificationId notificationId,
RequestStatus status,
Instant acceptedAt,
NotificationAcceptance acceptance) {
public NotificationReceipt {
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(acceptedAt, "acceptedAt");
Objects.requireNonNull(acceptance, "acceptance");
}
/**
* A receipt for a request that was accepted and will be sent.
*
* @param notificationId the notification
* @param status its status
* @param acceptedAt when it was accepted
* @return the receipt
*/
public static NotificationReceipt accepted(
NotificationId notificationId, RequestStatus status, Instant acceptedAt) {
return new NotificationReceipt(
notificationId, status, acceptedAt, NotificationAcceptance.ACCEPTED);
}
/**
* Whether this request will actually produce a delivery.
*
* @return false when deduplication dropped or converged it
*/
public boolean willDeliver() {
return acceptance == NotificationAcceptance.ACCEPTED;
}
}
@@ -0,0 +1,252 @@
package dev.caskeleton.application.notification.platform.api;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A template variable value, from a closed set of shapes.
*
* <p>The plan took {@code Map<String, Object>}. That made three separate problems inevitable and
* none of them fixable from the outside:
*
* <ul>
* <li>The fingerprint had to render values with {@code toString()}, so two different values could
* produce the same text and two accepted requests could collapse onto one idempotency key.
* <li>{@code Object} admits a mutable type the plan cannot copy, so a caller could change a
* nested value after the fingerprint was computed and before the payload was encoded — the
* stored request then not being the request that was hashed.
* <li>The hash and the persisted payload were produced by different code, so nothing forced them
* to agree about what the variables were.
* </ul>
*
* <p>A closed algebra makes each value's type part of its identity, gives every shape one canonical
* encoding, and makes "unrepresentable" a compile error rather than a runtime surprise.
*
* <p>{@code toString} is the record default on purpose for the structural cases, but scalars
* deliberately do not print their content: a variable is caller data, and caller data belongs in a
* notification, not in a log line or an exception message.
*/
public sealed interface NotificationVariable
permits NotificationVariable.TextValue,
NotificationVariable.NumberValue,
NotificationVariable.BooleanValue,
NotificationVariable.NullValue,
NotificationVariable.ListValue,
NotificationVariable.ObjectValue {
/** Deepest nesting a variable graph may have. */
int MAX_DEPTH = 16;
/** Most entries one object or list may hold. */
int MAX_ENTRIES = 256;
/** Longest a single text value may be, in UTF-8 bytes. */
int MAX_TEXT_BYTES = 8192;
/** Longest a single key may be, in UTF-8 bytes. */
int MAX_KEY_BYTES = 128;
/**
* How deep this value nests, counting itself as one.
*
* @return the depth
*/
int depth();
/** Text. */
record TextValue(String value) implements NotificationVariable {
public TextValue {
Objects.requireNonNull(value, "value");
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_TEXT_BYTES) {
throw new IllegalArgumentException("text variable exceeds " + MAX_TEXT_BYTES + " bytes");
}
}
@Override
public int depth() {
return 1;
}
@Override
public String toString() {
// The length, not the content. A variable can be an order total, a name, or a reset link.
return "TextValue[" + value.length() + " chars]";
}
}
/**
* A number, held as {@link BigDecimal}.
*
* <p>Not {@code double}: {@code 0.1 + 0.2} and {@code 0.3} are the same double and different
* decimals, and a fingerprint that cannot tell them apart merges two different requests.
*/
record NumberValue(BigDecimal value) implements NotificationVariable {
public NumberValue {
Objects.requireNonNull(value, "value");
}
@Override
public int depth() {
return 1;
}
@Override
public String toString() {
return "NumberValue[…]";
}
}
/** A boolean. */
record BooleanValue(boolean value) implements NotificationVariable {
@Override
public int depth() {
return 1;
}
}
/** An explicit absence, distinct from a key that is not present. */
record NullValue() implements NotificationVariable {
/** The single instance. */
public static final NullValue INSTANCE = new NullValue();
@Override
public int depth() {
return 1;
}
}
/** An ordered list; order is part of the identity. */
record ListValue(List<NotificationVariable> values) implements NotificationVariable {
public ListValue {
values = List.copyOf(Objects.requireNonNull(values, "values"));
if (values.size() > MAX_ENTRIES) {
throw new IllegalArgumentException("list variable exceeds " + MAX_ENTRIES + " entries");
}
requireWithinDepth(values.stream().mapToInt(NotificationVariable::depth));
}
@Override
public int depth() {
return 1 + values.stream().mapToInt(NotificationVariable::depth).max().orElse(0);
}
}
/** A keyed object; iteration order is normalised at encoding time, not here. */
record ObjectValue(Map<String, NotificationVariable> values) implements NotificationVariable {
public ObjectValue {
values = Map.copyOf(Objects.requireNonNull(values, "values"));
if (values.size() > MAX_ENTRIES) {
throw new IllegalArgumentException("object variable exceeds " + MAX_ENTRIES + " entries");
}
values.forEach(
(key, value) -> {
Objects.requireNonNull(key, "variable key");
Objects.requireNonNull(value, "variable value");
if (key.getBytes(StandardCharsets.UTF_8).length > MAX_KEY_BYTES) {
throw new IllegalArgumentException(
"variable key exceeds " + MAX_KEY_BYTES + " bytes");
}
});
requireWithinDepth(values.values().stream().mapToInt(NotificationVariable::depth));
}
@Override
public int depth() {
return 1 + values.values().stream().mapToInt(NotificationVariable::depth).max().orElse(0);
}
}
/**
* Refuse a graph that nests deeper than the bound.
*
* <p>Checked while building rather than after, so a caller cannot construct a structure the
* encoder would have to walk before discovering it is too deep.
*
* @param childDepths the depths of the children being wrapped
*/
private static void requireWithinDepth(java.util.stream.IntStream childDepths) {
int deepest = childDepths.max().orElse(0);
if (deepest + 1 > MAX_DEPTH) {
throw new IllegalArgumentException("variable graph exceeds depth " + MAX_DEPTH);
}
}
/**
* Text.
*
* @param value the text
* @return the variable
*/
static NotificationVariable text(String value) {
return new TextValue(value);
}
/**
* A number.
*
* @param value the number
* @return the variable
*/
static NotificationVariable number(BigDecimal value) {
return new NumberValue(value);
}
/**
* A number from a long.
*
* @param value the number
* @return the variable
*/
static NotificationVariable number(long value) {
return new NumberValue(BigDecimal.valueOf(value));
}
/**
* A boolean.
*
* @param value the flag
* @return the variable
*/
static NotificationVariable bool(boolean value) {
return new BooleanValue(value);
}
/**
* An explicit null.
*
* @return the variable
*/
static NotificationVariable nullValue() {
return NullValue.INSTANCE;
}
/**
* A list.
*
* @param values the ordered values
* @return the variable
*/
static NotificationVariable list(List<NotificationVariable> values) {
return new ListValue(values);
}
/**
* An object.
*
* @param values the keyed values
* @return the variable
*/
static NotificationVariable object(Map<String, NotificationVariable> values) {
return new ObjectValue(new LinkedHashMap<>(values));
}
}
@@ -10,4 +10,17 @@ public interface DeliveryProjectionStorePort {
/** Store a projection for an attempt and roll it up into the recipient and request. */
void save(DeliveryAttemptId attemptId, DeliveryProjection projection);
/**
* Claims the right to apply this attempt's suppression side effect, once.
*
* <p>The ledger is replayed by design — an event that arrives before its attempt is stored
* PENDING and projected later, and a failed projection is retried — so "apply the suppression
* when the projection says to" ran the side effect again on every replay. The claim is durable
* and part of the projection write's transaction, so exactly one caller ever sees true.
*
* @param attemptId the attempt
* @return true when this caller may run the side effect
*/
boolean claimSuppressionSideEffect(DeliveryAttemptId attemptId);
}
@@ -1,5 +1,7 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.CallbackIngestionResult;
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
@@ -17,38 +19,62 @@ import java.util.Optional;
/**
* The callback pipeline, in the one order that is safe.
*
* <p>Limits, then signature, then durable append, then projection. Appending before projecting is
* what makes a fast 2xx honest: the provider is told the event is safely recorded, and a projector
* defect becomes a replay problem instead of a lost event.
* <p>Limits, then signature, then one transactional durable append. Projection is a worker's job,
* not this path's: the endpoint promises the provider a fast 2xx once the event is durable, and
* projecting inline broke that promise in both directions a projector that threw failed a
* callback the provider had already been told was accepted, and a slow projector held the
* provider's connection open while it ran.
*
* <p>Appending before projecting is what makes a fast 2xx honest: the provider is told the event is
* safely recorded, and a projector defect becomes a replay problem instead of a lost event.
*/
public final class ProviderCallbackIngestionService {
@dev.caskeleton.application.capability.UseCaseCapability(
transactionMode = dev.caskeleton.application.transaction.TransactionMode.WRITE,
idempotency = dev.caskeleton.application.capability.Idempotency.IDEMPOTENT,
repositoryAccess = dev.caskeleton.application.capability.RepositoryAccess.WRITE_REPOSITORY,
// The ledger append is the write; the callback adapter it consults verifies a signature and may
// fetch a provider certificate, which is an outbound call this use case genuinely makes.
externalOutboundAllowed = true)
@dev.caskeleton.application.security.RequiresPermission("notification-callback:ingest")
public final class IngestProviderCallbackApplicationUseCase
implements dev.caskeleton.application.notification.platform.port.in
.IngestProviderCallbackUseCase {
private final ProviderCallbackAdapterRegistry adapters;
private final ProviderEventLedger ledger;
private final ProviderEventProjectionService projection;
private final dev.caskeleton.application.transaction.TransactionPort transactions;
private final CallbackPayloadProtectionPort payloadProtection;
private final NotificationSecurityAuditPort securityAudit;
private final NotificationMetricsPort metrics;
private final Clock clock;
public ProviderCallbackIngestionService(
public IngestProviderCallbackApplicationUseCase(
ProviderCallbackAdapterRegistry adapters,
ProviderEventLedger ledger,
ProviderEventProjectionService projection,
dev.caskeleton.application.transaction.TransactionPort transactions,
CallbackPayloadProtectionPort payloadProtection,
NotificationSecurityAuditPort securityAudit,
NotificationMetricsPort metrics,
Clock clock) {
this.adapters = Objects.requireNonNull(adapters, "adapters");
this.ledger = Objects.requireNonNull(ledger, "ledger");
this.projection = Objects.requireNonNull(projection, "projection");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.payloadProtection = Objects.requireNonNull(payloadProtection, "payloadProtection");
this.securityAudit = Objects.requireNonNull(securityAudit, "securityAudit");
this.metrics = Objects.requireNonNull(metrics, "metrics");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public CallbackIngestionResult handle(
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
command) {
Objects.requireNonNull(command, "command");
return ingest(command.request());
}
/** Ingest one callback. */
@Override
public CallbackIngestionResult ingest(CallbackRequest request) {
Objects.requireNonNull(request, "request");
@@ -69,6 +95,19 @@ public final class ProviderCallbackIngestionService {
}
ProviderCallbackAdapter adapter = adapters.require(request.providerProfileId());
// The URL names a provider and the path names a profile, and the two are supplied by the
// caller. Binding them before verification means a caller cannot present one provider's
// callback against another provider's profile and have the platform fetch that provider's
// certificate to check it which is work, and a lookup, done on an unauthenticated request's
// say-so.
if (!adapter.providerId().equals(request.providerId())) {
securityAudit.callbackRejectedByLimit(
request.providerProfileId(), "PROVIDER_PROFILE_MISMATCH");
throw new CallbackValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.CALLBACK_PAYLOAD_REJECTED,
FailureCategory.CALLBACK_VALIDATION_FAILURE));
}
CallbackVerificationResult verification = adapter.verify(request);
if (!verification.valid()) {
// A rejected signature is a security event, not a provider event: writing it to the ledger
@@ -102,8 +141,16 @@ public final class ProviderCallbackIngestionService {
payloadProtection.fingerprint(request.providerProfileId(), event, rawDigest)));
}
AppendEventResult append = ledger.appendAll(events);
append.newEvents().forEach(projection::project);
// One write transaction around the whole batch. Appending event by event let a failure part
// way through commit the events before it and lose the rest, and the caller was told nothing
// about the split.
AppendEventResult append = transactions.inWrite(() -> ledger.appendAll(events));
// Deliberately not projected here. The endpoint's contract is "durable append, then a fast
// 2xx", and projecting synchronously broke it in both directions: a projector that threw
// failed the callback the provider had already been promised was accepted, and a slow
// projector held the provider's connection open. The events are PENDING and
// ProviderEventReplayWorker drains them.
metrics.increment(
NotificationMetricName.CALLBACK,
@@ -1,5 +1,6 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import java.util.List;
@@ -28,6 +28,33 @@ public interface ProviderEventLedger {
/** Events that could not be matched to an attempt yet. */
List<ProviderEventRecord> unmatched(int limit);
/**
* Binds events that arrived before the attempt they belong to.
*
* <p>A provider callback routinely reaches this platform before the submitting transaction has
* written the provider request id — the provider answers faster than the caller commits. Such an
* event is stored with no attempt, and until now nothing ever went back for it: the repository
* had a {@code bindAttemptIfUnbound} statement with no caller, so a callback-before-outcome event
* stayed unmatched permanently and its delivery was never confirmed.
*
* <p>The identifier is passed in clear and hashed by the implementation. Only the hash is stored
* — the ledger never keeps a provider's request id in clear text — and hashing is the adapter's
* concern, not this contract's.
*
* <p>Scoped to the profile as well as the hash. The hash is of a provider's own request id, and
* two providers — or two profiles of one provider — can mint the same id; binding on the hash
* alone would attach one tenant's callback to another tenant's attempt.
*
* @param providerProfileId the profile whose attempt this is
* @param providerRequestId the provider's own request id, as the attempt received it
* @param attemptId the attempt to bind to
* @return the events this call bound, which are the ones to project
*/
List<ProviderEventRecord> bindUnmatched(
dev.caskeleton.application.notification.platform.api.ProviderProfileId providerProfileId,
String providerRequestId,
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId);
/** Every stored event for one attempt, oldest first, for projection replay. */
List<ProviderEventRecord> eventsForAttempt(
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId);
@@ -66,7 +66,11 @@ public final class ProviderEventProjectionService {
ProjectionResult result = projector.get().project(attempt.get(), event, current);
if (result.changed()) {
projections.save(attempt.get().attemptId(), result.projection());
if (result.projection().suppressionFacts().requiresSuppression()) {
// Claimed, not merely conditioned on the facts. The ledger is replayed by design,
// so "the projection says suppressed" is true on every replay and the side effect
// ran each time.
if (result.projection().suppressionFacts().requiresSuppression()
&& projections.claimSuppressionSideEffect(attempt.get().attemptId())) {
sideEffects.apply(attempt.get(), result.projection().suppressionFacts());
}
}
@@ -1,5 +1,6 @@
package dev.caskeleton.application.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
import java.util.Map;
import java.util.Objects;
@@ -11,6 +11,31 @@ public sealed interface ContactPointValue
/** Physical kind, used as uniqueness scope and encryption associated data. */
ContactPointType type();
/** Canonical form used for the keyed lookup fingerprint. */
/**
* Canonical form used for the keyed lookup fingerprint.
*
* <p>Identity only. For Web Push that is the endpoint and the client public key — the two fields
* that decide whether two subscriptions are the same one. It is deliberately <em>not</em> the
* form that gets encrypted: a fingerprint over a secret would make the lookup index a place to
* test guesses against.
*/
String normalized();
/**
* Complete form, encrypted at rest.
*
* <p>Separate from {@link #normalized()} because the two answer different questions, and
* conflating them silently destroyed data. The protector encrypted {@code normalized()}, so a Web
* Push subscription came back from storage with its auth secret replaced by sixteen zero bytes
* and its VAPID key id replaced by the literal {@code "restored"}. An RFC 8291 payload built from
* that cannot be decrypted by the browser it was addressed to, and no test noticed because both
* halves of the round trip used the same lossy form.
*
* <p>Versioned, so a future field can be added without making every stored row unreadable.
*
* @return the versioned serialized form
*/
default String serialized() {
return "v1:" + normalized();
}
}
@@ -75,11 +75,29 @@ public record WebPushSubscriptionValue(
@Override
public String normalized() {
// Identity: the endpoint and the client public key are what make two subscriptions the same
// one. The auth secret is deliberately absent — a lookup fingerprint over it would turn the
// index into an oracle for guessing it.
return endpoint.toString()
+ "|"
+ Base64.getUrlEncoder().withoutPadding().encodeToString(p256dh);
}
@Override
public String serialized() {
// All four fields. Encrypting normalized() lost the auth secret and the VAPID key id, and the
// reveal path invented replacements for both — sixteen zero bytes and the literal "restored" —
// so a stored subscription could never produce a payload its browser could decrypt.
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
return String.join(
"|",
"v1",
endpoint.toString(),
encoder.encodeToString(p256dh),
encoder.encodeToString(authSecret),
vapidKeyId);
}
@Override
public boolean equals(Object other) {
if (this == other) {
@@ -0,0 +1,48 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.notification.platform.api.CancelResult;
import dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand;
import dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* Stopping future logical attempts.
*
* <p>Its own class, so its capability is its own. A single class implementing submit, schedule,
* cancel and get cannot declare one honest transaction mode — the compiler says so too, because
* {@code CommandUseCase} cannot be inherited twice with different type arguments. The mandatory
* fitness gate reads these annotations; an orchestration that never implements the marker is simply
* not checked, which is how this entrypoint bypassed it.
*/
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
@RequiresPermission("notification:cancel")
public final class CancelNotificationApplicationUseCase implements CancelNotificationUseCase {
private final NotificationSubmissionService service;
private final TransactionPort transactions;
public CancelNotificationApplicationUseCase(
NotificationSubmissionService service, TransactionPort transactions) {
this.service = Objects.requireNonNull(service, "service");
// Injected, and called directly below. A use case that declares it owns a repository-backed
// transaction and then delegates the boundary to a collaborator is making a claim the
// architecture gate cannot verify and a reader cannot trust.
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public CancelResult handle(CancelNotificationCommand command) {
Objects.requireNonNull(command, "command");
return transactions.inWrite(
() -> service.cancelInTransaction(command.notificationId(), command.command()));
}
}
@@ -22,21 +22,36 @@ public final class CanonicalNotificationPlanWriter {
private final NotificationIdGeneratorPort ids;
private final NotificationRoutePlannerPort routePlanner;
private final NotificationVariablesCodecPort variables;
public CanonicalNotificationPlanWriter(
NotificationIdGeneratorPort ids,
NotificationRoutePlannerPort routePlanner,
NotificationVariablesCodecPort variables) {
NotificationIdGeneratorPort ids, NotificationRoutePlannerPort routePlanner) {
this.ids = Objects.requireNonNull(ids, "ids");
this.routePlanner = Objects.requireNonNull(routePlanner, "routePlanner");
this.variables = Objects.requireNonNull(variables, "variables");
}
/** Build the request row. */
/**
* Build the request row.
*
* <p>The stored variables payload comes from {@code encoded}, the same pass the fingerprint was
* taken over. The writer used to call a JSON codec of its own, so the bytes that were hashed and
* the bytes that were stored were produced by two different pieces of code and only agreed by
* coincidence.
*
* @param plan the submission intent
* @param encoded the canonical encoding of that plan
* @param fingerprint the hash of {@code encoded}
* @param scheduleAt when it should be dispatched, if later
* @param now the clock reading
* @return the request row
*/
public NotificationRequestRecord request(
NotificationPlan plan, String fingerprint, Optional<Instant> scheduleAt, Instant now) {
NotificationPlan plan,
dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan encoded,
String fingerprint,
Optional<Instant> scheduleAt,
Instant now) {
Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(encoded, "encoded");
Objects.requireNonNull(fingerprint, "fingerprint");
Objects.requireNonNull(scheduleAt, "scheduleAt");
Objects.requireNonNull(now, "now");
@@ -50,10 +65,13 @@ public final class CanonicalNotificationPlanWriter {
plan.category(),
plan.template(),
plan.deliveryStrategy().getClass().getSimpleName(),
variables.encode(plan.variables()),
encoded.variablesPayload(),
scheduleAt,
plan.notBefore(),
plan.expiresAt(),
// Frozen here, at acceptance. Recomputing a collapse key at dispatch time would give a
// retry a different key from the attempt it is retrying, which defeats collapsing.
plan.collapse(),
status,
correlationOf(plan),
plan.boundedMetadata(),
@@ -70,10 +88,10 @@ public final class CanonicalNotificationPlanWriter {
Instant activationAt = request.scheduleAt().orElse(now);
Instant dispatchAt = plan.notBefore().filter(activationAt::isBefore).orElse(activationAt);
RecipientDeliveryState initialState =
request.scheduleAt().isPresent()
? RecipientDeliveryState.PENDING
: RecipientDeliveryState.READY_TO_DISPATCH;
// One state, not two. A scheduled delivery is an ordinary queued delivery whose due time is in
// the future: PENDING was a second state that the claim query did not read, so every
// schedule(...) request sat in the table until someone noticed.
RecipientDeliveryState initialState = RecipientDeliveryState.READY_TO_DISPATCH;
List<RecipientDeliveryRecord> records = new ArrayList<>(plan.recipients().size());
for (RecipientSpec recipient : plan.recipients()) {
@@ -96,6 +114,10 @@ public final class CanonicalNotificationPlanWriter {
false,
false,
Optional.of(dispatchAt),
// The expiry travels onto the row so the claim can enforce it without reading the
// request. A scheduled delivery whose window closed while it waited must not go out
// late; the claim is the only place that can decide that atomically.
request.expiresAt(),
Optional.empty(),
Optional.empty(),
0,
@@ -6,6 +6,7 @@ import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOut
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.template.RenderedNotificationContent;
import java.time.Instant;
@@ -50,9 +51,9 @@ public final class DeliveryAttemptFactory {
profile.providerId(),
profile.profileId(),
Optional.empty(),
false,
false,
false,
// A row that exists before any call: nothing started, and that is a proven fact rather than
// an unknown one.
ProviderExecutionEvidence.notStarted(),
SubmissionOutcome.NOT_SUBMITTED,
DeliveryOutcome.UNKNOWN,
AttemptConfirmation.AMBIGUOUS,
@@ -11,12 +11,23 @@ import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLev
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Durable state of one physical attempt. */
/**
* Durable state of one physical attempt.
*
* <p>Execution evidence is stored as {@link ProviderExecutionEvidence}, not as three booleans. The
* three booleans were the {@code .value()} halves of four {@link
* dev.caskeleton.application.notification.platform.provider.EvidenceFact}s, so the certainty was
* dropped on the way to the database and {@code providerAcceptance} was not written at all. After a
* restart, "we know the body did not reach the provider" and "we have no idea whether the body
* reached the provider" were the same stored row — and those are the two cases that decide whether
* a retry is safe. The ambiguity model exists to keep them apart, and persistence collapsed them.
*/
public record DeliveryAttemptRecord(
DeliveryAttemptId id,
RecipientDeliveryId recipientDeliveryId,
@@ -26,9 +37,7 @@ public record DeliveryAttemptRecord(
ProviderId providerId,
ProviderProfileId providerProfileId,
Optional<String> providerRequestId,
boolean requestStarted,
boolean requestBodyCommitted,
boolean providerResponseReceived,
ProviderExecutionEvidence executionEvidence,
SubmissionOutcome submissionOutcome,
DeliveryOutcome deliveryOutcome,
AttemptConfirmation confirmation,
@@ -51,6 +60,7 @@ public record DeliveryAttemptRecord(
Objects.requireNonNull(providerId, "providerId");
Objects.requireNonNull(providerProfileId, "providerProfileId");
Objects.requireNonNull(providerRequestId, "providerRequestId");
Objects.requireNonNull(executionEvidence, "executionEvidence");
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
Objects.requireNonNull(confirmation, "confirmation");
@@ -21,15 +21,35 @@ public final class DispatchOutcomeRecorder {
private final DeliveryAttemptStorePort attempts;
private final RecipientDeliveryStorePort recipients;
private final ReconciliationJobStorePort reconciliationJobs;
private final dev.caskeleton.application.notification.platform.callback.ProviderEventLedger
ledger;
private final dev.caskeleton.application.notification.platform.callback
.ProviderEventProjectionService
projection;
private final NotificationMetricsPort metrics;
private final java.time.Duration firstReconciliationDelay;
public DispatchOutcomeRecorder(
DeliveryAttemptStorePort attempts,
RecipientDeliveryStorePort recipients,
NotificationMetricsPort metrics) {
ReconciliationJobStorePort reconciliationJobs,
dev.caskeleton.application.notification.platform.callback.ProviderEventLedger ledger,
dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService
projection,
NotificationMetricsPort metrics,
java.time.Duration firstReconciliationDelay) {
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.recipients = Objects.requireNonNull(recipients, "recipients");
this.reconciliationJobs = Objects.requireNonNull(reconciliationJobs, "reconciliationJobs");
this.ledger = Objects.requireNonNull(ledger, "ledger");
this.projection = Objects.requireNonNull(projection, "projection");
this.metrics = Objects.requireNonNull(metrics, "metrics");
this.firstReconciliationDelay =
Objects.requireNonNull(firstReconciliationDelay, "firstReconciliationDelay");
if (firstReconciliationDelay.isNegative()) {
throw new IllegalArgumentException("firstReconciliationDelay must not be negative");
}
}
/** Record the provider result on the attempt and roll it up into the recipient job. */
@@ -53,9 +73,10 @@ public final class DispatchOutcomeRecorder {
attempt.providerId(),
attempt.providerProfileId(),
result.providerRequestId(),
result.executionEvidence().requestStarted().value(),
result.executionEvidence().requestBodyCommitted().value(),
result.executionEvidence().responseReceived().value(),
// The whole evidence, not the .value() of three of its four facts. Dropping the
// certainty made "proven not sent" and "unknown whether sent" the same stored row, and
// those two decide whether a retry can safely happen.
result.executionEvidence(),
result.submissionOutcome(),
result.deliveryOutcome(),
result.confirmation(),
@@ -72,6 +93,18 @@ public final class DispatchOutcomeRecorder {
DeliveryAttemptRecord stored = attempts.recordOutcome(completed);
// The provider's request id is now on the attempt, so any callback that arrived before this
// moment can finally be attached to it. A provider answering faster than the caller commits is
// ordinary, and until this call nothing ever went back for those events: they stayed unmatched
// permanently and their delivery was never confirmed.
result
.providerRequestId()
.ifPresent(
requestId ->
ledger
.bindUnmatched(attempt.providerProfileId(), requestId, stored.id())
.forEach(projection::project));
boolean ambiguous = result.confirmation() == AttemptConfirmation.AMBIGUOUS;
RecipientDeliveryRecord updated =
new RecipientDeliveryRecord(
@@ -92,6 +125,7 @@ public final class DispatchOutcomeRecorder {
recipient.ambiguousAttemptExists() || ambiguous,
recipient.duplicateRisk() || ambiguous,
recipient.nextDispatchAt(),
recipient.expiresAt(),
recipient.leaseOwner(),
recipient.leaseUntil(),
recipient.attemptCount() + 1,
@@ -112,6 +146,12 @@ public final class DispatchOutcomeRecorder {
Map.of("channel", attempt.channel().name(), "provider", attempt.providerId().value()));
}
if (ambiguous) {
// The delivery is now RECONCILIATION_REQUIRED, and until this line nothing ever asked what
// happened to it: reconciliation ran only when a lease expired and recovery walked past the
// attempt, so a worker that recorded AMBIGUOUS and then exited cleanly left the delivery in
// that state permanently.
reconciliationJobs.schedule(
stored.id(), attempt.providerProfileId(), completedAt.plus(firstReconciliationDelay));
metrics.increment(
NotificationMetricName.AMBIGUOUS,
Map.of("channel", attempt.channel().name(), "provider", attempt.providerId().value()));
@@ -0,0 +1,45 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.notification.platform.api.NotificationSnapshot;
import dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery;
import dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* Reading a notification's current state.
*
* <p>A {@code QueryUseCase}, so the fitness gate holds it to {@code READ_ONLY} and {@code
* READ_REPOSITORY}. While this shared an interface with three writes, no single declaration could
* be true of all four, so none was made.
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
@RequiresPermission("notification:read")
public final class GetNotificationApplicationUseCase implements GetNotificationUseCase {
private final NotificationSubmissionService service;
private final TransactionPort transactions;
public GetNotificationApplicationUseCase(
NotificationSubmissionService service, TransactionPort transactions) {
this.service = Objects.requireNonNull(service, "service");
// Injected, and called directly below. A use case that declares it owns a repository-backed
// transaction and then delegates the boundary to a collaborator is making a claim the
// architecture gate cannot verify and a reader cannot trust.
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public NotificationSnapshot handle(GetNotificationQuery query) {
Objects.requireNonNull(query, "query");
return transactions.inRead(() -> service.readInTransaction(query.notificationId()));
}
}
@@ -24,6 +24,7 @@ import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -100,7 +101,7 @@ public final class NotificationDispatchService {
Optional<Work> loaded = transactions.inRead(() -> load(lease));
if (loaded.isEmpty()) {
leases.release(lease.recipientDeliveryId(), lease.owner());
leases.release(lease);
return;
}
Work work = loaded.get();
@@ -108,7 +109,7 @@ public final class NotificationDispatchService {
RoutingDecision decision = routing.next(routingContext(work, now));
if (decision.selected().isEmpty()) {
applyRoutingStop(work, decision);
leases.release(lease.recipientDeliveryId(), lease.owner());
leases.release(lease);
return;
}
RouteCandidate route = decision.selected().get();
@@ -124,8 +125,8 @@ public final class NotificationDispatchService {
if (guard instanceof DispatchGuardOutcome.Blocked blocked) {
transactions.inWrite(
() -> recipients.transition(work.recipient().id(), blocked.state(), Optional.empty()));
requests.refreshStatus(work.request().id());
leases.release(lease.recipientDeliveryId(), lease.owner());
refreshStatus(work);
leases.release(lease);
return;
}
ContactPointRecord contactPoint = ((DispatchGuardOutcome.Proceed) guard).contactPoint();
@@ -145,6 +146,13 @@ public final class NotificationDispatchService {
attempts.nextAttemptNo(work.recipient().id()),
clock.instant())));
if (!leases.stillHeld(lease)) {
// Checked immediately before the side effect, which is the last moment it can still be
// prevented. Everything above is database work another holder would simply redo; a provider
// submission is not — once it leaves, the recipient has the notification twice.
return;
}
ProviderSubmissionResult result =
submitOutsideTransaction(attempt, profile, contactPoint, content, work);
@@ -154,7 +162,25 @@ public final class NotificationDispatchService {
RetryDecision next = retryPolicy.decide(retryContext(work, recorded, result, profile));
applyNextAction(work, recorded, next, clock.instant());
leases.release(lease.recipientDeliveryId(), lease.owner());
leases.release(lease);
}
/**
* Recomputes and stores the request status.
*
* <p>The roll-up decision is applied here, in the use case, and the store is told the answer. It
* used to be made inside the JPA adapter, which imported this package's submission service to do
* it — so a rule about what "partially completed" means lived behind a persistence class.
*/
private void refreshStatus(Work work) {
List<RecipientDeliveryState> states =
requests.recipientsOf(work.request().tenantId(), work.request().id()).stream()
.map(RecipientDeliveryRecord::state)
.toList();
requests.updateStatus(
work.request().tenantId(),
work.request().id(),
NotificationRequestStatusPolicy.rollUp(states));
}
private ProviderSubmissionResult submitOutsideTransaction(
@@ -172,14 +198,39 @@ public final class NotificationDispatchService {
content,
work.request().expiresAt(),
Optional.of(attempt.id().value().toString()),
Optional.empty(),
// The frozen collapse, not Optional.empty(). The APNs and FCM mappers can send one and
// the capability model has a flag for it; the value simply never arrived.
requireCollapseSupported(profile, work),
java.util.Map.of(),
TraceContext.NONE);
try {
return gateway.submit(submission);
} catch (
dev.caskeleton.application.notification.platform.provider.ProviderCallNotStartedException
notStarted) {
// The adapter knows the transport never started: a payload that failed mapping, a contact
// point that could not be revealed, a profile with no credential.
return ProviderSubmissionResult.notSubmitted(notStarted.toFailure(), Duration.ZERO);
} catch (
dev.caskeleton.application.notification.platform.api.error.NotificationException
platformFailure) {
// The platform's own failures already say whether the submission could have been transmitted:
// NotificationFailureDescriptor.preDispatch sets ambiguous=false, and every limiter, runtime
// state and configuration rejection uses it. That flag was computed, recorded, and then
// discarded by a catch that treated all of them as "may already have been sent".
var descriptor = platformFailure.descriptor();
if (!descriptor.ambiguous()) {
return ProviderSubmissionResult.notSubmitted(
ProviderFailure.of(descriptor.code(), descriptor.category(), descriptor.retryable()),
Duration.ZERO);
}
return ProviderSubmissionResult.ambiguous(
ProviderFailure.of(descriptor.code(), descriptor.category(), descriptor.retryable()),
ProviderExecutionEvidence.responseLost(),
Duration.ZERO);
} catch (RuntimeException transportFailure) {
// A gateway that throws instead of classifying leaves us unable to prove anything about the
// submission, so the only honest record is an ambiguous one.
// Anything the adapter did not classify. The bytes may have reached the provider, so the only
// honest record is an ambiguous one — this branch is the residue, not the default.
return ProviderSubmissionResult.ambiguous(
ProviderFailure.of(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
@@ -190,6 +241,30 @@ public final class NotificationDispatchService {
}
}
/**
* The request's collapse specification, refused when the provider cannot honour it.
*
* <p>Silently dropping it would send a notification the caller believes will replace an earlier
* one and which will not — the user sees two. A typed pre-dispatch failure is the honest answer:
* nothing is transmitted, and the operator learns that this profile cannot do what was asked.
*/
private static Optional<dev.caskeleton.application.notification.platform.api.CollapseSpec>
requireCollapseSupported(ProviderProfileSnapshot profile, Work work) {
var collapse = work.request().collapse();
if (collapse.isEmpty()) {
return collapse;
}
if (!profile.capabilities().collapse()) {
throw new dev.caskeleton.application.notification.platform.provider
.ProviderCallNotStartedException(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.INVALID_PAYLOAD,
false,
"the request asks for collapsing and this provider profile does not support it");
}
return collapse;
}
private RenderedNotificationContent render(Work work, RouteCandidate route) {
return renderers
.rendererFor(route.channel())
@@ -267,7 +342,7 @@ public final class NotificationDispatchService {
transactions.inWrite(
() -> {
recipients.transition(work.recipient().id(), state, Optional.empty());
requests.refreshStatus(work.request().id());
refreshStatus(work);
});
}
@@ -296,7 +371,7 @@ public final class NotificationDispatchService {
: RecipientDeliveryState.FAILED,
Optional.empty());
}
requests.refreshStatus(work.request().id());
refreshStatus(work);
});
}
@@ -318,6 +393,7 @@ public final class NotificationDispatchService {
recipient.ambiguousAttemptExists(),
recipient.duplicateRisk(),
Optional.of(now),
recipient.expiresAt(),
Optional.empty(),
Optional.empty(),
recipient.attemptCount(),
@@ -0,0 +1,37 @@
package dev.caskeleton.application.notification.platform.dispatch;
import java.util.Objects;
/**
* What the database did with an insert, said without throwing.
*
* <p>The loser of an idempotency race used to find out by catching a unique-violation exception and
* then reading the winner — in the same transaction. PostgreSQL leaves a transaction aborted after
* a statement-level constraint violation and refuses every subsequent statement until rollback, so
* that read failed with SQLSTATE 25P02 or surfaced later as {@code UnexpectedRollbackException}.
* The convergence the whole idempotency contract promises — same key, same fingerprint, same
* receipt — could not happen on the path where it mattered.
*
* <p>An outcome instead of an exception, because the database can answer this without poisoning
* anything: {@code ON CONFLICT DO NOTHING RETURNING} returns a row when the caller won and no rows
* when it lost, and losing is an ordinary result rather than an error.
*
* @param stored the request as the database now holds it
* @param inserted whether this caller is the one that created it
*/
public record NotificationRequestInsertOutcome(NotificationRequestRecord stored, boolean inserted) {
public NotificationRequestInsertOutcome {
Objects.requireNonNull(stored, "stored");
}
/** This caller created the row. */
public static NotificationRequestInsertOutcome won(NotificationRequestRecord stored) {
return new NotificationRequestInsertOutcome(stored, true);
}
/** Another caller created it first; this is what they stored. */
public static NotificationRequestInsertOutcome lost(NotificationRequestRecord winner) {
return new NotificationRequestInsertOutcome(winner, false);
}
}
@@ -23,6 +23,7 @@ public record NotificationRequestRecord(
Optional<Instant> scheduleAt,
Optional<Instant> notBefore,
Optional<Instant> expiresAt,
Optional<dev.caskeleton.application.notification.platform.api.CollapseSpec> collapse,
RequestStatus status,
CorrelationId correlationId,
Map<String, String> metadata,
@@ -31,6 +32,7 @@ public record NotificationRequestRecord(
public NotificationRequestRecord {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(collapse, "collapse");
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
Objects.requireNonNull(requestFingerprint, "requestFingerprint");
@@ -50,7 +52,49 @@ public record NotificationRequestRecord(
/** Receipt for this stored request. */
public dev.caskeleton.application.notification.platform.api.NotificationReceipt receipt() {
return new dev.caskeleton.application.notification.platform.api.NotificationReceipt(
return dev.caskeleton.application.notification.platform.api.NotificationReceipt.accepted(
id, status, createdAt);
}
/**
* The receipt for a submission an earlier notification already covers.
*
* @return a receipt naming the earlier notification and saying so
*/
public dev.caskeleton.application.notification.platform.api.NotificationReceipt
convergedReceipt() {
return new dev.caskeleton.application.notification.platform.api.NotificationReceipt(
id,
status,
createdAt,
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
.CONVERGED_ON_EXISTING);
}
/**
* The same request with a different status.
*
* @param newStatus the status the use case decided on
*/
public NotificationRequestRecord withStatus(RequestStatus newStatus) {
Objects.requireNonNull(newStatus, "status");
return new NotificationRequestRecord(
id,
tenantId,
idempotencyKey,
requestFingerprint,
category,
template,
strategyType,
variablesPayload,
scheduleAt,
notBefore,
expiresAt,
collapse,
newStatus,
correlationId,
metadata,
createdAt,
updatedAt);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.RequestStatus;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import java.util.List;
import java.util.Objects;
/**
* What a request's status is, given the state of its recipient jobs.
*
* <p>A policy in application-core, because that is what it is: "one recipient completed and one
* failed means partially completed" is a product decision, not a storage detail. The persistence
* adapter used to import {@code NotificationSubmissionService} and call this decision itself, which
* put a business rule table behind a JPA store and meant changing the rule required editing both
* layers in step.
*/
public final class NotificationRequestStatusPolicy {
private NotificationRequestStatusPolicy() {}
/** Status the request should hold given its recipient jobs. */
public static RequestStatus rollUp(List<RecipientDeliveryState> states) {
Objects.requireNonNull(states, "states");
if (states.isEmpty()) {
return RequestStatus.CREATED;
}
boolean allTerminal = states.stream().allMatch(NotificationRequestStatusPolicy::isTerminal);
boolean anyCompleted = states.contains(RecipientDeliveryState.COMPLETED);
if (!allTerminal) {
return RequestStatus.PROCESSING;
}
if (states.stream().allMatch(state -> state == RecipientDeliveryState.CANCELED)) {
return RequestStatus.CANCELED;
}
if (states.stream().allMatch(state -> state == RecipientDeliveryState.EXPIRED)) {
return RequestStatus.EXPIRED;
}
if (anyCompleted
&& states.stream().anyMatch(state -> state != RecipientDeliveryState.COMPLETED)) {
return RequestStatus.PARTIALLY_COMPLETED;
}
return anyCompleted ? RequestStatus.COMPLETED : RequestStatus.FAILED;
}
/** Whether a recipient job has reached a state it will not leave. */
public static boolean isTerminal(RecipientDeliveryState state) {
return switch (state) {
case COMPLETED, FAILED, EXPIRED, CANCELED, SUPPRESSED -> true;
case PENDING, READY_TO_DISPATCH, DISPATCHING, RETRY_WAITING, RECONCILIATION_REQUIRED -> false;
};
}
}
@@ -1,15 +1,35 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.NotificationId;
import dev.caskeleton.application.notification.platform.api.RequestStatus;
import dev.caskeleton.application.notification.platform.api.TenantId;
import java.util.List;
import java.util.Optional;
/** Tenant-scoped durable store for logical requests and recipient jobs. */
/**
* Tenant-scoped durable store for logical requests and recipient jobs.
*
* <p>Every operation takes the tenant. Two of them used to take only a {@code NotificationId}, and
* were safe purely because their callers happened to check the tenant first — a property no
* signature expressed and no new caller would know to preserve. A store that accepts another
* tenant's identifier and answers is a cross-tenant read waiting for the first caller who forgets.
*
* <p>The store also no longer decides what a status should be. {@link
* NotificationRequestStatusPolicy} owns that, the use case applies it, and this records the result.
*/
public interface NotificationRequestStorePort {
/** Insert a request with its recipient jobs in one unit of work. */
NotificationRequestRecord insert(
/**
* Insert a request with its recipient jobs in one unit of work, or report that someone else did.
*
* <p>Returns rather than throws when the idempotency key is already claimed. A unique-violation
* exception leaves a PostgreSQL transaction aborted, so the caller that catches it cannot then
* read the winner — which is exactly what the idempotency contract asks it to do.
*
* <p>Recipient jobs are written only when this caller won. Writing them on the losing path would
* attach a second set of jobs to the winner's request.
*/
NotificationRequestInsertOutcome insert(
NotificationRequestRecord request, List<RecipientDeliveryRecord> recipients);
/** Look up by the idempotency scope. */
@@ -18,9 +38,14 @@ public interface NotificationRequestStorePort {
/** Look up by identity, inside a tenant. */
Optional<NotificationRequestRecord> findById(TenantId tenantId, NotificationId id);
/** Recipient jobs of a request. */
List<RecipientDeliveryRecord> recipientsOf(NotificationId notificationId);
/** Recipient jobs of a request, inside a tenant. */
List<RecipientDeliveryRecord> recipientsOf(TenantId tenantId, NotificationId notificationId);
/** Recompute and store the request status from its recipient jobs. */
NotificationRequestRecord refreshStatus(NotificationId notificationId);
/**
* Store a status the caller has already decided.
*
* @throws IllegalStateException when the request does not exist in this tenant
*/
NotificationRequestRecord updateStatus(
TenantId tenantId, NotificationId notificationId, RequestStatus status);
}
@@ -7,7 +7,6 @@ import dev.caskeleton.application.notification.platform.api.NotificationOrchestr
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
import dev.caskeleton.application.notification.platform.api.NotificationSnapshot;
import dev.caskeleton.application.notification.platform.api.RequestStatus;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
@@ -43,9 +42,13 @@ public final class NotificationSubmissionService implements NotificationOrchestr
private final DeliveryAttemptStorePort attempts;
private final TemplateRegistry templates;
private final RequestFingerprint fingerprints;
private final CanonicalNotificationPlanEncoder planEncoder =
new CanonicalNotificationPlanEncoder();
private final CanonicalNotificationPlanWriter writer;
private final TransactionPort transactions;
private final TenantContextPort tenants;
private final dev.caskeleton.application.notification.platform.policy.DeduplicationService
deduplication;
private final NotificationMetricsPort metrics;
private final Clock clock;
@@ -58,6 +61,7 @@ public final class NotificationSubmissionService implements NotificationOrchestr
CanonicalNotificationPlanWriter writer,
TransactionPort transactions,
TenantContextPort tenants,
dev.caskeleton.application.notification.platform.policy.DeduplicationService deduplication,
NotificationMetricsPort metrics,
Clock clock) {
this.requests = Objects.requireNonNull(requests, "requests");
@@ -68,6 +72,7 @@ public final class NotificationSubmissionService implements NotificationOrchestr
this.writer = Objects.requireNonNull(writer, "writer");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.tenants = Objects.requireNonNull(tenants, "tenants");
this.deduplication = Objects.requireNonNull(deduplication, "deduplication");
this.metrics = Objects.requireNonNull(metrics, "metrics");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -83,7 +88,23 @@ public final class NotificationSubmissionService implements NotificationOrchestr
return accept(plan, Optional.of(scheduleAt));
}
private NotificationReceipt accept(NotificationPlan plan, Optional<Instant> scheduleAt) {
/**
* Everything an acceptance does before a transaction is needed.
*
* <p>Split out so the inbound use case can own the transaction boundary itself. The boundary used
* to be opened here, which meant a use case declaring {@code WRITE + WRITE_REPOSITORY} did not
* actually call {@code TransactionPort} — a capability claim the architecture gate correctly
* refused, because a declaration nothing performs is the defect this whole review is about.
*
* <p>Template pinning and encoding stay outside the transaction: a version that does not exist
* can never be rendered later, so the check belongs before the write, not inside it holding a
* connection.
*
* @param plan the submission intent
* @param scheduleAt when it may first be dispatched
* @return everything the committed part needs
*/
PreparedSubmission prepare(NotificationPlan plan, Optional<Instant> scheduleAt) {
Objects.requireNonNull(plan, "plan");
Instant now = clock.instant();
scheduleAt.ifPresent(
@@ -94,43 +115,137 @@ public final class NotificationSubmissionService implements NotificationOrchestr
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
}
});
// Pinning the template before the write is what makes the fingerprint meaningful: a version
// that
// does not exist can never be rendered later, so accepting it would only defer the failure.
templates.get(plan.template());
// One encode per submission. The fingerprint and the stored payload both come out of it, so
// there is no second rendering of the request that could disagree with the first.
var encoded = planEncoder.encode(plan);
return new PreparedSubmission(encoded, fingerprints.of(encoded), scheduleAt, now);
}
String fingerprint = fingerprints.of(plan);
NotificationReceipt receipt =
transactions.inWrite(
() -> {
Optional<NotificationRequestRecord> existing =
requests.findByIdempotency(plan.tenantId(), plan.idempotencyKey().value());
if (existing.isPresent()) {
return converge(existing.get(), fingerprint);
}
try {
NotificationRequestRecord request =
writer.request(plan, fingerprint, scheduleAt, now);
List<RecipientDeliveryRecord> jobs = writer.recipients(plan, request, now);
return requests.insert(request, jobs).receipt();
} catch (DuplicateIdempotencyKeyException concurrent) {
NotificationRequestRecord winner =
requests
.findByIdempotency(plan.tenantId(), plan.idempotencyKey().value())
.orElseThrow(() -> concurrent);
return converge(winner, fingerprint);
}
});
/**
* The part that must run inside the caller's write transaction.
*
* @param plan the submission intent
* @param prepared what {@link #prepare} produced
* @return the receipt
*/
NotificationReceipt commit(NotificationPlan plan, PreparedSubmission prepared) {
Optional<NotificationRequestRecord> existing =
requests.findByIdempotency(plan.tenantId(), plan.idempotencyKey().value());
if (existing.isPresent()) {
return converge(existing.get(), prepared.fingerprint());
}
NotificationRequestRecord request =
writer.request(
plan,
prepared.encoded(),
prepared.fingerprint(),
prepared.scheduleAt(),
prepared.now());
// The deduplication claim lives inside this transaction, next to the insert it guards.
Optional<NotificationReceipt> deduplicated = applyDeduplication(plan, request, prepared.now());
if (deduplicated.isPresent()) {
return deduplicated.get();
}
List<RecipientDeliveryRecord> jobs = writer.recipients(plan, request, prepared.now());
// The store reports the race instead of raising it. Catching a unique violation left the
// PostgreSQL transaction aborted, so the read that follows could not run.
NotificationRequestInsertOutcome outcome = requests.insert(request, jobs);
return outcome.inserted()
? outcome.stored().receipt()
: converge(outcome.stored(), prepared.fingerprint());
}
/**
* Count one accepted submission.
*
* @param plan the accepted plan
*/
void recordAccepted(NotificationPlan plan) {
metrics.increment(
NotificationMetricName.REQUESTED,
Map.of(
"notificationCategory", plan.category(),
"strategy", plan.deliveryStrategy().getClass().getSimpleName()));
}
/**
* What an acceptance computed before it needed a transaction.
*
* @param encoded the canonical encoding
* @param fingerprint its hash
* @param scheduleAt when it may first be dispatched
* @param now the clock reading the whole acceptance uses
*/
record PreparedSubmission(
dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan encoded,
String fingerprint,
Optional<Instant> scheduleAt,
Instant now) {}
private NotificationReceipt accept(NotificationPlan plan, Optional<Instant> scheduleAt) {
PreparedSubmission prepared = prepare(plan, scheduleAt);
NotificationReceipt receipt = transactions.inWrite(() -> commit(plan, prepared));
recordAccepted(plan);
return receipt;
}
/**
* Claims the deduplication window, when the plan asks for one.
*
* <p>Multi-recipient plans are refused rather than guessed at. The window is keyed on one
* recipient identity, and a plan with several recipients has no single answer to "which recipient
* is this duplicate of" — accepting it would deduplicate against an arbitrary one of them.
*
* @return the receipt to return, when deduplication decided the outcome
*/
private Optional<NotificationReceipt> applyDeduplication(
NotificationPlan plan, NotificationRequestRecord request, Instant now) {
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec> spec =
plan.deduplication();
if (spec.isEmpty()) {
return Optional.empty();
}
if (plan.recipients().size() != 1) {
throw new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
}
var identity =
new dev.caskeleton.application.notification.platform.policy.RecipientIdentity(
plan.tenantId(), plan.recipients().get(0).recipientRef(), plan.category());
var result = deduplication.evaluate(identity, spec.get(), request.id());
if (result.existingNotificationId().isEmpty()) {
return Optional.empty();
}
// Two different outcomes, and the caller has to be able to tell them apart: DROP means nothing
// will ever be sent for this submission, RETURN_EXISTING means the id in the receipt is an
// earlier notification that covers it.
return Optional.of(
switch (spec.get().action()) {
case DROP ->
new NotificationReceipt(
request.id(),
dev.caskeleton.application.notification.platform.api.RequestStatus.CANCELED,
now,
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
.DROPPED_AS_DUPLICATE);
case RETURN_EXISTING ->
requests
.findById(plan.tenantId(), result.existingNotificationId().get())
.map(NotificationRequestRecord::convergedReceipt)
.orElseGet(
() ->
new NotificationReceipt(
result.existingNotificationId().get(),
dev.caskeleton.application.notification.platform.api.RequestStatus
.CREATED,
now,
dev.caskeleton.application.notification.platform.api
.NotificationAcceptance.CONVERGED_ON_EXISTING));
});
}
private NotificationReceipt converge(NotificationRequestRecord existing, String fingerprint) {
if (!existing.requestFingerprint().equals(fingerprint)) {
throw new IdempotencyConflictException(
@@ -144,90 +259,118 @@ public final class NotificationSubmissionService implements NotificationOrchestr
public CancelResult cancel(NotificationId notificationId, CancelCommand command) {
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(command, "command");
return transactions.inWrite(() -> cancelInTransaction(notificationId, command));
}
/**
* The cancellation body, which the caller's write transaction wraps.
*
* @param notificationId which notification
* @param command the cancellation request
* @return what was actually stopped
*/
CancelResult cancelInTransaction(NotificationId notificationId, CancelCommand command) {
TenantId tenant = tenants.currentTenant();
{
{
if (requests.findById(tenant, notificationId).isEmpty()) {
throw new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
}
return transactions.inWrite(
() -> {
if (requests.findById(tenant, notificationId).isEmpty()) {
throw new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
int canceled = 0;
int terminal = 0;
boolean uncertain = false;
List<String> reasons = new ArrayList<>();
for (RecipientDeliveryRecord job : requests.recipientsOf(tenant, notificationId)) {
if (NotificationRequestStatusPolicy.isTerminal(job.state())) {
terminal++;
continue;
}
int canceled = 0;
int terminal = 0;
boolean uncertain = false;
List<String> reasons = new ArrayList<>();
for (RecipientDeliveryRecord job : requests.recipientsOf(notificationId)) {
if (isTerminal(job.state())) {
if (job.ambiguousAttemptExists()
|| job.submissionOutcome() == SubmissionOutcome.CONFIRMED_ACCEPTED) {
// The logical follow-up stops, but a provider that already accepted the submission
// will not un-send it. Saying otherwise here would be the lie the whole evidence
// model
// exists to avoid.
uncertain = true;
reasons.add("EXTERNAL_SIDE_EFFECT_UNCERTAIN");
if (!command.cancelAmbiguousFollowUps()) {
terminal++;
continue;
}
if (job.ambiguousAttemptExists()
|| job.submissionOutcome() == SubmissionOutcome.CONFIRMED_ACCEPTED) {
// The logical follow-up stops, but a provider that already accepted the submission
// will not un-send it. Saying otherwise here would be the lie the whole evidence
// model
// exists to avoid.
uncertain = true;
reasons.add("EXTERNAL_SIDE_EFFECT_UNCERTAIN");
if (!command.cancelAmbiguousFollowUps()) {
terminal++;
continue;
}
}
recipients.transition(job.id(), RecipientDeliveryState.CANCELED, Optional.empty());
canceled++;
}
requests.refreshStatus(notificationId);
reasons.add(command.reasonCode());
return new CancelResult(
notificationId, canceled, terminal, uncertain, List.copyOf(reasons));
});
recipients.transition(job.id(), RecipientDeliveryState.CANCELED, Optional.empty());
canceled++;
}
requests.updateStatus(
tenant,
notificationId,
NotificationRequestStatusPolicy.rollUp(
requests.recipientsOf(tenant, notificationId).stream()
.map(RecipientDeliveryRecord::state)
.toList()));
reasons.add(command.reasonCode());
return new CancelResult(
notificationId, canceled, terminal, uncertain, List.copyOf(reasons));
}
}
}
@Override
public NotificationSnapshot get(NotificationId notificationId) {
Objects.requireNonNull(notificationId, "notificationId");
return transactions.inRead(() -> readInTransaction(notificationId));
}
/**
* The read body, which the caller's read transaction wraps.
*
* @param notificationId which notification
* @return the snapshot
*/
NotificationSnapshot readInTransaction(NotificationId notificationId) {
TenantId tenant = tenants.currentTenant();
return transactions.inRead(
() -> {
NotificationRequestRecord request =
requests
.findById(tenant, notificationId)
.orElseThrow(
() ->
new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED,
FailureCategory.INVALID_PAYLOAD)));
List<NotificationSnapshot.RecipientSnapshot> jobs = new ArrayList<>();
for (RecipientDeliveryRecord job : requests.recipientsOf(notificationId)) {
List<NotificationSnapshot.AttemptSnapshot> attemptViews =
attempts.attemptsOf(job.id()).stream()
.map(NotificationSubmissionService::toAttemptSnapshot)
.toList();
jobs.add(
new NotificationSnapshot.RecipientSnapshot(
job.id(),
job.state(),
job.submissionOutcome(),
job.deliveryOutcome(),
job.evidenceLevel(),
job.ambiguousAttemptExists(),
job.duplicateRisk(),
job.attemptCount(),
attemptViews));
}
return new NotificationSnapshot(
request.id(),
request.tenantId(),
request.status(),
request.createdAt(),
request.updatedAt(),
jobs);
});
{
{
NotificationRequestRecord request =
requests
.findById(tenant, notificationId)
.orElseThrow(
() ->
new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED,
FailureCategory.INVALID_PAYLOAD)));
List<NotificationSnapshot.RecipientSnapshot> jobs = new ArrayList<>();
for (RecipientDeliveryRecord job : requests.recipientsOf(tenant, notificationId)) {
List<NotificationSnapshot.AttemptSnapshot> attemptViews =
attempts.attemptsOf(job.id()).stream()
.map(NotificationSubmissionService::toAttemptSnapshot)
.toList();
jobs.add(
new NotificationSnapshot.RecipientSnapshot(
job.id(),
job.state(),
job.submissionOutcome(),
job.deliveryOutcome(),
job.evidenceLevel(),
job.ambiguousAttemptExists(),
job.duplicateRisk(),
job.attemptCount(),
attemptViews));
}
return new NotificationSnapshot(
request.id(),
request.tenantId(),
request.status(),
request.createdAt(),
request.updatedAt(),
jobs);
}
}
}
private static NotificationSnapshot.AttemptSnapshot toAttemptSnapshot(
@@ -245,35 +388,4 @@ public final class NotificationSubmissionService implements NotificationOrchestr
attempt.startedAt(),
attempt.completedAt());
}
private static boolean isTerminal(RecipientDeliveryState state) {
return switch (state) {
case COMPLETED, FAILED, EXPIRED, CANCELED, SUPPRESSED -> true;
case PENDING, READY_TO_DISPATCH, DISPATCHING, RETRY_WAITING, RECONCILIATION_REQUIRED -> false;
};
}
/** Status the request should hold given its recipient jobs. */
public static RequestStatus rollUp(List<RecipientDeliveryState> states) {
Objects.requireNonNull(states, "states");
if (states.isEmpty()) {
return RequestStatus.CREATED;
}
boolean allTerminal = states.stream().allMatch(NotificationSubmissionService::isTerminal);
boolean anyCompleted = states.contains(RecipientDeliveryState.COMPLETED);
if (!allTerminal) {
return RequestStatus.PROCESSING;
}
if (states.stream().allMatch(state -> state == RecipientDeliveryState.CANCELED)) {
return RequestStatus.CANCELED;
}
if (states.stream().allMatch(state -> state == RecipientDeliveryState.EXPIRED)) {
return RequestStatus.EXPIRED;
}
if (anyCompleted
&& states.stream().anyMatch(state -> state != RecipientDeliveryState.COMPLETED)) {
return RequestStatus.PARTIALLY_COMPLETED;
}
return anyCompleted ? RequestStatus.COMPLETED : RequestStatus.FAILED;
}
}
@@ -0,0 +1,104 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Which routes a recipient is eligible for, and in what order.
*
* <p>This was in the outbound notification adapter. Deciding that a recipient's stated channel
* preference outranks the strategy's default order, and that a blocked channel stays in the plan as
* an ineligible entry rather than vanishing from it, are product decisions — they were being made
* beside the code that speaks SMTP.
*
* <p>The adapter keeps {@link ProviderProfileCatalogPort}: which profile serves which channel,
* which is configuration and genuinely its own.
*/
public final class PolicyRoutePlanner implements NotificationRoutePlannerPort {
private final ProviderProfileCatalogPort catalog;
public PolicyRoutePlanner(ProviderProfileCatalogPort catalog) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
}
@Override
public List<RouteCandidate> plan(
TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(recipient, "recipient");
Objects.requireNonNull(strategy, "strategy");
List<Channel> strategyOrder =
switch (strategy) {
case ExplicitChannel explicit -> List.of(explicit.channel());
case OrderedFallback fallback -> fallback.channels();
};
List<Channel> ordered = applyPreferredOrder(strategyOrder, recipient);
List<RouteCandidate> routes = new ArrayList<>(ordered.size());
int index = 0;
for (Channel channel : ordered) {
Optional<ProviderProfileId> profileId = catalog.profileFor(channel);
if (profileId.isEmpty()) {
continue;
}
Optional<dev.caskeleton.application.notification.platform.api.ContactPointSelector> selector =
recipient.contactPoints().stream()
.filter(candidate -> candidate.channel() == channel)
.findFirst();
if (selector.isEmpty()) {
continue;
}
// A blocked channel stays in the plan and is marked ineligible rather than being removed:
// "we did not try SMS because the recipient blocked it" and "we never had an SMS route" are
// different answers, and an operator reading a stalled delivery needs to tell them apart.
boolean blocked =
recipient
.channelOverride()
.map(override -> override.blockedChannels().contains(channel))
.orElse(false);
routes.add(
new RouteCandidate(
index++, channel, selector.get().contactPointId(), profileId.get(), !blocked, true));
}
return List.copyOf(routes);
}
/**
* Put the recipient's preferred channels first, keeping the strategy's order for the rest.
*
* <p>An intersection, not a replacement: a preference names channels the recipient would rather
* receive on, and a channel the strategy never offered is not made available by preferring it.
*
* @param strategyOrder the order the caller asked for
* @param recipient the recipient, whose override may reorder it
* @return the effective order
*/
private static List<Channel> applyPreferredOrder(
List<Channel> strategyOrder, RecipientSpec recipient) {
List<Channel> preferred =
recipient
.channelOverride()
.map(ChannelPreferenceOverride::preferredOrder)
.orElse(List.of());
if (preferred.isEmpty()) {
return strategyOrder;
}
List<Channel> ordered = new ArrayList<>(strategyOrder.size());
preferred.stream().filter(strategyOrder::contains).forEach(ordered::add);
strategyOrder.stream().filter(channel -> !ordered.contains(channel)).forEach(ordered::add);
return List.copyOf(ordered);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import java.util.Optional;
/**
* Which provider profile is configured for a channel.
*
* <p>The whole of what the adapter knows and the application does not. Everything else the route
* planner was doing — honouring the recipient's preferred channel order, applying blocked channels,
* walking the strategy's fallback — is product policy, and it was being decided in the outbound
* adapter where no application test could reach it.
*/
public interface ProviderProfileCatalogPort {
/**
* The profile configured to serve a channel.
*
* @param channel the channel
* @return the profile, or empty when the deployment configured none
*/
Optional<ProviderProfileId> profileFor(Channel channel);
}
@@ -29,6 +29,7 @@ public record RecipientDeliveryRecord(
boolean ambiguousAttemptExists,
boolean duplicateRisk,
Optional<Instant> nextDispatchAt,
Optional<Instant> expiresAt,
Optional<String> leaseOwner,
Optional<Instant> leaseUntil,
int attemptCount,
@@ -38,6 +39,7 @@ public record RecipientDeliveryRecord(
public RecipientDeliveryRecord {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(recipientRef, "recipientRef");
@@ -4,9 +4,25 @@ import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
import java.time.Instant;
import java.util.Objects;
/** A claimed recipient job. Exactly one holder at a time. */
/**
* A claimed recipient job. Exactly one holder at a time, and the fence is what makes that true.
*
* <p>Identity alone is not ownership. A renew that matched only on the job id let a worker whose
* lease had already expired — and whose job another worker had since claimed — write its own owner
* and expiry back over the new holder's. Both workers then believed they held the job, and both
* sent the notification.
*
* <p>The fence increases on every claim, so a late renew from a previous holder carries a fence
* that no longer exists and updates nothing. It travels with the lease rather than being re-read,
* because re-reading is the race.
*
* @param recipientDeliveryId the job this lease is for
* @param owner the holder, including the process incarnation that claimed it
* @param fence the claim generation; a lower fence has been superseded
* @param leaseUntil when this lease stops being valid
*/
public record RecipientLease(
RecipientDeliveryId recipientDeliveryId, String owner, Instant leaseUntil) {
RecipientDeliveryId recipientDeliveryId, String owner, long fence, Instant leaseUntil) {
public RecipientLease {
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
@@ -15,5 +31,9 @@ public record RecipientLease(
if (owner.isBlank()) {
throw new IllegalArgumentException("owner");
}
if (fence < 1) {
// A zero fence is the value an unclaimed row holds; a lease can never legitimately carry it.
throw new IllegalArgumentException("fence");
}
}
}
@@ -3,24 +3,55 @@ package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
/**
* Durable queue claim.
*
* <p>Implementations use {@code FOR UPDATE SKIP LOCKED} so two workers never claim the same job and
* a crashed worker's lease simply expires instead of needing a coordinator.
*
* <p>Every operation after the claim is fenced. Expiry alone does not make a holder aware that it
* has been replaced: a worker paused past its lease resumes believing it still holds the job, and
* the only thing that can tell it otherwise is a write that fails. So renew returns an {@link
* Optional} rather than a lease — an empty result means the lease is gone, and the caller must stop
* rather than carry on with a token the database no longer honours.
*/
public interface RecipientLeaseStorePort {
/** Claim up to {@code limit} due jobs for a worker. */
List<RecipientLease> claim(String workerId, int limit, Duration leaseDuration);
/** Extend a lease that is still being worked on. */
RecipientLease renew(RecipientLease lease, Duration leaseDuration);
/**
* Extend a lease that is still being worked on.
*
* @return the extended lease, or empty when this holder no longer owns the job
*/
Optional<RecipientLease> renew(RecipientLease lease, Duration leaseDuration);
/** Release a lease without changing the job state. */
void release(RecipientDeliveryId recipientDeliveryId, String workerId);
/**
* Release a lease without changing the job state.
*
* <p>Takes the whole lease, not just the id: releasing by id would let a superseded holder clear
* the lease its replacement is actively working under.
*/
void release(RecipientLease lease);
/** Whether this lease is still the current one, for checking before an external side effect. */
boolean stillHeld(RecipientLease lease);
/** Jobs left {@code DISPATCHING} by a worker that died, for reconciliation. */
List<RecipientDeliveryId> expiredDispatching(int limit, Duration olderThan);
/**
* Retire deliveries whose window closed before anyone claimed them.
*
* <p>The claim refuses them, which on its own leaves a queue that looks stuck. Retiring them to a
* terminal state is what turns "never dispatched" from an absence into a fact an operator can
* count.
*
* @param limit how many to retire in one pass
* @return how many were retired
*/
int expireOverdue(int limit);
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* A scheduled question: what happened to this attempt?
*
* <p>The table has existed since V3 with no entity, no store and no worker. Reconciliation ran only
* when a lease expired and recovery happened to walk past the attempt — so an ambiguous submission
* whose worker exited cleanly was never asked about again. It sat {@code RECONCILIATION_REQUIRED}
* indefinitely, which looks like a queue that stopped rather than a delivery whose outcome nobody
* knows.
*
* <p>{@code attemptId} is unique in the schema, so an attempt cannot accumulate a backlog of
* duplicate questions: re-scheduling one that already exists moves its due time rather than adding
* a second row.
*
* @param id the job identity
* @param attemptId the attempt whose outcome is unknown
* @param providerProfileId the profile that must answer, since capability is per profile
* @param nextCheckAt when to ask next
* @param attempts how many times this job has already asked
* @param lastResult the previous answer, for operators reading the table directly
* @param createdAt when the job was first scheduled
* @param updatedAt when it last changed
*/
public record ReconciliationJob(
UUID id,
DeliveryAttemptId attemptId,
ProviderProfileId providerProfileId,
Instant nextCheckAt,
int attempts,
Optional<String> lastResult,
Instant createdAt,
Instant updatedAt) {
/** Validates the job. */
public ReconciliationJob {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(attemptId, "attemptId");
Objects.requireNonNull(providerProfileId, "providerProfileId");
Objects.requireNonNull(nextCheckAt, "nextCheckAt");
Objects.requireNonNull(lastResult, "lastResult");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(updatedAt, "updatedAt");
if (attempts < 0) {
throw new IllegalArgumentException("attempts must not be negative");
}
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import java.time.Instant;
import java.util.List;
/**
* Where the outstanding "what happened to this attempt?" questions live.
*
* <p>Separate from the attempt row on purpose. An attempt records what one submission did; a job
* records that the platform still owes an answer about it, and the two have different lifetimes —
* the answer may arrive hours later, from a status query rather than from the submission path.
*/
public interface ReconciliationJobStorePort {
/**
* Schedules a question about an attempt, or moves an existing one.
*
* <p>Idempotent by attempt: the schema makes {@code attempt_id} unique, so an attempt cannot
* accumulate duplicate jobs however many times an ambiguous outcome is recorded for it.
*
* @param attemptId the attempt
* @param providerProfileId the profile that must answer
* @param dueAt when to ask
* @return the scheduled job
*/
ReconciliationJob schedule(
DeliveryAttemptId attemptId, ProviderProfileId providerProfileId, Instant dueAt);
/**
* Claims due jobs for one worker pass.
*
* @param limit how many to take
* @param now the current instant
* @return the due jobs, oldest first
*/
List<ReconciliationJob> claimDue(int limit, Instant now);
/**
* Records that a job was asked and answered inconclusively, and when to ask again.
*
* @param job the job
* @param result a bounded description of the answer
* @param nextCheckAt when to ask next
*/
void reschedule(ReconciliationJob job, String result, Instant nextCheckAt);
/**
* Removes a job whose attempt now has a final outcome.
*
* @param job the job
*/
void complete(ReconciliationJob job);
}
@@ -33,6 +33,7 @@ public final class ReconciliationService {
private final ProviderEventProjectionService projection;
private final NotificationAuditPort audit;
private final NotificationMetricsPort metrics;
private final SyntheticEventFingerprint fingerprints;
private final Clock clock;
public ReconciliationService(
@@ -43,6 +44,7 @@ public final class ReconciliationService {
ProviderEventProjectionService projection,
NotificationAuditPort audit,
NotificationMetricsPort metrics,
SyntheticEventFingerprint fingerprints,
Clock clock) {
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.recipients = Objects.requireNonNull(recipients, "recipients");
@@ -51,6 +53,7 @@ public final class ReconciliationService {
this.projection = Objects.requireNonNull(projection, "projection");
this.audit = Objects.requireNonNull(audit, "audit");
this.metrics = Objects.requireNonNull(metrics, "metrics");
this.fingerprints = Objects.requireNonNull(fingerprints, "fingerprints");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -86,7 +89,8 @@ public final class ReconciliationService {
clock.instant(),
new byte[0],
emptyDigest(),
syntheticFingerprint(attempt, confirmed)));
fingerprints.of(
attempt.providerProfileId(), attempt.attemptId(), confirmed.event())));
appended.newEvents().forEach(projection::project);
audit.record(
new NotificationAuditEvent(
@@ -120,18 +124,4 @@ public final class ReconciliationService {
private static String emptyDigest() {
return "0".repeat(64);
}
private static String syntheticFingerprint(
DeliveryAttemptSnapshot attempt, ReconciliationResult.Confirmed confirmed) {
String seed =
attempt.attemptId().value()
+ "|"
+ confirmed.event().type().name()
+ "|"
+ confirmed.event().providerNativeType();
return String.format(
"%064x",
new java.math.BigInteger(1, seed.getBytes(java.nio.charset.StandardCharsets.UTF_8)))
.substring(0, 64);
}
}
@@ -1,20 +1,16 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
import java.nio.charset.StandardCharsets;
import dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* Canonical SHA-256 fingerprint of a submission.
* The idempotency fingerprint of a submission.
*
* <p>Everything that changes what the user receives is inside the fingerprint. That is what lets
* the same idempotency key with a different amount be rejected as a conflict instead of silently
* returning the receipt of the earlier, different notification.
* <p>SHA-256 over the canonical encoding, and nothing else. Every question about what the
* fingerprint covers — the whole fallback order, the channel override, the deduplication action,
* the collapse spec, the schedule, the metadata, the typed variables — is a question about {@link
* CanonicalNotificationPlanEncoder}, which is the single place a plan becomes bytes.
*/
public final class RequestFingerprint {
@@ -24,67 +20,19 @@ public final class RequestFingerprint {
this.digest = Objects.requireNonNull(digest, "digest");
}
/** Fingerprint of one plan. */
public String of(NotificationPlan plan) {
Objects.requireNonNull(plan, "plan");
return HexFormat.of()
.formatHex(digest.sha256(canonicalForm(plan).getBytes(StandardCharsets.UTF_8)));
}
private static String canonicalForm(NotificationPlan plan) {
StringBuilder canonical = new StringBuilder(512);
canonical
.append("tenant=")
.append(plan.tenantId().value())
.append("\ncategory=")
.append(plan.category())
.append("\ntemplate=")
.append(plan.template().templateId())
.append('@')
.append(plan.template().version())
.append('/')
.append(plan.template().locale().toLanguageTag())
.append("\nstrategy=")
.append(plan.deliveryStrategy().getClass().getSimpleName())
.append(':')
.append(plan.deliveryStrategy().primaryChannel())
.append("\nnotBefore=")
.append(plan.notBefore().map(Object::toString).orElse("-"))
.append("\nexpiresAt=")
.append(plan.expiresAt().map(Object::toString).orElse("-"))
.append("\ndedup=")
.append(plan.deduplication().map(spec -> spec.dedupKey() + "/" + spec.window()).orElse("-"))
.append("\ncollapse=")
.append(plan.collapse().map(spec -> spec.key() + "/" + spec.scope()).orElse("-"))
.append("\nvariables=")
.append(canonicalMap(plan.variables()))
.append("\nmetadata=")
.append(canonicalMap(plan.boundedMetadata()))
.append("\nrecipients=")
.append(
plan.recipients().stream()
.map(RequestFingerprint::canonicalRecipient)
.collect(Collectors.joining(";")));
return canonical.toString();
}
private static String canonicalRecipient(RecipientSpec recipient) {
return recipient.recipientRef()
+ "|"
+ recipient.locale().map(java.util.Locale::toLanguageTag).orElse("-")
+ "|"
+ recipient.timeZone().map(Object::toString).orElse("-")
+ "|"
+ recipient.contactPoints().stream()
.map(selector -> selector.channel() + ":" + selector.contactPointId().value())
.sorted()
.collect(Collectors.joining(","));
}
private static String canonicalMap(Map<String, ?> values) {
return new TreeMap<>(values)
.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.joining(","));
/**
* Fingerprint of one encoded plan.
*
* <p>This class used to build its own canonical string while the stored variables payload was
* produced separately by a JSON codec — two encodings of one request, in two places, with nothing
* making them agree. It now hashes exactly the bytes {@link CanonicalNotificationPlanEncoder}
* produced, and the persisted payload comes from that same pass.
*
* @param encoded the plan, encoded once
* @return 64 lowercase hex characters
*/
public String of(EncodedNotificationPlan encoded) {
Objects.requireNonNull(encoded, "encoded");
return HexFormat.of().formatHex(digest.sha256(encoded.bytes()));
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
import dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand;
import dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* Accepting a plan for later dispatch.
*
* <p>Its own class, so its capability is its own. A single class implementing submit, schedule,
* cancel and get cannot declare one honest transaction mode — the compiler says so too, because
* {@code CommandUseCase} cannot be inherited twice with different type arguments. The mandatory
* fitness gate reads these annotations; an orchestration that never implements the marker is simply
* not checked, which is how this entrypoint bypassed it.
*/
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
@RequiresPermission("notification:submit")
public final class ScheduleNotificationApplicationUseCase implements ScheduleNotificationUseCase {
private final NotificationSubmissionService service;
private final TransactionPort transactions;
public ScheduleNotificationApplicationUseCase(
NotificationSubmissionService service, TransactionPort transactions) {
this.service = Objects.requireNonNull(service, "service");
// Injected, and called directly below. A use case that declares it owns a repository-backed
// transaction and then delegates the boundary to a collaborator is making a claim the
// architecture gate cannot verify and a reader cannot trust.
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public NotificationReceipt handle(ScheduleNotificationCommand command) {
Objects.requireNonNull(command, "command");
NotificationSubmissionService.PreparedSubmission prepared =
service.prepare(command.plan(), java.util.Optional.of(command.scheduleAt()));
NotificationReceipt receipt =
transactions.inWrite(() -> service.commit(command.plan(), prepared));
service.recordAccepted(command.plan());
return receipt;
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
import dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand;
import dev.caskeleton.application.notification.platform.port.in.SubmitNotificationUseCase;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* Accepting a plan for immediate dispatch.
*
* <p>Its own class, so its capability is its own. A single class implementing submit, schedule,
* cancel and get cannot declare one honest transaction mode — the compiler says so too, because
* {@code CommandUseCase} cannot be inherited twice with different type arguments. The mandatory
* fitness gate reads these annotations; an orchestration that never implements the marker is simply
* not checked, which is how this entrypoint bypassed it.
*/
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
@RequiresPermission("notification:submit")
public final class SubmitNotificationApplicationUseCase implements SubmitNotificationUseCase {
private final NotificationSubmissionService service;
private final TransactionPort transactions;
public SubmitNotificationApplicationUseCase(
NotificationSubmissionService service, TransactionPort transactions) {
this.service = Objects.requireNonNull(service, "service");
// Injected, and called directly below. A use case that declares it owns a repository-backed
// transaction and then delegates the boundary to a collaborator is making a claim the
// architecture gate cannot verify and a reader cannot trust.
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public NotificationReceipt handle(SubmitNotificationCommand command) {
Objects.requireNonNull(command, "command");
NotificationSubmissionService.PreparedSubmission prepared =
service.prepare(command.plan(), java.util.Optional.empty());
NotificationReceipt receipt =
transactions.inWrite(() -> service.commit(command.plan(), prepared));
service.recordAccepted(command.plan());
return receipt;
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.application.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.Objects;
/**
* The identity of an event the platform synthesised by asking a provider what happened.
*
* <p>It was not a hash. The seed string — attempt id, event type, native type joined by pipes — was
* reinterpreted as a {@link java.math.BigInteger}, rendered as hex, and then truncated to its first
* 64 characters. Hex renders each byte as two characters in order, so those 64 characters are the
* first 32 <em>bytes of the seed</em>: the attempt's 36-character UUID and nothing else. The event
* type and the native type were appended after the point where the value was cut off, and could not
* affect the result.
*
* <p>The ledger enforces a unique fingerprint. So a reconciliation that learned an attempt had been
* accepted, and a later one that learned the same attempt had been delivered, produced the same
* fingerprint — and the second was silently discarded as a duplicate. The correction the platform
* went and fetched was thrown away on arrival, and the delivery stayed at the earlier status
* forever.
*
* <p>Every field is length-framed, so no value can be arranged to look like the boundary between
* two others.
*/
public final class SyntheticEventFingerprint {
/**
* Encoding version.
*
* <p>Inside the hashed bytes, so a future change to the field set produces different fingerprints
* by construction rather than by anyone remembering to migrate.
*/
private static final String VERSION = "1";
private final MessageDigestPort digest;
public SyntheticEventFingerprint(MessageDigestPort digest) {
this.digest = Objects.requireNonNull(digest, "digest");
}
/**
* Fingerprint of one synthesised reconciliation event.
*
* @param providerProfileId the profile that answered
* @param attemptId the attempt the answer is about
* @param event the normalized answer
* @return 64 lowercase hex characters
*/
public String of(
ProviderProfileId providerProfileId,
DeliveryAttemptId attemptId,
NormalizedProviderEvent event) {
Objects.requireNonNull(providerProfileId, "providerProfileId");
Objects.requireNonNull(attemptId, "attemptId");
Objects.requireNonNull(event, "event");
return HexFormat.of()
.formatHex(
digest.sha256(
canonicalForm(providerProfileId, attemptId, event)
.getBytes(StandardCharsets.UTF_8)));
}
/**
* The exact bytes the fingerprint is taken over.
*
* @param providerProfileId the profile that answered
* @param attemptId the attempt
* @param event the normalized answer
* @return the canonical, length-framed form
*/
private static String canonicalForm(
ProviderProfileId providerProfileId,
DeliveryAttemptId attemptId,
NormalizedProviderEvent event) {
StringBuilder canonical = new StringBuilder(256);
field(canonical, "v", VERSION);
field(canonical, "profile", providerProfileId.value());
field(canonical, "attempt", attemptId.value().toString());
field(canonical, "type", event.type().name());
field(canonical, "nativeType", event.providerNativeType());
// The provider's own timestamp, as epoch milliseconds rather than a rendered instant: the same
// moment written as +00:00 and as Z is the same moment, and must not be two identities.
field(
canonical,
"occurredAt",
event
.providerOccurredAt()
.map(instant -> Long.toString(instant.toEpochMilli()))
.orElse("-"));
// The provider's stable identity for this event, when it has one. Two reconciliations that read
// the same provider record are the same event; two that read different records are not, even
// when every other field matches.
field(canonical, "nativeId", event.providerEventId().orElse("-"));
field(canonical, "requestId", event.providerRequestId().orElse("-"));
return canonical.toString();
}
/**
* Append one length-framed field.
*
* @param canonical the buffer
* @param name the field name
* @param value the field value
*/
private static void field(StringBuilder canonical, String name, String value) {
canonical
.append(name)
.append(':')
.append(value.length())
.append(':')
.append(value)
.append('\n');
}
}
@@ -1,13 +1,29 @@
package dev.caskeleton.application.notification.platform.inbox;
/**
* Delivers the post-commit signal.
* Delivers the post-commit signal, best effort.
*
* <p>Publishing failures are retried by the relay and never roll back the inbox write: a WebSocket
* outage should cost a live badge update, not the notification itself.
* <p><strong>Not retried.</strong> The port used to say failures "are retried by the relay", and
* nothing retried them: the only implementation publishes from an after-commit callback and
* swallows the exception, so a single failed publish loses that signal permanently. There is no
* durable record of the attempt for anything to replay.
*
* <p>The inbox row remains the source of truth, so what a lost signal actually costs is a client
* that does not learn about the item until it next polls or reconnects. That is an acceptable
* contract; claiming retry that does not exist is not, because an operator who believes signals are
* durable will not build the reconciliation that makes them so.
*
* <p>Making this durable means writing the signal into an outbox inside the same application-owned
* transaction as the inbox mutation and relaying from there. An after-commit callback cannot
* provide durability — by the time it runs, the transaction that could have recorded the intent is
* over.
*/
public interface NotificationInboxSignalPort {
/** Publish after the inbox transaction has committed. */
/**
* Publish after the inbox transaction has committed.
*
* <p>A failure here is not propagated to the caller and is not replayed.
*/
void publish(InboxItemCreated event);
}
@@ -1,12 +1,29 @@
package dev.caskeleton.application.notification.platform.observation;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
/** Rejects metric tags outside the bounded vocabulary before they reach the metric backend. */
/**
* Bounds metric tags before they reach the metric backend — keys and values both.
*
* <p>The guard checked keys only. A key is a fixed list written in this file, so it was never the
* unbounded side: {@code notificationCategory} is caller-supplied text, {@code templateId} grows
* with the template catalogue, and a callback path is whatever a provider posted to. Each distinct
* value creates a new time series in the metric backend, so a caller passing a request id as a
* category converts one metric into a per-request one — which is how a metrics pipeline is brought
* down by a system it is only observing.
*
* <p>Unknown values are folded to {@code other} rather than refused. Refusing would turn a
* cardinality problem into a delivery failure, and the count is still correct — it just stops being
* broken down by a dimension that was never going to be usable at that width.
*/
public final class CardinalityGuard {
/** Tag keys the platform emits. */
private static final Set<String> ALLOWED_TAGS =
Set.of(
"channel",
@@ -26,16 +43,103 @@ public final class CardinalityGuard {
"attemptBucket",
"sizeBucket");
/**
* The value every unrecognised value becomes.
*
* <p>One extra series per tag, whatever arrives.
*/
public static final String OTHER = "other";
/**
* The shape a bounded value has: an enum constant, a bucket name or number, or a short alias.
*
* <p>Bounded in length as well as in alphabet, because a 200-character SCREAMING_SNAKE string is
* still a distinct series. This is only the cheap first filter — the per-tag ceiling below is
* what actually caps the series count, because a short value can still be caller-controlled.
*/
private static final Pattern BOUNDED_VALUE = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.+-]{0,31}");
/**
* How many distinct values one tag may carry before the rest fold to {@link #OTHER}.
*
* <p>A ceiling rather than an allowlist, because the platform cannot know the deployment's
* template ids or provider profile names in advance. The first values seen are the ones that get
* their own series; a burst of new ones does not multiply the series count.
*/
private static final int MAX_VALUES_PER_TAG = 64;
private final Map<String, Set<String>> observed = new java.util.concurrent.ConcurrentHashMap<>();
/** Allowed tag keys. */
public Set<String> allowedTags() {
return ALLOWED_TAGS;
}
/** Validate a tag map. */
/**
* Validate a tag map's keys.
*
* @param tags the tags
* @throws IllegalMetricTagException when a key is outside the vocabulary
*/
public void validate(Map<String, String> tags) {
Objects.requireNonNull(tags, "tags");
if (!ALLOWED_TAGS.containsAll(tags.keySet())) {
throw new IllegalMetricTagException(tags.keySet());
}
}
/**
* Validate the keys and bound the values.
*
* @param tags the tags as the caller supplied them
* @return the tags with every unbounded value folded to {@link #OTHER}
* @throws IllegalMetricTagException when a key is outside the vocabulary
*/
public Map<String, String> bound(Map<String, String> tags) {
validate(tags);
Map<String, String> bounded = new LinkedHashMap<>();
for (Map.Entry<String, String> tag : tags.entrySet()) {
bounded.put(tag.getKey(), boundValue(tag.getKey(), tag.getValue()));
}
return Map.copyOf(bounded);
}
/**
* The value this tag will actually carry.
*
* @param key the tag key
* @param value the caller's value
* @return the value, or {@link #OTHER}
*/
private String boundValue(String key, String value) {
if (value == null || !BOUNDED_VALUE.matcher(value).matches()) {
return OTHER;
}
if (SensitiveValueDetector.isSensitive(value)) {
// A tag is a label on a series that is kept for as long as the metric is; a recipient's
// address must not become one.
return OTHER;
}
Set<String> values =
observed.computeIfAbsent(key, unused -> java.util.concurrent.ConcurrentHashMap.newKeySet());
if (values.contains(value)) {
return value;
}
if (values.size() >= MAX_VALUES_PER_TAG) {
return OTHER;
}
values.add(value);
return value;
}
/**
* How many distinct values a tag has been allowed so far.
*
* @param key the tag key
* @return the count, which never exceeds the per-tag ceiling
*/
public int observedValueCount(String key) {
Set<String> values = observed.get(Objects.requireNonNull(key, "key").toLowerCase(Locale.ROOT));
return values == null ? observed.getOrDefault(key, Set.of()).size() : values.size();
}
}
@@ -5,7 +5,15 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** An audited administrative or lifecycle change. Contact point values never appear here. */
/**
* An audited administrative or lifecycle change.
*
* <p>Contact point values never appear here, and now the record is what makes that true rather than
* this sentence. Every string it carries goes through {@link SensitiveValueDetector}: an address, a
* phone number, a token or a one-time code is refused at construction. The audit trail is
* append-only and read by more people than any other store, so a value that reaches it is a value
* that cannot be taken back.
*/
public record NotificationAuditEvent(
String action,
String actorRef,
@@ -24,5 +32,12 @@ public record NotificationAuditEvent(
if (action.isBlank() || actorRef.isBlank()) {
throw new IllegalArgumentException("action and actorRef must not be blank");
}
SensitiveValueDetector.requireNotSensitive("actorRef", actorRef);
reasonCode.ifPresent(value -> SensitiveValueDetector.requireNotSensitive("reasonCode", value));
operationId.ifPresent(
value -> SensitiveValueDetector.requireNotSensitive("operationId", value));
for (Map.Entry<String, String> attribute : boundedAttributes.entrySet()) {
SensitiveValueDetector.requireNotSensitive(attribute.getKey(), attribute.getValue());
}
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.application.notification.platform.observation;
import java.time.Duration;
import java.util.Objects;
/**
* What the platform is actually doing right now, measured rather than assumed.
*
* <p>The health snapshot reported an empty queue map and the scheduler gauged the size of the batch
* it had just claimed. Both are always small and always available, so a platform whose backlog was
* growing without bound, whose leases were stuck on a dead worker, or whose projections had stopped
* being applied reported exactly the same numbers as an idle healthy one. Readiness that cannot
* distinguish those two states is not readiness.
*
* @param backlogDepth deliveries due now and not yet claimed
* @param oldestDueAge how long the oldest of them has been due
* @param stuckLeases leases whose holder has not finished and whose expiry has passed
* @param pendingProjections provider events stored and not yet applied
* @param failedProjections provider events whose projection failed
* @param unmatchedProjections provider events that match no known attempt
* @param oldestPendingProjectionAge how long the oldest unapplied event has waited
* @param reconciliationDue outstanding questions to providers
* @param oldestReconciliationAge how long the oldest of them has waited
*/
public record NotificationServingState(
long backlogDepth,
Duration oldestDueAge,
long stuckLeases,
long pendingProjections,
long failedProjections,
long unmatchedProjections,
Duration oldestPendingProjectionAge,
long reconciliationDue,
Duration oldestReconciliationAge) {
/** An idle platform: nothing waiting, nothing stuck. */
public static final NotificationServingState IDLE =
new NotificationServingState(0, Duration.ZERO, 0, 0, 0, 0, Duration.ZERO, 0, Duration.ZERO);
public NotificationServingState {
Objects.requireNonNull(oldestDueAge, "oldestDueAge");
Objects.requireNonNull(oldestPendingProjectionAge, "oldestPendingProjectionAge");
Objects.requireNonNull(oldestReconciliationAge, "oldestReconciliationAge");
if (backlogDepth < 0
|| stuckLeases < 0
|| pendingProjections < 0
|| failedProjections < 0
|| unmatchedProjections < 0
|| reconciliationDue < 0) {
throw new IllegalArgumentException("counts must not be negative");
}
if (oldestDueAge.isNegative()
|| oldestPendingProjectionAge.isNegative()
|| oldestReconciliationAge.isNegative()) {
throw new IllegalArgumentException("ages must not be negative");
}
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.notification.platform.observation;
/** Reads the platform's current serving state from wherever the durable rows live. */
public interface NotificationServingStatePort {
/**
* Measure the backlog, the stuck leases and the projection and reconciliation lag.
*
* @return the current state
*/
NotificationServingState currentState();
}
@@ -0,0 +1,86 @@
package dev.caskeleton.application.notification.platform.observation;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Recognises values that must never be written to a log, a metric tag, or an audit row.
*
* <p>{@code NotificationAuditEvent} carried the sentence "Contact point values never appear here"
* and nothing that made it true. The whole platform encrypts contact points at rest and hashes them
* for lookup; an audit trail that records the plaintext beside the operation undoes that, in the
* one store that is deliberately append-only and widely readable.
*
* <p>The check is a refusal rather than a redaction. Silently rewriting the value would leave the
* caller believing it recorded something it did not, and the caller passing a recipient's address
* into an audit attribute is the defect — masking it hides the defect rather than fixing it.
*/
public final class SensitiveValueDetector {
/** An address; the local part is deliberately loose because the point is the shape. */
private static final Pattern EMAIL = Pattern.compile("[^\\s@]+@[^\\s@]+\\.[^\\s@]+");
/** E.164 and the common national forms — 7 or more digits with optional separators. */
private static final Pattern PHONE = Pattern.compile("\\+?\\d[\\d\\-. ()]{5,}\\d");
/** A run of digits long enough to be a one-time code, an account number, or a card. */
private static final Pattern DIGIT_RUN = Pattern.compile("\\d{6,}");
/** A compact JWT. */
private static final Pattern JWT =
Pattern.compile("eyJ[A-Za-z0-9_-]{4,}\\.[A-Za-z0-9_-]{4,}\\.[A-Za-z0-9_-]*");
/** PEM-encoded key material. */
private static final Pattern PEM = Pattern.compile("-----BEGIN [A-Z ]*(KEY|CERTIFICATE)-----");
/** A long unbroken base64 or hex run, which is what raw key material looks like. */
private static final Pattern KEY_MATERIAL = Pattern.compile("[A-Za-z0-9+/=]{40,}");
/** Words that introduce a secret even when the value itself looks innocuous. */
private static final Pattern SECRET_PREFIX =
Pattern.compile(
"(?i)\\b(bearer|basic|password|passwd|secret|api[_-]?key|otp|token)\\b\\s*[:=]?\\s*\\S");
private SensitiveValueDetector() {}
/**
* Whether a value looks like something that must not be recorded.
*
* @param value the candidate, which may be null
* @return true when the value matches a contact, credential, or one-time-code shape
*/
public static boolean isSensitive(String value) {
if (value == null || value.isBlank()) {
return false;
}
String candidate = value.trim();
return EMAIL.matcher(candidate).find()
|| PHONE.matcher(candidate).find()
|| DIGIT_RUN.matcher(candidate).find()
|| JWT.matcher(candidate).find()
|| PEM.matcher(candidate).find()
|| KEY_MATERIAL.matcher(candidate).find()
|| SECRET_PREFIX.matcher(candidate).find();
}
/**
* Refuse a value that must not be recorded.
*
* @param field which field is being checked, for the message
* @param value the candidate
* @return the value, when it is safe to record
* @throws IllegalArgumentException when the value looks like a contact point, credential, or code
*/
public static String requireNotSensitive(String field, String value) {
if (isSensitive(value)) {
// The offending value is deliberately absent from the message: an exception message is itself
// logged, so naming the value here would leak it through the check meant to prevent the leak.
throw new IllegalArgumentException(
"audit field '"
+ field.toLowerCase(Locale.ROOT)
+ "' looks like a contact point, credential, or one-time code; record a reference "
+ "(an id, a hash, or a key id) instead of the value");
}
return value;
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.notification.platform.api.CancelCommand;
import dev.caskeleton.application.notification.platform.api.NotificationId;
import java.util.Objects;
/**
* Stop future logical attempts for a notification.
*
* @param notificationId which notification
* @param command the cancellation request
*/
public record CancelNotificationCommand(NotificationId notificationId, CancelCommand command)
implements Command {
public CancelNotificationCommand {
Objects.requireNonNull(notificationId, "notificationId");
Objects.requireNonNull(command, "command");
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.notification.platform.api.CancelResult;
import dev.caskeleton.application.usecase.CommandUseCase;
/**
* Cancelling a notification.
*
* <p>A write, and one that cannot promise what a caller might assume: an attempt already committed
* to a provider cannot be recalled, so the result reports what was actually stopped.
*/
public interface CancelNotificationUseCase
extends CommandUseCase<CancelNotificationCommand, CancelResult> {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.notification.platform.api.NotificationId;
import dev.caskeleton.application.query.Query;
import java.util.Objects;
/**
* Read the current projection of a notification.
*
* @param notificationId which notification
*/
public record GetNotificationQuery(NotificationId notificationId) implements Query {
public GetNotificationQuery {
Objects.requireNonNull(notificationId, "notificationId");
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.notification.platform.api.NotificationSnapshot;
import dev.caskeleton.application.usecase.QueryUseCase;
/**
* Reading a notification's current state.
*
* <p>A {@code QueryUseCase}, so the fitness gate holds it to {@code READ_ONLY} and {@code
* READ_REPOSITORY}. Sharing an interface with three writes made that impossible to state.
*/
public interface GetNotificationUseCase
extends QueryUseCase<GetNotificationQuery, NotificationSnapshot> {}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
import java.util.Objects;
/**
* One provider callback, as a command.
*
* @param request the verified-on-arrival callback request
*/
public record IngestProviderCallbackCommand(CallbackRequest request) implements Command {
/** Validates the command. */
public IngestProviderCallbackCommand {
Objects.requireNonNull(request, "request");
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.notification.platform.api.CallbackIngestionResult;
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
import dev.caskeleton.application.usecase.CommandUseCase;
/**
* The inbound port a callback endpoint depends on.
*
* <p>The MVC controller injected {@code IngestProviderCallbackApplicationUseCase} — a concrete
* application class — so the transport was coupled to an implementation and the platform's
* mandatory use-case fitness gate never saw the entrypoint. That gate is what declares transaction
* mode, repository access, idempotency and whether external outbound calls are permitted; an
* orchestration that does not implement the marker simply is not checked, and nothing noticed
* because the existing ArchUnit rules only inspect types that already implement it.
*
* <p>One operation, not four. {@code NotificationOrchestrator} mixes submit, schedule, cancel and
* get on one interface, which makes a class-level capability declaration impossible to state
* honestly: a read and a write cannot share one transaction mode.
*/
public interface IngestProviderCallbackUseCase
extends CommandUseCase<IngestProviderCallbackCommand, CallbackIngestionResult> {
/**
* Ingests one verified provider callback.
*
* @param command the callback to ingest
* @return how many events were appended and how many were duplicates
*/
@Override
CallbackIngestionResult handle(IngestProviderCallbackCommand command);
/**
* Convenience for callers holding a raw request.
*
* @param request the callback
* @return the ingestion result
*/
default CallbackIngestionResult ingest(CallbackRequest request) {
return handle(new IngestProviderCallbackCommand(request));
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import java.time.Instant;
import java.util.Objects;
/**
* Accept a plan that must not be activated before a given moment.
*
* @param plan the submission intent
* @param scheduleAt the earliest dispatch time
*/
public record ScheduleNotificationCommand(NotificationPlan plan, Instant scheduleAt)
implements Command {
public ScheduleNotificationCommand {
Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(scheduleAt, "scheduleAt");
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
import dev.caskeleton.application.usecase.CommandUseCase;
/** Accepting a notification for later dispatch. */
public interface ScheduleNotificationUseCase
extends CommandUseCase<ScheduleNotificationCommand, NotificationReceipt> {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import java.util.Objects;
/**
* Accept a plan for immediate dispatch.
*
* @param plan the submission intent
*/
public record SubmitNotificationCommand(NotificationPlan plan) implements Command {
public SubmitNotificationCommand {
Objects.requireNonNull(plan, "plan");
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.notification.platform.port.in;
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
import dev.caskeleton.application.usecase.CommandUseCase;
/**
* Accepting a notification for immediate dispatch.
*
* <p>One operation per port. {@code NotificationOrchestrator} carries submit, schedule, cancel and
* get on one interface, and a class implementing all four cannot declare a class-level capability
* that is true: {@code get} is a read and {@code submit} is a write, so any single transaction mode
* misstates one of them. Splitting them is what lets the mandatory fitness gate check each.
*/
public interface SubmitNotificationUseCase
extends CommandUseCase<SubmitNotificationCommand, NotificationReceipt> {}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.notification.platform.provider;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import java.util.Objects;
/**
* Thrown when a provider call failed before a single byte reached the wire.
*
* <p>The dispatch path converted <em>every</em> {@code RuntimeException} from the gateway into
* {@code responseLost()} — body committed, outcome ambiguous. Most of the exceptions that path can
* throw happen before any network call: a disabled runtime, an exhausted rate limiter or
* concurrency permit, a contact point that could not be revealed, a payload that failed mapping or
* exceeded the provider's size limit, a profile with no credential.
*
* <p>Recording those as "may already have been sent" is expensive in one direction only. An
* ambiguous attempt is deliberately never retried automatically and never falls back to another
* channel, because doing so risks a duplicate. So a rate-limiter rejection — a condition that will
* clear in a second — permanently blocked the delivery it rejected and sent it into a
* reconciliation that can only ever return "the provider has no record of this", forever.
*
* <p>An adapter throws this when it knows the transport had not started. Anything it does not know
* stays ambiguous: this type exists to narrow the ambiguous case honestly, not to shrink it by
* assumption.
*/
public final class ProviderCallNotStartedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String code;
private final FailureCategory category;
private final boolean retryable;
/**
* Creates a pre-write failure.
*
* @param code the platform failure code
* @param category the failure category
* @param retryable whether the caller may attempt this delivery again
* @param sanitizedMessage a message carrying no recipient or payload content
*/
public ProviderCallNotStartedException(
String code, FailureCategory category, boolean retryable, String sanitizedMessage) {
super(sanitizedMessage);
this.code = Objects.requireNonNull(code, "code");
if (code.isBlank()) {
throw new IllegalArgumentException("code must not be blank");
}
this.category = Objects.requireNonNull(category, "category");
this.retryable = retryable;
}
/**
* The platform failure code.
*
* @return the code
*/
public String code() {
return code;
}
/**
* The failure category.
*
* @return the category
*/
public FailureCategory category() {
return category;
}
/**
* Whether the delivery may be attempted again.
*
* @return true when a retry is safe
*/
public boolean retryable() {
return retryable;
}
/**
* The failure as the platform records it.
*
* @return the provider failure
*/
public ProviderFailure toFailure() {
return ProviderFailure.of(code, category, retryable);
}
}
@@ -40,5 +40,59 @@ public record ProviderSubmission(
approvedNativeOptions =
Map.copyOf(Objects.requireNonNull(approvedNativeOptions, "approvedNativeOptions"));
Objects.requireNonNull(traceContext, "traceContext");
requireCompatible(channel, profile, content);
}
/**
* Refuses a submission whose channel, profile and rendered content disagree.
*
* <p>Nothing checked this, so a webhook submission could carry email content and a test could
* construct one that no real dispatch would ever produce — which is how an adapter suite comes to
* pass against a shape the platform cannot emit. The channel decides which content type is
* meaningful, and the profile is bound to one channel, so both agreements are structural rather
* than a matter of convention.
*/
private static void requireCompatible(
Channel channel,
ProviderProfileSnapshot profile,
dev.caskeleton.application.notification.platform.template.RenderedNotificationContent
content) {
if (profile.channel() != channel) {
throw new IllegalArgumentException(
"profile "
+ profile.profileId().value()
+ " serves "
+ profile.channel()
+ ", not "
+ channel);
}
var payload = content.content();
boolean matches =
switch (channel) {
case EMAIL ->
payload
instanceof
dev.caskeleton.application.notification.platform.api.content.EmailContent;
case SMS ->
payload
instanceof
dev.caskeleton.application.notification.platform.api.content.SmsContent;
case PUSH ->
payload
instanceof
dev.caskeleton.application.notification.platform.api.content.MobilePushContent;
case WEB_PUSH ->
payload
instanceof
dev.caskeleton.application.notification.platform.api.content.WebPushContent;
case IN_APP, WEBHOOK ->
payload
instanceof
dev.caskeleton.application.notification.platform.api.content.InAppContent;
};
if (!matches) {
throw new IllegalArgumentException(
"channel " + channel + " cannot carry " + payload.getClass().getSimpleName());
}
}
}
@@ -8,5 +8,22 @@ public enum SecretPurpose {
PROVIDER_CREDENTIAL,
VAPID_SIGNING,
UNSUBSCRIBE_TOKEN,
PAYLOAD_ENCRYPTION
PAYLOAD_ENCRYPTION,
/**
* Hashing a provider's request id for the callback lookup.
*
* <p>Its own purpose because it was sharing {@link #CONTACT_LOOKUP_HMAC}. Purpose separation is
* the property that keeps one compromised key from being useful elsewhere, and reusing the
* contact-lookup key here meant an attacker able to forge one lookup could forge the other.
*/
PROVIDER_REQUEST_LOOKUP_HMAC,
/**
* Fingerprinting a callback payload for deduplication.
*
* <p>Separated for the same reason: a fingerprint key and a contact-lookup key protect different
* things and should not fall together.
*/
CALLBACK_FINGERPRINT_HMAC
}
@@ -37,7 +37,10 @@ public record OutboxEvent(
Objects.requireNonNull(eventId, "eventId");
Objects.requireNonNull(eventType, "eventType");
Objects.requireNonNull(aggregateId, "aggregateId");
Objects.requireNonNull(payload, "payload");
// Not merely non-null: the envelope serialiser inserts this verbatim and unescaped, so an
// invalid or oversized payload becomes a permanently unparseable message that the relay
// retries forever. See OutboxPayloadPolicy.
OutboxPayloadPolicy.requireValidPayload(payload);
Objects.requireNonNull(occurredAt, "occurredAt");
Objects.requireNonNull(correlationId, "correlationId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
@@ -17,4 +17,23 @@ public interface OutboxMessagePublishPort {
* @throws RuntimeException if the publish fails for any reason
*/
void publish(OutboxEvent event);
/**
* Publishes and reports what was achieved.
*
* <p>The throwing method above cannot express an ambiguous publish: a broker that accepted the
* frame and never confirmed it is reported the same way as one that refused it outright, and the
* relay then treats a possibly-stored message as definitely-unstored. An adapter that knows the
* difference implements this; the default keeps the fail-closed contract for one that does not.
*
* @param event the claimed outbox event
* @return what the attempt achieved
*/
default OutboxPublishOutcome publishForOutcome(OutboxEvent event) {
publish(event);
// A method that either returns or throws can only report these two. Reporting CONFIRMED here
// is honest for such an adapter — it is what "returned without throwing" has always meant —
// and an adapter that can tell an ambiguous publish apart overrides this.
return OutboxPublishOutcome.CONFIRMED;
}
}
@@ -0,0 +1,271 @@
package dev.caskeleton.application.outbox;
import java.nio.charset.StandardCharsets;
/**
* What an outbox payload has to be before a row is written.
*
* <p>{@code OutboxEvent} checked that the payload was non-null and nothing else, and the envelope
* serialiser then inserted it into the JSON document verbatim, unescaped, because it is documented
* as "already valid JSON". Nothing enforced that. A payload that was not valid JSON produced a
* malformed envelope that the broker accepted and every consumer failed to parse — permanently,
* because the row is durable and the relay retries it forever. A payload containing {@code
* "},"eventType":"} rewrote the envelope's own fields.
*
* <p>Size was equally unbounded. The row goes into a database column, through a broker with a
* message limit, and into a JSON log line; a payload larger than any of those is a failure
* discovered after the transaction committed, which is the one point at which it can no longer be
* rejected.
*
* <p>This is a validator, not a parser: it walks the text and returns nothing. Building a tree
* would mean a JSON library on {@code application-core}'s classpath, which the module's contract
* forbids, and would allocate a second copy of every payload for a check that needs neither.
*/
public final class OutboxPayloadPolicy {
/**
* The payload ceiling in UTF-8 bytes.
*
* <p>256 KiB: comfortably under the one mebibyte default of every broker in the platform, with
* room for the envelope's own fields and for a log line that quotes it.
*/
public static final int MAX_PAYLOAD_BYTES = 262_144;
private OutboxPayloadPolicy() {}
/**
* Refuses a payload that is not a single valid JSON value, or is too large.
*
* @param payload the serialised payload
* @throws IllegalArgumentException naming what is wrong and where
*/
public static void requireValidPayload(String payload) {
if (payload == null) {
throw new IllegalArgumentException("outbox payload must not be null");
}
int bytes = payload.getBytes(StandardCharsets.UTF_8).length;
if (bytes > MAX_PAYLOAD_BYTES) {
throw new IllegalArgumentException(
"outbox payload is "
+ bytes
+ " UTF-8 bytes; the limit is "
+ MAX_PAYLOAD_BYTES
+ ". Rejecting it here is the last point at which it can be rejected at all — after"
+ " the row commits, the relay retries it forever.");
}
Scanner scanner = new Scanner(payload);
scanner.skipWhitespace();
scanner.readValue(0);
scanner.skipWhitespace();
if (!scanner.exhausted()) {
throw new IllegalArgumentException(
"outbox payload has trailing content at offset "
+ scanner.position
+ "; it must be exactly one JSON value");
}
}
/** A single-pass structural JSON validator over the payload text. */
private static final class Scanner {
/**
* The nesting ceiling.
*
* <p>Present because this validator recurses: without it a payload of ten thousand open
* brackets overflows the stack of whatever thread appends the row.
*/
private static final int MAX_DEPTH = 64;
private final String text;
private int position;
private Scanner(String text) {
this.text = text;
}
private boolean exhausted() {
return position >= text.length();
}
private void skipWhitespace() {
while (position < text.length()) {
char current = text.charAt(position);
if (current == ' ' || current == '\t' || current == '\n' || current == '\r') {
position++;
} else {
return;
}
}
}
private void readValue(int depth) {
if (depth > MAX_DEPTH) {
throw fail("nesting deeper than " + MAX_DEPTH + " levels");
}
if (exhausted()) {
throw fail("a JSON value was expected");
}
char current = text.charAt(position);
switch (current) {
case '{' -> readObject(depth);
case '[' -> readArray(depth);
case '"' -> readString();
case 't' -> readLiteral("true");
case 'f' -> readLiteral("false");
case 'n' -> readLiteral("null");
default -> readNumber();
}
}
private void readObject(int depth) {
expect('{');
skipWhitespace();
if (peekIs('}')) {
position++;
return;
}
while (true) {
skipWhitespace();
readString();
skipWhitespace();
expect(':');
skipWhitespace();
readValue(depth + 1);
skipWhitespace();
if (peekIs(',')) {
position++;
continue;
}
expect('}');
return;
}
}
private void readArray(int depth) {
expect('[');
skipWhitespace();
if (peekIs(']')) {
position++;
return;
}
while (true) {
skipWhitespace();
readValue(depth + 1);
skipWhitespace();
if (peekIs(',')) {
position++;
continue;
}
expect(']');
return;
}
}
private void readString() {
expect('"');
while (true) {
if (exhausted()) {
throw fail("an unterminated string");
}
char current = text.charAt(position++);
if (current == '"') {
return;
}
if (current == '\\') {
readEscape();
} else if (current < 0x20) {
// A raw control character inside a string is invalid JSON, and it is also exactly what
// turns one log line into two.
throw fail("a raw control character inside a string");
}
}
}
private void readEscape() {
if (exhausted()) {
throw fail("an escape at the end of the payload");
}
char escaped = text.charAt(position++);
switch (escaped) {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't' -> {
// A complete escape.
}
case 'u' -> {
if (position + 4 > text.length()) {
throw fail("a truncated unicode escape");
}
for (int index = 0; index < 4; index++) {
char digit = text.charAt(position++);
boolean hexadecimal =
(digit >= '0' && digit <= '9')
|| (digit >= 'a' && digit <= 'f')
|| (digit >= 'A' && digit <= 'F');
if (!hexadecimal) {
throw fail("a unicode escape that is not hexadecimal");
}
}
}
default -> throw fail("an unknown escape \\" + escaped);
}
}
private void readNumber() {
int start = position;
if (peekIs('-')) {
position++;
}
readDigits();
if (peekIs('.')) {
position++;
readDigits();
}
if (peekIs('e') || peekIs('E')) {
position++;
if (peekIs('+') || peekIs('-')) {
position++;
}
readDigits();
}
if (position == start) {
throw fail("a JSON value was expected");
}
}
private void readDigits() {
int start = position;
while (position < text.length()
&& text.charAt(position) >= '0'
&& text.charAt(position) <= '9') {
position++;
}
if (position == start) {
throw fail("a number with no digits");
}
}
private void readLiteral(String literal) {
if (!text.startsWith(literal, position)) {
throw fail("an unrecognised literal");
}
position += literal.length();
}
private boolean peekIs(char expected) {
return position < text.length() && text.charAt(position) == expected;
}
private void expect(char expected) {
if (!peekIs(expected)) {
throw fail("'" + expected + "' was expected");
}
position++;
}
private IllegalArgumentException fail(String what) {
// Deliberately reports the offset and not the text: the payload is business content and this
// message reaches logs.
return new IllegalArgumentException(
"outbox payload is not valid JSON: " + what + " at offset " + position);
}
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.application.outbox;
/**
* What a publish attempt achieved, in the application's own vocabulary.
*
* <p>The port could only say "returned" or "threw". A broker that accepted a frame and then failed
* to confirm it is neither: the message may be stored, so retrying it may duplicate and not
* retrying it may lose. Collapsing that into an exception forces the relay to choose one of those
* two wrong answers, and it chooses the same one every time.
*
* <p>Four values, because two of them are the ones that matter operationally and the naming trap
* between the two outbox models is real:
*
* <ul>
* <li>the legacy {@code OutboxEventStatus.FAILED} is <em>retryable</em> and {@code DEAD} is
* terminal;
* <li>the platform's {@code OutboxStatus.FAILED} is a <em>definite rejection</em> and terminal,
* while {@code AMBIGUOUS} is the retryable one.
* </ul>
*
* <p>Mapping those by name inverts both meanings: a definitely-rejected message would be retried
* forever and an unknown one would be parked. This type exists so no bridge is ever tempted to map
* enum names.
*/
public enum OutboxPublishOutcome {
/** The broker acknowledged the message. Safe to mark published. */
CONFIRMED,
/**
* The message left this process and no acknowledgement arrived.
*
* <p>Retryable only under an idempotency key the broker or the consumer honours; the row stays
* claimable and the duplicate risk is explicit rather than hidden inside an exception.
*/
AMBIGUOUS,
/**
* Refused before anything was transmitted — validation, routing, an unknown destination.
*
* <p>Definite: nothing was stored anywhere, so a retry of the same payload fails the same way.
* This is what the legacy model calls {@code DEAD}, not what it calls {@code FAILED}.
*/
REJECTED_BEFORE_SEND,
/**
* The broker received the message and refused it.
*
* <p>Also definite, but distinct from {@link #REJECTED_BEFORE_SEND}: the message reached the
* broker, so an operator investigating looks at broker-side policy — quota, ACL, message size —
* rather than at this application's routing.
*/
REJECTED_AFTER_BROKER;
/** True when the relay may claim this row again. */
public boolean retryable() {
return this == AMBIGUOUS;
}
/** True when nothing was transmitted, so no duplicate can exist. */
public boolean nothingTransmitted() {
return this == REJECTED_BEFORE_SEND;
}
}
@@ -9,6 +9,14 @@ import java.util.Objects;
*
* <p>The report deliberately cannot carry an event payload, idempotency key, rendered message,
* severity, arbitrary fields, or the whole {@link OutboxEvent}.
*
* <p>It also cannot carry the exception. It used to, and the renderer passed that {@code Throwable}
* to the logger as the event's cause — so the operational JSON gained a message and a stack trace
* this allowlist had no say over. A driver's exception text routinely contains the endpoint it was
* connecting to, the statement it was running, and occasionally the credential it was using. What
* survives is the exception's class name and, when the exception carries a platform code, that one:
* enough to group failures and route a runbook, and nothing that is business content. The bounded
* code is {@link #code()}, which this allowlist already owned.
*/
public record OutboxRelayFailureReport(
OperationalError code,
@@ -18,7 +26,7 @@ public record OutboxRelayFailureReport(
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause) {
String causeType) {
public OutboxRelayFailureReport {
Objects.requireNonNull(code, "code must not be null");
@@ -26,7 +34,7 @@ public record OutboxRelayFailureReport(
requireNonBlank(eventType, "eventType");
requireNonBlank(aggregateId, "aggregateId");
requireNonBlank(correlationId, "correlationId");
Objects.requireNonNull(cause, "cause must not be null");
requireNonBlank(causeType, "causeType");
if (attemptCount < 1) {
throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount);
}
@@ -59,7 +67,7 @@ public record OutboxRelayFailureReport(
correlationId,
attemptCount,
nextAttemptAt,
cause);
causeTypeOf(cause));
}
public static OutboxRelayFailureReport deadLetter(
@@ -77,7 +85,18 @@ public record OutboxRelayFailureReport(
correlationId,
attemptCount,
null,
cause);
causeTypeOf(cause));
}
/**
* The exception's class name, which is a type and not content.
*
* @param cause the failure
* @return the fully qualified class name
*/
private static String causeTypeOf(RuntimeException cause) {
Objects.requireNonNull(cause, "cause must not be null");
return cause.getClass().getName();
}
private static void requireNonBlank(String value, String name) {
@@ -0,0 +1,21 @@
package dev.caskeleton.application.transaction;
/**
* What reconciliation concluded about a transaction whose commit outcome was unknown (design
* §17.4).
*
* <p>{@link #STILL_UNKNOWN} is a legitimate answer, and keeping it is the point. Forcing a binary
* result would push the resolver into guessing, and a wrong guess either duplicates a payment or
* loses one.
*/
public enum CompletionResolution {
/** Evidence shows the transaction committed; the use case must not be re-run. */
COMMITTED,
/** Evidence shows the transaction did not commit; the use case may be re-run. */
NOT_COMMITTED,
/** No conclusive evidence; the record stays in the reconciliation queue. */
STILL_UNKNOWN
}
@@ -0,0 +1,43 @@
package dev.caskeleton.application.transaction;
/**
* Marks that the current attempt performed an effect that cannot be undone by a rollback (design
* §19.2).
*
* <p>Database rollback only reverses database work. A use case that sent an email, charged a card,
* published to a broker, or wrote to object storage has already changed the world, and re-running
* it does it twice. A use case that cannot avoid such an effect calls {@link #mark()} before
* performing it; the retry policy then refuses to re-run that attempt no matter what the failure
* was.
*
* <p>In application-core because the use case is what calls {@link #mark()}, immediately before the
* effect it cannot undo. While this lived in the persistence adapter, marking an irreversible
* effect required a use case to import that adapter, so the one call site that matters was the one
* the architecture forbade.
*
* <p>The right fix is usually to move the effect out of the transaction entirely — the design
* forbids waiting on external calls inside a DB transaction. This flag exists for the cases where
* that refactor has not happened yet, so the unsafe retry is prevented rather than merely
* documented.
*/
public final class IrreversibleSideEffectContext {
private static final ThreadLocal<Boolean> PERFORMED = new ThreadLocal<>();
private IrreversibleSideEffectContext() {}
/** Declares that this attempt has performed an effect a rollback cannot reverse. */
public static void mark() {
PERFORMED.set(Boolean.TRUE);
}
/** Whether the current attempt declared an irreversible effect. */
public static boolean performed() {
return Boolean.TRUE.equals(PERFORMED.get());
}
/** Clears the marker; the retry coordinator calls this between attempts. */
public static void clear() {
PERFORMED.remove();
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.transaction;
/**
* The domain-specific SPI that decides whether an unknown transaction actually committed (design
* §17.4).
*
* <p>An outbound SPI in application-core, not in the persistence adapter. It used to live beside
* the JPA transaction engine while its own documentation said the domain implements it — which the
* domain cannot do without importing an outbound adapter, inverting the dependency. Declaring it
* here means the adapter depends on the contract and the domain implements it, both pointing the
* same way.
*
* <p>The core cannot implement this. Only the domain knows which unique constraint, idempotency
* record, business row, or outbox entry proves the write happened, so the platform provides the
* question and the domain provides the evidence.
*
* @param <K> the application's transaction key type
*/
@FunctionalInterface
public interface TransactionCompletionResolver<K> {
/**
* Resolves one unknown transaction against domain evidence.
*
* <p>Implementations must read evidence only. Re-running the original use case from a resolver is
* the exact duplicate-write this design forbids.
*/
CompletionResolution resolve(K transactionKey);
}
@@ -0,0 +1,162 @@
package dev.caskeleton.application.notification.platform.api;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Whether the public submission API still admits an untyped value anywhere.
*
* <p>{@code NotificationPlan.variables} was {@code Map<String, Object>}. Everything that followed
* from that — a fingerprint computed through {@code toString()}, a nested mutable value the plan
* could not copy, two encoders that only agreed by coincidence — was a consequence of one {@code
* Object} in a record component. A single case fixed by hand comes back the next time somebody
* needs a value the algebra does not have.
*
* <p>So the ban is structural: no record in the public API graph may carry {@code Object}, directly
* or inside a generic argument, at any depth.
*/
class PublicApiIsTypedTest {
private static final String API_PACKAGE = "dev.caskeleton.application.notification.platform.api";
@Test
@DisplayName("no public API record carries an untyped value at any depth")
void noPublicApiRecordCarriesAnUntypedValue() {
List<String> untyped = new ArrayList<>();
for (Class<?> type : apiRecords()) {
for (RecordComponent component : type.getRecordComponents()) {
if (mentionsObject(component.getGenericType())) {
untyped.add(type.getSimpleName() + "." + component.getName());
}
}
}
assertThat(untyped)
.as(
"an Object here is a value the fingerprint can only render with toString(), "
+ "and a value the plan cannot copy")
.isEmpty();
}
@Test
@DisplayName("the test actually scans the API package rather than an empty list")
void theTestActuallyScansSomething() {
// A reflection sweep that finds no classes passes silently. This is the guard that stops the
// rule above from becoming decoration if the package moves.
assertThat(apiRecords())
.as("the API package must contain records for the sweep above to mean anything")
.hasSizeGreaterThan(10);
}
@Test
@DisplayName("the variables map is the typed algebra")
void theVariablesMapIsTheTypedAlgebra() {
RecordComponent variables =
Stream.of(NotificationPlan.class.getRecordComponents())
.filter(component -> component.getName().equals("variables"))
.findFirst()
.orElseThrow();
assertThat(variables.getGenericType().getTypeName())
.isEqualTo("java.util.Map<java.lang.String, " + NotificationVariable.class.getName() + ">");
}
/**
* Whether a type mentions {@code Object} anywhere in its generic signature.
*
* @param type the type to inspect
* @return true when {@code Object} appears, directly or as a type argument
*/
private static boolean mentionsObject(Type type) {
if (type == Object.class) {
return true;
}
if (type instanceof ParameterizedType parameterized) {
for (Type argument : parameterized.getActualTypeArguments()) {
if (mentionsObject(argument)) {
return true;
}
}
return mentionsObject(parameterized.getRawType());
}
if (type instanceof WildcardType wildcard) {
// `? extends Object` is `?`, which is exactly as untyped as Object itself.
return Stream.of(wildcard.getUpperBounds()).allMatch(bound -> bound == Object.class);
}
return false;
}
/**
* Every record in the public API package tree.
*
* @return the records
*/
private static List<Class<?>> apiRecords() {
List<Class<?>> records = new ArrayList<>();
for (String name : classNamesUnder(API_PACKAGE)) {
Class<?> type;
try {
type = Class.forName(name);
} catch (ClassNotFoundException | NoClassDefFoundError unavailable) {
continue;
}
if (type.isRecord() && java.lang.reflect.Modifier.isPublic(type.getModifiers())) {
records.add(type);
}
}
return records;
}
/**
* Class names under a package, read from the compiled output directory.
*
* @param packageName the package root
* @return every class name below it
*/
private static List<String> classNamesUnder(String packageName) {
try {
// Anchored on a class that lives in the package being scanned, so the root is the main
// output directory whatever the test output layout is. Anchoring on this test class instead
// pointed at the test output root, where the API package does not exist — and the sweep then
// found nothing and passed. The vacuity guard below is what caught that.
Path root =
Path.of(
NotificationPlan.class.getProtectionDomain().getCodeSource().getLocation().toURI());
Path packageRoot = root.resolve(packageName.replace('.', '/'));
if (!Files.isDirectory(packageRoot)) {
return List.of();
}
try (Stream<Path> files = Files.walk(packageRoot)) {
return files
.filter(path -> path.toString().endsWith(".class"))
.map(
path ->
packageName
+ "."
+ packageRoot
.relativize(path)
.toString()
.replace(java.io.File.separatorChar, '.')
.replaceAll("\\.class$", ""))
.toList();
}
} catch (URISyntaxException | IOException unreadable) {
throw new UncheckedIOException(new IOException("cannot scan " + packageName, unreadable));
}
}
}
@@ -0,0 +1,297 @@
package dev.caskeleton.application.notification.platform.callback;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What the callback endpoint promises the provider, and when.
*
* <p>The comment said "durable append, then a fast 2xx". The code appended and then projected
* synchronously, so a projector that threw failed a callback the provider had already been told was
* accepted — and the provider, seeing a 5xx, redelivered an event that was already in the ledger. A
* slow projector held the provider's connection open for as long as it ran.
*
* <p>The append itself was not one unit either: events were inserted one at a time with no
* transaction of this path's own, so a failure part way through a batch committed the events before
* it and told the caller nothing about the split.
*/
class CallbackIngestionAtomicityTest {
private static final Instant NOW = Instant.parse("2026-08-15T09:00:00Z");
private static final ProviderId SES = new ProviderId("ses");
private static final ProviderProfileId PROFILE = new ProviderProfileId("email-main");
private final RecordingLedger ledger = new RecordingLedger();
private final RecordingTransactions transactions = new RecordingTransactions();
@Test
@DisplayName("the append runs inside one transaction")
void theAppendRunsInsideOneTransaction() {
service(SES).ingest(request(SES));
assertThat(transactions.writes)
.as("event-at-a-time appends could commit the first and lose the rest")
.isEqualTo(1);
assertThat(ledger.appended).hasSize(1);
}
@Test
@DisplayName("the endpoint returns once the event is durable, without projecting it")
void theEndpointReturnsOnceTheEventIsDurable() {
service(SES).ingest(request(SES));
assertThat(ledger.appended).as("the event is durable when the endpoint returns").hasSize(1);
// The strongest available statement: the service has no projector to call. It used to take one
// and invoke it inline, so a projector that threw failed a callback the provider had already
// been told was accepted, and the provider redelivered an event already in the ledger.
assertThat(IngestProviderCallbackApplicationUseCase.class.getDeclaredFields())
.noneMatch(field -> field.getType().equals(ProviderEventProjectionService.class));
}
@Test
@DisplayName(
"a callback presented against another provider's profile is refused before verifying")
void aMismatchedProviderIsRefusedBeforeVerifying() {
RecordingAdapter adapter = new RecordingAdapter(new ProviderId("twilio"));
assertThatThrownBy(() -> service(adapter).ingest(request(SES)))
.isInstanceOf(CallbackValidationException.class);
assertThat(adapter.verifications)
.as(
"verifying would fetch that provider's certificate on an unauthenticated caller's"
+ " say-so")
.isZero();
assertThat(ledger.appended).isEmpty();
}
@Test
@DisplayName("a matching provider is verified and appended")
void aMatchingProviderIsVerifiedAndAppended() {
RecordingAdapter adapter = new RecordingAdapter(SES);
service(adapter).ingest(request(SES));
assertThat(adapter.verifications).isEqualTo(1);
assertThat(ledger.appended).hasSize(1);
}
private IngestProviderCallbackApplicationUseCase service(ProviderId adapterProvider) {
return service(new RecordingAdapter(adapterProvider));
}
private IngestProviderCallbackApplicationUseCase service(RecordingAdapter adapter) {
return new IngestProviderCallbackApplicationUseCase(
new SingleAdapterRegistry(adapter),
ledger,
transactions,
new PassThroughProtection(),
new DiscardingSecurityAudit(),
new DiscardingMetrics(),
Clock.fixed(NOW, ZoneOffset.UTC));
}
private static CallbackRequest request(ProviderId providerId) {
return new CallbackRequest(
providerId,
PROFILE,
"https://callbacks.example/notifications/ses",
"POST",
Optional.of("application/json"),
Map.of("content-type", List.of("application/json")),
"{}".getBytes(java.nio.charset.StandardCharsets.UTF_8),
NOW);
}
/** Counts write boundaries, so "one transaction" is a fact rather than a claim. */
private static final class RecordingTransactions implements TransactionPort {
private int writes;
@Override
public <T> T inWrite(Supplier<T> action) {
writes++;
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return inWrite(action);
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return inWrite(action);
}
}
private static final class RecordingAdapter implements ProviderCallbackAdapter {
private final ProviderId providerId;
private int verifications;
private RecordingAdapter(ProviderId providerId) {
this.providerId = providerId;
}
@Override
public ProviderId providerId() {
return providerId;
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
verifications++;
return CallbackVerificationResult.valid(new VerifiedCallback(request, Map.of()));
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
return List.of(
new NormalizedProviderEvent(
NormalizedEventType.DELIVERY_CONFIRMED,
"delivered",
Optional.of("event-1"),
Optional.of("request-1"),
Optional.of(NOW),
Map.of()));
}
}
private record SingleAdapterRegistry(RecordingAdapter adapter)
implements ProviderCallbackAdapterRegistry {
@Override
public ProviderCallbackAdapter require(ProviderProfileId profileId) {
return adapter;
}
@Override
public CallbackLimits limitsFor(ProviderProfileId profileId) {
return new CallbackLimits(65_536L, java.util.Set.of("application/json"));
}
}
private static final class RecordingLedger implements ProviderEventLedger {
private final List<VerifiedProviderEvent> appended = new ArrayList<>();
@Override
public AppendEventResult append(VerifiedProviderEvent event) {
return appendAll(List.of(event));
}
@Override
public AppendEventResult appendAll(List<VerifiedProviderEvent> events) {
appended.addAll(events);
return new AppendEventResult(List.of(), List.of());
}
@Override
public List<ProviderEventRecord> pendingProjection(int limit) {
return List.of();
}
@Override
public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) {
// Not part of this contract.
}
@Override
public void markFailed(ProviderEventRecordId eventId, String errorCode) {
// Not part of this contract.
}
@Override
public List<ProviderEventRecord> unmatched(int limit) {
return List.of();
}
@Override
public List<ProviderEventRecord> bindUnmatched(
ProviderProfileId providerProfileId,
String providerRequestId,
DeliveryAttemptId attemptId) {
return List.of();
}
@Override
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
return List.of();
}
}
private static final class PassThroughProtection implements CallbackPayloadProtectionPort {
@Override
public byte[] protectRawPayload(byte[] rawPayload) {
return rawPayload;
}
@Override
public String digest(byte[] rawPayload) {
return "0".repeat(64);
}
@Override
public String fingerprint(
ProviderProfileId profileId, NormalizedProviderEvent event, String rawDigest) {
return "1".repeat(64);
}
}
private static final class DiscardingSecurityAudit
implements dev.caskeleton.application.notification.platform.observation
.NotificationSecurityAuditPort {
@Override
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
// Not asserted here.
}
@Override
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
// Not asserted here.
}
}
private static final class DiscardingMetrics implements NotificationMetricsPort {
@Override
public void increment(String metricName, Map<String, String> tags) {
// Not asserted here.
}
@Override
public void record(String metricName, Map<String, String> tags, java.time.Duration value) {
// Not asserted here.
}
@Override
public void gauge(String metricName, Map<String, String> tags, double value) {
// Not asserted here.
}
}
}
@@ -0,0 +1,269 @@
package dev.caskeleton.application.notification.platform.dispatch;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan;
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import dev.caskeleton.application.notification.platform.api.NotificationVariable;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Whether two requests that differ produce two encodings.
*
* <p>The plan's variables were {@code Map<String, Object>} and the fingerprint rendered them with
* {@code toString()}. So {@code "1"} and {@code 1} were the same variable, a value containing the
* delimiter could imitate the boundary between two other fields, and a nested mutable value could
* be changed after the hash was taken and before the payload was stored. Meanwhile the stored
* payload came from a different encoder entirely, so nothing made the hashed bytes and the stored
* bytes describe the same request.
*/
class CanonicalPlanEncodingTest {
private final CanonicalNotificationPlanEncoder encoder = new CanonicalNotificationPlanEncoder();
@Test
@DisplayName("a text and a number that print alike are different variables")
void aTextAndANumberThatPrintAlikeAreDifferent() {
String asText = payload(Map.of("amount", NotificationVariable.text("1")));
String asNumber = payload(Map.of("amount", NotificationVariable.number(1)));
assertThat(asText)
.as("toString() made these one variable, and two different requests one request")
.isNotEqualTo(asNumber);
}
@Test
@DisplayName("a boolean and the text 'true' are different variables")
void aBooleanAndItsTextAreDifferent() {
assertThat(payload(Map.of("flag", NotificationVariable.bool(true))))
.isNotEqualTo(payload(Map.of("flag", NotificationVariable.text("true"))));
}
@Test
@DisplayName("an explicit null and a missing key are different requests")
void anExplicitNullIsNotAMissingKey() {
assertThat(payload(Map.of("note", NotificationVariable.nullValue())))
.isNotEqualTo(payload(Map.of()));
}
@Test
@DisplayName("numbers that differ only in trailing zeros are one variable")
void trailingZerosDoNotChangeANumber() {
assertThat(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("1.50")))))
.as("1.50 and 1.5 are the same amount")
.isEqualTo(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("1.5")))));
}
@Test
@DisplayName("numbers a double cannot tell apart stay apart")
void decimalPrecisionSurvives() {
assertThat(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("0.30")))))
.isEqualTo(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("0.3")))));
assertThat(
payload(
Map.of(
"amount",
NotificationVariable.number(new BigDecimal("0.1000000000000000055")))))
.as("this and 0.1 are the same double and different decimals")
.isNotEqualTo(
payload(Map.of("amount", NotificationVariable.number(new BigDecimal("0.1")))));
}
@Test
@DisplayName("insertion order does not change the encoding")
void insertionOrderDoesNotChangeTheEncoding() {
Map<String, NotificationVariable> forward = new LinkedHashMap<>();
forward.put("a", NotificationVariable.text("1"));
forward.put("b", NotificationVariable.text("2"));
Map<String, NotificationVariable> backward = new LinkedHashMap<>();
backward.put("b", NotificationVariable.text("2"));
backward.put("a", NotificationVariable.text("1"));
assertThat(payload(forward)).isEqualTo(payload(backward));
}
@Test
@DisplayName("list order does change the encoding")
void listOrderChangesTheEncoding() {
assertThat(
payload(
Map.of(
"items",
NotificationVariable.list(
List.of(NotificationVariable.text("a"), NotificationVariable.text("b"))))))
.isNotEqualTo(
payload(
Map.of(
"items",
NotificationVariable.list(
List.of(NotificationVariable.text("b"), NotificationVariable.text("a"))))));
}
@Test
@DisplayName("a value containing the framing characters cannot imitate another pair")
void framingCharactersCannotBeSmuggled() {
// Under a delimited encoding, {a: "1,b=2"} and {a: "1", b: "2"} render the same text.
String smuggled = payload(Map.of("a", NotificationVariable.text("1,b=2")));
Map<String, NotificationVariable> twoKeys = new LinkedHashMap<>();
twoKeys.put("a", NotificationVariable.text("1"));
twoKeys.put("b", NotificationVariable.text("2"));
assertThat(smuggled).isNotEqualTo(payload(twoKeys));
}
@Test
@DisplayName("a nested collection cannot imitate a flat one")
void nestedCollectionsCannotImitateFlatOnes() {
Set<String> encodings =
Set.of(
payload(Map.of("a", NotificationVariable.text("b"))),
payload(
Map.of(
"a", NotificationVariable.object(Map.of("b", NotificationVariable.text(""))))),
payload(
Map.of("a", NotificationVariable.list(List.of(NotificationVariable.text("b"))))));
assertThat(encodings).as("three different shapes, three different encodings").hasSize(3);
}
@Test
@DisplayName("random variable graphs collide only when they are equal")
void randomGraphsCollideOnlyWhenEqual() {
// A property check rather than three hand-picked cases: the collisions this replaces were all
// shapes nobody thought to write a case for.
Random random = new Random(20260815L);
Map<String, Map<String, NotificationVariable>> byEncoding = new LinkedHashMap<>();
List<String> collisions = new ArrayList<>();
for (int i = 0; i < 2000; i++) {
Map<String, NotificationVariable> variables = randomVariables(random, 0);
String encoded = payload(variables);
Map<String, NotificationVariable> seen = byEncoding.putIfAbsent(encoded, variables);
if (seen != null && !seen.equals(variables)) {
collisions.add(seen + " and " + variables);
}
}
assertThat(collisions).isEmpty();
}
@Test
@DisplayName("the hashed bytes contain the payload that will be stored")
void theHashedBytesContainTheStoredPayload() {
EncodedNotificationPlan encoded =
encoder.encode(PlatformFakes.plan("idem-1", Map.of("amount", "1000")));
assertThat(new String(encoded.bytes(), StandardCharsets.UTF_8))
.as("the fingerprint used to cover a canonical string the stored payload had never seen")
.contains(encoded.variablesPayload());
}
@Test
@DisplayName("the encoding version is inside the bytes, not only beside them")
void theVersionIsInsideTheBytes() {
EncodedNotificationPlan encoded = encoder.encode(PlatformFakes.plan("idem-1", Map.of()));
assertThat(new String(encoded.bytes(), StandardCharsets.UTF_8))
.startsWith("1:v:1:" + CanonicalNotificationPlanEncoder.VERSION);
}
@Test
@DisplayName("a plan cannot be changed after it is built")
void aPlanCannotBeChangedAfterItIsBuilt() {
Map<String, NotificationVariable> mutable = new LinkedHashMap<>();
mutable.put("amount", NotificationVariable.text("1000"));
NotificationPlan plan = PlatformFakes.planTyped("idem-1", mutable);
String before = payload(plan);
mutable.put("amount", NotificationVariable.text("9999"));
assertThat(payload(plan))
.as("the caller's map is copied, and every value in it is already immutable")
.isEqualTo(before);
}
@Test
@DisplayName("a graph deeper than the bound is refused when it is built")
void aGraphDeeperThanTheBoundIsRefused() {
NotificationVariable deep = NotificationVariable.text("leaf");
for (int i = 0; i < NotificationVariable.MAX_DEPTH - 1; i++) {
deep = NotificationVariable.list(List.of(deep));
}
NotificationVariable atLimit = deep;
assertThatThrownBy(() -> NotificationVariable.list(List.of(atLimit)))
.as("refused while building, not while walking it during an encode")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("an oversized text value is refused")
void anOversizedTextValueIsRefused() {
String tooLong = "x".repeat(NotificationVariable.MAX_TEXT_BYTES + 1);
assertThatThrownBy(() -> NotificationVariable.text(tooLong))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a variable does not print its own content")
void aVariableDoesNotPrintItsContent() {
assertThat(NotificationVariable.text("user@example.com").toString())
.as("an exception message and a log line both go through toString()")
.doesNotContain("user@example.com");
assertThat(NotificationVariable.number(new BigDecimal("4111111111111111")).toString())
.doesNotContain("4111");
}
private String payload(Map<String, NotificationVariable> variables) {
return encoder.encode(PlatformFakes.planTyped("idem-1", variables)).variablesPayload();
}
private String payload(NotificationPlan plan) {
return encoder.encode(plan).variablesPayload();
}
private static Map<String, NotificationVariable> randomVariables(Random random, int depth) {
Map<String, NotificationVariable> variables = new LinkedHashMap<>();
int entries = random.nextInt(3);
for (int i = 0; i < entries; i++) {
variables.put(randomKey(random), randomValue(random, depth));
}
return variables;
}
private static String randomKey(Random random) {
// Keys drawn from a set that includes the framing characters on purpose.
String[] keys = {"a", "b", "a:b", "1:a", "", ":", "a\nb"};
return keys[random.nextInt(keys.length)];
}
private static NotificationVariable randomValue(Random random, int depth) {
int shape = random.nextInt(depth >= 3 ? 4 : 6);
return switch (shape) {
case 0 -> NotificationVariable.text(randomKey(random));
case 1 -> NotificationVariable.number(new BigDecimal(random.nextInt(5)));
case 2 -> NotificationVariable.bool(random.nextBoolean());
case 3 -> NotificationVariable.nullValue();
case 4 -> {
List<NotificationVariable> values = new ArrayList<>();
int size = random.nextInt(3);
for (int i = 0; i < size; i++) {
values.add(randomValue(random, depth + 1));
}
yield NotificationVariable.list(values);
}
default -> NotificationVariable.object(randomVariables(random, depth + 1));
};
}
}
@@ -48,11 +48,10 @@ class NotificationSubmissionServiceTest {
PlatformFakes.templates(true),
PlatformFakes.fingerprints(),
new CanonicalNotificationPlanWriter(
new PlatformFakes.SequentialIds(),
PlatformFakes.routes(Channel.EMAIL, Channel.SMS),
PlatformFakes.variablesCodec()),
new PlatformFakes.SequentialIds(), PlatformFakes.routes(Channel.EMAIL)),
transactions,
PlatformFakes.tenant("tenant-a"),
PlatformFakes.deduplication(CLOCK),
metrics,
CLOCK);
@@ -61,22 +60,34 @@ class NotificationSubmissionServiceTest {
var receipt = service.submit(PlatformFakes.plan("idem-1", Map.of("amount", "1000")));
assertThat(requests.byId).containsKey(receipt.notificationId());
assertThat(requests.recipientsOf(receipt.notificationId())).hasSize(1);
assertThat(requests.recipientsOf(receipt.notificationId()).get(0).state())
assertThat(requests.recipientsOf(new TenantId("tenant-a"), receipt.notificationId()))
.hasSize(1);
assertThat(
requests
.recipientsOf(new TenantId("tenant-a"), receipt.notificationId())
.get(0)
.state())
.isEqualTo(RecipientDeliveryState.READY_TO_DISPATCH);
assertThat(transactions.boundaries).containsExactly("WRITE");
}
@Test
void aScheduledRequestIsQueuedPendingRatherThanReadyToDispatch() {
var receipt =
service.schedule(
PlatformFakes.plan("idem-scheduled", Map.of()), CLOCK.instant().plusSeconds(3600));
void aScheduledRequestIsQueuedWithAFutureDueTimeRatherThanASecondState() {
var scheduledFor = CLOCK.instant().plusSeconds(3600);
var receipt = service.schedule(PlatformFakes.plan("idem-scheduled", Map.of()), scheduledFor);
assertThat(requests.byId.get(receipt.notificationId()).status())
.isEqualTo(RequestStatus.SCHEDULED);
assertThat(requests.recipientsOf(receipt.notificationId()).get(0).state())
.isEqualTo(RecipientDeliveryState.PENDING);
var recipient =
requests.recipientsOf(new TenantId("tenant-a"), receipt.notificationId()).get(0);
assertThat(recipient.state())
.as(
"PENDING was a second queue state the claim query never read, so every schedule(...)"
+ " request sat in the table until somebody noticed")
.isEqualTo(RecipientDeliveryState.READY_TO_DISPATCH);
assertThat(recipient.nextDispatchAt())
.as("the schedule is a due time, which the claim already compares against")
.hasValue(scheduledFor);
}
@Test
@@ -114,7 +125,7 @@ class NotificationSubmissionServiceTest {
assertThat(second.notificationId()).isEqualTo(first.notificationId());
assertThat(requests.byId).hasSize(1);
// One request row, not two: convergence is the point, not a second identical notification.
assertThat(requests.recipientsOf(first.notificationId())).hasSize(1);
assertThat(requests.recipientsOf(new TenantId("tenant-a"), first.notificationId())).hasSize(1);
}
@Test
@@ -150,11 +161,10 @@ class NotificationSubmissionServiceTest {
PlatformFakes.templates(false),
PlatformFakes.fingerprints(),
new CanonicalNotificationPlanWriter(
new PlatformFakes.SequentialIds(),
PlatformFakes.routes(Channel.EMAIL),
PlatformFakes.variablesCodec()),
new PlatformFakes.SequentialIds(), PlatformFakes.routes(Channel.EMAIL)),
transactions,
PlatformFakes.tenant("tenant-a"),
PlatformFakes.deduplication(CLOCK),
metrics,
CLOCK);
@@ -169,7 +179,7 @@ class NotificationSubmissionServiceTest {
@Test
void cancellationReportsExternalUncertaintyRatherThanClaimingTheMessageWasStopped() {
var receipt = service.submit(PlatformFakes.plan("idem-6", Map.of()));
var job = requests.recipientsOf(receipt.notificationId()).get(0);
var job = requests.recipientsOf(new TenantId("tenant-a"), receipt.notificationId()).get(0);
requests.replace(withAmbiguousAttempt(job));
var result =
@@ -204,18 +214,18 @@ class NotificationSubmissionServiceTest {
}
@Test
void rollUpTreatsAMixedTerminalOutcomeAsPartiallyCompleted() {
void theStatusPolicyTreatsAMixedTerminalOutcomeAsPartiallyCompleted() {
assertThat(
NotificationSubmissionService.rollUp(
NotificationRequestStatusPolicy.rollUp(
List.of(RecipientDeliveryState.COMPLETED, RecipientDeliveryState.FAILED)))
.isEqualTo(RequestStatus.PARTIALLY_COMPLETED);
assertThat(
NotificationSubmissionService.rollUp(
NotificationRequestStatusPolicy.rollUp(
List.of(RecipientDeliveryState.COMPLETED, RecipientDeliveryState.RETRY_WAITING)))
.isEqualTo(RequestStatus.PROCESSING);
assertThat(NotificationSubmissionService.rollUp(List.of(RecipientDeliveryState.CANCELED)))
assertThat(NotificationRequestStatusPolicy.rollUp(List.of(RecipientDeliveryState.CANCELED)))
.isEqualTo(RequestStatus.CANCELED);
assertThat(NotificationSubmissionService.rollUp(List.of())).isEqualTo(RequestStatus.CREATED);
assertThat(NotificationRequestStatusPolicy.rollUp(List.of())).isEqualTo(RequestStatus.CREATED);
}
private static RecipientDeliveryRecord withAmbiguousAttempt(RecipientDeliveryRecord job) {
@@ -235,6 +245,7 @@ class NotificationSubmissionServiceTest {
true,
job.duplicateRisk(),
job.nextDispatchAt(),
job.expiresAt(),
job.leaseOwner(),
job.leaseUntil(),
job.attemptCount(),
@@ -242,4 +253,95 @@ class NotificationSubmissionServiceTest {
job.createdAt(),
job.updatedAt());
}
@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("a duplicate inside the window is dropped, and says so")
void aDuplicateInsideTheWindowIsDropped() {
var spec =
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
"order-1",
java.time.Duration.ofMinutes(10),
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
var first = service.submit(planWithDedup("idem-dedup-1", spec));
var second = service.submit(planWithDedup("idem-dedup-2", spec));
assertThat(first.willDeliver()).isTrue();
assertThat(second.willDeliver())
.as("DeduplicationService and its store were beans nothing called, so every duplicate went")
.isFalse();
assertThat(second.acceptance())
.isEqualTo(
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
.DROPPED_AS_DUPLICATE);
}
@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("RETURN_EXISTING converges on the earlier notification")
void returnExistingConvergesOnTheEarlierNotification() {
var spec =
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
"order-2",
java.time.Duration.ofMinutes(10),
dev.caskeleton.application.notification.platform.api.DeduplicationAction
.RETURN_EXISTING);
var first = service.submit(planWithDedup("idem-dedup-3", spec));
var second = service.submit(planWithDedup("idem-dedup-4", spec));
assertThat(second.notificationId())
.as("the caller is told which notification already covers their request")
.isEqualTo(first.notificationId());
assertThat(second.acceptance())
.isEqualTo(
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
.CONVERGED_ON_EXISTING);
assertThat(second.willDeliver()).isFalse();
}
@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("a different window admits the next request")
void aDifferentWindowAdmitsTheNextRequest() {
var spec =
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
"order-3",
java.time.Duration.ofMinutes(10),
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
var otherKey =
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
"order-4",
java.time.Duration.ofMinutes(10),
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
service.submit(planWithDedup("idem-dedup-5", spec));
assertThat(service.submit(planWithDedup("idem-dedup-6", otherKey)).willDeliver()).isTrue();
}
@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("a multi-recipient plan with deduplication is refused")
void aMultiRecipientDedupPlanIsRefused() {
var spec =
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
"order-5",
java.time.Duration.ofMinutes(10),
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
assertThatThrownBy(() -> service.submit(PlatformFakes.multiRecipientPlanWithDedup(spec)))
.as("the window is keyed on one recipient identity; several recipients have no single one")
.isInstanceOf(RuntimeException.class);
}
private static dev.caskeleton.application.notification.platform.api.NotificationPlan
planWithDedup(
String idempotencyKey,
dev.caskeleton.application.notification.platform.api.DeduplicationSpec spec) {
return PlatformFakes.plan(
idempotencyKey,
Map.of(),
new dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel(
Channel.EMAIL),
java.util.Optional.empty(),
java.util.Optional.of(spec));
}
}
@@ -10,6 +10,7 @@ import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
import dev.caskeleton.application.notification.platform.api.RequestStatus;
import dev.caskeleton.application.notification.platform.api.TemplateSelection;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
@@ -121,21 +122,36 @@ public final class PlatformFakes {
public final Map<String, NotificationId> byIdempotency = new HashMap<>();
public final Map<NotificationId, List<RecipientDeliveryRecord>> recipients = new HashMap<>();
/** Set to make the next insert lose the race, as a unique-index violation would. */
/**
* Set to make the next insert lose the race, as a concurrent claim would.
*
* <p>Losing is reported, not thrown. The real store used to raise a unique violation, which
* left the PostgreSQL transaction aborted and made the winner unreadable — so a fake that
* throws would model the bug rather than the contract.
*/
private static final char KEY_SEPARATOR = '\u0000';
public boolean failNextInsertAsDuplicate;
@Override
public NotificationRequestRecord insert(
public NotificationRequestInsertOutcome insert(
NotificationRequestRecord request, List<RecipientDeliveryRecord> jobs) {
String key = request.tenantId().value() + '' + request.idempotencyKey();
String key = request.tenantId().value() + KEY_SEPARATOR + request.idempotencyKey();
if (failNextInsertAsDuplicate || byIdempotency.containsKey(key)) {
failNextInsertAsDuplicate = false;
throw new DuplicateIdempotencyKeyException("duplicate idempotency key");
NotificationRequestRecord winner = byId.get(byIdempotency.get(key));
if (winner == null) {
throw new DuplicateIdempotencyKeyException(
"idempotency key is claimed but its request cannot be read");
}
// Deliberately no recipient jobs on this path: the loser's jobs would attach a second set
// to the winner's request.
return NotificationRequestInsertOutcome.lost(winner);
}
byId.put(request.id(), request);
byIdempotency.put(key, request.id());
recipients.put(request.id(), new ArrayList<>(jobs));
return request;
return NotificationRequestInsertOutcome.won(request);
}
@Override
@@ -151,13 +167,26 @@ public final class PlatformFakes {
}
@Override
public List<RecipientDeliveryRecord> recipientsOf(NotificationId notificationId) {
public List<RecipientDeliveryRecord> recipientsOf(
TenantId tenantId, NotificationId notificationId) {
// Scoped like the real store: another tenant's id answers with nothing, not with rows.
NotificationRequestRecord owner = byId.get(notificationId);
if (owner == null || !owner.tenantId().equals(tenantId)) {
return List.of();
}
return List.copyOf(recipients.getOrDefault(notificationId, List.of()));
}
@Override
public NotificationRequestRecord refreshStatus(NotificationId notificationId) {
return byId.get(notificationId);
public NotificationRequestRecord updateStatus(
TenantId tenantId, NotificationId notificationId, RequestStatus status) {
NotificationRequestRecord current = byId.get(notificationId);
if (current == null || !current.tenantId().equals(tenantId)) {
throw new IllegalStateException("notification request no longer exists");
}
NotificationRequestRecord updated = current.withStatus(status);
byId.put(notificationId, updated);
return updated;
}
/** Overwrite a stored job, as the recipient store's save would. */
@@ -211,6 +240,7 @@ public final class PlatformFakes {
job.ambiguousAttemptExists(),
job.duplicateRisk(),
nextDispatchAt,
job.expiresAt(),
job.leaseOwner(),
job.leaseUntil(),
job.attemptCount(),
@@ -428,14 +458,193 @@ public final class PlatformFakes {
"a".repeat(64));
}
/**
* A deduplication service over an in-memory window store.
*
* <p>The real store is a JPA one whose uniqueness makes the claim atomic; this keeps the same
* shape — first claim wins, later claims in the same window see the winner — so a test that
* passes here is testing the protocol.
*/
public static dev.caskeleton.application.notification.platform.policy.DeduplicationService
deduplication(java.time.Clock clock) {
java.util.Map<String, dev.caskeleton.application.notification.platform.api.NotificationId>
claimed = new java.util.concurrent.ConcurrentHashMap<>();
dev.caskeleton.application.notification.platform.policy.DeduplicationStorePort store =
(recipient, dedupKey, windowBucket, candidate) -> {
String key =
recipient.tenantId().value()
+ "\u0000"
+ recipient.recipientRef()
+ "\u0000"
+ recipient.category()
+ "\u0000"
+ dedupKey
+ "\u0000"
+ windowBucket;
var winner = claimed.putIfAbsent(key, candidate);
return winner == null
? dev.caskeleton.application.notification.platform.policy.DeduplicationResult.first(
candidate)
: new dev.caskeleton.application.notification.platform.policy.DeduplicationResult(
candidate, java.util.Optional.of(winner));
};
return new dev.caskeleton.application.notification.platform.policy.DeduplicationService(
store, clock);
}
/** A two-recipient plan carrying a deduplication spec, which the platform must refuse. */
public static NotificationPlan multiRecipientPlanWithDedup(
dev.caskeleton.application.notification.platform.api.DeduplicationSpec spec) {
NotificationPlan single =
plan(
"idem-multi",
Map.of(),
new ExplicitChannel(Channel.EMAIL),
Optional.empty(),
Optional.of(spec));
RecipientSpec other =
new RecipientSpec(
"user-2",
Optional.empty(),
Optional.empty(),
List.of(
new ContactPointSelector(
Channel.EMAIL,
new ContactPointId(UUID.fromString("00000000-0000-7000-8000-000000000003")))),
Optional.empty());
return new NotificationPlan(
single.tenantId(),
single.idempotencyKey(),
single.category(),
single.template(),
single.variables(),
List.of(single.recipients().get(0), other),
single.deliveryStrategy(),
single.notBefore(),
single.expiresAt(),
single.deduplication(),
single.collapse(),
single.correlationId(),
single.boundedMetadata());
}
/** A one-recipient email plan. */
public static NotificationPlan plan(String idempotencyKey, Map<String, Object> variables) {
return plan(idempotencyKey, variables, new ExplicitChannel(Channel.EMAIL));
}
/** A one-recipient plan whose variables are already typed. */
public static NotificationPlan planTyped(
String idempotencyKey,
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable>
variables) {
return buildTyped(
idempotencyKey,
variables,
new ExplicitChannel(Channel.EMAIL),
Optional.empty(),
Optional.empty());
}
/** A one-recipient plan with an explicit strategy and a recipient channel override. */
public static NotificationPlan plan(
String idempotencyKey,
Map<String, Object> variables,
DeliveryStrategy strategy,
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
channelOverride) {
return plan(idempotencyKey, variables, strategy, channelOverride, Optional.empty());
}
/** A one-recipient plan with an explicit strategy, an override and a deduplication spec. */
public static NotificationPlan plan(
String idempotencyKey,
Map<String, Object> variables,
DeliveryStrategy strategy,
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
channelOverride,
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec>
deduplication) {
return build(idempotencyKey, variables, strategy, channelOverride, deduplication);
}
/** A one-recipient plan with an explicit strategy. */
public static NotificationPlan plan(
String idempotencyKey, Map<String, Object> variables, DeliveryStrategy strategy) {
return build(idempotencyKey, variables, strategy, Optional.empty(), Optional.empty());
}
/**
* Convert a loose test map into the closed variable algebra.
*
* <p>Test convenience only. Production callers construct {@link NotificationVariable} directly,
* which is the point: {@code Object} cannot reach a plan any more.
*
* @param variables the loose map
* @return the typed map
*/
public static Map<
String, dev.caskeleton.application.notification.platform.api.NotificationVariable>
typed(Map<String, Object> variables) {
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable> typed =
new java.util.LinkedHashMap<>();
variables.forEach((key, value) -> typed.put(key, typedValue(value)));
return Map.copyOf(typed);
}
private static dev.caskeleton.application.notification.platform.api.NotificationVariable
typedValue(Object value) {
if (value == null) {
return dev.caskeleton.application.notification.platform.api.NotificationVariable.nullValue();
}
if (value instanceof String text) {
return dev.caskeleton.application.notification.platform.api.NotificationVariable.text(text);
}
if (value instanceof Boolean flag) {
return dev.caskeleton.application.notification.platform.api.NotificationVariable.bool(flag);
}
if (value instanceof java.math.BigDecimal number) {
return dev.caskeleton.application.notification.platform.api.NotificationVariable.number(
number);
}
if (value instanceof Number number) {
return dev.caskeleton.application.notification.platform.api.NotificationVariable.number(
new java.math.BigDecimal(number.toString()));
}
if (value instanceof Map<?, ?> nested) {
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable> typed =
new java.util.LinkedHashMap<>();
nested.forEach((key, nestedValue) -> typed.put(String.valueOf(key), typedValue(nestedValue)));
return dev.caskeleton.application.notification.platform.api.NotificationVariable.object(
typed);
}
if (value instanceof java.util.List<?> nested) {
return dev.caskeleton.application.notification.platform.api.NotificationVariable.list(
nested.stream().map(PlatformFakes::typedValue).toList());
}
throw new IllegalArgumentException("no variable shape for " + value.getClass());
}
private static NotificationPlan build(
String idempotencyKey,
Map<String, Object> variables,
DeliveryStrategy strategy,
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
channelOverride,
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec>
deduplication) {
return buildTyped(idempotencyKey, typed(variables), strategy, channelOverride, deduplication);
}
private static NotificationPlan buildTyped(
String idempotencyKey,
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable>
variables,
DeliveryStrategy strategy,
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
channelOverride,
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec>
deduplication) {
return new NotificationPlan(
new TenantId("tenant-a"),
new IdempotencyKey(idempotencyKey),
@@ -456,11 +665,11 @@ public final class PlatformFakes {
Channel.SMS,
new ContactPointId(
UUID.fromString("00000000-0000-7000-8000-000000000002")))),
Optional.empty())),
channelOverride)),
strategy,
Optional.empty(),
Optional.empty(),
Optional.empty(),
deduplication,
Optional.empty(),
new CorrelationId("corr-1"),
Map.of());
@@ -0,0 +1,138 @@
package dev.caskeleton.application.notification.platform.dispatch;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import dev.caskeleton.application.notification.platform.api.ContactPointSelector;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Whether a recipient's channel preference decides anything.
*
* <p>The planner read {@code blockedChannels} and ignored {@code preferredOrder} entirely, so a
* recipient who asked to be reached on SMS before email was sent an email. The preference was
* accepted through the public API, stored, and then never consulted — which is worse than not
* offering it, because the caller believes it took effect.
*/
class PreferredOrderRoutingTest {
private static final TenantId TENANT = new TenantId("tenant-a");
private final PolicyRoutePlanner planner =
new PolicyRoutePlanner(
catalog(
Map.of(
Channel.EMAIL, new ProviderProfileId("email-main"),
Channel.SMS, new ProviderProfileId("sms-main"),
Channel.PUSH, new ProviderProfileId("push-main"))));
@Test
@DisplayName("a preferred channel is tried before the strategy's own order")
void aPreferredChannelIsTriedFirst() {
List<RouteCandidate> routes =
planner.plan(
TENANT,
recipient(Optional.of(new ChannelPreferenceOverride(List.of(Channel.SMS), Set.of()))),
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS)));
assertThat(routes.stream().map(RouteCandidate::channel))
.as("preferredOrder was read by nothing at all")
.containsExactly(Channel.SMS, Channel.EMAIL);
}
@Test
@DisplayName("channels the preference does not mention keep the strategy's order")
void unmentionedChannelsKeepTheStrategyOrder() {
List<RouteCandidate> routes =
planner.plan(
TENANT,
recipient(Optional.of(new ChannelPreferenceOverride(List.of(Channel.PUSH), Set.of()))),
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS, Channel.PUSH)));
assertThat(routes.stream().map(RouteCandidate::channel))
.as("the fallback the caller designed still holds behind the recipient's wishes")
.containsExactly(Channel.PUSH, Channel.EMAIL, Channel.SMS);
}
@Test
@DisplayName("preferring a channel the strategy never offered does not add it")
void preferringAnUnofferedChannelDoesNotAddIt() {
List<RouteCandidate> routes =
planner.plan(
TENANT,
recipient(Optional.of(new ChannelPreferenceOverride(List.of(Channel.PUSH), Set.of()))),
new OrderedFallback(List.of(Channel.EMAIL)));
assertThat(routes.stream().map(RouteCandidate::channel))
.as("a preference is an intersection with what was offered, not a replacement")
.containsExactly(Channel.EMAIL);
}
@Test
@DisplayName("a blocked channel is still routed but not eligible")
void aBlockedChannelIsStillRoutedButNotEligible() {
List<RouteCandidate> routes =
planner.plan(
TENANT,
recipient(Optional.of(new ChannelPreferenceOverride(List.of(), Set.of(Channel.EMAIL)))),
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS)));
assertThat(routes).hasSize(2);
assertThat(routes.get(0).contactPointActive())
.as("a blocked channel is still planned, so an operator can see it was considered")
.isFalse();
assertThat(routes.get(1).contactPointActive()).isTrue();
}
@Test
@DisplayName("no preference leaves the strategy's order untouched")
void noPreferenceLeavesTheOrderUntouched() {
List<RouteCandidate> routes =
planner.plan(
TENANT,
recipient(Optional.empty()),
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS)));
assertThat(routes.stream().map(RouteCandidate::channel))
.containsExactly(Channel.EMAIL, Channel.SMS);
}
private static RecipientSpec recipient(Optional<ChannelPreferenceOverride> override) {
return new RecipientSpec(
"user-1",
Optional.empty(),
Optional.empty(),
List.of(
new ContactPointSelector(Channel.EMAIL, new ContactPointId(UUID.randomUUID())),
new ContactPointSelector(Channel.SMS, new ContactPointId(UUID.randomUUID())),
new ContactPointSelector(Channel.PUSH, new ContactPointId(UUID.randomUUID()))),
override);
}
/**
* The configured catalogue, as a fake.
*
* @param profilesByChannel which profile serves which channel
* @return the catalogue port
*/
private static ProviderProfileCatalogPort catalog(
java.util.Map<
dev.caskeleton.application.notification.platform.api.routing.Channel,
dev.caskeleton.application.notification.platform.api.ProviderProfileId>
profilesByChannel) {
return channel -> java.util.Optional.ofNullable(profilesByChannel.get(channel));
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.application.notification.platform.dispatch;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride;
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Which requests the idempotency fingerprint treats as the same request.
*
* <p>It treated several genuinely different ones as identical. The strategy contributed only its
* class name and primary channel, so {@code [EMAIL, SMS]} and {@code [EMAIL, PUSH]} hashed the
* same. The recipient's channel override contributed nothing at all. The deduplication action — the
* difference between dropping a duplicate and collapsing it — was left out while the key and window
* were included.
*
* <p>The encoding was also ambiguous: {@code key=value} pairs joined by commas, over values
* rendered with {@code toString()}. A value containing a comma produced the same canonical string
* as two different variables would.
*/
class RequestFingerprintTest {
private final RequestFingerprint fingerprint = PlatformFakes.fingerprints();
@Test
@DisplayName("a different fallback tail is a different request")
void aDifferentFallbackTailIsADifferentRequest() {
String emailThenSms = hash(planWithStrategy(List.of(Channel.EMAIL, Channel.SMS)));
String emailThenPush = hash(planWithStrategy(List.of(Channel.EMAIL, Channel.PUSH)));
assertThat(emailThenSms)
.as("only the class name and the primary channel used to reach the hash")
.isNotEqualTo(emailThenPush);
}
@Test
@DisplayName("a different fallback order is a different request")
void aDifferentFallbackOrderIsADifferentRequest() {
assertThat(hash(planWithStrategy(List.of(Channel.EMAIL, Channel.SMS))))
.isNotEqualTo(hash(planWithStrategy(List.of(Channel.SMS, Channel.EMAIL))));
}
@Test
@DisplayName("a recipient channel override changes the fingerprint")
void aChannelOverrideChangesTheFingerprint() {
String withoutOverride = hash(planWithOverride(Optional.empty()));
String withOverride =
hash(
planWithOverride(
Optional.of(
new ChannelPreferenceOverride(List.of(Channel.SMS), Set.of(Channel.EMAIL)))));
assertThat(withoutOverride)
.as("the override decides which channels this recipient may receive on")
.isNotEqualTo(withOverride);
}
@Test
@DisplayName("a value containing the old separator no longer imitates another request")
void aValueContainingTheSeparatorDoesNotCollide() {
String withComma = hash(planWithVariables(Map.of("a", "1,b=2")));
String twoVariables = hash(planWithVariables(Map.of("a", "1", "b", "2")));
assertThat(withComma)
.as("comma-joined key=value pairs made these two the same canonical string")
.isNotEqualTo(twoVariables);
}
@Test
@DisplayName("map insertion order does not change the fingerprint")
void mapInsertionOrderDoesNotChangeTheFingerprint() {
Map<String, Object> oneOrder = new LinkedHashMap<>();
oneOrder.put("a", "1");
oneOrder.put("b", "2");
Map<String, Object> otherOrder = new LinkedHashMap<>();
otherOrder.put("b", "2");
otherOrder.put("a", "1");
assertThat(hash(planWithVariables(oneOrder))).isEqualTo(hash(planWithVariables(otherOrder)));
}
@Test
@DisplayName("mutating a nested value after construction does not change what was hashed")
void mutatingANestedValueAfterConstructionChangesNothing() {
Map<String, Object> nested = new LinkedHashMap<>();
nested.put("amount", "100");
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("order", nested);
NotificationPlan plan = planWithVariables(variables);
String before = hash(plan);
nested.put("amount", "999");
assertThat(hash(plan))
.as("a shallow copy left every nested collection the caller's own object")
.isEqualTo(before);
}
@Test
@DisplayName("the same plan fingerprints the same way twice")
void theSamePlanFingerprintsTheSameWayTwice() {
NotificationPlan plan = planWithStrategy(List.of(Channel.EMAIL, Channel.SMS));
assertThat(hash(plan)).isEqualTo(hash(plan));
}
private static NotificationPlan planWithStrategy(List<Channel> channels) {
return PlatformFakes.plan("idem-1", Map.of(), new OrderedFallback(channels), Optional.empty());
}
/**
* Hash a plan the way production does: encode once, then hash the bytes.
*
* @param plan the plan
* @return the fingerprint
*/
private String hash(NotificationPlan plan) {
return fingerprint.of(new CanonicalNotificationPlanEncoder().encode(plan));
}
private static NotificationPlan planWithVariables(Map<String, Object> variables) {
return PlatformFakes.plan(
"idem-1", variables, new OrderedFallback(List.of(Channel.EMAIL)), Optional.empty());
}
private static NotificationPlan planWithOverride(Optional<ChannelPreferenceOverride> override) {
return PlatformFakes.plan(
"idem-1", Map.of(), new OrderedFallback(List.of(Channel.EMAIL)), override);
}
}
@@ -0,0 +1,184 @@
package dev.caskeleton.application.notification.platform.dispatch;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Whether two different reconciliation answers about one attempt are two different events.
*
* <p>They were not. The fingerprint was a seed string reinterpreted as a {@code BigInteger},
* rendered as hex, and truncated to 64 characters — which is the first 32 bytes of the seed, all of
* them inside the attempt's UUID. The event type and native type sat past the cut and could not
* change the result, so "accepted" and "delivered" for the same attempt fingerprinted identically
* and the ledger's uniqueness constraint dropped whichever arrived second.
*/
class SyntheticEventFingerprintTest {
private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary");
private static final DeliveryAttemptId ATTEMPT =
new DeliveryAttemptId(UUID.fromString("0198f1a2-3b4c-7d8e-9f01-234567890abc"));
private final SyntheticEventFingerprint fingerprints =
new SyntheticEventFingerprint(SyntheticEventFingerprintTest::sha256);
@Test
@DisplayName("accepted and delivered for the same attempt are different events")
void acceptedAndDeliveredAreDifferentEvents() {
String accepted =
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.PROVIDER_ACCEPTED, "Send"));
String delivered =
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery"));
assertThat(accepted)
.as("the later correction used to be discarded as a duplicate of the earlier one")
.isNotEqualTo(delivered);
}
@Test
@DisplayName("the same answer read twice is the same event")
void theSameAnswerReadTwiceIsTheSameEvent() {
assertThat(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
.isEqualTo(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")));
}
@Test
@DisplayName("a different attempt is a different event")
void aDifferentAttemptIsADifferentEvent() {
DeliveryAttemptId other = new DeliveryAttemptId(UUID.randomUUID());
assertThat(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
.isNotEqualTo(
fingerprints.of(
PROFILE, other, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")));
}
@Test
@DisplayName("a different provider profile is a different event")
void aDifferentProfileIsADifferentEvent() {
assertThat(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
.isNotEqualTo(
fingerprints.of(
new ProviderProfileId("ses-secondary"),
ATTEMPT,
event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")));
}
@Test
@DisplayName("the same native type differing only by where a delimiter falls does not collide")
void aDelimiterCannotBeSmuggled() {
// "a|b" + "c" and "a" + "b|c" are the same joined string under a delimiter scheme. Length
// framing makes the boundary a count rather than a character a value can contain.
String first =
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "a|b"));
String second =
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "a"));
assertThat(first).isNotEqualTo(second);
}
@Test
@DisplayName("two spellings of one instant are one event")
void twoSpellingsOfOneInstantAreOneEvent() {
Instant utc = Instant.parse("2026-08-15T09:00:00Z");
Instant offset = OffsetDateTime.parse("2026-08-15T11:00:00+02:00").toInstant();
assertThat(fingerprints.of(PROFILE, ATTEMPT, eventAt(utc)))
.as("Z and +02:00 name the same moment; rendering them as text would make them two")
.isEqualTo(fingerprints.of(PROFILE, ATTEMPT, eventAt(offset)));
}
@Test
@DisplayName("two different instants are two events")
void twoDifferentInstantsAreTwoEvents() {
assertThat(fingerprints.of(PROFILE, ATTEMPT, eventAt(Instant.parse("2026-08-15T09:00:00Z"))))
.isNotEqualTo(
fingerprints.of(PROFILE, ATTEMPT, eventAt(Instant.parse("2026-08-15T09:00:01Z"))));
}
@Test
@DisplayName("a native type differing only in Unicode content is a different event")
void unicodeContentIsPartOfTheIdentity() {
assertThat(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
.isNotEqualTo(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivéry")));
}
@Test
@DisplayName("the result is 64 lowercase hex characters, as the ledger column requires")
void theResultIsSixtyFourHexCharacters() {
assertThat(
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
.hasSize(64)
.matches("[0-9a-f]{64}");
}
@Test
@DisplayName("the fingerprint actually depends on the event, not only on the attempt")
void theFingerprintDependsOnTheEvent() {
// The old implementation's defining property, stated as a test: every event for one attempt
// shared a fingerprint. Any implementation with that property fails here.
String first =
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.PROVIDER_ACCEPTED, "Send"));
String second =
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.UNDELIVERED, "Bounce"));
String third =
fingerprints.of(
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery"));
assertThat(java.util.Set.of(first, second, third)).hasSize(3);
}
private static NormalizedProviderEvent event(NormalizedEventType type, String nativeType) {
return new NormalizedProviderEvent(
type,
nativeType,
Optional.empty(),
Optional.empty(),
Optional.of(Instant.parse("2026-08-15T09:00:00Z")),
Map.of());
}
private static NormalizedProviderEvent eventAt(Instant occurredAt) {
return new NormalizedProviderEvent(
NormalizedEventType.DELIVERY_CONFIRMED,
"Delivery",
Optional.empty(),
Optional.empty(),
Optional.of(occurredAt),
Map.of());
}
private static byte[] sha256(byte[] input) {
try {
return MessageDigest.getInstance("SHA-256").digest(input);
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is required by every Java platform", unavailable);
}
}
}
@@ -0,0 +1,161 @@
package dev.caskeleton.application.notification.platform.observation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What the platform is allowed to say about itself.
*
* <p>Two claims were made and neither was enforced. The cardinality guard checked tag
* <em>keys</em>, which come from a fixed list in its own source — never the unbounded side; the
* values, which are caller-supplied categories, template ids and provider paths, went through
* untouched, so one metric could become one time series per request. And {@code
* NotificationAuditEvent} carried the sentence "Contact point values never appear here" with
* nothing checking it, in the one store that is append-only and widely readable.
*/
class BoundedObservabilityTest {
private static final Instant NOW = Instant.parse("2026-08-15T09:00:00Z");
@Test
@DisplayName("ten thousand distinct categories produce a bounded number of series")
void tenThousandCategoriesProduceABoundedNumberOfSeries() {
CardinalityGuard guard = new CardinalityGuard();
Set<String> series = new HashSet<>();
for (int i = 0; i < 10_000; i++) {
series.add(
guard.bound(Map.of("notificationCategory", "category-" + i)).get("notificationCategory"));
}
assertThat(series)
.as("a caller passing a request id as a category used to create a series per request")
.hasSizeLessThanOrEqualTo(65)
.contains(CardinalityGuard.OTHER);
}
@Test
@DisplayName("ten thousand distinct callback paths produce a bounded number of series")
void tenThousandPathsProduceABoundedNumberOfSeries() {
CardinalityGuard guard = new CardinalityGuard();
Set<String> series = new HashSet<>();
for (int i = 0; i < 10_000; i++) {
series.add(guard.bound(Map.of("templateId", "tpl-" + i)).get("templateId"));
}
assertThat(series).hasSizeLessThanOrEqualTo(65);
}
@Test
@DisplayName("the values a deployment really uses keep their own series")
void theValuesADeploymentReallyUsesKeepTheirOwnSeries() {
CardinalityGuard guard = new CardinalityGuard();
assertThat(guard.bound(Map.of("channel", "EMAIL"))).containsEntry("channel", "EMAIL");
assertThat(guard.bound(Map.of("channel", "SMS"))).containsEntry("channel", "SMS");
assertThat(guard.bound(Map.of("attemptBucket", "3"))).containsEntry("attemptBucket", "3");
}
@Test
@DisplayName("an address never becomes a metric tag")
void anAddressNeverBecomesAMetricTag() {
CardinalityGuard guard = new CardinalityGuard();
assertThat(guard.bound(Map.of("provider", "user@example.com")))
.containsEntry("provider", CardinalityGuard.OTHER);
}
@Test
@DisplayName("an unknown tag key is still refused outright")
void anUnknownTagKeyIsStillRefused() {
assertThatThrownBy(() -> new CardinalityGuard().bound(Map.of("recipientRef", "r-1")))
.isInstanceOf(IllegalMetricTagException.class);
}
@Test
@DisplayName("an audit event refuses an address, a phone number, a token and a code")
void anAuditEventRefusesContactAndCredentialValues() {
for (String leak :
new String[] {
"user@example.com",
"+1 415 555 0142",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
"Bearer sk-live-1",
"483920",
"-----BEGIN PRIVATE KEY-----"
}) {
assertThatThrownBy(() -> auditWithAttribute("detail", leak))
.as("%s", leak)
.isInstanceOf(IllegalArgumentException.class);
}
}
@Test
@DisplayName("the refusal message does not repeat the value it refused")
void theRefusalMessageDoesNotRepeatTheValue() {
assertThatThrownBy(() -> auditWithAttribute("detail", "user@example.com"))
.as("an exception message is itself logged, so naming the value leaks it through the check")
.hasMessageNotContaining("user@example.com");
}
@Test
@DisplayName("an actor, a reason and an operation id are checked too, not only attributes")
void everyStringTheEventCarriesIsChecked() {
assertThatThrownBy(
() ->
new NotificationAuditEvent(
"ADMIN_REDRIVE",
"operator@example.com",
Optional.of("REDRIVE"),
Optional.of("op-1"),
NOW,
Map.of()))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationAuditEvent(
"ADMIN_REDRIVE",
"operator-1",
Optional.of("otp=483920"),
Optional.of("op-1"),
NOW,
Map.of()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("an ordinary audit event is unaffected")
void anOrdinaryAuditEventIsUnaffected() {
assertThatCode(
() ->
new NotificationAuditEvent(
"ADMIN_REDRIVE",
"operator-1",
Optional.of("INCIDENT-4821"),
Optional.of("op-1"),
NOW,
Map.of("provider", "ses", "channel", "EMAIL", "generation", "3")))
.as("a reference — an id, a hash, a key id — is exactly what the audit trail should carry")
.doesNotThrowAnyException();
}
private static NotificationAuditEvent auditWithAttribute(String key, String value) {
return new NotificationAuditEvent(
"ADMIN_REDRIVE",
"operator-1",
Optional.of("REDRIVE"),
Optional.of("op-1"),
NOW,
Map.of(key, value));
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.application.notification.platform.provider;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import java.time.Duration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What a failure before the wire is allowed to claim.
*
* <p>The dispatch path caught every {@code RuntimeException} from the gateway and recorded {@code
* responseLost()} — body committed, outcome ambiguous. Most of what that path throws happens before
* any network call: a disabled runtime, an exhausted rate limiter or concurrency permit, a contact
* point that could not be revealed, a payload that failed mapping.
*
* <p>The cost is asymmetric. An ambiguous attempt is deliberately never retried automatically and
* never falls back to another channel, so a rate-limiter rejection — a condition that clears in a
* second — permanently blocked the delivery it rejected and sent it into a reconciliation that can
* only ever answer "the provider has no record of this".
*
* <p>The information was already there and thrown away: {@link
* NotificationFailureDescriptor#preDispatch} sets {@code ambiguous=false}, and every limiter,
* runtime-state and configuration rejection uses it.
*/
class PreDispatchFailureEvidenceTest {
@Test
@DisplayName("a pre-dispatch descriptor already says nothing was transmitted")
void aPreDispatchDescriptorSaysNothingWasTransmitted() {
NotificationFailureDescriptor descriptor =
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED);
assertThat(descriptor.ambiguous())
.as("this flag was computed, recorded, and then discarded by the dispatch catch block")
.isFalse();
}
@Test
@DisplayName("a not-submitted result carries no transmission evidence")
void aNotSubmittedResultCarriesNoTransmissionEvidence() {
ProviderSubmissionResult result =
ProviderSubmissionResult.notSubmitted(
ProviderFailure.of(
NotificationFailureCode.PROVIDER_UNAVAILABLE,
FailureCategory.CAPACITY_REJECTED,
true),
Duration.ZERO);
assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.NOT_SUBMITTED);
assertThat(result.confirmation())
.as("a limiter rejection is a definite non-send, not an unknown")
.isNotEqualTo(AttemptConfirmation.AMBIGUOUS);
assertThat(result.executionEvidence().requestBodyCommitted().value()).isFalse();
assertThat(result.executionEvidence().requestStarted().value()).isFalse();
}
@Test
@DisplayName("an ambiguous result is what a lost response looks like, and only that")
void anAmbiguousResultIsWhatALostResponseLooksLike() {
ProviderSubmissionResult result =
ProviderSubmissionResult.ambiguous(
ProviderFailure.of(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
FailureCategory.AMBIGUOUS_SUBMISSION,
false),
ProviderExecutionEvidence.responseLost(),
Duration.ZERO);
assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
assertThat(result.executionEvidence().requestBodyCommitted().value())
.as("the body reached the provider; only the answer was lost")
.isTrue();
}
@Test
@DisplayName("a typed pre-write exception converts to a definite non-send")
void aTypedPreWriteExceptionConvertsToADefiniteNonSend() {
ProviderCallNotStartedException notStarted =
new ProviderCallNotStartedException(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT,
FailureCategory.INVALID_PAYLOAD,
false,
"the rendered payload exceeds the provider limit");
ProviderFailure failure = notStarted.toFailure();
assertThat(failure.code()).isEqualTo(NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT);
assertThat(failure.retryable()).isFalse();
assertThat(notStarted.getMessage())
.as("the message reaches logs, so it carries no recipient or payload content")
.doesNotContain("@");
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What the outbox accepts as a payload.
*
* <p>It accepted anything. The envelope serialiser inserts the payload into the JSON document
* verbatim and unescaped — that is documented, and nothing enforced the precondition it documents.
* A payload that is not valid JSON produces an envelope no consumer can parse, permanently, because
* the row is durable and the relay retries it until the attempt budget is gone. A payload
* containing the envelope's own field syntax rewrites the envelope.
*/
class OutboxPayloadPolicyTest {
@Test
@DisplayName("ordinary JSON values are accepted")
void ordinaryJsonValuesAreAccepted() {
assertThatCode(
() -> {
OutboxPayloadPolicy.requireValidPayload("{}");
OutboxPayloadPolicy.requireValidPayload("{\"orderId\":\"o-1\",\"amount\":12.5e3}");
OutboxPayloadPolicy.requireValidPayload("[1,2,{\"a\":[true,false,null]}]");
OutboxPayloadPolicy.requireValidPayload("\"a string\"");
OutboxPayloadPolicy.requireValidPayload("-0.5");
OutboxPayloadPolicy.requireValidPayload(" {\"padded\": true} ");
})
.doesNotThrowAnyException();
}
@Test
@DisplayName("a payload that is not JSON is refused before the row is written")
void aPayloadThatIsNotJsonIsRefused() {
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("not json"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not valid JSON");
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":}"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":1,}"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("[1,2"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload(""))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a payload that would rewrite the envelope's own fields is refused")
void aPayloadThatWouldRewriteTheEnvelopeIsRefused() {
// Inserted verbatim, this closes the payload value and appends a field of its own.
assertThatThrownBy(
() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":1},\"eventType\":\"forged\""))
.as("the payload is concatenated into the envelope, so trailing content is an injection")
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("trailing content");
}
@Test
@DisplayName("two JSON values are not one payload")
void twoJsonValuesAreNotOnePayload() {
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{} {}"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("exactly one JSON value");
}
@Test
@DisplayName("a raw control character inside a string is refused")
void aRawControlCharacterInsideAStringIsRefused() {
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":\"line\nbreak\"}"))
.as("a raw newline is invalid JSON and is also what turns one log line into two")
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("control character");
}
@Test
@DisplayName("the size limit is applied in UTF-8 bytes, at the boundary")
void theSizeLimitIsAppliedInUtf8Bytes() {
// A multi-byte character: a limit counted in char units would accept three times too much.
String fill = "";
int perCharacter = fill.getBytes(StandardCharsets.UTF_8).length;
int charactersThatFit = (OutboxPayloadPolicy.MAX_PAYLOAD_BYTES - 2) / perCharacter;
assertThatCode(
() ->
OutboxPayloadPolicy.requireValidPayload(
"\"" + fill.repeat(charactersThatFit) + "\""))
.doesNotThrowAnyException();
assertThatThrownBy(
() ->
OutboxPayloadPolicy.requireValidPayload(
"\"" + fill.repeat(charactersThatFit + 1) + "\""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("UTF-8 bytes");
}
@Test
@DisplayName("a surrogate pair survives the validator")
void aSurrogatePairSurvivesTheValidator() {
assertThatCode(() -> OutboxPayloadPolicy.requireValidPayload("{\"emoji\":\"🚀\"}"))
.doesNotThrowAnyException();
assertThatCode(
() -> OutboxPayloadPolicy.requireValidPayload("{\"escaped\":\"\\ud83d\\ude80\"}"))
.doesNotThrowAnyException();
}
@Test
@DisplayName("deep nesting is refused rather than overflowing the appending thread's stack")
void deepNestingIsRefused() {
String deep = "[".repeat(5_000) + "]".repeat(5_000);
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload(deep))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nesting");
}
@Test
@DisplayName("the outbox event applies the policy at construction")
void theOutboxEventAppliesThePolicy() {
assertThatThrownBy(
() ->
new OutboxEvent(
"evt-1",
"WorkLogReserved",
"agg-1",
"{\"unterminated\": ",
java.time.Instant.parse("2026-08-10T09:00:00Z"),
"corr-1",
"idem-1",
OutboxEventStatus.PENDING,
0))
.as("after the row commits, the relay retries an unparseable message until it is DEAD")
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The semantics a bridge must preserve, and the trap it must not fall into.
*
* <p>Two outbox models exist. The legacy {@code OutboxEventStatus.FAILED} is <em>retryable</em> and
* {@code DEAD} is terminal; the platform's {@code OutboxStatus.FAILED} is a definite rejection and
* terminal, while {@code AMBIGUOUS} is the retryable one. Mapping those by name — which is what a
* mechanical bridge does — inverts both meanings: a definitely-rejected message is retried forever
* and an unknown one is parked where nobody looks.
*/
class OutboxPublishOutcomeTest {
@Test
@DisplayName("only an ambiguous outcome is retryable")
void onlyAmbiguousIsRetryable() {
assertThat(OutboxPublishOutcome.AMBIGUOUS.retryable()).isTrue();
assertThat(OutboxPublishOutcome.CONFIRMED.retryable()).isFalse();
assertThat(OutboxPublishOutcome.REJECTED_BEFORE_SEND.retryable())
.as("a definite rejection retried forever is a queue that never drains")
.isFalse();
assertThat(OutboxPublishOutcome.REJECTED_AFTER_BROKER.retryable()).isFalse();
}
@Test
@DisplayName("only a pre-send rejection guarantees nothing was transmitted")
void onlyPreSendRejectionGuaranteesNothingWasTransmitted() {
assertThat(OutboxPublishOutcome.REJECTED_BEFORE_SEND.nothingTransmitted()).isTrue();
assertThat(OutboxPublishOutcome.REJECTED_AFTER_BROKER.nothingTransmitted())
.as("the broker received it and refused it; that is a different investigation")
.isFalse();
assertThat(OutboxPublishOutcome.AMBIGUOUS.nothingTransmitted())
.as("the whole point of AMBIGUOUS is that this cannot be asserted")
.isFalse();
}
@Test
@DisplayName("the legacy retryable state is not the one the platform calls FAILED")
void theLegacyRetryableStateIsNotThePlatformsFailed() {
// Written as an assertion because the inversion is the trap: `FAILED` is the retryable state
// in the legacy model and the terminal one in the platform's. A bridge that maps by name turns
// a definite rejection into an infinite retry and an unknown outcome into a parked row.
assertThat(OutboxEventStatus.FAILED)
.as("legacy FAILED is retryable — it carries next_attempt_at")
.isNotEqualTo(OutboxEventStatus.DEAD);
assertThat(OutboxPublishOutcome.valueOf("REJECTED_BEFORE_SEND").retryable())
.as("the outcome that corresponds to legacy DEAD, not to legacy FAILED")
.isFalse();
assertThat(OutboxPublishOutcome.AMBIGUOUS.retryable())
.as("the outcome that corresponds to legacy FAILED")
.isTrue();
}
@Test
@DisplayName("a port that can only return or throw reports CONFIRMED")
void aPortThatCanOnlyReturnOrThrowReportsConfirmed() {
OutboxMessagePublishPort legacy = event -> {};
assertThat(legacy.publishForOutcome(null))
.as("'returned without throwing' has always meant confirmed for such an adapter")
.isEqualTo(OutboxPublishOutcome.CONFIRMED);
}
}
@@ -27,7 +27,9 @@ class OutboxRelayFailureReportTest {
"correlationId",
"attemptCount",
"nextAttemptAt",
"cause");
// Not "cause": a Throwable component is a Throwable in the operational JSON, and the
// allowlist that guards every other field had no say over its message or stack.
"causeType");
}
@Test
@@ -43,7 +45,9 @@ class OutboxRelayFailureReportTest {
assertThat(report.correlationId()).isEqualTo("corr-1");
assertThat(report.attemptCount()).isEqualTo(2);
assertThat(report.nextAttemptAt()).isEqualTo(NEXT_ATTEMPT_AT);
assertThat(report.cause()).isSameAs(CAUSE);
assertThat(report.causeType())
.as("the raw Throwable used to travel into the operational JSON log with it")
.isEqualTo(CAUSE.getClass().getName());
}
@Test
@@ -54,7 +58,9 @@ class OutboxRelayFailureReportTest {
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
assertThat(report.nextAttemptAt()).isNull();
assertThat(report.cause()).isSameAs(CAUSE);
assertThat(report.causeType())
.as("the raw Throwable used to travel into the operational JSON log with it")
.isEqualTo(CAUSE.getClass().getName());
}
@Test
@@ -69,7 +75,7 @@ class OutboxRelayFailureReportTest {
"corr-1",
1,
null,
CAUSE))
CAUSE.getClass().getName()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("code");
}
@@ -115,7 +121,7 @@ class OutboxRelayFailureReportTest {
"corr-1",
1,
null,
CAUSE))
CAUSE.getClass().getName()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nextAttemptAt");
}
@@ -132,7 +138,7 @@ class OutboxRelayFailureReportTest {
"corr-1",
1,
NEXT_ATTEMPT_AT,
CAUSE))
CAUSE.getClass().getName()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nextAttemptAt");
}