merge: integrate notification production capability

# Conflicts:
#	docs/superpowers/plans/2026-07-28-notification-production-capability.md
#	src/adapter/outbound/persistence-jpa/build.gradle
#	src/adapter/outbound/persistence-jpa/gradle.lockfile
#	src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
#	src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java
#	src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java
#	src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java
This commit is contained in:
donghyeon-ka
2026-08-01 00:11:53 +09:00
77 changed files with 7051 additions and 68 deletions
@@ -0,0 +1,151 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/**
* Fully resolved immutable route graph; only {@link NotificationBindingCompiler} can construct it.
*/
public final class CompiledNotificationBinding {
private final NotificationRouteDescriptor route;
private final NotificationTemplateDescriptor template;
private final List<CompiledTarget> targets;
private final String bindingDigest;
CompiledNotificationBinding(
NotificationRouteDescriptor route,
NotificationTemplateDescriptor template,
List<CompiledTarget> targets,
String bindingDigest) {
this.route = Objects.requireNonNull(route, "compiled notification route must be non-null");
this.template =
Objects.requireNonNull(template, "compiled notification template must be non-null");
Objects.requireNonNull(targets, "compiled notification targets must be non-null");
this.targets =
targets.stream()
.map(target -> Objects.requireNonNull(target, "compiled target must be non-null"))
.sorted(Comparator.comparing(target -> target.target().targetId()))
.toList();
if (this.targets.size() != route.targets().size()) {
throw new IllegalArgumentException(
"compiled notification target count must match route target count");
}
this.bindingDigest =
NotificationCatalogException.requireDigest(
"compiled notification binding digest", bindingDigest);
}
public NotificationRouteDescriptor route() {
return route;
}
public NotificationTemplateDescriptor template() {
return template;
}
public List<CompiledTarget> targets() {
return targets;
}
public String bindingDigest() {
return bindingDigest;
}
@Override
public boolean equals(Object other) {
return this == other
|| (other instanceof CompiledNotificationBinding that
&& route.equals(that.route)
&& template.equals(that.template)
&& targets.equals(that.targets)
&& bindingDigest.equals(that.bindingDigest));
}
@Override
public int hashCode() {
return Objects.hash(route, template, targets, bindingDigest);
}
@Override
public String toString() {
return "CompiledNotificationBinding[route="
+ route.routeId().value()
+ ", routeRevision="
+ route.routeRevision()
+ ", template="
+ template.templateRef().templateId()
+ "-v"
+ template.templateRef().version()
+ ", targets=<redacted>, bindingDigest="
+ bindingDigest
+ "]";
}
/** Non-forgeable resolved target exposed read-only to provider protocol implementations. */
public static final class CompiledTarget {
private final NotificationRouteDescriptor.Target target;
private final NotificationProviderRuntimeProfile runtimeProfile;
private final NotificationProviderDescriptor provider;
private final NotificationProviderCapabilityCard capabilityCard;
CompiledTarget(
NotificationRouteDescriptor.Target target,
NotificationProviderRuntimeProfile runtimeProfile,
NotificationProviderDescriptor provider,
NotificationProviderCapabilityCard capabilityCard) {
this.target = Objects.requireNonNull(target, "compiled route target must be non-null");
this.runtimeProfile =
Objects.requireNonNull(runtimeProfile, "compiled runtime profile must be non-null");
this.provider = Objects.requireNonNull(provider, "compiled provider must be non-null");
this.capabilityCard =
Objects.requireNonNull(capabilityCard, "compiled capability card must be non-null");
}
public NotificationRouteDescriptor.Target target() {
return target;
}
public NotificationProviderRuntimeProfile runtimeProfile() {
return runtimeProfile;
}
public NotificationProviderDescriptor provider() {
return provider;
}
public NotificationProviderCapabilityCard capabilityCard() {
return capabilityCard;
}
@Override
public boolean equals(Object other) {
return this == other
|| (other instanceof CompiledTarget that
&& target.equals(that.target)
&& runtimeProfile.equals(that.runtimeProfile)
&& provider.equals(that.provider)
&& capabilityCard.equals(that.capabilityCard));
}
@Override
public int hashCode() {
return Objects.hash(target, runtimeProfile, provider, capabilityCard);
}
@Override
public String toString() {
return "CompiledTarget[targetId="
+ target.targetId()
+ ", runtimeProfileId="
+ runtimeProfile.profileId()
+ ", providerId="
+ provider.providerId()
+ ", capabilityCardId="
+ capabilityCard.cardId()
+ ", sensitiveReferences=<redacted>]";
}
}
}
@@ -0,0 +1,516 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationRouteId;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
/**
* Pure deterministic compiler over explicit provider, template, route and runtime-profile inputs.
*/
public final class NotificationBindingCompiler {
private static final Set<String> INITIAL_CARD_IDS =
Set.of(
"slack-web-api-inline-single-local-v1",
"slack-web-api-durable-single-local-v1",
"aws-ses-v2-durable-single-local-sns-v1");
private static final Map<String, String> APPROVED_CARD_PROVIDERS =
Map.of(
"slack-web-api-inline-single-local-v1",
"slack-web-api",
"slack-web-api-durable-single-local-v1",
"slack-web-api",
"aws-ses-v2-durable-single-local-sns-v1",
"aws-ses-v2");
private final List<NotificationProviderDescriptor> providers;
private final List<NotificationTemplateDescriptor> templates;
private final List<NotificationRouteDescriptor> routes;
private final List<NotificationProviderRuntimeProfile> runtimeProfiles;
private final Map<NotificationRouteId, Integer> activeRouteRevisions;
public NotificationBindingCompiler(
List<NotificationProviderDescriptor> providers,
List<NotificationTemplateDescriptor> templates,
List<NotificationRouteDescriptor> routes,
List<NotificationProviderRuntimeProfile> runtimeProfiles) {
this(providers, templates, routes, runtimeProfiles, singletonActiveRevisions(routes));
}
public NotificationBindingCompiler(
List<NotificationProviderDescriptor> providers,
List<NotificationTemplateDescriptor> templates,
List<NotificationRouteDescriptor> routes,
List<NotificationProviderRuntimeProfile> runtimeProfiles,
Map<NotificationRouteId, Integer> activeRouteRevisions) {
this.providers = immutableCopy("providers", providers);
this.templates = immutableCopy("templates", templates);
this.routes = immutableCopy("routes", routes);
this.runtimeProfiles = immutableCopy("runtime profiles", runtimeProfiles);
this.activeRouteRevisions =
Map.copyOf(
Objects.requireNonNull(
activeRouteRevisions, "active notification route revisions must be non-null"));
}
public CompiledGraph compile() {
Map<String, NotificationProviderDescriptor> providerIndex =
uniqueIndex(providers, NotificationProviderDescriptor::providerId, "provider");
Map<NotificationTemplateRef, NotificationTemplateDescriptor> templateIndex =
uniqueIndex(templates, NotificationTemplateDescriptor::templateRef, "template");
Map<String, NotificationProviderRuntimeProfile> profileIndex =
uniqueIndex(
runtimeProfiles, NotificationProviderRuntimeProfile::profileId, "runtime profile");
uniqueIndex(
routes, route -> route.routeId().value() + "#" + route.routeRevision(), "route revision");
validateActiveRevisions(routes, activeRouteRevisions);
validateRegisteredCards(providerIndex.values());
List<CompiledNotificationBinding> bindings =
routes.stream()
.sorted(
Comparator.comparing((NotificationRouteDescriptor route) -> route.routeId().value())
.thenComparingInt(NotificationRouteDescriptor::routeRevision))
.map(route -> compileRoute(route, providerIndex, templateIndex, profileIndex))
.toList();
if (bindings.isEmpty() || bindings.size() > 100) {
throw new NotificationCatalogException(
"notification binding graph must contain 1..100 routes");
}
return new CompiledGraph(
bindings, activeRouteRevisions, manifestDigest(bindings, activeRouteRevisions));
}
private static CompiledNotificationBinding compileRoute(
NotificationRouteDescriptor route,
Map<String, NotificationProviderDescriptor> providers,
Map<NotificationTemplateRef, NotificationTemplateDescriptor> templates,
Map<String, NotificationProviderRuntimeProfile> profiles) {
NotificationTemplateDescriptor template = templates.get(route.templateRef());
if (template == null) {
throw new NotificationCatalogException(
"unknown template for route " + route.routeId().value());
}
if (template.channel() != route.channel()) {
throw new NotificationCatalogException(
"template channel does not match route " + route.routeId().value());
}
validateRouteShape(route);
List<CompiledNotificationBinding.CompiledTarget> targets = new ArrayList<>();
for (NotificationRouteDescriptor.Target target : route.targets()) {
NotificationProviderRuntimeProfile profile = profiles.get(target.runtimeProfileId());
if (profile == null) {
throw new NotificationCatalogException(
"unknown runtime profile for target " + target.targetId());
}
NotificationProviderDescriptor provider = providers.get(profile.providerId());
if (provider == null) {
throw new NotificationCatalogException(
"unknown provider for runtime profile " + profile.profileId());
}
if (provider.legacyFailOpen()) {
throw new NotificationCatalogException(
"legacy fail-open provider cannot participate in a canonical binding");
}
NotificationProviderCapabilityCard card =
provider.capabilityCards().stream()
.filter(candidate -> candidate.cardId().equals(profile.capabilityCardId()))
.findFirst()
.orElseThrow(
() ->
new NotificationCatalogException(
"unknown capability card for runtime profile " + profile.profileId()));
validateCompatibility(route, template, provider, card);
targets.add(new CompiledNotificationBinding.CompiledTarget(target, profile, provider, card));
}
String bindingDigest = bindingDigest(route, template, targets);
return new CompiledNotificationBinding(route, template, targets, bindingDigest);
}
private static void validateRouteShape(NotificationRouteDescriptor route) {
if (route.routeStrategy()
!= dev.caskeleton.application.notification.NotificationRouteStrategy.SINGLE) {
throw new NotificationCatalogException(
"initial notification catalog supports SINGLE route strategy only");
}
if (route.targets().size() != route.maximumTargets()) {
throw new NotificationCatalogException(
"route target count must equal its maximum target bound");
}
long worstCaseCalls =
Math.addExact(
Math.multiplyExact(
(long) route.maximumTargets(), (long) route.maximumPhysicalAttempts()),
route.maximumReconcileCalls());
if (worstCaseCalls > route.maximumTotalProviderCalls()) {
throw new NotificationCatalogException(
"route amplification exceeds maximum total provider calls");
}
switch (route.routeStrategy()) {
case SINGLE -> {
if (route.maximumTargets() != 1
|| route.maximumFallbackActivations() != 0
|| route.targets().stream().anyMatch(target -> target.fallbackTargetId().isPresent())) {
throw new NotificationCatalogException(
"SINGLE route requires one target and no fallback");
}
}
case FAN_OUT_ALL -> {
if (route.maximumFallbackActivations() != 0
|| route.targets().stream().anyMatch(target -> target.fallbackTargetId().isPresent())) {
throw new NotificationCatalogException("FAN_OUT_ALL route cannot define fallback");
}
}
case ORDERED_FALLBACK -> {
if (route.maximumTargets() < 2
|| route.maximumFallbackActivations() < 1
|| route.maximumFallbackActivations() > route.maximumTargets() - 1) {
throw new NotificationCatalogException("ORDERED_FALLBACK route has invalid bounds");
}
}
default ->
throw new NotificationCatalogException(
"unknown notification route strategy: " + route.routeStrategy());
}
validateFallbackGraph(route);
}
private static void validateFallbackGraph(NotificationRouteDescriptor route) {
Map<String, String> edges = new HashMap<>();
Set<String> targets =
route.targets().stream()
.map(NotificationRouteDescriptor.Target::targetId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
route
.targets()
.forEach(
target ->
target
.fallbackTargetId()
.ifPresent(
fallback -> {
if (!targets.contains(fallback)) {
throw new NotificationCatalogException(
"fallback references unknown target");
}
edges.put(target.targetId(), fallback);
}));
for (String start : targets) {
Set<String> visited = new HashSet<>();
String current = start;
while (current != null) {
if (!visited.add(current)) {
throw new NotificationCatalogException("cyclic notification fallback graph");
}
current = edges.get(current);
}
}
}
private static void validateCompatibility(
NotificationRouteDescriptor route,
NotificationTemplateDescriptor template,
NotificationProviderDescriptor provider,
NotificationProviderCapabilityCard card) {
if (provider.channel() != route.channel() || card.channel() != route.channel()) {
throw new NotificationCatalogException(
"provider channel does not match route " + route.routeId().value());
}
if (card.mode() != route.mode()) {
throw new NotificationCatalogException(
"provider mode does not match route " + route.routeId().value());
}
if (card.routeStrategy() != route.routeStrategy()) {
throw new NotificationCatalogException(
"provider route strategy does not match route " + route.routeId().value());
}
if (route.maximumTargets() > card.maximumTargets()) {
throw new NotificationCatalogException("route target bound exceeds provider capability");
}
if (route.receiptRequired() && !card.receiptSupported()) {
throw new NotificationCatalogException(
"receipt-required route uses provider without receipt capability");
}
if (route.maximumReconcileCalls() > card.maximumReconcileCalls()
|| (route.maximumReconcileCalls() > 0 && !card.reconciliationSupported())) {
throw new NotificationCatalogException(
"route reconciliation bound exceeds provider capability");
}
if (route.maximumPhysicalAttempts() > card.maximumPhysicalAttempts()) {
throw new NotificationCatalogException("route attempt bound exceeds provider capability");
}
if (route.maximumTotalProviderCalls() > card.maximumTotalProviderCalls()) {
throw new NotificationCatalogException(
"route amplification bound exceeds provider capability");
}
if (template.maximumRenderedBytes() > card.maximumPayloadBytes()) {
throw new NotificationCatalogException("template payload bound exceeds provider capability");
}
if (route.fallbackAfterIndeterminate() && card.terminalIndeterminatePossible()) {
throw new NotificationCatalogException(
"fallback after an indeterminate submission is unsafe");
}
}
private static void validateRegisteredCards(
java.util.Collection<NotificationProviderDescriptor> providers) {
Set<String> seen = new HashSet<>();
providers.forEach(
provider ->
provider
.capabilityCards()
.forEach(
card -> {
if (!INITIAL_CARD_IDS.contains(card.cardId())) {
throw new NotificationCatalogException(
"unknown provider capability card: " + card.cardId());
}
if (!provider
.providerId()
.equals(APPROVED_CARD_PROVIDERS.get(card.cardId()))) {
throw new NotificationCatalogException(
"provider capability card is bound to a different provider family");
}
NotificationProviderCapabilityCard approved =
NotificationProviderCapabilityCard.initial(card.cardId());
if (!approved.equals(card)) {
throw new NotificationCatalogException(
"provider capability card differs from approved definition: "
+ card.cardId());
}
if (!seen.add(card.cardId())) {
throw new NotificationCatalogException(
"duplicate provider capability card: " + card.cardId());
}
}));
}
private static String bindingDigest(
NotificationRouteDescriptor route,
NotificationTemplateDescriptor template,
List<CompiledNotificationBinding.CompiledTarget> targets) {
MessageDigest digest = NotificationCatalogException.sha256();
updateRoute(digest, route);
NotificationCatalogException.update(digest, template.templateRef().templateId());
NotificationCatalogException.update(digest, template.templateRef().version());
NotificationCatalogException.update(digest, template.rendererRevision());
NotificationCatalogException.update(digest, template.checksum());
template.supportedLocales().stream()
.map(java.util.Locale::toLanguageTag)
.sorted()
.forEach(locale -> NotificationCatalogException.update(digest, locale));
NotificationCatalogException.update(digest, template.fallbackLocale().toLanguageTag());
template.parameterNames().stream()
.sorted()
.forEach(parameter -> NotificationCatalogException.update(digest, parameter));
NotificationCatalogException.update(digest, template.maximumRenderedBytes());
targets.stream()
.sorted(Comparator.comparing(target -> target.target().targetId()))
.forEach(
target -> {
NotificationCatalogException.update(digest, target.target().targetId());
NotificationCatalogException.update(
digest, target.target().fallbackTargetId().orElse(""));
NotificationCatalogException.update(digest, target.runtimeProfile().profileId());
NotificationCatalogException.update(
digest, target.runtimeProfile().bindingRevision());
NotificationCatalogException.update(
digest, target.runtimeProfile().credentialGeneration());
NotificationCatalogException.update(
digest, target.runtimeProfile().credentialReference());
NotificationCatalogException.update(
digest, target.runtimeProfile().destinationReference());
NotificationCatalogException.update(digest, target.provider().providerId());
NotificationCatalogException.update(digest, target.capabilityCard().cardId());
});
return NotificationCatalogException.finish(digest);
}
private static String manifestDigest(
List<CompiledNotificationBinding> bindings,
Map<NotificationRouteId, Integer> activeRouteRevisions) {
MessageDigest digest = NotificationCatalogException.sha256();
bindings.forEach(
binding -> {
NotificationCatalogException.update(digest, binding.route().routeId().value());
NotificationCatalogException.update(digest, binding.route().routeRevision());
NotificationCatalogException.update(digest, binding.bindingDigest());
});
activeRouteRevisions.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.comparing(NotificationRouteId::value)))
.forEach(
entry -> {
NotificationCatalogException.update(digest, "active");
NotificationCatalogException.update(digest, entry.getKey().value());
NotificationCatalogException.update(digest, entry.getValue());
});
return NotificationCatalogException.finish(digest);
}
private static Map<NotificationRouteId, Integer> singletonActiveRevisions(
List<NotificationRouteDescriptor> routes) {
Objects.requireNonNull(routes, "notification routes must be non-null");
Map<NotificationRouteId, Integer> active = new HashMap<>();
routes.forEach(
route -> {
Objects.requireNonNull(route, "notification route entry is null");
if (active.putIfAbsent(route.routeId(), route.routeRevision()) != null) {
throw new NotificationCatalogException(
"multiple retained route revisions require an explicit active revision map");
}
});
return Map.copyOf(active);
}
private static void validateActiveRevisions(
List<NotificationRouteDescriptor> routes,
Map<NotificationRouteId, Integer> activeRouteRevisions) {
Set<NotificationRouteId> routeIds =
routes.stream()
.map(NotificationRouteDescriptor::routeId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
if (!activeRouteRevisions.keySet().equals(routeIds)) {
throw new NotificationCatalogException(
"active route revision map must exactly match retained route IDs");
}
activeRouteRevisions.forEach(
(routeId, revision) -> {
Objects.requireNonNull(routeId, "active route ID must be non-null");
Objects.requireNonNull(revision, "active route revision must be non-null");
if (routes.stream()
.noneMatch(
route -> route.routeId().equals(routeId) && route.routeRevision() == revision)) {
throw new NotificationCatalogException(
"active route revision does not exist in retained bindings");
}
});
}
private static void updateRoute(MessageDigest digest, NotificationRouteDescriptor route) {
NotificationCatalogException.update(digest, route.routeId().value());
NotificationCatalogException.update(digest, route.routeRevision());
NotificationCatalogException.update(digest, route.channel().name());
NotificationCatalogException.update(digest, route.mode().name());
NotificationCatalogException.update(digest, route.admissionClass().name());
NotificationCatalogException.update(digest, route.routeStrategy().name());
NotificationCatalogException.update(digest, route.receiptRequired());
NotificationCatalogException.update(digest, route.fallbackAfterIndeterminate());
NotificationCatalogException.update(digest, route.maximumTargets());
NotificationCatalogException.update(digest, route.maximumPhysicalAttempts());
NotificationCatalogException.update(digest, route.maximumFallbackActivations());
NotificationCatalogException.update(digest, route.maximumReconcileCalls());
NotificationCatalogException.update(digest, route.maximumTotalProviderCalls());
NotificationCatalogException.update(digest, route.perAttemptDeadline().toNanos());
}
private static <T> List<T> immutableCopy(String label, List<T> source) {
Objects.requireNonNull(source, "notification " + label + " must be non-null");
return source.stream()
.map(value -> Objects.requireNonNull(value, "notification " + label + " entry is null"))
.toList();
}
private static <K, V> Map<K, V> uniqueIndex(List<V> values, Function<V, K> key, String label) {
Map<K, V> index = new HashMap<>();
values.forEach(
value -> {
K itemKey = key.apply(value);
if (index.putIfAbsent(itemKey, value) != null) {
throw new NotificationCatalogException("duplicate " + label + ": " + itemKey);
}
});
return Map.copyOf(index);
}
/** Sorted immutable result and digest suitable for release evidence. */
public static final class CompiledGraph {
private final List<CompiledNotificationBinding> bindings;
private final Map<NotificationRouteId, Integer> activeRouteRevisions;
private final String manifestDigest;
private CompiledGraph(
List<CompiledNotificationBinding> bindings,
Map<NotificationRouteId, Integer> activeRouteRevisions,
String manifestDigest) {
Objects.requireNonNull(bindings, "compiled notification bindings must be non-null");
this.bindings =
bindings.stream()
.map(binding -> Objects.requireNonNull(binding, "compiled binding must be non-null"))
.sorted(
Comparator.comparing(
(CompiledNotificationBinding binding) ->
binding.route().routeId().value())
.thenComparingInt(binding -> binding.route().routeRevision()))
.toList();
this.activeRouteRevisions =
Map.copyOf(
Objects.requireNonNull(
activeRouteRevisions, "active route revisions must be non-null"));
this.manifestDigest =
NotificationCatalogException.requireDigest(
"notification graph manifest digest", manifestDigest);
}
public List<CompiledNotificationBinding> bindings() {
return bindings;
}
public Map<NotificationRouteId, Integer> activeRouteRevisions() {
return activeRouteRevisions;
}
public String manifestDigest() {
return manifestDigest;
}
public CompiledNotificationBinding activeBinding(NotificationRouteId routeId) {
Objects.requireNonNull(routeId, "active notification route ID must be non-null");
Integer revision = activeRouteRevisions.get(routeId);
if (revision == null) {
return null;
}
return bindings.stream()
.filter(binding -> binding.route().routeId().equals(routeId))
.filter(binding -> binding.route().routeRevision() == revision)
.findFirst()
.orElseThrow(
() -> new IllegalStateException("active notification binding is not retained"));
}
@Override
public boolean equals(Object other) {
return this == other
|| (other instanceof CompiledGraph that
&& bindings.equals(that.bindings)
&& activeRouteRevisions.equals(that.activeRouteRevisions)
&& manifestDigest.equals(that.manifestDigest));
}
@Override
public int hashCode() {
return Objects.hash(bindings, activeRouteRevisions, manifestDigest);
}
@Override
public String toString() {
return "CompiledGraph[bindingCount="
+ bindings.size()
+ ", activeRouteCount="
+ activeRouteRevisions.size()
+ ", manifestDigest="
+ manifestDigest
+ "]";
}
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet;
import dev.caskeleton.application.notification.NotificationRouteId;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Retained key-only source of truth derived only from the compiled binding graph. */
public final class NotificationCanonicalRouteCatalog {
private final List<RouteKey> routes;
private NotificationCanonicalRouteCatalog(List<RouteKey> routes) {
Objects.requireNonNull(routes, "canonical notification routes must be non-null");
this.routes =
routes.stream()
.map(route -> Objects.requireNonNull(route, "canonical route key must be non-null"))
.sorted(Comparator.naturalOrder())
.toList();
if (this.routes.isEmpty() || this.routes.size() > 100) {
throw new IllegalArgumentException("canonical route catalog must contain 1..100 routes");
}
if (new HashSet<>(this.routes).size() != this.routes.size()) {
throw new NotificationCatalogException("duplicate canonical route key");
}
if (this.routes.stream().map(RouteKey::routeId).distinct().count() != this.routes.size()) {
throw new NotificationCatalogException("duplicate canonical route ID has multiple revisions");
}
}
public static NotificationCanonicalRouteCatalog fromCompiledGraph(
NotificationBindingCompiler.CompiledGraph graph) {
Objects.requireNonNull(graph, "compiled notification graph must be non-null");
return new NotificationCanonicalRouteCatalog(
graph.activeRouteRevisions().entrySet().stream()
.map(entry -> new RouteKey(entry.getKey(), entry.getValue()))
.toList());
}
static NotificationCanonicalRouteCatalog fromRoutes(List<NotificationRouteDescriptor> routes) {
Objects.requireNonNull(routes, "notification route descriptors must be non-null");
return new NotificationCanonicalRouteCatalog(
routes.stream()
.map(
route -> {
Objects.requireNonNull(route, "notification route descriptor must be non-null");
return new RouteKey(route.routeId(), route.routeRevision());
})
.toList());
}
public List<RouteKey> routes() {
return routes;
}
public NotificationCanonicalWriterRouteSet toApplication(
Map<RouteKey, Long> predecessorGenerations) {
Objects.requireNonNull(
predecessorGenerations, "canonical predecessor generations must be non-null");
if (!predecessorGenerations.keySet().equals(Set.copyOf(routes))) {
throw new NotificationCatalogException(
"canonical generation config must exactly match route catalog keys");
}
return new NotificationCanonicalWriterRouteSet(
routes.stream()
.map(
route ->
new NotificationCanonicalWriterRouteSet.RouteRevision(
route.routeId(),
route.routeRevision(),
Objects.requireNonNull(
predecessorGenerations.get(route),
"predecessor generation must be non-null")))
.toList());
}
public String digest() {
MessageDigest digest = NotificationCatalogException.sha256();
routes.forEach(
route -> {
NotificationCatalogException.update(digest, route.routeId().value());
NotificationCatalogException.update(digest, route.routeRevision());
});
return NotificationCatalogException.finish(digest);
}
@Override
public boolean equals(Object other) {
return this == other
|| (other instanceof NotificationCanonicalRouteCatalog that && routes.equals(that.routes));
}
@Override
public int hashCode() {
return routes.hashCode();
}
@Override
public String toString() {
return "NotificationCanonicalRouteCatalog[routes=" + routes + "]";
}
public record RouteKey(NotificationRouteId routeId, int routeRevision)
implements Comparable<RouteKey> {
public RouteKey {
Objects.requireNonNull(routeId, "canonical notification route ID must be non-null");
if (routeRevision < 1 || routeRevision > 1_000_000) {
throw new IllegalArgumentException("canonical route revision must be in 1..1000000");
}
}
@Override
public int compareTo(RouteKey other) {
int routeOrder = routeId.value().compareTo(other.routeId.value());
return routeOrder != 0 ? routeOrder : Integer.compare(routeRevision, other.routeRevision);
}
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/** Fail-closed error for an invalid checked-in notification catalog. */
public final class NotificationCatalogException extends RuntimeException {
public NotificationCatalogException(String message) {
super(message);
}
static String requireSlug(String field, String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
static String requireOpaque(String field, String value) {
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException(
field + " must contain 1..128 opaque identifier characters");
}
return value;
}
static String requireDigest(String field, String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
}
return value;
}
static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException(
"SHA-256 must be available on every Java runtime", unavailable);
}
}
static void update(MessageDigest digest, String value) {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
digest.update(encoded);
}
static void update(MessageDigest digest, int value) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
}
static void update(MessageDigest digest, long value) {
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array());
}
static void update(MessageDigest digest, boolean value) {
digest.update((byte) (value ? 1 : 0));
}
static String finish(MessageDigest digest) {
return HexFormat.of().formatHex(digest.digest());
}
}
@@ -0,0 +1,151 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet;
import dev.caskeleton.application.notification.NotificationWriterRouteSet;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** PRE-only legacy proof metadata decorating exactly the retained canonical route keys. */
public record NotificationCutoverRouteCatalog(
NotificationCanonicalRouteCatalog canonical, List<CutoverRoute> routes) {
public NotificationCutoverRouteCatalog {
Objects.requireNonNull(canonical, "canonical route catalog must be non-null");
Objects.requireNonNull(routes, "cutover route catalog must be non-null");
routes =
routes.stream()
.map(route -> Objects.requireNonNull(route, "cutover route must be non-null"))
.sorted(Comparator.comparing(CutoverRoute::route))
.toList();
if (!routes.stream().map(CutoverRoute::route).toList().equals(canonical.routes())) {
throw new NotificationCatalogException(
"cutover route keys must exactly equal canonical route keys");
}
List<String> aliases = routes.stream().flatMap(route -> route.legacyAlias().stream()).toList();
if (new HashSet<>(aliases).size() != aliases.size()) {
throw new NotificationCatalogException("cutover route contains duplicate legacy alias");
}
}
public NotificationWriterRouteSet toApplication(
Map<NotificationCanonicalRouteCatalog.RouteKey, Long> predecessorGenerations) {
NotificationCanonicalWriterRouteSet applicationCanonical =
canonical.toApplication(predecessorGenerations);
Map<
NotificationCanonicalRouteCatalog.RouteKey,
NotificationCanonicalWriterRouteSet.RouteRevision>
routeIndex =
applicationCanonical.routes().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
route ->
new NotificationCanonicalRouteCatalog.RouteKey(
route.routeId(), route.routeRevision()),
route -> route));
return new NotificationWriterRouteSet(
applicationCanonical,
routes.stream()
.map(
route ->
new NotificationWriterRouteSet.RouteProfile(
routeIndex.get(route.route()),
route.legacyAlias(),
route.transportProfiles().stream()
.map(LegacyTransportProfile::toApplication)
.toList()))
.toList());
}
public String digest() {
MessageDigest digest = NotificationCatalogException.sha256();
NotificationCatalogException.update(digest, canonical.digest());
routes.forEach(
route -> {
NotificationCatalogException.update(digest, route.route().routeId().value());
NotificationCatalogException.update(digest, route.route().routeRevision());
NotificationCatalogException.update(digest, route.legacyAlias().orElse(""));
route
.transportProfiles()
.forEach(
profile -> {
NotificationCatalogException.update(digest, profile.profileId());
NotificationCatalogException.update(digest, profile.proofClass().name());
NotificationCatalogException.update(digest, profile.evidenceRevision());
NotificationCatalogException.update(digest, profile.activeAdmissionProfile());
NotificationCatalogException.update(
digest, profile.reviewedHardBoundEvidence());
});
});
return NotificationCatalogException.finish(digest);
}
public record CutoverRoute(
NotificationCanonicalRouteCatalog.RouteKey route,
Optional<String> legacyAlias,
List<LegacyTransportProfile> transportProfiles) {
public CutoverRoute {
Objects.requireNonNull(route, "cutover route key must be non-null");
Objects.requireNonNull(legacyAlias, "legacy route alias container must be non-null");
legacyAlias =
legacyAlias.map(
alias -> NotificationCatalogException.requireSlug("legacy route alias", alias));
Objects.requireNonNull(
transportProfiles, "legacy transport profile registry must be non-null");
transportProfiles =
transportProfiles.stream()
.map(
profile ->
Objects.requireNonNull(profile, "legacy transport profile must be non-null"))
.sorted(Comparator.comparing(LegacyTransportProfile::profileId))
.toList();
if (transportProfiles.isEmpty() || transportProfiles.size() > 8) {
throw new IllegalArgumentException(
"legacy transport profile registry must contain 1..8 entries");
}
if (new HashSet<>(transportProfiles.stream().map(LegacyTransportProfile::profileId).toList())
.size()
!= transportProfiles.size()) {
throw new IllegalArgumentException(
"legacy transport profile registry contains duplicate profile");
}
if (transportProfiles.stream().filter(LegacyTransportProfile::activeAdmissionProfile).count()
!= 1) {
throw new IllegalArgumentException(
"legacy transport registry requires exactly one active admission profile");
}
}
}
public record LegacyTransportProfile(
String profileId,
NotificationWriterRouteSet.ProofClass proofClass,
String evidenceRevision,
boolean activeAdmissionProfile,
boolean reviewedHardBoundEvidence) {
public LegacyTransportProfile {
profileId =
NotificationCatalogException.requireSlug("legacy transport profile ID", profileId);
Objects.requireNonNull(proofClass, "legacy transport proof class must be non-null");
evidenceRevision =
NotificationCatalogException.requireSlug(
"legacy transport evidence revision", evidenceRevision);
if (proofClass == NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN
&& !reviewedHardBoundEvidence) {
throw new IllegalArgumentException(
"HARD_BOUND_PROVEN requires reviewed integration evidence");
}
}
NotificationWriterRouteSet.TransportProfile toApplication() {
return new NotificationWriterRouteSet.TransportProfile(
profileId, proofClass, evidenceRevision, activeAdmissionProfile);
}
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationIntentDraft;
import dev.caskeleton.application.notification.NotificationKindPolicy;
import dev.caskeleton.application.notification.NotificationPlanPort;
import dev.caskeleton.application.notification.NotificationPlanningResult;
import dev.caskeleton.application.notification.NotificationReasonCode;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
/** Converts a selected adapter-local binding to the application-owned immutable plan snapshot. */
public final class NotificationPlanAdapter implements NotificationPlanPort {
private static final NotificationReasonCode ROUTE_UNAVAILABLE =
new NotificationReasonCode("CATALOG_ROUTE_UNAVAILABLE");
private static final NotificationReasonCode POLICY_MISMATCH =
new NotificationReasonCode("CATALOG_POLICY_MISMATCH");
private static final NotificationReasonCode PARAMETERS_MISMATCH =
new NotificationReasonCode("CATALOG_PARAMETERS_MISMATCH");
private final NotificationBindingCompiler.CompiledGraph graph;
public NotificationPlanAdapter(NotificationBindingCompiler.CompiledGraph graph) {
this.graph = Objects.requireNonNull(graph, "compiled notification graph must be non-null");
}
@Override
public NotificationPlanningResult plan(NotificationIntentDraft draft) {
Objects.requireNonNull(draft, "notification intent draft must be non-null");
CompiledNotificationBinding binding = graph.activeBinding(draft.policy().routeId());
if (binding == null) {
return new NotificationPlanningResult.CapabilityUnavailable(ROUTE_UNAVAILABLE);
}
if (!matchesPolicy(binding.route(), draft.policy())) {
return new NotificationPlanningResult.Rejected(POLICY_MISMATCH);
}
if (!binding.template().parameterNames().equals(draft.parameters().values().keySet())) {
return new NotificationPlanningResult.Rejected(PARAMETERS_MISMATCH);
}
Locale locale = binding.template().selectLocale(draft.requestedLocale());
List<NotificationFrozenPlan.FrozenTarget> targets =
binding.targets().stream()
.sorted(Comparator.comparing(target -> target.target().targetId()))
.map(
target ->
new NotificationFrozenPlan.FrozenTarget(
binding.targets().stream()
.sorted(Comparator.comparing(item -> item.target().targetId()))
.toList()
.indexOf(target),
target.target().targetId(),
target.capabilityCard().cardId(),
target.runtimeProfile().bindingRevision(),
target.runtimeProfile().credentialGeneration()))
.toList();
NotificationFrozenPlan.BindingSnapshot snapshot =
new NotificationFrozenPlan.BindingSnapshot(
binding.route().routeRevision(),
binding.bindingDigest(),
binding.template().checksum(),
binding.template().rendererRevision(),
targets,
binding.route().receiptRequired(),
binding.route().perAttemptDeadline());
return new NotificationPlanningResult.Planned(
NotificationFrozenPlan.from(draft, locale, snapshot));
}
private static boolean matchesPolicy(
NotificationRouteDescriptor route, NotificationKindPolicy policy) {
return route.channel() == policy.channel()
&& route.mode() == policy.mode()
&& route.admissionClass() == policy.admissionClass()
&& route.routeStrategy() == policy.routeStrategy()
&& route.templateRef().equals(policy.templateRef())
&& route.maximumTargets() == policy.maxTargetsPerRecipient()
&& route.maximumPhysicalAttempts() == policy.maxPhysicalAttemptsPerDelivery()
&& route.maximumFallbackActivations() == policy.maxFallbackActivations()
&& route.maximumReconcileCalls() == policy.maxReconcileCalls()
&& route.maximumTotalProviderCalls() == policy.maxTotalProviderCallsPerIntent();
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationRouteStrategy;
import java.util.Objects;
/** Closed, code-owned capability statement for one qualified provider protocol shape. */
public record NotificationProviderCapabilityCard(
String cardId,
NotificationChannel channel,
NotificationMode mode,
NotificationRouteStrategy routeStrategy,
boolean receiptSupported,
boolean reconciliationSupported,
boolean hiddenRetriesControlled,
boolean terminalIndeterminatePossible,
int maximumTargets,
int maximumPhysicalAttempts,
int maximumReconcileCalls,
int maximumTotalProviderCalls,
int maximumPayloadBytes) {
public NotificationProviderCapabilityCard {
cardId = NotificationCatalogException.requireSlug("provider capability card ID", cardId);
Objects.requireNonNull(channel, "provider capability channel must be non-null");
Objects.requireNonNull(mode, "provider capability mode must be non-null");
Objects.requireNonNull(routeStrategy, "provider capability route strategy must be non-null");
if (maximumTargets < 1
|| maximumTargets > 16
|| maximumPhysicalAttempts < 1
|| maximumPhysicalAttempts > 10
|| maximumReconcileCalls < 0
|| maximumReconcileCalls > 10
|| maximumTotalProviderCalls < 1
|| maximumTotalProviderCalls > 64
|| maximumPayloadBytes < 1
|| maximumPayloadBytes > 10_000_000) {
throw new IllegalArgumentException("provider capability bounds are outside supported limits");
}
if (!hiddenRetriesControlled) {
throw new IllegalArgumentException("provider capability must control hidden retries");
}
}
/** Returns one of the only three initially qualified provider capability cards. */
public static NotificationProviderCapabilityCard initial(String cardId) {
return switch (cardId) {
case "slack-web-api-inline-single-local-v1" ->
new NotificationProviderCapabilityCard(
cardId,
NotificationChannel.SLACK,
NotificationMode.BEST_EFFORT_INLINE,
NotificationRouteStrategy.SINGLE,
false,
false,
true,
true,
1,
1,
0,
1,
32_000);
case "slack-web-api-durable-single-local-v1" ->
new NotificationProviderCapabilityCard(
cardId,
NotificationChannel.SLACK,
NotificationMode.DURABLE_ASYNC,
NotificationRouteStrategy.SINGLE,
false,
false,
true,
true,
1,
1,
0,
1,
32_000);
case "aws-ses-v2-durable-single-local-sns-v1" ->
new NotificationProviderCapabilityCard(
cardId,
NotificationChannel.EMAIL,
NotificationMode.DURABLE_ASYNC,
NotificationRouteStrategy.SINGLE,
true,
true,
true,
true,
1,
1,
1,
2,
64_000);
default ->
throw new NotificationCatalogException(
"unknown initial provider capability card: " + cardId);
};
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationProviderCapabilityDescriptor;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** Derives application-owned actual capability facts from the compiled provider cards. */
public final class NotificationProviderCapabilityDescriptorSource {
private final List<NotificationProviderCapabilityDescriptor> descriptors;
public NotificationProviderCapabilityDescriptorSource(
NotificationBindingCompiler.CompiledGraph graph) {
Objects.requireNonNull(graph, "compiled notification graph must be non-null");
this.descriptors =
graph.bindings().stream()
.flatMap(binding -> binding.targets().stream())
.map(CompiledNotificationBinding.CompiledTarget::capabilityCard)
.distinct()
.sorted(Comparator.comparing(NotificationProviderCapabilityCard::cardId))
.map(NotificationProviderCapabilityDescriptorSource::toApplication)
.toList();
}
public List<NotificationProviderCapabilityDescriptor> descriptors() {
return descriptors;
}
private static NotificationProviderCapabilityDescriptor toApplication(
NotificationProviderCapabilityCard card) {
return new NotificationProviderCapabilityDescriptor(
card.cardId(),
card.channel(),
Set.of(card.mode()),
card.receiptSupported(),
card.reconciliationSupported(),
card.hiddenRetriesControlled(),
card.maximumTargets(),
card.maximumPayloadBytes());
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationChannel;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Provider family descriptor; legacy fail-open providers deliberately have no canonical cards. */
public record NotificationProviderDescriptor(
String providerId,
NotificationChannel channel,
boolean legacyFailOpen,
List<NotificationProviderCapabilityCard> capabilityCards) {
public NotificationProviderDescriptor {
providerId = NotificationCatalogException.requireSlug("notification provider ID", providerId);
Objects.requireNonNull(channel, "notification provider channel must be non-null");
Objects.requireNonNull(capabilityCards, "provider capability cards must be non-null");
capabilityCards =
capabilityCards.stream()
.map(card -> Objects.requireNonNull(card, "provider capability card must be non-null"))
.sorted(Comparator.comparing(NotificationProviderCapabilityCard::cardId))
.toList();
if (new HashSet<>(
capabilityCards.stream().map(NotificationProviderCapabilityCard::cardId).toList())
.size()
!= capabilityCards.size()) {
throw new IllegalArgumentException("provider descriptor contains duplicate capability cards");
}
if (legacyFailOpen && !capabilityCards.isEmpty()) {
throw new IllegalArgumentException("legacy provider cannot advertise canonical capability");
}
if (!legacyFailOpen && capabilityCards.isEmpty()) {
throw new IllegalArgumentException("canonical provider must advertise a capability card");
}
if (capabilityCards.stream().anyMatch(card -> card.channel() != channel)) {
throw new IllegalArgumentException("provider capability card channel must match provider");
}
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
/** Runtime indirection for secrets and destinations; sensitive references never leave this type. */
public record NotificationProviderRuntimeProfile(
String profileId,
String providerId,
String bindingRevision,
String capabilityCardId,
String credentialGeneration,
String credentialReference,
String destinationReference) {
public NotificationProviderRuntimeProfile {
profileId = NotificationCatalogException.requireSlug("provider runtime profile ID", profileId);
providerId = NotificationCatalogException.requireSlug("provider ID", providerId);
bindingRevision =
NotificationCatalogException.requireSlug("provider binding revision", bindingRevision);
capabilityCardId =
NotificationCatalogException.requireSlug("provider capability card ID", capabilityCardId);
credentialGeneration =
NotificationCatalogException.requireSlug(
"provider credential generation", credentialGeneration);
credentialReference =
NotificationCatalogException.requireOpaque(
"provider credential reference", credentialReference);
destinationReference =
NotificationCatalogException.requireOpaque(
"provider destination reference", destinationReference);
}
@Override
public String toString() {
return "NotificationProviderRuntimeProfile[profileId="
+ profileId
+ ", providerId="
+ providerId
+ ", bindingRevision="
+ bindingRevision
+ ", capabilityCardId="
+ capabilityCardId
+ ", credentialGeneration="
+ credentialGeneration
+ ", credentialReference=<redacted>, destinationReference=<redacted>]";
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationAdmissionClass;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationRouteId;
import dev.caskeleton.application.notification.NotificationRouteStrategy;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import java.time.Duration;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Provider-neutral route declaration compiled against explicit local catalogs. */
public record NotificationRouteDescriptor(
NotificationRouteId routeId,
int routeRevision,
NotificationChannel channel,
NotificationMode mode,
NotificationAdmissionClass admissionClass,
NotificationRouteStrategy routeStrategy,
NotificationTemplateRef templateRef,
boolean receiptRequired,
boolean fallbackAfterIndeterminate,
int maximumTargets,
int maximumPhysicalAttempts,
int maximumFallbackActivations,
int maximumReconcileCalls,
int maximumTotalProviderCalls,
Duration perAttemptDeadline,
List<Target> targets) {
public NotificationRouteDescriptor {
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (routeRevision < 1 || routeRevision > 1_000_000) {
throw new IllegalArgumentException("notification route revision must be in 1..1000000");
}
Objects.requireNonNull(channel, "notification route channel must be non-null");
Objects.requireNonNull(mode, "notification route mode must be non-null");
Objects.requireNonNull(admissionClass, "notification admission class must be non-null");
Objects.requireNonNull(routeStrategy, "notification route strategy must be non-null");
Objects.requireNonNull(templateRef, "notification template reference must be non-null");
if (maximumTargets < 1
|| maximumTargets > 16
|| maximumPhysicalAttempts < 1
|| maximumPhysicalAttempts > 10
|| maximumFallbackActivations < 0
|| maximumFallbackActivations > 15
|| maximumReconcileCalls < 0
|| maximumReconcileCalls > 10
|| maximumTotalProviderCalls < 1
|| maximumTotalProviderCalls > 64) {
throw new IllegalArgumentException("notification route bounds are outside supported limits");
}
Objects.requireNonNull(perAttemptDeadline, "per-attempt deadline must be non-null");
if (perAttemptDeadline.isZero()
|| perAttemptDeadline.isNegative()
|| perAttemptDeadline.compareTo(Duration.ofMinutes(5)) > 0) {
throw new IllegalArgumentException(
"per-attempt deadline must be positive and at most five minutes");
}
Objects.requireNonNull(targets, "notification route targets must be non-null");
targets =
targets.stream()
.map(target -> Objects.requireNonNull(target, "notification target must be non-null"))
.sorted(Comparator.comparing(Target::targetId))
.toList();
if (targets.isEmpty() || targets.size() > 16) {
throw new IllegalArgumentException("notification route targets must contain 1..16 entries");
}
if (new HashSet<>(targets.stream().map(Target::targetId).toList()).size() != targets.size()) {
throw new IllegalArgumentException("notification route contains duplicate target IDs");
}
}
/** One provider leg and optional next fallback leg. */
public record Target(
String targetId, String runtimeProfileId, Optional<String> fallbackTargetId) {
public Target {
targetId = NotificationCatalogException.requireSlug("notification target ID", targetId);
runtimeProfileId =
NotificationCatalogException.requireSlug(
"notification runtime profile ID", runtimeProfileId);
Objects.requireNonNull(fallbackTargetId, "fallback target ID container must be non-null");
fallbackTargetId =
fallbackTargetId.map(
value -> NotificationCatalogException.requireSlug("fallback target ID", value));
}
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** Immutable checked-in template manifest metadata used by the binding compiler. */
public record NotificationTemplateDescriptor(
NotificationTemplateRef templateRef,
NotificationChannel channel,
String rendererRevision,
String checksum,
Set<Locale> supportedLocales,
Locale fallbackLocale,
Set<String> parameterNames,
int maximumRenderedBytes) {
public NotificationTemplateDescriptor {
Objects.requireNonNull(templateRef, "notification template reference must be non-null");
Objects.requireNonNull(channel, "notification template channel must be non-null");
rendererRevision =
NotificationCatalogException.requireSlug(
"notification renderer revision", rendererRevision);
checksum =
NotificationCatalogException.requireDigest("notification template checksum", checksum);
Objects.requireNonNull(supportedLocales, "supported template locales must be non-null");
supportedLocales =
supportedLocales.stream()
.map(locale -> requireLocale("supported template locale", locale))
.sorted(Comparator.comparing(Locale::toLanguageTag))
.collect(Collectors.toUnmodifiableSet());
if (supportedLocales.isEmpty() || supportedLocales.size() > 32) {
throw new IllegalArgumentException("supported template locales must contain 1..32 entries");
}
fallbackLocale = requireLocale("template fallback locale", fallbackLocale);
if (!supportedLocales.contains(fallbackLocale)) {
throw new IllegalArgumentException("template fallback locale must be supported");
}
Objects.requireNonNull(parameterNames, "template parameter names must be non-null");
parameterNames =
parameterNames.stream()
.map(NotificationTemplateDescriptor::requireParameterName)
.collect(Collectors.toUnmodifiableSet());
if (parameterNames.size() > 32) {
throw new IllegalArgumentException("template parameter names exceed 32 entries");
}
if (new HashSet<>(parameterNames).size() != parameterNames.size()) {
throw new IllegalArgumentException("template parameter names contain duplicates");
}
if (maximumRenderedBytes < 1 || maximumRenderedBytes > 10_000_000) {
throw new IllegalArgumentException("maximum rendered bytes must be in 1..10000000");
}
}
Locale selectLocale(Locale requested) {
Locale normalized = requireLocale("requested template locale", requested);
return supportedLocales.contains(normalized) ? normalized : fallbackLocale;
}
private static Locale requireLocale(String field, Locale locale) {
Objects.requireNonNull(locale, field + " must be non-null");
String tag = locale.toLanguageTag();
if (locale.equals(Locale.ROOT) || tag.equals("und") || tag.isBlank() || tag.length() > 35) {
throw new IllegalArgumentException(field + " must be an explicit bounded locale");
}
return Locale.forLanguageTag(tag);
}
private static String requireParameterName(String name) {
if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) {
throw new IllegalArgumentException(
"template parameter name must match [a-z][A-Za-z0-9]{0,63}");
}
return name;
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/** Opaque non-PII correlation identity created before a provider send. */
public record AttemptCorrelationId(String value)
implements NotificationProviderAttemptClient.ReconciliationReference {
public AttemptCorrelationId {
value = requireOpaque("attempt correlation ID", value);
}
static AttemptCorrelationId derive(String seed) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return new AttemptCorrelationId(
"ca-" + HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8))));
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException(
"SHA-256 must be available on every Java runtime", unavailable);
}
}
static String requireOpaque(String field, String value) {
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException(
field + " must contain 1..128 opaque identifier characters");
}
return value;
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.application.notification.InlineNotificationAttemptPort;
import dev.caskeleton.application.notification.NotificationAttemptId;
import dev.caskeleton.application.notification.NotificationDeliveryId;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationRequestResult;
import dev.caskeleton.application.notification.TargetAttemptOutcome;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Bounded inline port using the same internal one-authorized-attempt protocol. */
public final class InlineNotificationAttemptAdapter implements InlineNotificationAttemptPort {
private final NotificationProviderAttemptAdapter attempts;
public InlineNotificationAttemptAdapter(NotificationProviderAttemptAdapter attempts) {
this.attempts = Objects.requireNonNull(attempts, "provider attempt adapter must be non-null");
}
@Override
public NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan) {
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
if (plan.mode() != NotificationMode.BEST_EFFORT_INLINE) {
throw new IllegalArgumentException("inline attempt requires BEST_EFFORT_INLINE plan");
}
List<TargetAttemptOutcome> outcomes = new ArrayList<>();
for (int ordinal = 0; ordinal < plan.binding().targets().size(); ordinal++) {
String suffix =
AttemptCorrelationId.derive(plan.intentId().value() + ":" + ordinal)
.value()
.substring(3, 35);
NotificationDeliveryId deliveryId = new NotificationDeliveryId("inline-delivery-" + suffix);
NotificationAttemptId attemptId = new NotificationAttemptId("inline-attempt-" + suffix);
String executionToken = "inline-execution-" + suffix;
Instant deadline =
minimum(plan.expiresAt(), attempts.now().plus(plan.binding().perAttemptDeadline()));
outcomes.add(
new TargetAttemptOutcome(
ordinal,
deliveryId,
attempts.attemptInline(
plan, ordinal, deliveryId, attemptId, executionToken, deadline)));
}
return new NotificationRequestResult.InlineCompleted(plan.intentId(), outcomes);
}
private static Instant minimum(Instant left, Instant right) {
return left.isBefore(right) ? left : right;
}
}
@@ -0,0 +1,163 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
import dev.caskeleton.application.notification.NotificationAdmissionReadinessPort;
import dev.caskeleton.application.notification.NotificationReasonCode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Bridges application admission operations to exact adapter-internal provider readiness evidence.
*/
public final class NotificationAdmissionReadinessAdapter
implements NotificationAdmissionReadinessPort {
private static final int MAXIMUM_PENDING_PROBES = 1_024;
private static final Duration PROBE_DEADLINE = Duration.ofSeconds(30);
private static final NotificationReasonCode PROBE_FAILED =
new NotificationReasonCode("READINESS_PROBE_FAILED");
private static final NotificationReasonCode TOKEN_CONFLICT =
new NotificationReasonCode("READINESS_TOKEN_CONFLICT");
private static final NotificationReasonCode CAPACITY_EXCEEDED =
new NotificationReasonCode("READINESS_CAPACITY_EXCEEDED");
private final NotificationAdmissionReadinessPort state;
private final Function<ResumeRequest, NotificationProviderRuntimeProfile> profiles;
private final NotificationProviderReadinessProbe readiness;
private final Clock clock;
private final ConcurrentHashMap<String, CapturedProbe> fresh = new ConcurrentHashMap<>();
public NotificationAdmissionReadinessAdapter(
NotificationAdmissionReadinessPort state,
Function<ResumeRequest, NotificationProviderRuntimeProfile> profiles,
NotificationProviderReadinessProbe readiness,
Clock clock) {
this.state = Objects.requireNonNull(state, "admission state port must be non-null");
this.profiles = Objects.requireNonNull(profiles, "runtime profile resolver must be non-null");
this.readiness = Objects.requireNonNull(readiness, "provider readiness probe must be non-null");
this.clock = Objects.requireNonNull(clock, "provider readiness clock must be non-null");
}
@Override
public ParkResult park(ParkRequest request) {
return Objects.requireNonNull(state.park(request), "admission park result must be non-null");
}
@Override
public ReadinessProbe probe(ResumeRequest request) {
Objects.requireNonNull(request, "admission resume request must be non-null");
Instant probeStartedAt = clock.instant();
evictExpired(probeStartedAt);
try {
NotificationProviderRuntimeProfile profile =
Objects.requireNonNull(profiles.apply(request), "runtime profile must be non-null");
CapturedProbe existing = fresh.get(request.operationToken());
if (existing != null) {
if (!existing.request().equals(request) || !existing.profile().equals(profile)) {
return new ReadinessProbe(false, TOKEN_CONFLICT);
}
return new ReadinessProbe(existing.snapshot().ready(), existing.snapshot().reasonCode());
}
NotificationProviderReadinessSnapshot snapshot =
Objects.requireNonNull(
readiness.probe(profile, probeStartedAt.plus(PROBE_DEADLINE)),
"provider readiness snapshot must be non-null");
Instant validatedAt = clock.instant();
validateExact(profile, snapshot, validatedAt);
if (snapshot.ready()) {
if (fresh.size() >= MAXIMUM_PENDING_PROBES) {
return new ReadinessProbe(false, CAPACITY_EXCEEDED);
}
CapturedProbe captured = new CapturedProbe(request, profile, snapshot);
CapturedProbe collision = fresh.putIfAbsent(request.operationToken(), captured);
if (collision != null && !collision.equals(captured)) {
return new ReadinessProbe(false, TOKEN_CONFLICT);
}
}
return new ReadinessProbe(snapshot.ready(), snapshot.reasonCode());
} catch (RuntimeException probeFailure) {
return new ReadinessProbe(false, PROBE_FAILED);
}
}
@Override
public ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
Objects.requireNonNull(request, "admission resume request must be non-null");
Objects.requireNonNull(probe, "application readiness probe must be non-null");
Objects.requireNonNull(resumedAt, "admission resume time must be non-null");
CapturedProbe captured = fresh.remove(request.operationToken());
if (captured == null
|| !captured.request().equals(request)
|| !probe.ready()
|| !captured.snapshot().reasonCode().equals(probe.reasonCode())
|| !captured.snapshot().expiresAt().isAfter(resumedAt)) {
throw new IllegalStateException(
"admission resume requires fresh exact provider readiness evidence");
}
NotificationProviderRuntimeProfile currentProfile;
try {
currentProfile =
Objects.requireNonNull(profiles.apply(request), "runtime profile must be non-null");
} catch (RuntimeException profileFailure) {
throw new IllegalStateException(
"admission resume requires fresh exact provider readiness evidence");
}
if (!captured.profile().equals(currentProfile)) {
throw new IllegalStateException(
"admission resume requires the exact probed provider generation");
}
Instant freshAt = clock.instant();
if (resumedAt.isAfter(freshAt) || !captured.snapshot().expiresAt().isAfter(freshAt)) {
throw new IllegalStateException(
"admission resume requires currently fresh provider readiness evidence");
}
return Objects.requireNonNull(
state.resume(request, probe, freshAt), "admission resume result must be non-null");
}
@Override
public String toString() {
return "NotificationAdmissionReadinessAdapter[state=<redacted>, profiles=<redacted>, "
+ "readiness=<redacted>, pendingProbeCount="
+ fresh.size()
+ "]";
}
private static void validateExact(
NotificationProviderRuntimeProfile profile,
NotificationProviderReadinessSnapshot snapshot,
Instant now) {
if (!snapshot.profileId().equals(profile.profileId())
|| !snapshot.bindingRevision().equals(profile.bindingRevision())
|| !snapshot.capabilityCardId().equals(profile.capabilityCardId())
|| !snapshot.credentialGeneration().equals(profile.credentialGeneration())) {
throw new IllegalStateException(
"provider readiness snapshot does not match the exact runtime profile");
}
if (snapshot.observedAt().isAfter(now)
|| !snapshot.expiresAt().isAfter(now)
|| snapshot.observedAt().isAfter(snapshot.expiresAt())) {
throw new IllegalStateException("provider readiness snapshot is not currently valid");
}
}
private void evictExpired(Instant now) {
fresh.entrySet().removeIf(entry -> !entry.getValue().snapshot().expiresAt().isAfter(now));
}
private record CapturedProbe(
ResumeRequest request,
NotificationProviderRuntimeProfile profile,
NotificationProviderReadinessSnapshot snapshot) {
private CapturedProbe {
Objects.requireNonNull(request, "captured resume request must be non-null");
Objects.requireNonNull(profile, "captured runtime profile must be non-null");
Objects.requireNonNull(snapshot, "captured readiness snapshot must be non-null");
}
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.application.notification.NotificationAttemptId;
import dev.caskeleton.application.notification.NotificationDeliveryId;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Immutable one-attempt identities and absolute deadline passed to provider preparation. */
public record NotificationAttemptContext(
NotificationDeliveryId deliveryId,
NotificationAttemptId attemptId,
int targetOrdinal,
AttemptCorrelationId correlationId,
Optional<ProviderClientOperationKey> clientOperationKey,
Instant absoluteDeadline) {
public NotificationAttemptContext {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(attemptId, "notification attempt ID must be non-null");
if (targetOrdinal < 0 || targetOrdinal > 15) {
throw new IllegalArgumentException("notification target ordinal must be in 0..15");
}
Objects.requireNonNull(correlationId, "attempt correlation ID must be non-null");
Objects.requireNonNull(
clientOperationKey, "provider client operation key container must be non-null");
Objects.requireNonNull(absoluteDeadline, "attempt absolute deadline must be non-null");
}
@Override
public String toString() {
return "NotificationAttemptContext[deliveryId=<redacted>, attemptId="
+ attemptId
+ ", targetOrdinal="
+ targetOrdinal
+ ", correlationId=<redacted>, clientOperationKey=<redacted>, absoluteDeadline="
+ absoluteDeadline
+ "]";
}
/** Native provider operation key, distinct from local correlation and provider message IDs. */
public record ProviderClientOperationKey(String value)
implements NotificationProviderAttemptClient.ReconciliationReference {
public ProviderClientOperationKey {
value = AttemptCorrelationId.requireOpaque("provider client operation key", value);
}
@Override
public String toString() {
return "ProviderClientOperationKey[value=<redacted>]";
}
}
}
@@ -0,0 +1,363 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler;
import dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateRenderer;
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
import dev.caskeleton.application.notification.NotificationAttemptId;
import dev.caskeleton.application.notification.NotificationDeliveryId;
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
import dev.caskeleton.application.notification.NotificationFaultScope;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationProviderAttemptPort;
import dev.caskeleton.application.notification.NotificationReasonCode;
import dev.caskeleton.application.notification.ProviderAttemptOutcome;
import dev.caskeleton.application.notification.RetryDisposition;
import dev.caskeleton.application.notification.SubmissionCertainty;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Application provider port backed by one compiled binding, renderer and internal client call. */
public final class NotificationProviderAttemptAdapter implements NotificationProviderAttemptPort {
private final NotificationBindingCompiler.CompiledGraph graph;
private final Map<String, NotificationTemplateRenderer<? extends RenderedNotification>> renderers;
private final Map<String, NotificationProviderAttemptClient> clients;
private final NotificationProviderRateAdmission rateAdmission;
private final NotificationProviderSecretMaterialProvider secrets;
private final Clock clock;
public NotificationProviderAttemptAdapter(
NotificationBindingCompiler.CompiledGraph graph,
Map<String, NotificationTemplateRenderer<? extends RenderedNotification>> renderers,
Map<String, NotificationProviderAttemptClient> clients,
NotificationProviderRateAdmission rateAdmission,
NotificationProviderSecretMaterialProvider secrets,
Clock clock) {
this.graph = Objects.requireNonNull(graph, "compiled notification graph must be non-null");
this.renderers = Map.copyOf(Objects.requireNonNull(renderers, "renderers must be non-null"));
this.clients = Map.copyOf(Objects.requireNonNull(clients, "provider clients must be non-null"));
this.rateAdmission =
Objects.requireNonNull(rateAdmission, "provider rate admission must be non-null");
this.secrets =
Objects.requireNonNull(secrets, "provider secret material provider must be non-null");
this.clock = Objects.requireNonNull(clock, "provider attempt clock must be non-null");
}
@Override
public ProviderAttemptOutcome attempt(NotificationDeliveryStorePort.AuthorizedAttempt attempt) {
Objects.requireNonNull(attempt, "authorized notification attempt must be non-null");
Instant deadline =
minimum(
attempt.absoluteDeadline(),
clock.instant().plus(attempt.plan().binding().perAttemptDeadline()));
return attemptOne(
attempt.plan(),
attempt.targetOrdinal(),
attempt.deliveryId(),
attempt.attemptId(),
attempt.executionToken(),
deadline);
}
ProviderAttemptOutcome attemptInline(
NotificationFrozenPlan plan,
int targetOrdinal,
NotificationDeliveryId deliveryId,
NotificationAttemptId attemptId,
String executionToken,
Instant deadline) {
return attemptOne(plan, targetOrdinal, deliveryId, attemptId, executionToken, deadline);
}
Instant now() {
return clock.instant();
}
private ProviderAttemptOutcome attemptOne(
NotificationFrozenPlan plan,
int targetOrdinal,
NotificationDeliveryId deliveryId,
NotificationAttemptId attemptId,
String executionToken,
Instant deadline) {
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
AttemptCorrelationId correlation =
AttemptCorrelationId.derive(executionToken + ":" + targetOrdinal);
if (!deadline.isAfter(clock.instant())) {
return definitelyNotApplied(
correlation,
RetryDisposition.TERMINAL,
NotificationFaultScope.DELIVERY,
"ATTEMPT_DEADLINE_EXPIRED",
Optional.empty());
}
CompiledNotificationBinding binding;
CompiledNotificationBinding.CompiledTarget target;
try {
binding = requireBinding(plan);
target = requireTarget(binding, plan, targetOrdinal);
} catch (RuntimeException retainedBindingFailure) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.ROUTE_REVISION,
"FROZEN_BINDING_UNAVAILABLE",
Optional.empty());
}
NotificationProviderRateAdmission.Decision admission;
try {
admission =
Objects.requireNonNull(
rateAdmission.admit(target.runtimeProfile(), deadline),
"provider rate admission decision must be non-null");
} catch (RuntimeException admissionFailure) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_RATE_ADMISSION_FAILED",
Optional.empty());
}
if (!admission.admitted()) {
Instant retryAt = admission.retryNotBefore().orElseThrow();
if (!retryAt.isAfter(clock.instant()) || retryAt.isAfter(deadline)) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_RETRY_BOUND_INVALID",
Optional.empty());
}
return definitelyNotApplied(
correlation,
RetryDisposition.RETRY_AT,
NotificationFaultScope.PROVIDER_BINDING,
admission.reasonCode().value(),
admission.retryNotBefore());
}
NotificationTemplateRenderer<? extends RenderedNotification> renderer =
renderers.get(binding.template().rendererRevision());
NotificationProviderAttemptClient client = clients.get(target.capabilityCard().cardId());
if (renderer == null || client == null) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.ROUTE_REVISION,
"PROVIDER_BINDING_COMPONENT_UNAVAILABLE",
Optional.empty());
}
NotificationAttemptContext context =
new NotificationAttemptContext(
deliveryId, attemptId, targetOrdinal, correlation, Optional.empty(), deadline);
RenderedNotification rendered;
try {
rendered =
Objects.requireNonNull(renderer.render(plan), "rendered notification must be non-null");
} catch (RuntimeException renderingFailure) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.ROUTE_REVISION,
"TEMPLATE_RENDERING_FAILED",
Optional.empty());
}
if (rendered.utf8Bytes() > binding.template().maximumRenderedBytes()
|| rendered.utf8Bytes() > target.capabilityCard().maximumPayloadBytes()) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.ROUTE_REVISION,
"RENDERED_PAYLOAD_BOUND_EXCEEDED",
Optional.empty());
}
PreparedNotificationAttempt prepared;
try {
prepared =
Objects.requireNonNull(
client.prepare(rendered, target, context),
"prepared notification attempt must be non-null");
if (!prepared.rendered().equals(rendered)
|| !prepared.target().equals(target)
|| !prepared.context().equals(context)) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_PREPARED_ATTEMPT_MISMATCH",
Optional.empty());
}
} catch (NotificationProviderAttemptClient.PreWireDeliveryRejectedException rejected) {
return definitelyNotApplied(
correlation,
RetryDisposition.TERMINAL,
NotificationFaultScope.DELIVERY,
"PROVIDER_PREWIRE_VALIDATION_FAILED",
Optional.empty());
} catch (RuntimeException bindingFailure) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_PREWIRE_BINDING_FAILED",
Optional.empty());
}
NotificationSecretMaterialHandle acquired;
try {
acquired =
Objects.requireNonNull(
secrets.acquire(target.runtimeProfile()),
"provider secret material handle must be non-null");
} catch (RuntimeException secretFailure) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_SECRET_ACQUISITION_FAILED",
Optional.empty());
}
try (NotificationSecretMaterialHandle secret = acquired) {
if (!secret.revision().equals(target.runtimeProfile().credentialGeneration())) {
return definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_SECRET_GENERATION_MISMATCH",
Optional.empty());
}
Instant beforeWire = clock.instant();
if (!deadline.isAfter(beforeWire)) {
return definitelyNotApplied(
correlation,
RetryDisposition.TERMINAL,
NotificationFaultScope.DELIVERY,
"ATTEMPT_DEADLINE_EXPIRED",
Optional.empty());
}
try {
NotificationProviderAttemptClient.ClientAttemptResult result =
Objects.requireNonNull(
client.sendOneAuthorizedAttempt(prepared, executionToken, secret, deadline),
"provider client result must be non-null");
return map(result, correlation, clock.instant(), deadline);
} catch (RuntimeException possibleWriteFailure) {
return new ProviderAttemptOutcome(
SubmissionCertainty.INDETERMINATE,
RetryDisposition.NOT_APPLICABLE,
NotificationFaultScope.DELIVERY,
new NotificationReasonCode("PROVIDER_RESPONSE_INDETERMINATE"),
Optional.empty(),
correlation.value(),
Optional.empty());
}
}
}
private CompiledNotificationBinding requireBinding(NotificationFrozenPlan plan) {
return graph.bindings().stream()
.filter(binding -> binding.bindingDigest().equals(plan.binding().bindingDigest()))
.filter(binding -> binding.route().routeId().equals(plan.routeId()))
.filter(binding -> binding.route().routeRevision() == plan.binding().routeRevision())
.findFirst()
.orElseThrow(
() ->
new IllegalArgumentException(
"frozen plan is outside the compiled notification binding graph"));
}
private static CompiledNotificationBinding.CompiledTarget requireTarget(
CompiledNotificationBinding binding, NotificationFrozenPlan plan, int ordinal) {
java.util.List<CompiledNotificationBinding.CompiledTarget> ordered =
binding.targets().stream()
.sorted(Comparator.comparing(target -> target.target().targetId()))
.toList();
if (ordinal < 0 || ordinal >= ordered.size()) {
throw new IllegalArgumentException("notification target ordinal is outside binding");
}
CompiledNotificationBinding.CompiledTarget target = ordered.get(ordinal);
NotificationFrozenPlan.FrozenTarget frozen = plan.binding().targets().get(ordinal);
if (!frozen.targetReference().equals(target.target().targetId())
|| !frozen.providerCapabilityReference().equals(target.capabilityCard().cardId())
|| !frozen.providerBindingRevision().equals(target.runtimeProfile().bindingRevision())
|| !frozen.credentialGeneration().equals(target.runtimeProfile().credentialGeneration())) {
throw new IllegalArgumentException(
"frozen notification target does not match compiled binding");
}
return target;
}
private static ProviderAttemptOutcome map(
NotificationProviderAttemptClient.ClientAttemptResult result,
AttemptCorrelationId correlation,
Instant observedAt,
Instant deadline) {
return switch (result) {
case NotificationProviderAttemptClient.ClientAttemptResult.Accepted accepted ->
new ProviderAttemptOutcome(
SubmissionCertainty.PROVIDER_ACCEPTED,
RetryDisposition.NOT_APPLICABLE,
NotificationFaultScope.DELIVERY,
new NotificationReasonCode("PROVIDER_ACCEPTED"),
Optional.empty(),
correlation.value(),
Optional.of(accepted.providerMessageReference().value()));
case NotificationProviderAttemptClient.ClientAttemptResult.DefinitelyNotApplied rejected -> {
if (rejected.retryDisposition() == RetryDisposition.RETRY_AT
&& (rejected.retryNotBefore().orElseThrow().isAfter(deadline)
|| !rejected.retryNotBefore().orElseThrow().isAfter(observedAt))) {
yield definitelyNotApplied(
correlation,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
"PROVIDER_RETRY_BOUND_INVALID",
Optional.empty());
}
yield new ProviderAttemptOutcome(
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
rejected.retryDisposition(),
rejected.faultScope(),
rejected.reasonCode(),
rejected.retryNotBefore(),
correlation.value(),
Optional.empty());
}
case NotificationProviderAttemptClient.ClientAttemptResult.Indeterminate indeterminate ->
new ProviderAttemptOutcome(
SubmissionCertainty.INDETERMINATE,
RetryDisposition.NOT_APPLICABLE,
NotificationFaultScope.DELIVERY,
indeterminate.reasonCode(),
Optional.empty(),
correlation.value(),
Optional.empty());
};
}
private static ProviderAttemptOutcome definitelyNotApplied(
AttemptCorrelationId correlation,
RetryDisposition retryDisposition,
NotificationFaultScope scope,
String reasonCode,
Optional<Instant> retryNotBefore) {
return new ProviderAttemptOutcome(
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
retryDisposition,
scope,
new NotificationReasonCode(reasonCode),
retryNotBefore,
correlation.value(),
Optional.empty());
}
private static Instant minimum(Instant left, Instant right) {
return left.isBefore(right) ? left : right;
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
import dev.caskeleton.application.notification.NotificationFaultScope;
import dev.caskeleton.application.notification.NotificationReasonCode;
import dev.caskeleton.application.notification.RetryDisposition;
import dev.caskeleton.application.notification.SubmissionCertainty;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Adapter-internal provider protocol: pure prepare, then one authorized wire attempt. */
public interface NotificationProviderAttemptClient {
PreparedNotificationAttempt prepare(
RenderedNotification rendered,
CompiledNotificationBinding.CompiledTarget target,
NotificationAttemptContext context);
ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline);
default ClientReconciliationResult reconcile(
ReconciliationReference reference,
ReconciliationLookupMode lookupMode,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
return new ClientReconciliationResult(
SubmissionCertainty.INDETERMINATE,
new NotificationReasonCode("RECONCILIATION_UNSUPPORTED"));
}
sealed interface ReconciliationReference
permits AttemptCorrelationId,
ProviderMessageReference,
NotificationAttemptContext.ProviderClientOperationKey {
String value();
}
sealed interface ClientAttemptResult
permits ClientAttemptResult.Accepted,
ClientAttemptResult.DefinitelyNotApplied,
ClientAttemptResult.Indeterminate {
record Accepted(ProviderMessageReference providerMessageReference)
implements ClientAttemptResult {
public Accepted {
Objects.requireNonNull(
providerMessageReference, "provider message reference must be non-null");
}
}
record DefinitelyNotApplied(
RetryDisposition retryDisposition,
NotificationFaultScope faultScope,
NotificationReasonCode reasonCode,
Optional<Instant> retryNotBefore)
implements ClientAttemptResult {
public DefinitelyNotApplied {
Objects.requireNonNull(retryDisposition, "retry disposition must be non-null");
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null");
if (retryDisposition == RetryDisposition.NOT_APPLICABLE
|| (retryDisposition == RetryDisposition.RETRY_AT) != retryNotBefore.isPresent()) {
throw new IllegalArgumentException(
"definite non-application requires an explicit consistent disposition");
}
}
}
record Indeterminate(NotificationReasonCode reasonCode) implements ClientAttemptResult {
public Indeterminate {
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
}
record ClientReconciliationResult(
SubmissionCertainty submissionCertainty, NotificationReasonCode reasonCode) {
public ClientReconciliationResult {
Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
/** Explicit provider-proven invalid recipient/content detected before wire I/O. */
final class PreWireDeliveryRejectedException extends RuntimeException {
public PreWireDeliveryRejectedException() {
super("provider rejected the prepared delivery before wire I/O");
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
import dev.caskeleton.application.notification.NotificationReasonCode;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/** Provider-local bounded quota/rate admission checked before a wire attempt. */
@FunctionalInterface
public interface NotificationProviderRateAdmission {
Decision admit(NotificationProviderRuntimeProfile profile, Instant absoluteDeadline);
record Decision(
boolean admitted, Optional<Instant> retryNotBefore, NotificationReasonCode reasonCode) {
public Decision {
Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null");
Objects.requireNonNull(reasonCode, "rate admission reason code must be non-null");
if (admitted == retryNotBefore.isPresent()) {
throw new IllegalArgumentException(
"admitted rate decision must not have retry time and rejection must have one");
}
}
public static Decision admitted(NotificationReasonCode reasonCode) {
return new Decision(true, Optional.empty(), reasonCode);
}
public static Decision retryAt(Instant retryAt, NotificationReasonCode reasonCode) {
return new Decision(false, Optional.of(retryAt), reasonCode);
}
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
import java.time.Instant;
/** Bounded control-plane probe that returns non-secret provider identity and capability facts. */
@FunctionalInterface
public interface NotificationProviderReadinessProbe {
NotificationProviderReadinessSnapshot probe(
NotificationProviderRuntimeProfile profile, Instant absoluteDeadline);
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.application.notification.NotificationReasonCode;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/** Bounded, non-secret, exact provider readiness observation. */
public record NotificationProviderReadinessSnapshot(
String profileId,
String bindingRevision,
String capabilityCardId,
String credentialGeneration,
boolean ready,
NotificationReasonCode reasonCode,
Instant observedAt,
Instant expiresAt) {
public NotificationProviderReadinessSnapshot {
profileId = requireSlug("provider profile ID", profileId);
bindingRevision = requireSlug("provider binding revision", bindingRevision);
capabilityCardId = requireSlug("provider capability card ID", capabilityCardId);
credentialGeneration = requireSlug("provider credential generation", credentialGeneration);
Objects.requireNonNull(reasonCode, "provider readiness reason code must be non-null");
Objects.requireNonNull(observedAt, "provider readiness observation time must be non-null");
Objects.requireNonNull(expiresAt, "provider readiness expiry time must be non-null");
Duration validity = Duration.between(observedAt, expiresAt);
if (validity.isZero()
|| validity.isNegative()
|| validity.compareTo(Duration.ofMinutes(5)) > 0) {
throw new IllegalArgumentException(
"provider readiness validity must be positive and at most five minutes");
}
}
private static String requireSlug(String field, String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
/** Acquires one operation-scoped mutable secret copy for a runtime profile. */
@FunctionalInterface
public interface NotificationProviderSecretMaterialProvider {
NotificationSecretMaterialHandle acquire(NotificationProviderRuntimeProfile profile);
}
@@ -0,0 +1,151 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
import dev.caskeleton.application.notification.NotificationReasonCode;
import dev.caskeleton.application.notification.NotificationReconciliationPort;
import dev.caskeleton.application.notification.SubmissionCertainty;
import java.time.Clock;
import java.util.Objects;
import java.util.function.Function;
/** Maps retained lookup identities to bounded provider reconciliation calls. */
public final class NotificationReconciliationAdapter implements NotificationReconciliationPort {
private final Function<NotificationDeliveryStorePort.ReconciliationClaim, ReconciliationBinding>
bindings;
private final NotificationProviderSecretMaterialProvider secrets;
private final Clock clock;
public NotificationReconciliationAdapter(
Function<NotificationDeliveryStorePort.ReconciliationClaim, ReconciliationBinding> bindings,
NotificationProviderSecretMaterialProvider secrets,
Clock clock) {
this.bindings =
Objects.requireNonNull(bindings, "reconciliation binding resolver must be non-null");
this.secrets =
Objects.requireNonNull(secrets, "reconciliation secret provider must be non-null");
this.clock = Objects.requireNonNull(clock, "reconciliation clock must be non-null");
}
@Override
public ReconciliationOutcome reconcile(NotificationDeliveryStorePort.ReconciliationClaim claim) {
Objects.requireNonNull(claim, "notification reconciliation claim must be non-null");
if (!claim.absoluteDeadline().isAfter(clock.instant())) {
return indeterminate("RECONCILIATION_DEADLINE_EXPIRED");
}
try {
ReconciliationBinding binding =
Objects.requireNonNull(bindings.apply(claim), "reconciliation binding must be non-null");
if (!binding.matches(claim)) {
return indeterminate("RECONCILIATION_BINDING_MISMATCH");
}
if (!supportsLookup(binding.target(), claim.lookupKind())) {
return indeterminate("RECONCILIATION_UNSUPPORTED");
}
NotificationProviderAttemptClient.ReconciliationReference reference = reference(claim);
ReconciliationLookupMode mode = mode(claim.lookupKind());
NotificationProviderRuntimeProfile profile = binding.target().runtimeProfile();
try (NotificationSecretMaterialHandle secret = secrets.acquire(profile)) {
if (!secret.revision().equals(profile.credentialGeneration())) {
return indeterminate("RECONCILIATION_SECRET_GENERATION_MISMATCH");
}
if (!claim.absoluteDeadline().isAfter(clock.instant())) {
return indeterminate("RECONCILIATION_DEADLINE_EXPIRED");
}
NotificationProviderAttemptClient.ClientReconciliationResult result =
Objects.requireNonNull(
binding.client().reconcile(reference, mode, secret, claim.absoluteDeadline()),
"provider reconciliation result must be non-null");
if (result.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED) {
return indeterminate("RECONCILIATION_NONAPPLICATION_UNPROVEN");
}
return new ReconciliationOutcome(result.submissionCertainty(), result.reasonCode());
}
} catch (RuntimeException providerFailure) {
return indeterminate("RECONCILIATION_INDETERMINATE");
}
}
private static NotificationProviderAttemptClient.ReconciliationReference reference(
NotificationDeliveryStorePort.ReconciliationClaim claim) {
return switch (claim.lookupKind()) {
case PRE_SEND_CORRELATION -> new AttemptCorrelationId(claim.lookupReference());
case CLIENT_OPERATION_KEY ->
new NotificationAttemptContext.ProviderClientOperationKey(claim.lookupReference());
case MESSAGE_REFERENCE -> new ProviderMessageReference(claim.lookupReference());
};
}
private static ReconciliationLookupMode mode(
NotificationDeliveryStorePort.ReconciliationLookupKind kind) {
return ReconciliationLookupMode.valueOf(kind.name());
}
private static boolean supportsLookup(
CompiledNotificationBinding.CompiledTarget target,
NotificationDeliveryStorePort.ReconciliationLookupKind lookupKind) {
return target.capabilityCard().reconciliationSupported()
&& target.capabilityCard().maximumReconcileCalls() > 0
&& target.capabilityCard().cardId().equals("aws-ses-v2-durable-single-local-sns-v1")
&& lookupKind
== NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION;
}
private static ReconciliationOutcome indeterminate(String reason) {
return new ReconciliationOutcome(
SubmissionCertainty.INDETERMINATE, new NotificationReasonCode(reason));
}
public static final class ReconciliationBinding {
private final CompiledNotificationBinding binding;
private final int targetOrdinal;
private final NotificationProviderAttemptClient client;
private final CompiledNotificationBinding.CompiledTarget target;
public ReconciliationBinding(
CompiledNotificationBinding binding,
int targetOrdinal,
NotificationProviderAttemptClient client) {
this.binding =
Objects.requireNonNull(binding, "reconciliation compiled binding must be non-null");
if (targetOrdinal < 0 || targetOrdinal >= binding.targets().size()) {
throw new IllegalArgumentException(
"reconciliation target ordinal is outside the compiled binding");
}
this.targetOrdinal = targetOrdinal;
this.client = Objects.requireNonNull(client, "reconciliation client must be non-null");
this.target = binding.targets().get(targetOrdinal);
}
public NotificationProviderAttemptClient client() {
return client;
}
public CompiledNotificationBinding.CompiledTarget target() {
return target;
}
private boolean matches(NotificationDeliveryStorePort.ReconciliationClaim claim) {
return binding.route().routeId().equals(claim.routeId())
&& binding.route().routeRevision() == claim.routeRevision()
&& binding.bindingDigest().equals(claim.bindingDigest())
&& targetOrdinal == claim.targetOrdinal()
&& target.target().targetId().equals(claim.targetReference())
&& target.capabilityCard().cardId().equals(claim.providerCapabilityReference())
&& target.runtimeProfile().bindingRevision().equals(claim.providerBindingRevision())
&& target.runtimeProfile().credentialGeneration().equals(claim.credentialGeneration());
}
@Override
public String toString() {
return "ReconciliationBinding[bindingDigest="
+ binding.bindingDigest()
+ ", targetOrdinal="
+ targetOrdinal
+ ", client=<redacted>, target=<redacted>]";
}
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import java.util.Arrays;
import java.util.Objects;
import java.util.function.Function;
/** Versioned operation-scoped mutable secret copy that wipes itself on close. */
public final class NotificationSecretMaterialHandle implements AutoCloseable {
private final String revision;
private byte[] bytes;
private char[] characters;
private boolean closed;
private NotificationSecretMaterialHandle(String revision, byte[] bytes, char[] characters) {
this.revision = requireSlug(revision);
this.bytes = bytes;
this.characters = characters;
}
public static NotificationSecretMaterialHandle fromBytes(String revision, byte[] value) {
Objects.requireNonNull(value, "secret byte material must be non-null");
if (value.length < 1 || value.length > 65_536) {
throw new IllegalArgumentException("secret byte material must contain 1..65536 bytes");
}
return new NotificationSecretMaterialHandle(revision, value.clone(), null);
}
public static NotificationSecretMaterialHandle fromCharacters(String revision, char[] value) {
Objects.requireNonNull(value, "secret character material must be non-null");
if (value.length < 1 || value.length > 65_536) {
throw new IllegalArgumentException("secret character material must contain 1..65536 values");
}
return new NotificationSecretMaterialHandle(revision, null, value.clone());
}
public String revision() {
return revision;
}
public synchronized <T> T readBytes(Function<byte[], T> reader) {
Objects.requireNonNull(reader, "secret byte reader must be non-null");
requireOpen();
if (bytes == null) {
throw new IllegalStateException("secret handle does not contain byte material");
}
return reader.apply(bytes);
}
public synchronized <T> T readCharacters(Function<char[], T> reader) {
Objects.requireNonNull(reader, "secret character reader must be non-null");
requireOpen();
if (characters == null) {
throw new IllegalStateException("secret handle does not contain character material");
}
return reader.apply(characters);
}
@Override
public synchronized void close() {
if (!closed) {
if (bytes != null) {
Arrays.fill(bytes, (byte) 0);
}
if (characters != null) {
Arrays.fill(characters, '\0');
}
closed = true;
}
}
@Override
public String toString() {
return "NotificationSecretMaterialHandle[revision="
+ revision
+ ", material=<redacted>, closed="
+ closed
+ "]";
}
private void requireOpen() {
if (closed) {
throw new IllegalStateException("secret material handle is closed");
}
}
private static String requireSlug(String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException("secret revision must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding;
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
import java.util.Objects;
/** Side-effect-free provider preparation result for exactly one compiled target. */
public record PreparedNotificationAttempt(
RenderedNotification rendered,
CompiledNotificationBinding.CompiledTarget target,
NotificationAttemptContext context,
String payloadDigest) {
public PreparedNotificationAttempt {
Objects.requireNonNull(rendered, "rendered notification must be non-null");
Objects.requireNonNull(target, "compiled notification target must be non-null");
Objects.requireNonNull(context, "notification attempt context must be non-null");
if (payloadDigest == null || !payloadDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("prepared payload digest must be lowercase SHA-256");
}
}
@Override
public String toString() {
return "PreparedNotificationAttempt[target="
+ target.target().targetId()
+ ", rendered=<redacted>, context=<redacted>, payloadDigest="
+ payloadDigest
+ "]";
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.adapter.outbound.notification.provider;
/** Opaque provider message identity available only after an accepted response or receipt. */
public record ProviderMessageReference(String value)
implements NotificationProviderAttemptClient.ReconciliationReference {
public ProviderMessageReference {
value = AttemptCorrelationId.requireOpaque("provider message reference", value);
}
@Override
public String toString() {
return "ProviderMessageReference[value=<redacted>]";
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.notification.provider;
/** Provider-documented lookup identity available for reconciliation. */
public enum ReconciliationLookupMode {
PRE_SEND_CORRELATION,
CLIENT_OPERATION_KEY,
MESSAGE_REFERENCE,
UNSUPPORTED
}
@@ -0,0 +1,473 @@
package dev.caskeleton.adapter.outbound.notification.template;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationTemplateValue;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Context-aware local email renderer for reviewed subject, text and HTML resources. */
public final class LocalEmailRenderer
implements NotificationTemplateRenderer<RenderedNotification.Email> {
private static final Pattern TOKEN =
Pattern.compile("\\{\\{([a-z][A-Za-z0-9]{0,63})\\|([a-z_]+)}}");
private static final Pattern HTML_NAME = Pattern.compile("[a-z][a-z0-9-]{0,31}");
private static final java.util.Set<String> APPROVED_HTML_TAGS =
java.util.Set.of(
"a", "b", "body", "br", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6", "head", "html",
"i", "img", "li", "ol", "p", "span", "strong", "table", "tbody", "td", "th", "thead",
"tr", "ul");
private static final java.util.Set<String> APPROVED_INERT_ATTRIBUTES =
java.util.Set.of("alt", "aria-label", "title");
private final NotificationTemplateCatalog catalog;
public LocalEmailRenderer(NotificationTemplateCatalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "template catalog must be non-null");
}
@Override
public RenderedNotification.Email render(NotificationFrozenPlan plan) {
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
NotificationTemplateManifest manifest = catalog.require(plan.policy().templateRef());
validatePlan(plan, manifest, NotificationChannel.EMAIL);
NotificationTemplateCatalog.LoadedResourceSet sources =
catalog.sources(manifest, plan.selectedLocale());
String subjectTemplate = sources.subject().orElseThrow();
String textTemplate = sources.text().orElseThrow();
String htmlTemplate = sources.html().orElseThrow();
validateTemplateUsage(
java.util.List.of(subjectTemplate, textTemplate, htmlTemplate), manifest.parameterNames());
validateContexts(subjectTemplate, java.util.Set.of("header"), "email subject");
validateContexts(textTemplate, java.util.Set.of("text"), "email text");
validateHtmlContexts(htmlTemplate);
requireAggregateBoundedExpansion(
java.util.List.of(subjectTemplate, textTemplate, htmlTemplate),
plan.parameters().values(),
manifest.maximumRenderedBytes(),
12);
String subject =
stripOneTrailingLineBreak(
render(
subjectTemplate,
plan.parameters().values(),
manifest.parameterNames(),
manifest.maximumRenderedBytes()));
if (subject.indexOf('\r') >= 0 || subject.indexOf('\n') >= 0) {
throw new TemplateRenderingException("rendered email header contains a line break");
}
String text =
render(
textTemplate,
plan.parameters().values(),
manifest.parameterNames(),
manifest.maximumRenderedBytes());
String html =
render(
htmlTemplate,
plan.parameters().values(),
manifest.parameterNames(),
manifest.maximumRenderedBytes());
int bytes =
subject.getBytes(StandardCharsets.UTF_8).length
+ text.getBytes(StandardCharsets.UTF_8).length
+ html.getBytes(StandardCharsets.UTF_8).length;
if (bytes > manifest.maximumRenderedBytes()) {
throw new TemplateRenderingException("rendered email exceeds the manifest byte bound");
}
return new RenderedNotification.Email(sources.locale(), subject, text, html, bytes);
}
static void validatePlan(
NotificationFrozenPlan plan,
NotificationTemplateManifest manifest,
NotificationChannel expectedChannel) {
if (plan.policy().channel() != expectedChannel || manifest.channel() != expectedChannel) {
throw new TemplateRenderingException("template channel does not match renderer");
}
if (!plan.binding().templateChecksum().equals(manifest.checksum())) {
throw new TemplateRenderingException("frozen template checksum does not match manifest");
}
if (!plan.binding().rendererRevision().equals(manifest.rendererRevision())) {
throw new TemplateRenderingException("frozen renderer revision does not match manifest");
}
if (!plan.parameters().values().keySet().equals(manifest.parameterNames())) {
throw new TemplateRenderingException(
"template parameter names must exactly match the manifest");
}
}
static String scalar(NotificationTemplateValue value) {
return switch (value) {
case NotificationTemplateValue.SafeText safeText -> safeText.value();
case NotificationTemplateValue.TrustedAbsoluteLinkReference link -> link.value();
case NotificationTemplateValue.LocalDateValue date -> date.value().toString();
case NotificationTemplateValue.LocalDateTimeValue dateTime ->
dateTime.value().atZone(dateTime.zone()).toString();
case NotificationTemplateValue.IntegerValue integer -> Long.toString(integer.value());
case NotificationTemplateValue.MoneyValue money ->
money.amount().toPlainString() + " " + money.currency().getCurrencyCode();
};
}
static String html(String value) {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
static String urlComponent(String value) {
StringBuilder encoded = new StringBuilder();
for (byte item : value.getBytes(StandardCharsets.UTF_8)) {
int unsigned = item & 0xff;
char character = (char) unsigned;
if ((character >= 'a' && character <= 'z')
|| (character >= 'A' && character <= 'Z')
|| (character >= '0' && character <= '9')
|| character == '-'
|| character == '.'
|| character == '_'
|| character == '~') {
encoded.append(character);
} else {
encoded.append('%');
encoded.append(Character.toUpperCase(Character.forDigit((unsigned >>> 4) & 0xf, 16)));
encoded.append(Character.toUpperCase(Character.forDigit(unsigned & 0xf, 16)));
}
}
return encoded.toString();
}
static void validateTemplateUsage(
java.util.List<String> templates, java.util.Set<String> manifestNames) {
java.util.Set<String> used = new java.util.HashSet<>();
templates.forEach(
template -> {
Matcher matcher = TOKEN.matcher(template);
while (matcher.find()) {
used.add(matcher.group(1));
}
});
if (!used.equals(manifestNames)) {
throw new TemplateRenderingException(
"template parameter usage must exactly match the manifest");
}
}
private static void validateContexts(
String template, java.util.Set<String> allowedContexts, String assetRole) {
Matcher matcher = TOKEN.matcher(template);
while (matcher.find()) {
if (!allowedContexts.contains(matcher.group(2))) {
throw new TemplateRenderingException(
assetRole + " uses a rendering context that is not approved for that asset");
}
}
}
static void validateHtmlContexts(String template) {
validateRestrictedHtml(template);
Matcher matcher = TOKEN.matcher(template);
while (matcher.find()) {
boolean insideTag = isInsideMarkupTag(template, matcher.start());
switch (matcher.group(2)) {
case "html_text" -> {
if (insideTag) {
throw new TemplateRenderingException(
"HTML text parameter must occur outside markup tags");
}
}
case "html_attr" -> {
if (!insideTag || !isApprovedHtmlAttribute(template, matcher)) {
throw new TemplateRenderingException(
"HTML attribute parameter is outside an approved inert attribute");
}
}
case "url_component" -> {
if (!insideTag || !isApprovedUrlComponent(template, matcher)) {
throw new TemplateRenderingException(
"URL component parameter is outside an approved HTTPS link attribute");
}
}
default ->
throw new TemplateRenderingException(
"email HTML uses a rendering context that is not approved for that asset");
}
}
}
private static String render(
String template,
Map<String, NotificationTemplateValue> parameters,
java.util.Set<String> manifestNames,
int maximumBytes) {
requireBoundedExpansion(template, parameters, maximumBytes);
Matcher matcher = TOKEN.matcher(template);
StringBuilder output = new StringBuilder(Math.min(template.length(), maximumBytes));
while (matcher.find()) {
String name = matcher.group(1);
NotificationTemplateValue parameter = parameters.get(name);
if (parameter == null || !manifestNames.contains(name)) {
throw new TemplateRenderingException("template references an unknown parameter");
}
String raw = scalar(parameter);
String replacement =
switch (matcher.group(2)) {
case "header" -> {
if (raw.indexOf('\r') >= 0 || raw.indexOf('\n') >= 0) {
throw new TemplateRenderingException(
"rendered email header parameter contains a line break");
}
yield raw;
}
case "text" -> raw;
case "html_text" -> html(raw);
case "html_attr" -> htmlAttribute(template, matcher, raw);
case "url_component" -> {
if (!(parameter instanceof NotificationTemplateValue.SafeText)) {
throw new TemplateRenderingException(
"URL component parameters must use bounded safe text values");
}
yield urlComponent(raw);
}
default -> throw new TemplateRenderingException("unknown email rendering context");
};
matcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(output);
if (output.indexOf("{{") >= 0) {
throw new TemplateRenderingException("template has an unresolved parameter");
}
return output.toString();
}
private static void requireBoundedExpansion(
String template, Map<String, NotificationTemplateValue> parameters, int maximumBytes) {
requireAggregateBoundedExpansion(java.util.List.of(template), parameters, maximumBytes, 12);
}
static void requireAggregateBoundedExpansion(
java.util.List<String> templates,
Map<String, NotificationTemplateValue> parameters,
int maximumBytes,
int maximumEscapedBytesPerInputByte) {
long projectedBytes = 0;
for (String template : templates) {
projectedBytes =
Math.addExact(projectedBytes, template.getBytes(StandardCharsets.UTF_8).length);
Matcher matcher = TOKEN.matcher(template);
while (matcher.find()) {
NotificationTemplateValue value = parameters.get(matcher.group(1));
if (value != null) {
long rawBytes = scalar(value).getBytes(StandardCharsets.UTF_8).length;
projectedBytes =
Math.addExact(
projectedBytes,
Math.multiplyExact(rawBytes, (long) maximumEscapedBytesPerInputByte));
}
if (projectedBytes > maximumBytes) {
throw new TemplateRenderingException(
"rendered template expansion exceeds the manifest byte bound");
}
}
}
}
private static String htmlAttribute(String template, Matcher matcher, String raw) {
if (!isApprovedHtmlAttribute(template, matcher)
|| raw.indexOf('`') >= 0
|| raw.chars().anyMatch(Character::isISOControl)) {
throw new TemplateRenderingException(
"HTML attribute parameter is outside an approved quoted non-URL context");
}
return html(raw);
}
private static boolean isApprovedHtmlAttribute(String template, Matcher matcher) {
boolean exactlyQuoted =
matcher.start() > 0
&& matcher.end() < template.length()
&& template.charAt(matcher.start() - 1) == '"'
&& template.charAt(matcher.end()) == '"';
String prefix = template.substring(Math.max(0, matcher.start() - 64), matcher.start());
return exactlyQuoted
&& prefix
.toLowerCase(java.util.Locale.ROOT)
.matches("(?s).*(title|alt|aria-label)\\s*=\\s*\"$");
}
private static boolean isApprovedUrlComponent(String template, Matcher matcher) {
int tagStart = template.lastIndexOf('<', matcher.start());
int quoteEnd = template.indexOf('"', matcher.end());
int tagEnd = template.indexOf('>', matcher.end());
if (tagStart < 0 || quoteEnd < matcher.end() || tagEnd < quoteEnd) {
return false;
}
String attributePrefix =
template.substring(tagStart, matcher.start()).toLowerCase(java.util.Locale.ROOT);
String attributeSuffix = template.substring(matcher.end(), quoteEnd);
return attributePrefix.matches(
"(?s).*\\shref\\s*=\\s*\"https://"
+ "[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::[0-9]{1,5})?"
+ "(?:/[^\"{}]*|\\?[^\"{}]*|#[^\"{}]*)$")
&& !attributeSuffix.contains("{{")
&& !attributeSuffix.contains("}}");
}
private static boolean isInsideMarkupTag(String template, int position) {
boolean insideTag = false;
char quote = 0;
for (int index = 0; index < position; index++) {
char current = template.charAt(index);
if (!insideTag) {
if (current == '<') {
insideTag = true;
}
} else if (quote != 0) {
if (current == quote) {
quote = 0;
}
} else if (current == '"' || current == '\'') {
quote = current;
} else if (current == '>') {
insideTag = false;
}
}
return insideTag;
}
private static void validateRestrictedHtml(String template) {
ArrayDeque<String> openTags = new ArrayDeque<>();
int cursor = 0;
while (cursor < template.length()) {
int tagStart = template.indexOf('<', cursor);
if (tagStart < 0) {
break;
}
int tagEnd = findTagEnd(template, tagStart + 1);
if (tagEnd < 0) {
throw new TemplateRenderingException("email HTML contains an unterminated markup tag");
}
String body = template.substring(tagStart + 1, tagEnd).trim();
if (body.isEmpty() || body.startsWith("!") || body.startsWith("?")) {
throw new TemplateRenderingException("email HTML contains unsupported markup");
}
if (body.startsWith("/")) {
String closingName = body.substring(1).trim().toLowerCase(java.util.Locale.ROOT);
if (!HTML_NAME.matcher(closingName).matches()
|| openTags.isEmpty()
|| !openTags.removeLast().equals(closingName)) {
throw new TemplateRenderingException("email HTML contains mismatched markup");
}
} else {
boolean selfClosing = body.endsWith("/");
String opening = selfClosing ? body.substring(0, body.length() - 1).trim() : body;
int nameEnd = 0;
while (nameEnd < opening.length()
&& (Character.isLetterOrDigit(opening.charAt(nameEnd))
|| opening.charAt(nameEnd) == '-')) {
nameEnd++;
}
String tagName = opening.substring(0, nameEnd).toLowerCase(java.util.Locale.ROOT);
if (!HTML_NAME.matcher(tagName).matches() || !APPROVED_HTML_TAGS.contains(tagName)) {
throw new TemplateRenderingException("email HTML contains an unsupported tag");
}
validateAttributes(tagName, opening.substring(nameEnd));
boolean voidElement = tagName.equals("br") || tagName.equals("img");
if (selfClosing != voidElement && (selfClosing || voidElement)) {
throw new TemplateRenderingException("email HTML uses an invalid void element shape");
}
if (!voidElement) {
openTags.addLast(tagName);
}
}
cursor = tagEnd + 1;
}
if (!openTags.isEmpty()) {
throw new TemplateRenderingException("email HTML contains unclosed markup");
}
}
private static int findTagEnd(String template, int start) {
char quote = 0;
for (int index = start; index < template.length(); index++) {
char current = template.charAt(index);
if (quote != 0) {
if (current == quote) {
quote = 0;
}
} else if (current == '"' || current == '\'') {
quote = current;
} else if (current == '>') {
return index;
}
}
return -1;
}
private static void validateAttributes(String tagName, String attributes) {
int cursor = 0;
while (cursor < attributes.length()) {
while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) {
cursor++;
}
if (cursor == attributes.length()) {
return;
}
int nameStart = cursor;
while (cursor < attributes.length()
&& (Character.isLetterOrDigit(attributes.charAt(cursor))
|| attributes.charAt(cursor) == '-')) {
cursor++;
}
String attributeName =
attributes.substring(nameStart, cursor).toLowerCase(java.util.Locale.ROOT);
if (!HTML_NAME.matcher(attributeName).matches()) {
throw new TemplateRenderingException("email HTML contains malformed attributes");
}
while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) {
cursor++;
}
if (cursor >= attributes.length() || attributes.charAt(cursor++) != '=') {
throw new TemplateRenderingException("email HTML attributes must have explicit values");
}
while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) {
cursor++;
}
if (cursor >= attributes.length() || attributes.charAt(cursor++) != '"') {
throw new TemplateRenderingException("email HTML attributes must be double quoted");
}
int valueEnd = attributes.indexOf('"', cursor);
if (valueEnd < 0) {
throw new TemplateRenderingException("email HTML contains an unterminated attribute");
}
String value = attributes.substring(cursor, valueEnd);
if (attributeName.equals("href")) {
if (!tagName.equals("a") || !value.startsWith("https://")) {
throw new TemplateRenderingException("email HTML links must be static HTTPS links");
}
} else if (!APPROVED_INERT_ATTRIBUTES.contains(attributeName)) {
throw new TemplateRenderingException("email HTML contains an unsupported attribute");
}
cursor = valueEnd + 1;
}
}
private static String stripOneTrailingLineBreak(String value) {
if (value.endsWith("\r\n")) {
return value.substring(0, value.length() - 2);
}
if (value.endsWith("\n")) {
return value.substring(0, value.length() - 1);
}
return value;
}
}
@@ -0,0 +1,253 @@
package dev.caskeleton.adapter.outbound.notification.template;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Loads only manifest-named classpath assets, validates exact checksums, and retains immutable
* text.
*/
public final class NotificationTemplateCatalog {
private static final int MAXIMUM_RESOURCE_BYTES = 1_000_000;
private static final long MAXIMUM_CATALOG_BYTES = 64L * 1024L * 1024L;
private static final int MAXIMUM_CATALOG_RESOURCES = 4_096;
private static final long MAXIMUM_MANIFEST_BYTES = 16L * 1024L * 1024L;
private static final int MAXIMUM_MANIFEST_RESOURCES = 256;
private final Map<NotificationTemplateRef, LoadedTemplate> templates;
public NotificationTemplateCatalog(
ClassLoader classLoader, List<NotificationTemplateManifest> manifests) {
Objects.requireNonNull(classLoader, "template class loader must be non-null");
Objects.requireNonNull(manifests, "template manifests must be non-null");
Map<NotificationTemplateRef, LoadedTemplate> loaded = new HashMap<>();
LoadBudget budget = new LoadBudget(MAXIMUM_CATALOG_BYTES, MAXIMUM_CATALOG_RESOURCES);
manifests.stream()
.sorted(
Comparator.comparing(
(NotificationTemplateManifest manifest) -> manifest.templateRef().templateId())
.thenComparingInt(manifest -> manifest.templateRef().version()))
.forEach(
manifest -> {
Objects.requireNonNull(manifest, "template manifest must be non-null");
if (loaded.containsKey(manifest.templateRef())) {
throw new TemplateRenderingException("duplicate template manifest");
}
Map<Locale, LoadedResourceSet> sources =
load(classLoader, manifest.resources(), budget);
String actual = checksumLoaded(sources);
if (!actual.equals(manifest.checksum())) {
throw new TemplateRenderingException("template manifest checksum mismatch");
}
sources.values().forEach(NotificationTemplateCatalog::rejectDirectives);
loaded.put(manifest.templateRef(), new LoadedTemplate(manifest, sources));
});
if (loaded.isEmpty() || loaded.size() > 100) {
throw new TemplateRenderingException("template catalog must contain 1..100 manifests");
}
this.templates = Map.copyOf(loaded);
}
public NotificationTemplateManifest require(NotificationTemplateRef templateRef) {
Objects.requireNonNull(templateRef, "template reference must be non-null");
LoadedTemplate template = templates.get(templateRef);
if (template == null) {
throw new TemplateRenderingException("unknown template revision");
}
return template.manifest();
}
LoadedResourceSet sources(NotificationTemplateManifest manifest, Locale requested) {
LoadedTemplate loaded = templates.get(manifest.templateRef());
if (loaded == null || !loaded.manifest().equals(manifest)) {
throw new TemplateRenderingException("template manifest is outside the loaded catalog");
}
Objects.requireNonNull(requested, "frozen selected locale must be non-null");
Locale selected = Locale.forLanguageTag(requested.toLanguageTag());
if (!manifest.supportedLocales().contains(selected)) {
throw new TemplateRenderingException(
"frozen selected locale is outside the exact template manifest");
}
LoadedResourceSet sources = loaded.sources().get(selected);
if (sources == null) {
throw new TemplateRenderingException("selected template locale has no exact resource set");
}
return sources.withLocale(selected);
}
public static String checksum(
ClassLoader classLoader, Map<Locale, NotificationTemplateManifest.ResourceSet> resources) {
Objects.requireNonNull(classLoader, "template class loader must be non-null");
Objects.requireNonNull(resources, "template resources must be non-null");
return checksumLoaded(
load(
classLoader,
resources,
new LoadBudget(MAXIMUM_MANIFEST_BYTES, MAXIMUM_MANIFEST_RESOURCES)));
}
private static Map<Locale, LoadedResourceSet> load(
ClassLoader classLoader,
Map<Locale, NotificationTemplateManifest.ResourceSet> resources,
LoadBudget budget) {
LinkedHashMap<Locale, LoadedResourceSet> loaded = new LinkedHashMap<>();
resources.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.comparing(Locale::toLanguageTag)))
.forEach(
entry -> {
NotificationTemplateManifest.ResourceSet paths = entry.getValue();
loaded.put(
entry.getKey(),
new LoadedResourceSet(
entry.getKey(),
paths.subject().map(path -> text(classLoader, path, budget)),
paths.text().map(path -> text(classLoader, path, budget)),
paths.html().map(path -> text(classLoader, path, budget)),
paths.slack().map(path -> text(classLoader, path, budget))));
});
return Map.copyOf(loaded);
}
private static String checksumLoaded(Map<Locale, LoadedResourceSet> sources) {
MessageDigest digest = sha256();
sources.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.comparing(Locale::toLanguageTag)))
.forEach(
entry -> {
update(digest, entry.getKey().toLanguageTag());
LoadedResourceSet resource = entry.getValue();
updateOptional(digest, "subject", resource.subject());
updateOptional(digest, "text", resource.text());
updateOptional(digest, "html", resource.html());
updateOptional(digest, "slack", resource.slack());
});
return HexFormat.of().formatHex(digest.digest());
}
private static void updateOptional(
MessageDigest digest, String role, java.util.Optional<String> content) {
content.ifPresent(
value -> {
update(digest, role);
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
digest.update(encoded);
});
}
private static void rejectDirectives(LoadedResourceSet sources) {
java.util.stream.Stream.of(sources.subject(), sources.text(), sources.html(), sources.slack())
.flatMap(java.util.Optional::stream)
.forEach(
content -> {
if (content.contains("{{>")
|| content.contains("{%")
|| content.contains("${")
|| content.contains("{{#")) {
throw new TemplateRenderingException(
"template contains unsupported include or reflection directive");
}
});
}
private static String text(ClassLoader classLoader, String path, LoadBudget budget) {
byte[] bytes = read(classLoader, path);
budget.consume(bytes.length);
return new String(bytes, StandardCharsets.UTF_8);
}
private static byte[] read(ClassLoader classLoader, String path) {
try (InputStream stream = classLoader.getResourceAsStream(path)) {
if (stream == null) {
throw new TemplateRenderingException("manifest resource is missing");
}
byte[] bytes = stream.readNBytes(MAXIMUM_RESOURCE_BYTES + 1);
if (bytes.length > MAXIMUM_RESOURCE_BYTES) {
throw new TemplateRenderingException("manifest resource exceeds the byte bound");
}
return bytes;
} catch (IOException failure) {
throw new TemplateRenderingException("manifest resource could not be loaded", failure);
}
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException(
"SHA-256 must be available on every Java runtime", unavailable);
}
}
private static void update(MessageDigest digest, String value) {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
digest.update(encoded);
}
record LoadedTemplate(
NotificationTemplateManifest manifest, Map<Locale, LoadedResourceSet> sources) {
LoadedTemplate {
Objects.requireNonNull(manifest, "loaded template manifest must be non-null");
sources = Map.copyOf(Objects.requireNonNull(sources, "loaded sources must be non-null"));
}
}
record LoadedResourceSet(
Locale locale,
java.util.Optional<String> subject,
java.util.Optional<String> text,
java.util.Optional<String> html,
java.util.Optional<String> slack) {
LoadedResourceSet {
Objects.requireNonNull(locale, "loaded resource locale must be non-null");
Objects.requireNonNull(subject, "loaded subject container must be non-null");
Objects.requireNonNull(text, "loaded text container must be non-null");
Objects.requireNonNull(html, "loaded HTML container must be non-null");
Objects.requireNonNull(slack, "loaded Slack container must be non-null");
}
LoadedResourceSet withLocale(Locale selected) {
return new LoadedResourceSet(selected, subject, text, html, slack);
}
}
private static final class LoadBudget {
private final long maximumBytes;
private final int maximumResources;
private long loadedBytes;
private int loadedResources;
private LoadBudget(long maximumBytes, int maximumResources) {
this.maximumBytes = maximumBytes;
this.maximumResources = maximumResources;
}
private void consume(int bytes) {
loadedBytes = Math.addExact(loadedBytes, bytes);
loadedResources = Math.addExact(loadedResources, 1);
if (loadedBytes > maximumBytes || loadedResources > maximumResources) {
throw new TemplateRenderingException(
"template catalog exceeds the aggregate resource budget");
}
}
}
}
@@ -0,0 +1,175 @@
package dev.caskeleton.adapter.outbound.notification.template;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
/** Exact immutable classpath resource manifest for one reviewed template revision. */
public record NotificationTemplateManifest(
NotificationTemplateRef templateRef,
NotificationChannel channel,
String rendererRevision,
String checksum,
Set<Locale> supportedLocales,
Locale fallbackLocale,
Set<String> parameterNames,
int maximumRenderedBytes,
Map<Locale, ResourceSet> resources) {
public NotificationTemplateManifest {
Objects.requireNonNull(templateRef, "template reference must be non-null");
Objects.requireNonNull(channel, "template channel must be non-null");
rendererRevision = requireSlug("renderer revision", rendererRevision);
if (checksum == null || !checksum.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("template checksum must be a lowercase SHA-256 digest");
}
Objects.requireNonNull(supportedLocales, "supported locales must be non-null");
supportedLocales =
supportedLocales.stream()
.map(NotificationTemplateManifest::requireLocale)
.sorted(Comparator.comparing(Locale::toLanguageTag))
.collect(Collectors.toUnmodifiableSet());
if (supportedLocales.isEmpty() || supportedLocales.size() > 32) {
throw new IllegalArgumentException("supported locales must contain 1..32 entries");
}
fallbackLocale = requireLocale(fallbackLocale);
if (!supportedLocales.contains(fallbackLocale)) {
throw new IllegalArgumentException("fallback locale must be supported");
}
Objects.requireNonNull(parameterNames, "template parameter names must be non-null");
parameterNames =
parameterNames.stream()
.map(NotificationTemplateManifest::requireParameter)
.collect(Collectors.toUnmodifiableSet());
if (parameterNames.size() > 32) {
throw new IllegalArgumentException("template parameter names exceed 32 entries");
}
Objects.requireNonNull(resources, "template resources must be non-null");
TreeMap<String, Map.Entry<Locale, ResourceSet>> sorted = new TreeMap<>();
resources.forEach(
(locale, resourceSet) -> {
Locale normalized = requireLocale(locale);
if (sorted.put(
normalized.toLanguageTag(),
Map.entry(
normalized,
Objects.requireNonNull(
resourceSet, "template resource set must be non-null")))
!= null) {
throw new IllegalArgumentException("duplicate normalized template locale");
}
});
resources =
sorted.values().stream()
.collect(
Collectors.toUnmodifiableMap(
Map.Entry::getKey, Map.Entry::getValue, (left, right) -> left));
if (!resources.keySet().equals(supportedLocales)) {
throw new IllegalArgumentException(
"template resource locales must exactly match supported locales");
}
if (new HashSet<>(resources.keySet()).size() != resources.size()) {
throw new IllegalArgumentException("template resources contain duplicate locales");
}
resources.values().forEach(resource -> resource.validateFor(channel));
if (maximumRenderedBytes < 1 || maximumRenderedBytes > 10_000_000) {
throw new IllegalArgumentException("maximum rendered bytes must be in 1..10000000");
}
}
public Locale selectLocale(Locale requested) {
Locale normalized = requireLocale(requested);
return supportedLocales.contains(normalized) ? normalized : fallbackLocale;
}
private static Locale requireLocale(Locale locale) {
Objects.requireNonNull(locale, "template locale must be non-null");
String tag = locale.toLanguageTag();
if (locale.equals(Locale.ROOT) || tag.equals("und") || tag.isBlank() || tag.length() > 35) {
throw new IllegalArgumentException("template locale must be explicit and bounded");
}
return Locale.forLanguageTag(tag);
}
private static String requireParameter(String name) {
if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) {
throw new IllegalArgumentException("template parameter must match [a-z][A-Za-z0-9]{0,63}");
}
return name;
}
private static String requireSlug(String field, String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
/** Channel-shaped exact classpath paths; paths cannot escape the notification template root. */
public record ResourceSet(
Optional<String> subject,
Optional<String> text,
Optional<String> html,
Optional<String> slack) {
public ResourceSet {
subject = validateOptional(subject);
text = validateOptional(text);
html = validateOptional(html);
slack = validateOptional(slack);
}
public static ResourceSet email(String subject, String text, String html) {
return new ResourceSet(
Optional.of(subject), Optional.of(text), Optional.of(html), Optional.empty());
}
public static ResourceSet slack(String slack) {
return new ResourceSet(
Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(slack));
}
Map<String, String> rolePaths() {
java.util.LinkedHashMap<String, String> paths = new java.util.LinkedHashMap<>();
subject.ifPresent(path -> paths.put("subject", path));
text.ifPresent(path -> paths.put("text", path));
html.ifPresent(path -> paths.put("html", path));
slack.ifPresent(path -> paths.put("slack", path));
return Map.copyOf(paths);
}
void validateFor(NotificationChannel channel) {
boolean emailShape =
subject.isPresent() && text.isPresent() && html.isPresent() && slack.isEmpty();
boolean slackShape =
subject.isEmpty() && text.isEmpty() && html.isEmpty() && slack.isPresent();
if ((channel == NotificationChannel.EMAIL && !emailShape)
|| (channel == NotificationChannel.SLACK && !slackShape)) {
throw new IllegalArgumentException("template resources do not match channel shape");
}
}
private static Optional<String> validateOptional(Optional<String> path) {
Objects.requireNonNull(path, "template resource path container must be non-null");
return path.map(
value -> {
if (!value.matches("notification/templates/[a-z0-9._/-]{1,180}")
|| value.contains("..")
|| value.startsWith("/")
|| value.contains("\\")) {
throw new IllegalArgumentException(
"template resource path must remain under notification/templates");
}
return value;
});
}
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.adapter.outbound.notification.template;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
/** Deterministic local renderer over an application-owned frozen plan. */
@FunctionalInterface
public interface NotificationTemplateRenderer<T extends RenderedNotification> {
T render(NotificationFrozenPlan plan);
}
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.outbound.notification.template;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
/** Closed typed renderer output; arbitrary provider JSON is deliberately absent. */
public sealed interface RenderedNotification
permits RenderedNotification.Email, RenderedNotification.Slack {
Locale locale();
int utf8Bytes();
record Email(Locale locale, String subject, String text, String html, int utf8Bytes)
implements RenderedNotification {
public Email {
Objects.requireNonNull(locale, "rendered email locale must be non-null");
Objects.requireNonNull(subject, "rendered email subject must be non-null");
Objects.requireNonNull(text, "rendered email text must be non-null");
Objects.requireNonNull(html, "rendered email HTML must be non-null");
int actual =
subject.getBytes(StandardCharsets.UTF_8).length
+ text.getBytes(StandardCharsets.UTF_8).length
+ html.getBytes(StandardCharsets.UTF_8).length;
if (utf8Bytes != actual || utf8Bytes < 1) {
throw new IllegalArgumentException("rendered email byte count must be exact and positive");
}
}
@Override
public String toString() {
return "Email[locale="
+ locale.toLanguageTag()
+ ", content=<redacted>, utf8Bytes="
+ utf8Bytes
+ "]";
}
}
record Slack(Locale locale, List<SlackBlock> blocks, int maximumDepth, int utf8Bytes)
implements RenderedNotification {
public Slack {
Objects.requireNonNull(locale, "rendered Slack locale must be non-null");
Objects.requireNonNull(blocks, "rendered Slack blocks must be non-null");
blocks =
blocks.stream()
.map(block -> Objects.requireNonNull(block, "Slack block must be non-null"))
.toList();
if (blocks.isEmpty() || blocks.size() > 50) {
throw new IllegalArgumentException("rendered Slack block count must be in 1..50");
}
if (maximumDepth < 1 || maximumDepth > 8) {
throw new IllegalArgumentException("rendered Slack depth must be in 1..8");
}
int actual =
blocks.stream()
.mapToInt(block -> block.text().getBytes(StandardCharsets.UTF_8).length)
.sum();
if (utf8Bytes != actual || utf8Bytes < 1) {
throw new IllegalArgumentException("rendered Slack byte count must be exact and positive");
}
}
@Override
public String toString() {
return "Slack[locale="
+ locale.toLanguageTag()
+ ", blocks=<redacted>, maximumDepth="
+ maximumDepth
+ ", utf8Bytes="
+ utf8Bytes
+ "]";
}
}
record SlackBlock(TextType type, String text, boolean verbatim) {
public SlackBlock {
Objects.requireNonNull(type, "Slack block text type must be non-null");
Objects.requireNonNull(text, "Slack block text must be non-null");
if (text.isBlank() || text.length() > 3_000) {
throw new IllegalArgumentException("Slack block text must contain 1..3000 characters");
}
if (type == TextType.MRKDWN && !verbatim) {
throw new IllegalArgumentException(
"Slack mrkdwn blocks must disable automatic link and mention expansion");
}
}
public static SlackBlock mrkdwn(String text) {
return new SlackBlock(TextType.MRKDWN, text, true);
}
public static SlackBlock plainText(String text) {
return new SlackBlock(TextType.PLAIN_TEXT, text, false);
}
@Override
public String toString() {
return "SlackBlock[type=" + type + ", text=<redacted>]";
}
}
enum TextType {
MRKDWN,
PLAIN_TEXT
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.adapter.outbound.notification.template;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationTemplateValue;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Typed Block Kit renderer preserving plain_text versus mrkdwn context and bounded depth. */
public final class SlackBlockKitRenderer
implements NotificationTemplateRenderer<RenderedNotification.Slack> {
private static final Pattern TOKEN =
Pattern.compile("\\{\\{([a-z][A-Za-z0-9]{0,63})\\|([a-z_]+)}}");
private final NotificationTemplateCatalog catalog;
public SlackBlockKitRenderer(NotificationTemplateCatalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "template catalog must be non-null");
}
@Override
public RenderedNotification.Slack render(NotificationFrozenPlan plan) {
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
NotificationTemplateManifest manifest = catalog.require(plan.policy().templateRef());
LocalEmailRenderer.validatePlan(plan, manifest, NotificationChannel.SLACK);
NotificationTemplateCatalog.LoadedResourceSet sources =
catalog.sources(manifest, plan.selectedLocale());
String template = sources.slack().orElseThrow();
LocalEmailRenderer.validateTemplateUsage(List.of(template), manifest.parameterNames());
LocalEmailRenderer.requireAggregateBoundedExpansion(
List.of(template), plan.parameters().values(), manifest.maximumRenderedBytes(), 6);
List<RenderedNotification.SlackBlock> blocks =
renderBlocks(
template,
plan.parameters().values(),
manifest.parameterNames(),
manifest.maximumRenderedBytes());
int bytes =
blocks.stream()
.mapToInt(block -> block.text().getBytes(StandardCharsets.UTF_8).length)
.sum();
if (bytes > manifest.maximumRenderedBytes()) {
throw new TemplateRenderingException(
"rendered Slack message exceeds the manifest byte bound");
}
return new RenderedNotification.Slack(sources.locale(), blocks, 3, bytes);
}
private static List<RenderedNotification.SlackBlock> renderBlocks(
String template,
Map<String, NotificationTemplateValue> parameters,
Set<String> manifestNames,
int maximumBytes) {
List<RenderedNotification.SlackBlock> blocks = new ArrayList<>();
java.util.Iterator<String> lines = template.lines().iterator();
while (lines.hasNext()) {
if (blocks.size() == 50) {
throw new TemplateRenderingException("rendered Slack block count is outside 1..50");
}
String line = lines.next();
boolean mrkdwn = line.contains("|mrkdwn}}");
if (mrkdwn && (line.contains("|plain}}") || line.contains("|mention}}"))) {
throw new TemplateRenderingException(
"Slack block cannot mix mrkdwn and plain_text contexts");
}
String rendered = renderLine(line, parameters, manifestNames, maximumBytes);
blocks.add(
mrkdwn
? RenderedNotification.SlackBlock.mrkdwn(rendered)
: RenderedNotification.SlackBlock.plainText(rendered));
}
if (blocks.isEmpty()) {
throw new TemplateRenderingException("rendered Slack block count is outside 1..50");
}
return List.copyOf(blocks);
}
private static String renderLine(
String template,
Map<String, NotificationTemplateValue> parameters,
Set<String> manifestNames,
int maximumBytes) {
Matcher matcher = TOKEN.matcher(template);
StringBuilder output = new StringBuilder(Math.min(template.length(), maximumBytes));
while (matcher.find()) {
String name = matcher.group(1);
NotificationTemplateValue parameter = parameters.get(name);
if (parameter == null || !manifestNames.contains(name)) {
throw new TemplateRenderingException("Slack template references an unknown parameter");
}
String plain = escapeSlackPlain(LocalEmailRenderer.scalar(parameter));
String replacement =
switch (matcher.group(2)) {
case "plain" -> plain;
case "mrkdwn" ->
plain
.replace("\\", "\\\\")
.replace("*", "\\*")
.replace("_", "\\_")
.replace("~", "\\~")
.replace("`", "\\`");
case "mention" -> plain.replace("@", "@\u200B");
default -> throw new TemplateRenderingException("unknown Slack rendering context");
};
matcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(output);
if (output.indexOf("{{") >= 0) {
throw new TemplateRenderingException("Slack template has an unresolved parameter");
}
return output.toString();
}
private static String escapeSlackPlain(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.notification.template;
/** Redacted fail-closed template loading or rendering error. */
public final class TemplateRenderingException extends RuntimeException {
public TemplateRenderingException(String safeMessage) {
super(safeMessage);
}
public TemplateRenderingException(String safeMessage, Throwable cause) {
super(safeMessage, cause);
}
}
@@ -0,0 +1,489 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.ConsentCheckMode;
import dev.caskeleton.application.notification.EmailRecipientReference;
import dev.caskeleton.application.notification.NotificationAdmissionClass;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationIntentDraft;
import dev.caskeleton.application.notification.NotificationIntentId;
import dev.caskeleton.application.notification.NotificationKindId;
import dev.caskeleton.application.notification.NotificationKindPolicy;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationPlanningResult;
import dev.caskeleton.application.notification.NotificationRouteId;
import dev.caskeleton.application.notification.NotificationRouteStrategy;
import dev.caskeleton.application.notification.NotificationTemplateParameters;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import dev.caskeleton.application.notification.NotificationTemplateValue;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class NotificationBindingCompilerTest {
@Test
void explicitCatalogCompilesToSortedImmutableGraphAndApplicationFrozenPlan() {
NotificationBindingCompiler compiler = validCompiler();
NotificationBindingCompiler.CompiledGraph graph = compiler.compile();
assertThat(graph.bindings())
.extracting(binding -> binding.route().routeId().value())
.containsExactly("security-email", "security-slack");
assertThat(graph.bindings()).isUnmodifiable();
assertThat(graph.manifestDigest())
.isEqualTo("de53ed4517827786b460009132c5aadad0825a12d3b8e442bb444203c7221ea7");
assertThat(validCompiler().compile()).isEqualTo(graph);
NotificationPlanAdapter planner = new NotificationPlanAdapter(graph);
NotificationPlanningResult result = planner.plan(emailDraft());
assertThat(result).isInstanceOf(NotificationPlanningResult.Planned.class);
NotificationFrozenPlan plan = ((NotificationPlanningResult.Planned) result).plan();
assertThat(plan.policy().policyRevision()).isEqualTo(17);
assertThat(plan.binding().routeRevision()).isEqualTo(3);
assertThat(plan.binding().bindingDigest())
.isEqualTo("f9d50c2d559236a397d3607e5fed100141c8673ab8262539ecbeb736b3bfb51d");
assertThat(plan.binding().rendererRevision()).isEqualTo("email-renderer-r1");
assertThat(plan.binding().targets())
.extracting(NotificationFrozenPlan.FrozenTarget::providerCapabilityReference)
.containsExactly("aws-ses-v2-durable-single-local-sns-v1");
assertThat(plan.toString()).doesNotContain("secret-ref", "destination-ref");
}
@Test
void duplicateUnknownAndChannelDriftFailClosed() {
NotificationProviderDescriptor ses = sesProvider();
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
List.of(ses, ses), validTemplates(), validRoutes(), validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("duplicate provider");
NotificationRouteDescriptor unknownProfile =
emailRoute(
List.of(
new NotificationRouteDescriptor.Target(
"email-primary", "missing-profile", Optional.empty())));
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(unknownProfile),
validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("unknown runtime profile");
NotificationProviderRuntimeProfile channelDrift =
new NotificationProviderRuntimeProfile(
"email-runtime-r1",
"slack-web-api",
"slack-binding-r1",
"slack-web-api-durable-single-local-v1",
"credential-r1",
"secret-ref-r1",
"destination-ref-r1");
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(emailRoute()),
List.of(channelDrift))
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("channel");
NotificationProviderDescriptor masquerading =
new NotificationProviderDescriptor(
"unknown-slack-family",
NotificationChannel.SLACK,
false,
List.of(
NotificationProviderCapabilityCard.initial(
"slack-web-api-durable-single-local-v1")));
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
List.of(masquerading),
validTemplates(),
List.of(slackRoute("slack-runtime-r1", false, false, 1)),
List.of(
new NotificationProviderRuntimeProfile(
"slack-runtime-r1",
"unknown-slack-family",
"slack-binding-r1",
"slack-web-api-durable-single-local-v1",
"credential-r1",
"secret-ref-r1",
"destination-ref-r1")))
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("provider family");
}
@Test
void retainedRouteRevisionsRemainCompiledWhileOneExplicitRevisionIsActive() {
NotificationRouteDescriptor retainedEmail = withRevision(emailRoute(), 2);
NotificationBindingCompiler.CompiledGraph graph =
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(
retainedEmail, emailRoute(), slackRoute("slack-runtime-r1", false, false, 1)),
validProfiles(),
Map.of(
new NotificationRouteId("security-email"),
3,
new NotificationRouteId("security-slack"),
3))
.compile();
assertThat(graph.bindings())
.extracting(
binding -> binding.route().routeId().value() + "#" + binding.route().routeRevision())
.containsExactly("security-email#2", "security-email#3", "security-slack#3");
assertThat(
graph.activeBinding(new NotificationRouteId("security-email")).route().routeRevision())
.isEqualTo(3);
assertThat(graph.bindings())
.anyMatch(
binding ->
binding.route().routeId().value().equals("security-email")
&& binding.route().routeRevision() == 2);
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(retainedEmail, emailRoute()),
validProfiles()))
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("explicit active revision");
}
@Test
void compiledArtifactsCannotBeForgedThroughPublicConstructors() {
assertThat(CompiledNotificationBinding.class.getDeclaredConstructors())
.allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers()));
assertThat(CompiledNotificationBinding.CompiledTarget.class.getDeclaredConstructors())
.allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers()));
assertThat(NotificationBindingCompiler.CompiledGraph.class.getDeclaredConstructors())
.allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers()));
}
@Test
void legacyReceiptUnsafeFallbackBoundsAndCyclesAreRejected() {
NotificationProviderDescriptor legacy =
new NotificationProviderDescriptor(
"slack-webhook", NotificationChannel.SLACK, true, List.of());
NotificationProviderRuntimeProfile legacyProfile =
new NotificationProviderRuntimeProfile(
"legacy-runtime-r0",
"slack-webhook",
"legacy-binding-r0",
"legacy-r0",
"credential-r0",
"legacy-secret-ref",
"legacy-destination-ref");
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
List.of(legacy),
validTemplates(),
List.of(slackRoute("legacy-runtime-r0", false, false, 1)),
List.of(legacyProfile))
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("legacy");
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(slackRoute("slack-runtime-r1", true, false, 1)),
validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("receipt");
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(slackRoute("slack-runtime-r1", false, true, 1)),
validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("indeterminate");
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(slackRoute("slack-runtime-r1", false, false, 2)),
validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("attempt");
NotificationRouteDescriptor unsupportedFanOut =
new NotificationRouteDescriptor(
new NotificationRouteId("security-slack"),
3,
NotificationChannel.SLACK,
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.TRANSACTIONAL,
NotificationRouteStrategy.FAN_OUT_ALL,
new NotificationTemplateRef("security-slack", 1),
false,
false,
2,
1,
0,
0,
2,
Duration.ofSeconds(5),
List.of(
new NotificationRouteDescriptor.Target(
"target-a", "slack-runtime-r1", Optional.empty()),
new NotificationRouteDescriptor.Target(
"target-b", "slack-runtime-r1", Optional.empty())));
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(),
validTemplates(),
List.of(unsupportedFanOut),
validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("strategy");
NotificationRouteDescriptor cyclic =
new NotificationRouteDescriptor(
new NotificationRouteId("security-slack"),
3,
NotificationChannel.SLACK,
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.TRANSACTIONAL,
NotificationRouteStrategy.ORDERED_FALLBACK,
new NotificationTemplateRef("security-slack", 1),
false,
false,
2,
1,
1,
0,
2,
Duration.ofSeconds(5),
List.of(
new NotificationRouteDescriptor.Target(
"target-a", "slack-runtime-r1", Optional.of("target-b")),
new NotificationRouteDescriptor.Target(
"target-b", "slack-runtime-r1", Optional.of("target-a"))));
assertThatThrownBy(
() ->
new NotificationBindingCompiler(
validProviders(), validTemplates(), List.of(cyclic), validProfiles())
.compile())
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("strategy");
}
static NotificationBindingCompiler validCompiler() {
return new NotificationBindingCompiler(
validProviders(), validTemplates(), validRoutes(), validProfiles());
}
static List<NotificationProviderDescriptor> validProviders() {
return List.of(
sesProvider(),
new NotificationProviderDescriptor(
"slack-web-api",
NotificationChannel.SLACK,
false,
List.of(
NotificationProviderCapabilityCard.initial("slack-web-api-inline-single-local-v1"),
NotificationProviderCapabilityCard.initial(
"slack-web-api-durable-single-local-v1"))));
}
static NotificationProviderDescriptor sesProvider() {
return new NotificationProviderDescriptor(
"aws-ses-v2",
NotificationChannel.EMAIL,
false,
List.of(
NotificationProviderCapabilityCard.initial("aws-ses-v2-durable-single-local-sns-v1")));
}
static List<NotificationTemplateDescriptor> validTemplates() {
return List.of(
new NotificationTemplateDescriptor(
new NotificationTemplateRef("security-email", 1),
NotificationChannel.EMAIL,
"email-renderer-r1",
"1".repeat(64),
Set.of(Locale.ENGLISH, Locale.KOREAN),
Locale.ENGLISH,
Set.of("displayName"),
64_000),
new NotificationTemplateDescriptor(
new NotificationTemplateRef("security-slack", 1),
NotificationChannel.SLACK,
"slack-renderer-r1",
"2".repeat(64),
Set.of(Locale.ENGLISH),
Locale.ENGLISH,
Set.of("displayName"),
32_000));
}
static List<NotificationRouteDescriptor> validRoutes() {
return List.of(emailRoute(), slackRoute("slack-runtime-r1", false, false, 1));
}
static NotificationRouteDescriptor emailRoute() {
return emailRoute(
List.of(
new NotificationRouteDescriptor.Target(
"email-primary", "email-runtime-r1", Optional.empty())));
}
static NotificationRouteDescriptor emailRoute(List<NotificationRouteDescriptor.Target> targets) {
return new NotificationRouteDescriptor(
new NotificationRouteId("security-email"),
3,
NotificationChannel.EMAIL,
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.SECURITY_CRITICAL,
NotificationRouteStrategy.SINGLE,
new NotificationTemplateRef("security-email", 1),
true,
false,
1,
1,
0,
1,
2,
Duration.ofSeconds(5),
targets);
}
private static NotificationRouteDescriptor withRevision(
NotificationRouteDescriptor route, int revision) {
return new NotificationRouteDescriptor(
route.routeId(),
revision,
route.channel(),
route.mode(),
route.admissionClass(),
route.routeStrategy(),
route.templateRef(),
route.receiptRequired(),
route.fallbackAfterIndeterminate(),
route.maximumTargets(),
route.maximumPhysicalAttempts(),
route.maximumFallbackActivations(),
route.maximumReconcileCalls(),
route.maximumTotalProviderCalls(),
route.perAttemptDeadline(),
route.targets());
}
static NotificationRouteDescriptor slackRoute(
String profileId,
boolean receiptRequired,
boolean fallbackAfterIndeterminate,
int maximumAttempts) {
return new NotificationRouteDescriptor(
new NotificationRouteId("security-slack"),
3,
NotificationChannel.SLACK,
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.TRANSACTIONAL,
NotificationRouteStrategy.SINGLE,
new NotificationTemplateRef("security-slack", 1),
receiptRequired,
fallbackAfterIndeterminate,
1,
maximumAttempts,
0,
0,
maximumAttempts,
Duration.ofSeconds(5),
List.of(
new NotificationRouteDescriptor.Target("slack-primary", profileId, Optional.empty())));
}
static List<NotificationProviderRuntimeProfile> validProfiles() {
return List.of(
new NotificationProviderRuntimeProfile(
"email-runtime-r1",
"aws-ses-v2",
"ses-binding-r1",
"aws-ses-v2-durable-single-local-sns-v1",
"credential-r1",
"secret-ref-r1",
"destination-ref-r1"),
new NotificationProviderRuntimeProfile(
"slack-runtime-r1",
"slack-web-api",
"slack-binding-r1",
"slack-web-api-durable-single-local-v1",
"credential-r1",
"secret-ref-r1",
"destination-ref-r1"));
}
private static NotificationIntentDraft emailDraft() {
NotificationKindPolicy policy =
new NotificationKindPolicy(
new NotificationKindId("security-alert"),
NotificationChannel.EMAIL,
new NotificationRouteId("security-email"),
new NotificationTemplateRef("security-email", 1),
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.SECURITY_CRITICAL,
NotificationRouteStrategy.SINGLE,
ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY,
17,
1,
1,
0,
1,
2,
Duration.ofMinutes(10));
return new NotificationIntentDraft(
new NotificationIntentId("intent-42"),
policy,
Locale.KOREAN,
new EmailRecipientReference("recipient-ref-42"),
new NotificationTemplateParameters(
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
"idempotency-scope-42",
"operation-42",
Optional.empty(),
"correlation-42",
Optional.empty(),
Instant.parse("2026-07-29T00:00:00Z"),
Instant.parse("2026-07-29T00:10:00Z"));
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class NotificationCanonicalRouteCatalogTest {
@Test
void retainedKeyOnlyCatalogMapsOnlyWithExactReviewedGenerationConfig() {
NotificationCanonicalRouteCatalog catalog =
NotificationCanonicalRouteCatalog.fromCompiledGraph(
NotificationBindingCompilerTest.validCompiler().compile());
List<NotificationCanonicalRouteCatalog.RouteKey> keys = catalog.routes();
Map<NotificationCanonicalRouteCatalog.RouteKey, Long> generations =
Map.of(keys.get(0), 7L, keys.get(1), 3L);
NotificationCanonicalWriterRouteSet application = catalog.toApplication(generations);
assertThat(application.routes())
.extracting(route -> route.routeId().value())
.containsExactly("security-email", "security-slack");
assertThat(catalog.digest())
.isEqualTo("c5bad4ab5dffa98fb73e14629e9b62dd6d62ec4c562c526b67680937484559e1");
assertThat(
NotificationCanonicalRouteCatalog.fromCompiledGraph(
NotificationBindingCompilerTest.validCompiler().compile()))
.isEqualTo(catalog);
}
@Test
void generationConfigAndRouteKeysMustMatchExactly() {
NotificationCanonicalRouteCatalog catalog =
NotificationCanonicalRouteCatalog.fromCompiledGraph(
NotificationBindingCompilerTest.validCompiler().compile());
NotificationCanonicalRouteCatalog.RouteKey first = catalog.routes().getFirst();
assertThatThrownBy(() -> catalog.toApplication(Map.of(first, 7L)))
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("exactly");
assertThatThrownBy(
() ->
NotificationCanonicalRouteCatalog.fromRoutes(
List.of(
NotificationBindingCompilerTest.emailRoute(),
NotificationBindingCompilerTest.emailRoute())))
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("duplicate");
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.adapter.outbound.notification.catalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.NotificationWriterRouteSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class NotificationCutoverRouteCatalogTest {
@Test
void preDecoratorCannotChangeCanonicalKeysAndDigestCoversCompleteProofRegistry() {
NotificationCanonicalRouteCatalog canonical =
NotificationCanonicalRouteCatalog.fromCompiledGraph(
NotificationBindingCompilerTest.validCompiler().compile());
NotificationCutoverRouteCatalog catalog =
new NotificationCutoverRouteCatalog(canonical, validCutoverRoutes(canonical));
Map<NotificationCanonicalRouteCatalog.RouteKey, Long> generations =
Map.of(canonical.routes().get(0), 7L, canonical.routes().get(1), 3L);
NotificationWriterRouteSet application = catalog.toApplication(generations);
assertThat(application.routeProfiles()).hasSize(2);
assertThat(application.routeProfiles().getFirst().transportProfiles())
.extracting(NotificationWriterRouteSet.TransportProfile::profileId)
.containsExactly("legacy-http-v1", "legacy-http-v2");
assertThat(catalog.digest())
.isEqualTo("62b2da4489adbec34cc124da093b1e95e8d0c4999ca3f3e824b2859592474faa");
NotificationCutoverRouteCatalog changed =
new NotificationCutoverRouteCatalog(
canonical,
List.of(
new NotificationCutoverRouteCatalog.CutoverRoute(
canonical.routes().get(0),
Optional.of("legacy-email"),
List.of(
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
"legacy-http-v1",
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
"evidence-r2",
true,
false))),
validCutoverRoutes(canonical).get(1)));
assertThat(changed.digest()).isNotEqualTo(catalog.digest());
}
@Test
void missingKeysAliasesProfilesAndUnreviewedHardBoundsFailClosed() {
NotificationCanonicalRouteCatalog canonical =
NotificationCanonicalRouteCatalog.fromCompiledGraph(
NotificationBindingCompilerTest.validCompiler().compile());
assertThatThrownBy(
() ->
new NotificationCutoverRouteCatalog(
canonical, List.of(validCutoverRoutes(canonical).getFirst())))
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("exactly");
List<NotificationCutoverRouteCatalog.CutoverRoute> duplicateAlias =
List.of(
validCutoverRoutes(canonical).get(0),
new NotificationCutoverRouteCatalog.CutoverRoute(
canonical.routes().get(1),
Optional.of("legacy-email"),
List.of(
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
"legacy-slack-v1",
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
"evidence-r1",
true,
false))));
assertThatThrownBy(() -> new NotificationCutoverRouteCatalog(canonical, duplicateAlias))
.isInstanceOf(NotificationCatalogException.class)
.hasMessageContaining("alias");
assertThatThrownBy(
() ->
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
"legacy-hard-v1",
NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN,
"hard-bound-r1",
true,
false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("reviewed");
}
static List<NotificationCutoverRouteCatalog.CutoverRoute> validCutoverRoutes(
NotificationCanonicalRouteCatalog canonical) {
return List.of(
new NotificationCutoverRouteCatalog.CutoverRoute(
canonical.routes().get(0),
Optional.of("legacy-email"),
List.of(
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
"legacy-http-v1",
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
"evidence-r1",
false,
false),
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
"legacy-http-v2",
NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED,
"evidence-r2",
true,
false))),
new NotificationCutoverRouteCatalog.CutoverRoute(
canonical.routes().get(1),
Optional.of("legacy-slack"),
List.of(
new NotificationCutoverRouteCatalog.LegacyTransportProfile(
"legacy-slack-v1",
NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN,
"hard-bound-r2",
true,
true))));
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationRequestResult;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class InlineNotificationAttemptAdapterTest {
@Test
void inlinePlanInvokesOneClientPerFrozenTargetAndDurablePlanIsRejected() {
AtomicInteger sends = new AtomicInteger();
NotificationProviderAttemptClient client =
new NotificationProviderAttemptClient() {
@Override
public PreparedNotificationAttempt prepare(
RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64));
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
sends.incrementAndGet();
return new ClientAttemptResult.Accepted(
new ProviderMessageReference("provider-message-42"));
}
};
InlineNotificationAttemptAdapter adapter =
new InlineNotificationAttemptAdapter(
ProviderTestFixtures.attemptAdapter(client, NotificationMode.BEST_EFFORT_INLINE));
NotificationRequestResult.InlineCompleted result =
adapter.attempt(ProviderTestFixtures.plan(NotificationMode.BEST_EFFORT_INLINE));
assertThat(sends).hasValue(1);
assertThat(result.outcomes()).hasSize(1);
assertThatThrownBy(
() -> adapter.attempt(ProviderTestFixtures.plan(NotificationMode.DURABLE_ASYNC)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("inline");
}
}
@@ -0,0 +1,195 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
import dev.caskeleton.application.notification.NotificationAdmissionReadinessPort;
import dev.caskeleton.application.notification.NotificationFaultScope;
import dev.caskeleton.application.notification.NotificationReasonCode;
import dev.caskeleton.application.notification.NotificationRouteId;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class NotificationAdmissionReadinessAdapterTest {
@Test
void internalNonSecretProbeMustBeFreshAndExactBeforeDelegatedResume() {
AtomicBoolean resumed = new AtomicBoolean();
NotificationAdmissionReadinessPort state =
new NotificationAdmissionReadinessPort() {
@Override
public ParkResult park(ParkRequest request) {
return ParkResult.PARKED;
}
@Override
public ResumeResult resume(
ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
resumed.set(true);
return new ResumeResult(
ResumeStatus.RESUMED, request.expectedGeneration() + 1, 1, 0, 0, 0, 0, 0);
}
};
NotificationProviderRuntimeProfile profile = profile("credential-r1");
NotificationAdmissionReadinessAdapter adapter =
new NotificationAdmissionReadinessAdapter(
state,
request -> profile,
(selected, deadline) ->
new NotificationProviderReadinessSnapshot(
selected.profileId(),
selected.bindingRevision(),
selected.capabilityCardId(),
selected.credentialGeneration(),
true,
new NotificationReasonCode("READINESS_CONFIRMED"),
ProviderTestFixtures.NOW,
ProviderTestFixtures.NOW.plusSeconds(10)),
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
NotificationAdmissionReadinessPort.ResumeRequest request = request();
NotificationAdmissionReadinessPort.ReadinessProbe probe = adapter.probe(request);
var result = adapter.resume(request, probe, ProviderTestFixtures.NOW);
assertThat(probe.ready()).isTrue();
assertThat(result.status()).isEqualTo(NotificationAdmissionReadinessPort.ResumeStatus.RESUMED);
assertThat(resumed).isTrue();
assertThat(adapter.toString()).doesNotContain("secret-ref-r1", "destination-ref-r1");
assertThatThrownBy(
() -> adapter.resume(request, probe, ProviderTestFixtures.NOW.plusSeconds(2)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("fresh");
}
@Test
void probeFailuresTokenSubstitutionAndCredentialRotationFailClosed() {
AtomicReference<NotificationProviderRuntimeProfile> current =
new AtomicReference<>(profile("credential-r1"));
NotificationAdmissionReadinessAdapter adapter =
new NotificationAdmissionReadinessAdapter(
request -> NotificationAdmissionReadinessPort.ParkResult.PARKED,
request -> current.get(),
(selected, deadline) ->
new NotificationProviderReadinessSnapshot(
selected.profileId(),
selected.bindingRevision(),
selected.capabilityCardId(),
selected.credentialGeneration(),
true,
new NotificationReasonCode("READINESS_CONFIRMED"),
ProviderTestFixtures.NOW,
ProviderTestFixtures.NOW.plusSeconds(10)),
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
NotificationAdmissionReadinessPort.ResumeRequest original = request();
NotificationAdmissionReadinessPort.ReadinessProbe ready = adapter.probe(original);
NotificationAdmissionReadinessPort.ResumeRequest substituted =
new NotificationAdmissionReadinessPort.ResumeRequest(
original.operationToken(),
original.routeId(),
original.policyRevision(),
original.faultScope(),
original.scopeReference(),
original.expectedGeneration(),
original.maximumParkedLegs(),
"different-operator",
original.reasonCode());
NotificationAdmissionReadinessPort.ReadinessProbe conflict = adapter.probe(substituted);
assertThat(conflict.ready()).isFalse();
assertThat(conflict.reasonCode().value()).isEqualTo("READINESS_TOKEN_CONFLICT");
current.set(profile("credential-r2"));
assertThatThrownBy(
() -> adapter.resume(original, ready, ProviderTestFixtures.NOW.plusSeconds(1)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("generation");
NotificationAdmissionReadinessAdapter failing =
new NotificationAdmissionReadinessAdapter(
request -> NotificationAdmissionReadinessPort.ParkResult.PARKED,
request -> {
throw new IllegalStateException("secret reference must not escape");
},
(selected, deadline) -> {
throw new AssertionError("probe must not run");
},
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
NotificationAdmissionReadinessPort.ReadinessProbe failed = failing.probe(request());
assertThat(failed.ready()).isFalse();
assertThat(failed.reasonCode().value()).isEqualTo("READINESS_PROBE_FAILED");
assertThat(failed.toString()).doesNotContain("secret reference");
}
@Test
void readinessObservationAfterProbeIoIsValidatedAgainstPostCallTime() {
AtomicReference<Instant> currentTime = new AtomicReference<>(ProviderTestFixtures.NOW);
Clock advancingClock =
new Clock() {
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return currentTime.get();
}
};
NotificationAdmissionReadinessAdapter adapter =
new NotificationAdmissionReadinessAdapter(
request -> NotificationAdmissionReadinessPort.ParkResult.PARKED,
request -> profile("credential-r1"),
(selected, deadline) -> {
currentTime.set(ProviderTestFixtures.NOW.plusSeconds(1));
return new NotificationProviderReadinessSnapshot(
selected.profileId(),
selected.bindingRevision(),
selected.capabilityCardId(),
selected.credentialGeneration(),
true,
new NotificationReasonCode("READINESS_CONFIRMED"),
currentTime.get(),
currentTime.get().plusSeconds(10));
},
advancingClock);
NotificationAdmissionReadinessPort.ReadinessProbe probe = adapter.probe(request());
assertThat(probe.ready()).isTrue();
}
private static NotificationProviderRuntimeProfile profile(String credentialGeneration) {
return new NotificationProviderRuntimeProfile(
"slack-runtime-r1",
"slack-web-api",
"slack-binding-r1",
"slack-web-api-durable-single-local-v1",
credentialGeneration,
"secret-ref-r1",
"destination-ref-r1");
}
private static NotificationAdmissionReadinessPort.ResumeRequest request() {
return new NotificationAdmissionReadinessPort.ResumeRequest(
"resume-operation-42",
new NotificationRouteId("security-slack"),
3,
NotificationFaultScope.PROVIDER_BINDING,
"provider-binding-scope-42",
7,
10,
"operator-42",
new NotificationReasonCode("OPERATOR_RESUME"));
}
}
@@ -0,0 +1,402 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationPlanAdapter;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityCard;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderDescriptor;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationRouteDescriptor;
import dev.caskeleton.adapter.outbound.notification.catalog.NotificationTemplateDescriptor;
import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification;
import dev.caskeleton.application.notification.ConsentCheckMode;
import dev.caskeleton.application.notification.NotificationAdmissionClass;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationDeliveryId;
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationIntentDraft;
import dev.caskeleton.application.notification.NotificationIntentId;
import dev.caskeleton.application.notification.NotificationKindId;
import dev.caskeleton.application.notification.NotificationKindPolicy;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationPlanningResult;
import dev.caskeleton.application.notification.NotificationProviderAttemptPort;
import dev.caskeleton.application.notification.NotificationReasonCode;
import dev.caskeleton.application.notification.NotificationRouteId;
import dev.caskeleton.application.notification.NotificationRouteStrategy;
import dev.caskeleton.application.notification.NotificationTemplateParameters;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import dev.caskeleton.application.notification.NotificationTemplateValue;
import dev.caskeleton.application.notification.RetryDisposition;
import dev.caskeleton.application.notification.SlackAudienceReference;
import dev.caskeleton.application.notification.SubmissionCertainty;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class NotificationProviderAttemptContractTest {
@Test
void oneAuthorizationPreparesWithoutWireIoAndInvokesClientExactlyOnceWithinAbsoluteDeadline() {
AtomicInteger prepareCalls = new AtomicInteger();
AtomicInteger wireCalls = new AtomicInteger();
AtomicReference<Instant> observedDeadline = new AtomicReference<>();
NotificationProviderAttemptClient client =
new NotificationProviderAttemptClient() {
@Override
public PreparedNotificationAttempt prepare(
RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
prepareCalls.incrementAndGet();
assertThat(wireCalls).hasValue(0);
return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64));
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
wireCalls.incrementAndGet();
observedDeadline.set(absoluteDeadline);
return new ClientAttemptResult.Accepted(
new ProviderMessageReference("provider-message-42"));
}
};
Instant now = ProviderTestFixtures.NOW;
NotificationDeliveryStorePort.AuthorizedAttempt authorized =
ProviderTestFixtures.authorized(NotificationMode.DURABLE_ASYNC, now.plusSeconds(3));
NotificationProviderAttemptPort adapter =
ProviderTestFixtures.attemptAdapter(client, NotificationMode.DURABLE_ASYNC);
var outcome = adapter.attempt(authorized);
assertThat(prepareCalls).hasValue(1);
assertThat(wireCalls).hasValue(1);
assertThat(observedDeadline.get()).isEqualTo(now.plusSeconds(3));
assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.PROVIDER_ACCEPTED);
assertThat(outcome.providerMessageReference()).contains("provider-message-42");
assertThat(outcome.attemptCorrelationReference()).isNotEqualTo(authorized.executionToken());
}
@Test
void preWireValidationAndPossibleWriteFailuresHaveDifferentCertaintyAndSdkErrorsNeverEscape() {
NotificationProviderAttemptClient invalidBeforeWire =
new NotificationProviderAttemptClient() {
@Override
public PreparedNotificationAttempt prepare(
RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
throw new NotificationProviderAttemptClient.PreWireDeliveryRejectedException();
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
throw new AssertionError("wire call must not run");
}
};
NotificationProviderAttemptClient responseLost =
new NotificationProviderAttemptClient() {
@Override
public PreparedNotificationAttempt prepare(
RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64));
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
throw new IllegalStateException("SDK body and token must not escape");
}
};
var preWire =
ProviderTestFixtures.attemptAdapter(invalidBeforeWire, NotificationMode.DURABLE_ASYNC)
.attempt(
ProviderTestFixtures.authorized(
NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3)));
var possibleWrite =
ProviderTestFixtures.attemptAdapter(responseLost, NotificationMode.DURABLE_ASYNC)
.attempt(
ProviderTestFixtures.authorized(
NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3)));
assertThat(preWire.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED);
assertThat(preWire.retryDisposition()).isEqualTo(RetryDisposition.TERMINAL);
assertThat(possibleWrite.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE);
assertThat(possibleWrite.toString()).doesNotContain("SDK body", "token must not escape");
}
@Test
void preparedAttemptMustRemainBoundToTheAuthorizedRenderedTargetAndContext() {
AtomicInteger wireCalls = new AtomicInteger();
NotificationProviderAttemptClient misbindingClient =
new NotificationProviderAttemptClient() {
@Override
public PreparedNotificationAttempt prepare(
RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
NotificationAttemptContext differentContext =
new NotificationAttemptContext(
context.deliveryId(),
context.attemptId(),
context.targetOrdinal(),
context.correlationId(),
Optional.of(
new NotificationAttemptContext.ProviderClientOperationKey(
"different-operation-key")),
context.absoluteDeadline());
return new PreparedNotificationAttempt(
rendered, target, differentContext, "c".repeat(64));
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
wireCalls.incrementAndGet();
throw new AssertionError("misbound prepared attempt must not reach the wire");
}
};
var outcome =
ProviderTestFixtures.attemptAdapter(misbindingClient, NotificationMode.DURABLE_ASYNC)
.attempt(
ProviderTestFixtures.authorized(
NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3)));
assertThat(wireCalls).hasValue(0);
assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED);
assertThat(outcome.retryDisposition()).isEqualTo(RetryDisposition.PARK_BINDING);
assertThat(outcome.reasonCode().value()).isEqualTo("PROVIDER_PREPARED_ATTEMPT_MISMATCH");
}
}
final class ProviderTestFixtures {
static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z");
private ProviderTestFixtures() {}
static NotificationProviderAttemptAdapter attemptAdapter(
NotificationProviderAttemptClient client, NotificationMode mode) {
NotificationBindingCompiler.CompiledGraph graph = graph(mode);
return new NotificationProviderAttemptAdapter(
graph,
Map.of(
"slack-renderer-r1",
plan ->
new RenderedNotification.Slack(
plan.selectedLocale(),
List.of(RenderedNotification.SlackBlock.mrkdwn("safe message")),
3,
"safe message".getBytes(java.nio.charset.StandardCharsets.UTF_8).length)),
Map.of(cardId(mode), client),
(profile, deadline) ->
NotificationProviderRateAdmission.Decision.admitted(
new NotificationReasonCode("RATE_ADMISSION_GRANTED")),
profile ->
NotificationSecretMaterialHandle.fromBytes(
profile.credentialGeneration(),
"provider-test-secret-material-32"
.getBytes(java.nio.charset.StandardCharsets.UTF_8)),
Clock.fixed(NOW, ZoneOffset.UTC));
}
static NotificationDeliveryStorePort.AuthorizedAttempt authorized(
NotificationMode mode, Instant deadline) {
NotificationFrozenPlan plan = plan(mode);
return new NotificationDeliveryStorePort.AuthorizedAttempt(
new NotificationDeliveryId("delivery-42"),
new dev.caskeleton.application.notification.NotificationAttemptId("attempt-42"),
plan,
0,
"claim-token-42",
"execution-token-42",
3,
7,
"provider-binding-scope-42",
deadline);
}
static NotificationFrozenPlan plan(NotificationMode mode) {
NotificationKindPolicy policy =
new NotificationKindPolicy(
new NotificationKindId("security-slack"),
NotificationChannel.SLACK,
new NotificationRouteId("security-slack"),
new NotificationTemplateRef("security-slack", 1),
mode,
NotificationAdmissionClass.TRANSACTIONAL,
NotificationRouteStrategy.SINGLE,
ConsentCheckMode.SNAPSHOT_AT_APPEND,
3,
1,
1,
0,
0,
1,
Duration.ofMinutes(10));
NotificationIntentDraft draft =
new NotificationIntentDraft(
new NotificationIntentId("intent-42"),
policy,
Locale.ENGLISH,
new SlackAudienceReference("workspace-ref-42", "audience-ref-42"),
new NotificationTemplateParameters(
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
"scope-42",
"operation-42",
Optional.empty(),
"correlation-42",
Optional.empty(),
NOW,
NOW.plusSeconds(60));
NotificationPlanningResult result = new NotificationPlanAdapter(graph(mode)).plan(draft);
return ((NotificationPlanningResult.Planned) result).plan();
}
static NotificationBindingCompiler.CompiledGraph graph(NotificationMode mode) {
String cardId = cardId(mode);
NotificationProviderDescriptor provider =
new NotificationProviderDescriptor(
"slack-web-api",
NotificationChannel.SLACK,
false,
List.of(NotificationProviderCapabilityCard.initial(cardId)));
NotificationTemplateDescriptor template =
new NotificationTemplateDescriptor(
new NotificationTemplateRef("security-slack", 1),
NotificationChannel.SLACK,
"slack-renderer-r1",
"b".repeat(64),
Set.of(Locale.ENGLISH),
Locale.ENGLISH,
Set.of("displayName"),
32_000);
NotificationRouteDescriptor route =
new NotificationRouteDescriptor(
new NotificationRouteId("security-slack"),
3,
NotificationChannel.SLACK,
mode,
NotificationAdmissionClass.TRANSACTIONAL,
NotificationRouteStrategy.SINGLE,
template.templateRef(),
false,
false,
1,
1,
0,
0,
1,
Duration.ofSeconds(5),
List.of(
new NotificationRouteDescriptor.Target(
"slack-primary", "slack-runtime-r1", Optional.empty())));
NotificationProviderRuntimeProfile profile =
new NotificationProviderRuntimeProfile(
"slack-runtime-r1",
"slack-web-api",
"slack-binding-r1",
cardId,
"credential-r1",
"secret-ref-r1",
"destination-ref-r1");
return new NotificationBindingCompiler(
List.of(provider), List.of(template), List.of(route), List.of(profile))
.compile();
}
static NotificationBindingCompiler.CompiledGraph emailGraph() {
String cardId = "aws-ses-v2-durable-single-local-sns-v1";
NotificationProviderDescriptor provider =
new NotificationProviderDescriptor(
"aws-ses-v2",
NotificationChannel.EMAIL,
false,
List.of(NotificationProviderCapabilityCard.initial(cardId)));
NotificationTemplateDescriptor template =
new NotificationTemplateDescriptor(
new NotificationTemplateRef("security-email", 1),
NotificationChannel.EMAIL,
"email-renderer-r1",
"e".repeat(64),
Set.of(Locale.ENGLISH),
Locale.ENGLISH,
Set.of("displayName"),
64_000);
NotificationRouteDescriptor route =
new NotificationRouteDescriptor(
new NotificationRouteId("security-email"),
3,
NotificationChannel.EMAIL,
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.SECURITY_CRITICAL,
NotificationRouteStrategy.SINGLE,
template.templateRef(),
true,
false,
1,
1,
0,
1,
2,
Duration.ofSeconds(5),
List.of(
new NotificationRouteDescriptor.Target(
"email-primary", "email-runtime-r1", Optional.empty())));
NotificationProviderRuntimeProfile profile =
new NotificationProviderRuntimeProfile(
"email-runtime-r1",
"aws-ses-v2",
"ses-binding-r1",
cardId,
"credential-r1",
"secret-ref-r1",
"destination-ref-r1");
return new NotificationBindingCompiler(
List.of(provider), List.of(template), List.of(route), List.of(profile))
.compile();
}
static String cardId(NotificationMode mode) {
return mode == NotificationMode.BEST_EFFORT_INLINE
? "slack-web-api-inline-single-local-v1"
: "slack-web-api-durable-single-local-v1";
}
}
@@ -0,0 +1,194 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.NotificationDeliveryId;
import dev.caskeleton.application.notification.NotificationDeliveryStorePort;
import dev.caskeleton.application.notification.NotificationReasonCode;
import dev.caskeleton.application.notification.SubmissionCertainty;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class NotificationReconciliationAdapterTest {
@Test
void expiredLookupRemainsIndeterminateAndDoesNotCallProvider() {
NotificationDeliveryStorePort.ReconciliationClaim expired =
claim(
"correlation-42",
NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION,
ProviderTestFixtures.NOW);
NotificationReconciliationAdapter adapter =
new NotificationReconciliationAdapter(
ignored -> {
throw new AssertionError("expired reconciliation must not resolve a client");
},
ignored -> {
throw new AssertionError("expired reconciliation must not acquire a secret");
},
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
var result = adapter.reconcile(expired);
assertThat(result.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE);
assertThat(result.reasonCode().value()).isEqualTo("RECONCILIATION_DEADLINE_EXPIRED");
}
@Test
void approvedPreSendCorrelationLookupIsBoundedAndClientFailuresBecomeIndeterminate() {
AtomicReference<ReconciliationLookupMode> mode = new AtomicReference<>();
AtomicReference<NotificationProviderAttemptClient.ClientReconciliationResult> clientResult =
new AtomicReference<>(
new NotificationProviderAttemptClient.ClientReconciliationResult(
SubmissionCertainty.PROVIDER_ACCEPTED,
new NotificationReasonCode("PROVIDER_ACCEPTED")));
NotificationProviderAttemptClient client =
new NotificationProviderAttemptClient() {
@Override
public PreparedNotificationAttempt prepare(
dev.caskeleton.adapter.outbound.notification.template.RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
throw new UnsupportedOperationException();
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
throw new UnsupportedOperationException();
}
@Override
public ClientReconciliationResult reconcile(
ReconciliationReference reference,
ReconciliationLookupMode lookupMode,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
mode.set(lookupMode);
return clientResult.get();
}
};
NotificationDeliveryStorePort.ReconciliationClaim claim =
claim(
"attempt-correlation-42",
NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION,
ProviderTestFixtures.NOW.plusSeconds(3));
NotificationReconciliationAdapter adapter =
new NotificationReconciliationAdapter(
ignored -> binding(client),
profile ->
NotificationSecretMaterialHandle.fromBytes(
profile.credentialGeneration(), new byte[] {1}),
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
var result = adapter.reconcile(claim);
assertThat(mode).hasValue(ReconciliationLookupMode.PRE_SEND_CORRELATION);
assertThat(result.submissionCertainty()).isEqualTo(SubmissionCertainty.PROVIDER_ACCEPTED);
clientResult.set(
new NotificationProviderAttemptClient.ClientReconciliationResult(
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
new NotificationReasonCode("PROVIDER_EVENT_NOT_FOUND")));
var unprovenNonApplication = adapter.reconcile(claim);
assertThat(unprovenNonApplication.submissionCertainty())
.isEqualTo(SubmissionCertainty.INDETERMINATE);
assertThat(unprovenNonApplication.reasonCode().value())
.isEqualTo("RECONCILIATION_NONAPPLICATION_UNPROVEN");
mode.set(null);
var unsupported =
adapter.reconcile(
claim(
"provider-message-42",
NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE,
ProviderTestFixtures.NOW.plusSeconds(3)));
assertThat(mode.get()).isNull();
assertThat(unsupported.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE);
assertThat(unsupported.reasonCode().value()).isEqualTo("RECONCILIATION_UNSUPPORTED");
NotificationReconciliationAdapter failing =
new NotificationReconciliationAdapter(
ignored -> binding(new ThrowingReconciliationClient()),
profile ->
NotificationSecretMaterialHandle.fromBytes(
profile.credentialGeneration(), new byte[] {1}),
Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC));
assertThat(failing.reconcile(claim).submissionCertainty())
.isEqualTo(SubmissionCertainty.INDETERMINATE);
}
private static NotificationDeliveryStorePort.ReconciliationClaim claim(
String lookupReference,
NotificationDeliveryStorePort.ReconciliationLookupKind lookupKind,
Instant deadline) {
var compiled = compiledBinding();
var target = compiled.targets().getFirst();
return new NotificationDeliveryStorePort.ReconciliationClaim(
new NotificationDeliveryId("delivery-42"),
"reconcile-token-42",
3,
compiled.route().routeId(),
compiled.route().routeRevision(),
compiled.bindingDigest(),
0,
target.target().targetId(),
target.capabilityCard().cardId(),
target.runtimeProfile().bindingRevision(),
target.runtimeProfile().credentialGeneration(),
lookupReference,
lookupKind,
deadline);
}
private static NotificationReconciliationAdapter.ReconciliationBinding binding(
NotificationProviderAttemptClient client) {
var compiled = compiledBinding();
return new NotificationReconciliationAdapter.ReconciliationBinding(compiled, 0, client);
}
private static dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
compiledBinding() {
return ProviderTestFixtures.emailGraph().bindings().getFirst();
}
private static final class ThrowingReconciliationClient
implements NotificationProviderAttemptClient {
@Override
public PreparedNotificationAttempt prepare(
dev.caskeleton.adapter.outbound.notification.template.RenderedNotification rendered,
dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding
.CompiledTarget
target,
NotificationAttemptContext context) {
throw new UnsupportedOperationException();
}
@Override
public ClientAttemptResult sendOneAuthorizedAttempt(
PreparedNotificationAttempt prepared,
String executionToken,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
throw new UnsupportedOperationException();
}
@Override
public ClientReconciliationResult reconcile(
ReconciliationReference reference,
ReconciliationLookupMode lookupMode,
NotificationSecretMaterialHandle secret,
Instant absoluteDeadline) {
throw new IllegalStateException("raw SDK reconciliation body");
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.notification.provider;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class NotificationSecretMaterialHandleTest {
@Test
void mutableCopyIsVersionedRedactedWipedAndRejectedAfterClose() {
byte[] caller = "sensitive-token".getBytes(StandardCharsets.UTF_8);
AtomicReference<byte[]> borrowed = new AtomicReference<>();
NotificationSecretMaterialHandle handle =
NotificationSecretMaterialHandle.fromBytes("secret-r7", caller);
Arrays.fill(caller, (byte) 0);
String observed =
handle.readBytes(
bytes -> {
borrowed.set(bytes);
return new String(bytes, StandardCharsets.UTF_8);
});
assertThat(observed).isEqualTo("sensitive-token");
assertThat(handle.revision()).isEqualTo("secret-r7");
assertThat(handle.toString()).doesNotContain("sensitive-token");
handle.close();
assertThat(borrowed.get()).containsOnly((byte) 0);
assertThatThrownBy(() -> handle.readBytes(bytes -> bytes.length))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("closed")
.hasMessageNotContaining("sensitive-token");
}
}
@@ -0,0 +1,390 @@
package dev.caskeleton.adapter.outbound.notification.template;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.ConsentCheckMode;
import dev.caskeleton.application.notification.EmailRecipientReference;
import dev.caskeleton.application.notification.NotificationAdmissionClass;
import dev.caskeleton.application.notification.NotificationChannel;
import dev.caskeleton.application.notification.NotificationFrozenPlan;
import dev.caskeleton.application.notification.NotificationIntentDraft;
import dev.caskeleton.application.notification.NotificationIntentId;
import dev.caskeleton.application.notification.NotificationKindId;
import dev.caskeleton.application.notification.NotificationKindPolicy;
import dev.caskeleton.application.notification.NotificationMode;
import dev.caskeleton.application.notification.NotificationRouteId;
import dev.caskeleton.application.notification.NotificationRouteStrategy;
import dev.caskeleton.application.notification.NotificationTemplateParameters;
import dev.caskeleton.application.notification.NotificationTemplateRef;
import dev.caskeleton.application.notification.NotificationTemplateValue;
import dev.caskeleton.application.notification.SlackAudienceReference;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class NotificationTemplateRendererTest {
private static final NotificationTemplateRef EMAIL_REF =
new NotificationTemplateRef("contract-email", 1);
private static final NotificationTemplateRef SLACK_REF =
new NotificationTemplateRef("contract-slack", 1);
@Test
void exactManifestChecksumAndRendererRevisionProduceBoundedEmail() {
NotificationTemplateCatalog catalog = validCatalog(2_000);
NotificationFrozenPlan plan =
emailPlan(
Locale.KOREAN,
Map.of(
"displayName", new NotificationTemplateValue.SafeText("<Ada & \"Lovelace\">"),
"query", new NotificationTemplateValue.SafeText("alpha beta&gamma")),
catalog.require(EMAIL_REF));
RenderedNotification.Email rendered = new LocalEmailRenderer(catalog).render(plan);
assertThat(rendered.locale()).isEqualTo(Locale.KOREAN);
assertThat(rendered.subject()).isEqualTo("Contract notice for <Ada & \"Lovelace\">");
assertThat(rendered.html())
.contains("&lt;Ada &amp; &quot;Lovelace&quot;&gt;")
.contains("alpha%20beta%26gamma")
.doesNotContain("<Ada");
assertThat(rendered.utf8Bytes()).isLessThanOrEqualTo(2_000);
}
@Test
void localeFallbackIsExactAndIndependentOfJvmDefault() {
NotificationTemplateCatalog catalog = validCatalog(2_000);
assertThat(catalog.require(EMAIL_REF).selectLocale(Locale.FRENCH)).isEqualTo(Locale.ENGLISH);
Locale original = Locale.getDefault();
try {
Locale.setDefault(Locale.JAPANESE);
RenderedNotification.Email rendered =
new LocalEmailRenderer(catalog)
.render(
emailPlan(
Locale.ENGLISH,
Map.of(
"displayName", new NotificationTemplateValue.SafeText("Ada"),
"query", new NotificationTemplateValue.SafeText("contract")),
catalog.require(EMAIL_REF)));
assertThat(rendered.locale()).isEqualTo(Locale.ENGLISH);
} finally {
Locale.setDefault(original);
}
}
@Test
void checksumRevisionParametersHeadersAndOutputBoundsFailClosedAndRedacted() {
NotificationTemplateCatalog catalog = validCatalog(80);
NotificationTemplateManifest manifest = catalog.require(EMAIL_REF);
NotificationTemplateCatalog roomyCatalog = validCatalog(2_000);
NotificationTemplateManifest roomyManifest = roomyCatalog.require(EMAIL_REF);
assertThatThrownBy(
() ->
new LocalEmailRenderer(catalog)
.render(
emailPlan(
Locale.ENGLISH,
Map.of(
"displayName", new NotificationTemplateValue.SafeText("Ada"),
"query", new NotificationTemplateValue.SafeText("secret-value")),
"0".repeat(64),
manifest.rendererRevision())))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("checksum")
.hasMessageNotContaining("secret-value");
assertThatThrownBy(
() ->
new LocalEmailRenderer(catalog)
.render(
emailPlan(
Locale.ENGLISH,
Map.of(
"displayName", new NotificationTemplateValue.SafeText("Ada"),
"query", new NotificationTemplateValue.SafeText("secret-value")),
manifest.checksum(),
"different-renderer")))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("renderer")
.hasMessageNotContaining("secret-value");
assertThatThrownBy(
() ->
new LocalEmailRenderer(catalog)
.render(
emailPlan(
Locale.ENGLISH,
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada")),
manifest)))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("parameter");
assertThatThrownBy(
() ->
new LocalEmailRenderer(roomyCatalog)
.render(
emailPlan(
Locale.ENGLISH,
Map.of(
"displayName",
new NotificationTemplateValue.SafeText("Ada\r\nBcc: victim"),
"query", new NotificationTemplateValue.SafeText("secret-value")),
roomyManifest)))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("header")
.hasMessageNotContaining("victim");
assertThatThrownBy(
() ->
new LocalEmailRenderer(catalog)
.render(
emailPlan(
Locale.ENGLISH,
Map.of(
"displayName",
new NotificationTemplateValue.SafeText("A".repeat(100)),
"query", new NotificationTemplateValue.SafeText("secret-value")),
manifest)))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("bound")
.hasMessageNotContaining("secret-value");
}
@Test
void slackBuilderEscapesMrkdwnPlainTextAndBroadcastMentions() {
NotificationTemplateCatalog catalog = validCatalog(2_000);
NotificationTemplateManifest manifest = catalog.require(SLACK_REF);
NotificationFrozenPlan plan =
slackPlan(
Map.of(
"displayName", new NotificationTemplateValue.SafeText("*Ada* <admin>"),
"audience", new NotificationTemplateValue.SafeText("<!channel> & guests"),
"mention", new NotificationTemplateValue.SafeText("@here <@U123>")),
manifest);
RenderedNotification.Slack rendered = new SlackBlockKitRenderer(catalog).render(plan);
assertThat(rendered.blocks()).hasSize(3);
assertThat(rendered.blocks().get(0).type()).isEqualTo(RenderedNotification.TextType.MRKDWN);
assertThat(rendered.blocks().get(0).verbatim()).isTrue();
assertThat(rendered.blocks().get(0).text())
.contains("\\*Ada\\* &lt;admin&gt;")
.doesNotContain("<admin>");
assertThat(rendered.blocks().get(1).type()).isEqualTo(RenderedNotification.TextType.PLAIN_TEXT);
assertThat(rendered.blocks().get(1).text())
.contains("&lt;!channel&gt; &amp; guests")
.doesNotContain("<!channel>");
assertThat(rendered.blocks().get(2).text())
.contains("@\u200Bhere &lt;@\u200BU123&gt;")
.doesNotContain("<@U123>");
assertThat(rendered.maximumDepth()).isLessThanOrEqualTo(8);
}
@Test
void htmlContextsCannotCrossIntoActiveAttributesOrMarkup() {
assertThatThrownBy(
() ->
LocalEmailRenderer.validateHtmlContexts(
"<img onerror=\"{{displayName|html_text}}\">"))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("attribute");
assertThatThrownBy(
() ->
LocalEmailRenderer.validateHtmlContexts(
"<div style=\"{{displayName|html_attr}}\"></div>"))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("attribute");
assertThatThrownBy(
() ->
LocalEmailRenderer.validateHtmlContexts(
"<img src=\"https://example.invalid/{{query|url_component}}\">"))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("attribute");
assertThatThrownBy(
() ->
LocalEmailRenderer.validateHtmlContexts(
"<a href=\"https://{{query|url_component}}\">open</a>"))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("HTTPS link");
LocalEmailRenderer.validateHtmlContexts(
"<p>{{displayName|html_text}}</p>"
+ "<a title=\"{{displayName|html_attr}}\" "
+ "href=\"https://example.invalid/?q={{query|url_component}}\">open</a>");
}
@Test
void includeDirectivesAndUnknownResourcesAreRejectedWithoutFileOrNetworkFallback() {
String path = "notification/templates/email/unsafe.txt";
ClassLoader loader =
new ClassLoader(null) {
@Override
public InputStream getResourceAsStream(String name) {
if (path.equals(name)) {
return new ByteArrayInputStream(
"{{> file:/etc/passwd}}".getBytes(StandardCharsets.UTF_8));
}
return null;
}
};
NotificationTemplateManifest.ResourceSet resources =
NotificationTemplateManifest.ResourceSet.email(path, path, path);
String checksum =
NotificationTemplateCatalog.checksum(loader, Map.of(Locale.ENGLISH, resources));
NotificationTemplateManifest manifest =
new NotificationTemplateManifest(
new NotificationTemplateRef("unsafe-email", 1),
NotificationChannel.EMAIL,
"email-renderer-r1",
checksum,
Set.of(Locale.ENGLISH),
Locale.ENGLISH,
Set.of(),
1_000,
Map.of(Locale.ENGLISH, resources));
assertThatThrownBy(() -> new NotificationTemplateCatalog(loader, List.of(manifest)))
.isInstanceOf(TemplateRenderingException.class)
.hasMessageContaining("directive")
.hasMessageNotContaining("/etc/passwd");
}
private static NotificationTemplateCatalog validCatalog(int emailMaximumBytes) {
ClassLoader loader = NotificationTemplateRendererTest.class.getClassLoader();
NotificationTemplateManifest.ResourceSet emailResources =
NotificationTemplateManifest.ResourceSet.email(
"notification/templates/email/contract-v1.subject.txt",
"notification/templates/email/contract-v1.text.txt",
"notification/templates/email/contract-v1.html");
NotificationTemplateManifest.ResourceSet slackResources =
NotificationTemplateManifest.ResourceSet.slack(
"notification/templates/slack/contract-v1.txt");
NotificationTemplateManifest email =
new NotificationTemplateManifest(
EMAIL_REF,
NotificationChannel.EMAIL,
"email-renderer-r1",
NotificationTemplateCatalog.checksum(
loader, Map.of(Locale.ENGLISH, emailResources, Locale.KOREAN, emailResources)),
Set.of(Locale.ENGLISH, Locale.KOREAN),
Locale.ENGLISH,
Set.of("displayName", "query"),
emailMaximumBytes,
Map.of(Locale.ENGLISH, emailResources, Locale.KOREAN, emailResources));
NotificationTemplateManifest slack =
new NotificationTemplateManifest(
SLACK_REF,
NotificationChannel.SLACK,
"slack-renderer-r1",
NotificationTemplateCatalog.checksum(loader, Map.of(Locale.ENGLISH, slackResources)),
Set.of(Locale.ENGLISH),
Locale.ENGLISH,
Set.of("displayName", "audience", "mention"),
2_000,
Map.of(Locale.ENGLISH, slackResources));
return new NotificationTemplateCatalog(loader, List.of(slack, email));
}
private static NotificationFrozenPlan emailPlan(
Locale locale,
Map<String, NotificationTemplateValue> values,
NotificationTemplateManifest manifest) {
return emailPlan(locale, values, manifest.checksum(), manifest.rendererRevision());
}
private static NotificationFrozenPlan emailPlan(
Locale locale,
Map<String, NotificationTemplateValue> values,
String checksum,
String rendererRevision) {
return plan(
EMAIL_REF,
NotificationChannel.EMAIL,
locale,
new EmailRecipientReference("recipient-ref-42"),
values,
checksum,
rendererRevision);
}
private static NotificationFrozenPlan slackPlan(
Map<String, NotificationTemplateValue> values, NotificationTemplateManifest manifest) {
return plan(
SLACK_REF,
NotificationChannel.SLACK,
Locale.ENGLISH,
new SlackAudienceReference("workspace-ref-42", "audience-ref-42"),
values,
manifest.checksum(),
manifest.rendererRevision());
}
private static NotificationFrozenPlan plan(
NotificationTemplateRef templateRef,
NotificationChannel channel,
Locale locale,
dev.caskeleton.application.notification.NotificationRecipientReference recipient,
Map<String, NotificationTemplateValue> values,
String checksum,
String rendererRevision) {
NotificationKindPolicy policy =
new NotificationKindPolicy(
new NotificationKindId("contract-notice"),
channel,
new NotificationRouteId(
channel == NotificationChannel.EMAIL ? "email-route" : "slack-route"),
templateRef,
NotificationMode.DURABLE_ASYNC,
NotificationAdmissionClass.TRANSACTIONAL,
NotificationRouteStrategy.SINGLE,
ConsentCheckMode.SNAPSHOT_AT_APPEND,
1,
1,
1,
0,
channel == NotificationChannel.EMAIL ? 1 : 0,
channel == NotificationChannel.EMAIL ? 2 : 1,
Duration.ofHours(1));
Instant now = Instant.parse("2026-07-29T00:00:00Z");
NotificationIntentDraft draft =
new NotificationIntentDraft(
new NotificationIntentId("intent-42"),
policy,
locale,
recipient,
new NotificationTemplateParameters(values),
"scope-42",
"operation-42",
Optional.empty(),
"correlation-42",
Optional.empty(),
now,
now.plusSeconds(60));
return NotificationFrozenPlan.from(
draft,
locale,
new NotificationFrozenPlan.BindingSnapshot(
1,
"a".repeat(64),
checksum,
rendererRevision,
List.of(
new NotificationFrozenPlan.FrozenTarget(
0,
"target-42",
channel == NotificationChannel.EMAIL
? "aws-ses-v2-durable-single-local-sns-v1"
: "slack-web-api-durable-single-local-v1",
"binding-r1",
"credential-r1")),
channel == NotificationChannel.EMAIL,
Duration.ofSeconds(5)));
}
}
@@ -0,0 +1,2 @@
<p>Hello {{displayName|html_text}}.</p>
<a href="https://example.invalid/contracts?q={{query|url_component}}">Open contract</a>
@@ -0,0 +1 @@
Contract notice for {{displayName|header}}
@@ -0,0 +1,2 @@
Hello {{displayName|text}}.
Reference: {{query|text}}
@@ -0,0 +1,3 @@
*Alert for {{displayName|mrkdwn}}*
Audience: {{audience|plain}}
Mention: {{mention|mention}}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/** Direct AES-256-GCM field encryption with a fresh 96-bit nonce and 128-bit tag. */
public final class DirectAeadNotificationPayloadCrypto {
private static final String ALGORITHM = "AES-256-GCM";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final String PROFILE = "notification-direct-aead-v1";
private static final String AAD_REVISION = "notification-aad-v1";
private static final int TAG_BITS = 128;
private final NotificationKeyMaterialProvider keys;
private final SecureRandom random;
public DirectAeadNotificationPayloadCrypto(
NotificationKeyMaterialProvider keys, SecureRandom random) {
this.keys = Objects.requireNonNull(keys, "notification key provider must be non-null");
this.random = Objects.requireNonNull(random, "notification secure random must be non-null");
}
public NotificationCiphertext encrypt(
byte[] plaintext,
NotificationCiphertext.AadContext context,
String keyReference,
String keyVersion) {
Objects.requireNonNull(plaintext, "notification plaintext must be non-null");
Objects.requireNonNull(context, "notification AAD context must be non-null");
if (plaintext.length < 1 || plaintext.length > 10_000_000) {
throw new IllegalArgumentException(
"notification plaintext must contain 1..10000000 bytes");
}
requireProfile(context);
byte[] nonce = new byte[12];
random.nextBytes(nonce);
try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) {
byte[] encrypted =
handle.readBytes(
material -> transform(Cipher.ENCRYPT_MODE, material, nonce, context, plaintext));
return new NotificationCiphertext(
ALGORITHM,
handle.keyReference(),
handle.keyVersion(),
PROFILE,
AAD_REVISION,
nonce,
encrypted);
}
}
public byte[] decrypt(
NotificationCiphertext encrypted, NotificationCiphertext.AadContext context) {
Objects.requireNonNull(encrypted, "notification ciphertext must be non-null");
Objects.requireNonNull(context, "notification AAD context must be non-null");
requireProfile(context);
if (!ALGORITHM.equals(encrypted.algorithm())
|| !PROFILE.equals(encrypted.cryptoProfileVersion())
|| !AAD_REVISION.equals(encrypted.aadRevision())) {
throw new NotificationCryptoException(
"notification ciphertext cryptographic profile is unsupported");
}
try (NotificationKeyMaterialHandle handle =
keys.acquire(encrypted.keyReference(), encrypted.keyVersion())) {
return handle.readBytes(
material ->
transform(
Cipher.DECRYPT_MODE,
material,
encrypted.nonce(),
context,
encrypted.ciphertext()));
} catch (NotificationCryptoException failure) {
throw failure;
} catch (RuntimeException failure) {
throw new NotificationCryptoException(
"notification ciphertext authentication failed", failure);
}
}
private static byte[] transform(
int mode,
byte[] material,
byte[] nonce,
NotificationCiphertext.AadContext context,
byte[] input) {
if (material.length != 32) {
throw new NotificationCryptoException(
"notification AES-256 key revision has an invalid length");
}
byte[] keyCopy = material.clone();
try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(
mode,
new SecretKeySpec(keyCopy, "AES"),
new GCMParameterSpec(TAG_BITS, nonce));
cipher.updateAAD(context.canonicalBytes());
return cipher.doFinal(input);
} catch (GeneralSecurityException failure) {
throw new NotificationCryptoException(
"notification ciphertext authentication failed", failure);
} finally {
Arrays.fill(keyCopy, (byte) 0);
}
}
private static void requireProfile(NotificationCiphertext.AadContext context) {
if (!PROFILE.equals(context.cryptoProfileVersion())) {
throw new NotificationCryptoException(
"notification AAD crypto profile is unsupported");
}
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
/** Non-secret AES-GCM metadata and ciphertext; mutable arrays are defensively copied. */
public record NotificationCiphertext(
String algorithm,
String keyReference,
String keyVersion,
String cryptoProfileVersion,
String aadRevision,
byte[] nonce,
byte[] ciphertext) {
public NotificationCiphertext {
if (!"AES-256-GCM".equals(algorithm)) {
throw new IllegalArgumentException("notification ciphertext algorithm must be AES-256-GCM");
}
keyReference =
NotificationKeyMaterialHandle.requireSlug(
"notification ciphertext key reference", keyReference);
keyVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification ciphertext key version", keyVersion);
cryptoProfileVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification crypto profile version", cryptoProfileVersion);
aadRevision =
NotificationKeyMaterialHandle.requireSlug(
"notification AAD revision", aadRevision);
Objects.requireNonNull(nonce, "notification ciphertext nonce must be non-null");
Objects.requireNonNull(ciphertext, "notification ciphertext bytes must be non-null");
if (nonce.length != 12 || ciphertext.length < 17 || ciphertext.length > 10_000_016) {
throw new IllegalArgumentException(
"notification ciphertext nonce/tag/payload bounds are invalid");
}
nonce = nonce.clone();
ciphertext = ciphertext.clone();
}
@Override
public byte[] nonce() {
return nonce.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof NotificationCiphertext that)) {
return false;
}
return algorithm.equals(that.algorithm)
&& keyReference.equals(that.keyReference)
&& keyVersion.equals(that.keyVersion)
&& cryptoProfileVersion.equals(that.cryptoProfileVersion)
&& aadRevision.equals(that.aadRevision)
&& Arrays.equals(nonce, that.nonce)
&& Arrays.equals(ciphertext, that.ciphertext);
}
@Override
public int hashCode() {
int result =
Objects.hash(
algorithm, keyReference, keyVersion, cryptoProfileVersion, aadRevision);
result = 31 * result + Arrays.hashCode(nonce);
return 31 * result + Arrays.hashCode(ciphertext);
}
@Override
public String toString() {
return "NotificationCiphertext[algorithm="
+ algorithm
+ ", keyReference="
+ keyReference
+ ", keyVersion="
+ keyVersion
+ ", cryptoProfileVersion="
+ cryptoProfileVersion
+ ", aadRevision="
+ aadRevision
+ ", nonce=<redacted>, ciphertext=<redacted>]";
}
/** Exact approved length-prefixed AAD hierarchy for one encrypted notification field. */
public record AadContext(
String schemaTable,
String recordId,
String notificationId,
Optional<String> deliveryId,
Optional<String> attemptId,
String fieldPurpose,
String providerBindingRevision,
String cryptoProfileVersion) {
public AadContext {
if (schemaTable == null || !schemaTable.matches("[a-z][a-z0-9_]{0,62}")) {
throw new IllegalArgumentException(
"notification AAD table must match [a-z][a-z0-9_]{0,62}");
}
recordId = requireOpaque("notification AAD record ID", recordId);
notificationId = requireOpaque("notification AAD notification ID", notificationId);
deliveryId = requireOptional("notification AAD delivery ID", deliveryId);
attemptId = requireOptional("notification AAD attempt ID", attemptId);
fieldPurpose =
NotificationKeyMaterialHandle.requireSlug(
"notification AAD field purpose", fieldPurpose);
providerBindingRevision =
NotificationKeyMaterialHandle.requireSlug(
"notification AAD provider binding revision", providerBindingRevision);
cryptoProfileVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification AAD crypto profile", cryptoProfileVersion);
}
byte[] canonicalBytes() {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
update(output, schemaTable);
update(output, recordId);
update(output, notificationId);
updateOptional(output, deliveryId);
updateOptional(output, attemptId);
update(output, fieldPurpose);
update(output, providerBindingRevision);
update(output, cryptoProfileVersion);
return output.toByteArray();
}
private static Optional<String> requireOptional(
String field, Optional<String> value) {
Objects.requireNonNull(value, field + " container must be non-null");
return value.map(item -> requireOpaque(field, item));
}
private static String requireOpaque(String field, String value) {
if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) {
throw new IllegalArgumentException(
field + " must contain 1..128 opaque identifier characters");
}
return value;
}
private static void update(java.io.ByteArrayOutputStream output, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
output.writeBytes(bytes);
}
private static void updateOptional(
java.io.ByteArrayOutputStream output, Optional<String> value) {
output.write(value.isPresent() ? 1 : 0);
value.ifPresent(item -> update(output, item));
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
/** Redacted fail-closed notification cryptographic operation error. */
public final class NotificationCryptoException extends RuntimeException {
public NotificationCryptoException(String safeMessage) {
super(safeMessage);
}
public NotificationCryptoException(String safeMessage, Throwable cause) {
super(safeMessage, cause);
}
}
@@ -0,0 +1,136 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** Purpose-separated length-prefixed HMAC-SHA-256 over non-secret canonical tuple fields. */
public final class NotificationHmacDigester {
private static final String ALGORITHM = "HmacSHA256";
private static final int MAXIMUM_VERIFICATION_VERSIONS = 4;
private final NotificationKeyMaterialProvider keys;
public NotificationHmacDigester(NotificationKeyMaterialProvider keys) {
this.keys = Objects.requireNonNull(keys, "notification HMAC key provider must be non-null");
}
public Digest digest(
String purpose,
List<String> fields,
String keyReference,
String keyVersion) {
byte[] canonical = canonical(purpose, fields);
try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) {
String value =
handle.readBytes(
material -> HexFormat.of().formatHex(hmac(material, canonical)));
return new Digest(handle.keyReference(), handle.keyVersion(), value);
} finally {
Arrays.fill(canonical, (byte) 0);
}
}
public boolean verify(Digest expected, String purpose, List<String> fields) {
Objects.requireNonNull(expected, "expected notification HMAC must be non-null");
List<String> versions =
List.copyOf(
Objects.requireNonNull(
keys.verificationVersions(expected.keyReference()),
"notification HMAC verification versions must be non-null"));
if (versions.isEmpty()
|| versions.size() > MAXIMUM_VERIFICATION_VERSIONS
|| new HashSet<>(versions).size() != versions.size()) {
throw new NotificationCryptoException(
"notification HMAC verification key set must be unique and bounded");
}
if (!versions.contains(expected.keyVersion())) {
return false;
}
Digest actual =
digest(purpose, fields, expected.keyReference(), expected.keyVersion());
return MessageDigest.isEqual(
HexFormat.of().parseHex(expected.value()),
HexFormat.of().parseHex(actual.value()));
}
private static byte[] canonical(String purpose, List<String> fields) {
if (purpose == null || !purpose.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException(
"notification HMAC purpose must match [a-z][a-z0-9-]{0,62}");
}
Objects.requireNonNull(fields, "notification HMAC fields must be non-null");
if (fields.isEmpty() || fields.size() > 32) {
throw new IllegalArgumentException("notification HMAC fields must contain 1..32 entries");
}
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
update(output, purpose);
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(fields.size()).array());
fields.forEach(
field -> {
if (field == null || field.length() > 4_096) {
throw new IllegalArgumentException(
"notification HMAC field must contain at most 4096 characters");
}
update(output, field);
});
return output.toByteArray();
}
private static byte[] hmac(byte[] material, byte[] canonical) {
if (material.length < 32) {
throw new NotificationCryptoException(
"notification HMAC key revision has an invalid length");
}
byte[] keyCopy = material.clone();
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(keyCopy, ALGORITHM));
return mac.doFinal(canonical);
} catch (GeneralSecurityException failure) {
throw new NotificationCryptoException("notification HMAC operation failed", failure);
} finally {
Arrays.fill(keyCopy, (byte) 0);
}
}
private static void update(java.io.ByteArrayOutputStream output, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
output.writeBytes(bytes);
}
public record Digest(String keyReference, String keyVersion, String value) {
public Digest {
keyReference =
NotificationKeyMaterialHandle.requireSlug(
"notification HMAC key reference", keyReference);
keyVersion =
NotificationKeyMaterialHandle.requireSlug(
"notification HMAC key version", keyVersion);
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"notification HMAC value must be lowercase SHA-256 hex");
}
}
@Override
public String toString() {
return "Digest[keyReference="
+ keyReference
+ ", keyVersion="
+ keyVersion
+ ", value=<redacted>]";
}
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.util.Arrays;
import java.util.Objects;
import java.util.function.Function;
/** Operation-scoped mutable key copy that wipes on close and rejects use after close. */
public final class NotificationKeyMaterialHandle implements AutoCloseable {
private final String keyReference;
private final String keyVersion;
private final byte[] material;
private boolean closed;
private NotificationKeyMaterialHandle(
String keyReference, String keyVersion, byte[] material) {
this.keyReference = requireSlug("notification key reference", keyReference);
this.keyVersion = requireSlug("notification key version", keyVersion);
this.material = material;
}
public static NotificationKeyMaterialHandle fromBytes(
String keyReference, String keyVersion, byte[] material) {
Objects.requireNonNull(material, "notification key material must be non-null");
if (material.length < 32 || material.length > 65_536) {
throw new IllegalArgumentException(
"notification key material must contain 32..65536 bytes");
}
return new NotificationKeyMaterialHandle(keyReference, keyVersion, material.clone());
}
public String keyReference() {
return keyReference;
}
public String keyVersion() {
return keyVersion;
}
public synchronized <T> T readBytes(Function<byte[], T> reader) {
Objects.requireNonNull(reader, "notification key reader must be non-null");
if (closed) {
throw new IllegalStateException("notification key material handle is closed");
}
return reader.apply(material);
}
@Override
public synchronized void close() {
if (!closed) {
Arrays.fill(material, (byte) 0);
closed = true;
}
}
@Override
public synchronized String toString() {
return "NotificationKeyMaterialHandle[keyReference="
+ keyReference
+ ", keyVersion="
+ keyVersion
+ ", material=<redacted>, closed="
+ closed
+ "]";
}
static String requireSlug(String field, String value) {
if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) {
throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}");
}
return value;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import java.util.List;
/** Acquires versioned mutable key copies and declares bounded current-plus-retiring verification. */
public interface NotificationKeyMaterialProvider {
NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion);
List<String> verificationVersions(String keyReference);
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
class NotificationHmacDigesterTest {
@Test
void purposeAndLengthPrefixesSeparateAmbiguousTuplesAndOrdering() {
NotificationHmacDigester digester =
new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard());
NotificationHmacDigester.Digest split =
digester.digest("intent-dedupe-v1", List.of("a", "bc"), "alias-key", "hmac-r2");
NotificationHmacDigester.Digest joined =
digester.digest("intent-dedupe-v1", List.of("ab", "c"), "alias-key", "hmac-r2");
NotificationHmacDigester.Digest reordered =
digester.digest("intent-dedupe-v1", List.of("bc", "a"), "alias-key", "hmac-r2");
NotificationHmacDigester.Digest otherPurpose =
digester.digest("recipient-alias-v1", List.of("a", "bc"), "alias-key", "hmac-r2");
assertThat(split.value()).matches("[0-9a-f]{64}");
assertThat(split.value())
.isNotEqualTo(joined.value())
.isNotEqualTo(reordered.value())
.isNotEqualTo(otherPurpose.value());
assertThat(split.toString()).doesNotContain(split.value());
}
@Test
void currentAndBoundedRetiringVersionsVerifyWhileUnknownAndOversizedSetsFailClosed() {
NotificationHmacDigester digester =
new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard());
NotificationHmacDigester.Digest retiring =
digester.digest("intent-dedupe-v1", List.of("tenant-42", "scope-42"), "alias-key", "hmac-r1");
assertThat(digester.verify(retiring, "intent-dedupe-v1", List.of("tenant-42", "scope-42")))
.isTrue();
assertThat(digester.verify(retiring, "recipient-alias-v1", List.of("tenant-42", "scope-42")))
.isFalse();
assertThat(
digester.verify(
new NotificationHmacDigester.Digest(
"alias-key", "hmac-r9", retiring.value()),
"intent-dedupe-v1",
List.of("tenant-42", "scope-42")))
.isFalse();
NotificationKeyMaterialProvider oversized =
new NotificationKeyMaterialProvider() {
@Override
public NotificationKeyMaterialHandle acquire(String reference, String version) {
return NotificationKeyMaterialHandle.fromBytes(
reference,
version,
NotificationPayloadCryptoTest.InMemoryKeys.HMAC_R2);
}
@Override
public List<String> verificationVersions(String reference) {
return List.of("hmac-r1", "hmac-r2", "hmac-r3", "hmac-r4", "hmac-r5");
}
};
assertThatThrownBy(
() ->
new NotificationHmacDigester(oversized)
.verify(
retiring,
"intent-dedupe-v1",
List.of("tenant-42", "scope-42")))
.isInstanceOf(NotificationCryptoException.class)
.hasMessageContaining("bounded");
}
}
@@ -0,0 +1,168 @@
package dev.caskeleton.adapter.outbound.persistence.notification.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class NotificationPayloadCryptoTest {
@Test
void aes256GcmUsesFreshNonceExactMetadataAndContextBoundLengthPrefixedAad() {
InMemoryKeys keys = InMemoryKeys.standard();
DirectAeadNotificationPayloadCrypto crypto =
new DirectAeadNotificationPayloadCrypto(keys, new SecureRandom());
NotificationCiphertext.AadContext context = context("record-42", "parameters");
byte[] plaintext = "recipient-secret-value".getBytes(StandardCharsets.UTF_8);
NotificationCiphertext first =
crypto.encrypt(plaintext, context, "payload-key", "payload-r2");
NotificationCiphertext second =
crypto.encrypt(plaintext, context, "payload-key", "payload-r2");
assertThat(first.algorithm()).isEqualTo("AES-256-GCM");
assertThat(first.cryptoProfileVersion()).isEqualTo("notification-direct-aead-v1");
assertThat(first.aadRevision()).isEqualTo("notification-aad-v1");
assertThat(first.nonce()).hasSize(12).isNotEqualTo(second.nonce());
assertThat(crypto.decrypt(first, context)).isEqualTo(plaintext);
assertThat(first.toString())
.doesNotContain("recipient-secret-value")
.doesNotContain(Arrays.toString(InMemoryKeys.PAYLOAD_R2));
}
@Test
void tupleFieldSwapsCiphertextSwapsAndWrongKeyRevisionFailAuthenticationAndStayRedacted() {
InMemoryKeys keys = InMemoryKeys.standard();
DirectAeadNotificationPayloadCrypto crypto =
new DirectAeadNotificationPayloadCrypto(keys, new SecureRandom());
NotificationCiphertext.AadContext parameters = context("record-42", "parameters");
NotificationCiphertext.AadContext recipient = context("record-43", "recipient");
NotificationCiphertext encrypted =
crypto.encrypt(
"secret-A".getBytes(StandardCharsets.UTF_8),
parameters,
"payload-key",
"payload-r2");
NotificationCiphertext other =
crypto.encrypt(
"secret-B".getBytes(StandardCharsets.UTF_8),
recipient,
"payload-key",
"payload-r2");
assertThatThrownBy(() -> crypto.decrypt(encrypted, recipient))
.isInstanceOf(NotificationCryptoException.class)
.hasMessageContaining("authentication")
.hasMessageNotContaining("secret-A")
.hasMessageNotContaining("secret-B");
NotificationCiphertext swapped =
new NotificationCiphertext(
encrypted.algorithm(),
encrypted.keyReference(),
encrypted.keyVersion(),
encrypted.cryptoProfileVersion(),
encrypted.aadRevision(),
other.nonce(),
other.ciphertext());
assertThatThrownBy(() -> crypto.decrypt(swapped, parameters))
.isInstanceOf(NotificationCryptoException.class)
.hasMessageContaining("authentication");
NotificationCiphertext wrongVersion =
new NotificationCiphertext(
encrypted.algorithm(),
encrypted.keyReference(),
"payload-r1",
encrypted.cryptoProfileVersion(),
encrypted.aadRevision(),
encrypted.nonce(),
encrypted.ciphertext());
assertThatThrownBy(() -> crypto.decrypt(wrongVersion, parameters))
.isInstanceOf(NotificationCryptoException.class)
.hasMessageNotContaining(Arrays.toString(InMemoryKeys.PAYLOAD_R1));
}
@Test
void acquiredMutableKeyHandleWipesOnCloseAndRejectsUseAfterClose() {
AtomicReference<byte[]> borrowed = new AtomicReference<>();
NotificationKeyMaterialHandle handle =
NotificationKeyMaterialHandle.fromBytes(
"payload-key", "payload-r2", InMemoryKeys.PAYLOAD_R2);
handle.readBytes(
bytes -> {
borrowed.set(bytes);
return null;
});
handle.close();
assertThat(borrowed.get()).containsOnly((byte) 0);
assertThatThrownBy(() -> handle.readBytes(bytes -> bytes.length))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("closed");
assertThat(handle.toString()).doesNotContain(Arrays.toString(InMemoryKeys.PAYLOAD_R2));
}
private static NotificationCiphertext.AadContext context(String recordId, String purpose) {
return new NotificationCiphertext.AadContext(
"notification_intent",
recordId,
"intent-42",
Optional.of("delivery-42"),
Optional.of("attempt-42"),
purpose,
"provider-binding-r3",
"notification-direct-aead-v1");
}
static final class InMemoryKeys implements NotificationKeyMaterialProvider {
static final byte[] PAYLOAD_R1 = "1".repeat(32).getBytes(StandardCharsets.US_ASCII);
static final byte[] PAYLOAD_R2 = "2".repeat(32).getBytes(StandardCharsets.US_ASCII);
static final byte[] HMAC_R1 = "3".repeat(32).getBytes(StandardCharsets.US_ASCII);
static final byte[] HMAC_R2 = "4".repeat(32).getBytes(StandardCharsets.US_ASCII);
private final Map<String, byte[]> keys;
private final Map<String, List<String>> verificationVersions;
private InMemoryKeys(
Map<String, byte[]> keys, Map<String, List<String>> verificationVersions) {
this.keys = keys;
this.verificationVersions = verificationVersions;
}
static InMemoryKeys standard() {
return new InMemoryKeys(
Map.of(
"payload-key:payload-r1",
PAYLOAD_R1,
"payload-key:payload-r2",
PAYLOAD_R2,
"alias-key:hmac-r1",
HMAC_R1,
"alias-key:hmac-r2",
HMAC_R2),
Map.of("alias-key", List.of("hmac-r2", "hmac-r1")));
}
@Override
public NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion) {
byte[] key = keys.get(keyReference + ":" + keyVersion);
if (key == null) {
throw new NotificationCryptoException("notification key revision is unavailable");
}
return NotificationKeyMaterialHandle.fromBytes(keyReference, keyVersion, key);
}
@Override
public List<String> verificationVersions(String keyReference) {
return verificationVersions.getOrDefault(keyReference, List.of());
}
}
}
@@ -6,6 +6,7 @@ import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.EvaluationResult;
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
@@ -1231,8 +1231,9 @@ class CleanArchitectureTest {
static final ArchRule OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES =
methods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..adapter.outbound..")
.areDeclaredInClassesThat(
JavaClass.Predicates.resideInAPackage("..adapter.outbound..")
.and(implementsApplicationLayerPort()))
// @Configuration factory classes are excluded: a @Bean method legitimately
// returns the adapter's own outbound port type for DI wiring (e.g.
// MessagingConfig#messagePublisher -> MessagePublisher) — that is
@@ -1277,7 +1278,7 @@ class CleanArchitectureTest {
"..adapter.inbound.web..",
"..adapter.outbound.persistence.."))
.as(
"B7: externally reachable outbound adapter public methods must return domain types"
"B7: outbound application-port adapter public methods must return domain types"
+ " (or primitives/wrappers/Optional) — raw external response types must not"
+ " escape the adapter package (feature-boundary-validation-mapping-contract B7"
+ " ACL). @Configuration @Bean factory methods and @ConfigurationProperties"
@@ -1553,6 +1554,17 @@ class CleanArchitectureTest {
};
}
private static DescribedPredicate<JavaClass> implementsApplicationLayerPort() {
return DescribedPredicate.describe(
"implement an application-layer port",
type ->
type.getAllRawInterfaces().stream()
.anyMatch(
iface ->
iface.getPackageName().contains(".application.")
|| iface.getPackageName().endsWith(".application")));
}
@ArchTest
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
classes()
@@ -1,11 +1,14 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture;
/**
* Negative fixture for {@code OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES}. Returns a
* class still inside the outbound adapter package — the ACL bypass the contract forbids (B7).
*/
public class RawTypeLeakingAdapterFixture {
public class RawTypeLeakingAdapterFixture implements RawLeakPortFixture {
@Override
public RawExternalResponseFixture leakRaw() {
return new RawExternalResponseFixture();
}
@@ -1,11 +1,13 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.httpclient.activation;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture;
/** Proves that an activation package name cannot bypass the outbound raw-type leak guard. */
public class EvilActivationLeak {
public class EvilActivationLeak implements RawLeakPortFixture {
public RawExternalResponseFixture require() {
@Override
public RawExternalResponseFixture leakRaw() {
return new RawExternalResponseFixture();
}
@@ -1,11 +1,13 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.settingsbypass;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture;
/** Proves that a Settings suffix cannot bypass the outbound raw-type leak guard. */
public class EvilSettings {
public class EvilSettings implements RawLeakPortFixture {
public RawExternalResponseFixture leak() {
@Override
public RawExternalResponseFixture leakRaw() {
return new RawExternalResponseFixture();
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.bootstrap.architecture.violations.application;
/** Application-port side of the B7 raw adapter response negative fixture. */
public interface RawLeakPortFixture {
Object leakRaw();
}
+23
View File
@@ -74,6 +74,29 @@ Package root: `dev.caskeleton.application`.
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
writer-cutover command contracts.
- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort`
returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning
handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence
entity, compiled adapter binding and raw recipient/template payload types are forbidden here.
- Provider calls run outside database transactions. Dispatch and reconciliation use bounded
claim/authorize/finalize transactions with opaque claim/version/execution tokens; an
`INDETERMINATE` submission is terminal and must not be blindly retried.
- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce
and complaint facts may request technical suppression; consent/unsubscribe policy is outside this
capability.
- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile
registries are application-owned exact inputs; signed inventory/quiescence verification is
delegated to narrow verifier ports and the persistence operation must enforce locked durable
state/journal invariants.
- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking,
provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the
notification/persistence/bootstrap adapters.
## Notification R1 application boundary
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and
writer-cutover command contracts.
@@ -27,6 +27,10 @@ public final class NotificationCapabilityCompatibilityValidator {
reasons,
provider.maximumTargets() < policy.maxTargetsPerRecipient(),
"PROVIDER_TARGET_BOUND_INSUFFICIENT");
addIf(
reasons,
policy.maxReconcileCalls() > 0 && !provider.reconciliationSupported(),
"PROVIDER_RECONCILIATION_UNSUPPORTED");
if (policy.mode() == NotificationMode.DURABLE_ASYNC) {
addIf(
reasons,
@@ -146,6 +146,20 @@ public interface NotificationDeliveryStorePort {
&& providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) {
throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition");
}
if (providerOutcome.retryDisposition() == RetryDisposition.PARK_BINDING) {
if (parkResult == NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED) {
throw new IllegalArgumentException("PARK_BINDING requires an admission park result");
}
boolean parked =
parkResult == NotificationAdmissionReadinessPort.ParkResult.PARKED
|| parkResult == NotificationAdmissionReadinessPort.ParkResult.ALREADY_PARKED;
TerminalState expected =
parked ? TerminalState.PARKED_BINDING : TerminalState.RETRY_SCHEDULED;
if (terminalState != expected) {
throw new IllegalArgumentException(
"terminal state must reflect the generation-guarded admission park result");
}
}
}
}
@@ -168,19 +182,46 @@ public interface NotificationDeliveryStorePort {
NotificationDeliveryId deliveryId,
String executionToken,
long expectedRowVersion,
String providerMessageReference,
NotificationRouteId routeId,
int routeRevision,
String bindingDigest,
int targetOrdinal,
String targetReference,
String providerCapabilityReference,
String providerBindingRevision,
String credentialGeneration,
String lookupReference,
ReconciliationLookupKind lookupKind,
Instant absoluteDeadline) {
public ReconciliationClaim {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
executionToken =
NotificationIntentId.requireOpaque("reconciliation execution token", executionToken);
if (expectedRowVersion < 0) {
throw new IllegalArgumentException("reconciliation row version must be non-negative");
if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) {
throw new IllegalArgumentException(
"reconciliation row version, route revision and target ordinal are invalid");
}
providerMessageReference =
Objects.requireNonNull(routeId, "reconciliation route ID must be non-null");
if (bindingDigest == null || !bindingDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"reconciliation binding digest must be a lowercase SHA-256 digest");
}
targetReference =
NotificationIntentId.requireOpaque("reconciliation target reference", targetReference);
providerCapabilityReference =
NotificationIntentId.requireOpaque(
"provider message reference", providerMessageReference);
"reconciliation provider capability reference", providerCapabilityReference);
providerBindingRevision =
NotificationIntentId.requireOpaque(
"reconciliation provider binding revision", providerBindingRevision);
credentialGeneration =
NotificationIntentId.requireSlug(
"reconciliation credential generation", credentialGeneration);
lookupReference =
NotificationIntentId.requireOpaque(
"provider reconciliation lookup reference", lookupReference);
Objects.requireNonNull(lookupKind, "provider reconciliation lookup kind must be non-null");
Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null");
}
@@ -189,12 +230,34 @@ public interface NotificationDeliveryStorePort {
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
+ "expectedRowVersion="
+ expectedRowVersion
+ ", providerMessageReference=<redacted>, absoluteDeadline="
+ ", routeId="
+ routeId
+ ", routeRevision="
+ routeRevision
+ ", bindingDigest="
+ bindingDigest
+ ", targetOrdinal="
+ targetOrdinal
+ ", targetReference=<redacted>, providerCapabilityReference="
+ providerCapabilityReference
+ ", providerBindingRevision="
+ providerBindingRevision
+ ", credentialGeneration="
+ credentialGeneration
+ ", lookupReference=<redacted>, lookupKind="
+ lookupKind
+ ", absoluteDeadline="
+ absoluteDeadline
+ "]";
}
}
enum ReconciliationLookupKind {
PRE_SEND_CORRELATION,
CLIENT_OPERATION_KEY,
MESSAGE_REFERENCE
}
enum ReconciliationFinalizationResult {
APPLIED,
LATE_EXACT_APPLIED,
@@ -134,7 +134,7 @@ public final class NotificationDispatchUseCase
NotificationDeliveryStorePort.AttemptFinalization finalization =
new NotificationDeliveryStorePort.AttemptFinalization(
outcome, terminalState(outcome), fallbackEligible(outcome), parkResult);
outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult);
NotificationDeliveryStorePort.FinalizationResult result =
Objects.requireNonNull(
store.finalizeAttempt(attempt, finalization, clock.instant()),
@@ -143,7 +143,7 @@ public final class NotificationDispatchUseCase
}
private static NotificationDeliveryStorePort.TerminalState terminalState(
ProviderAttemptOutcome outcome) {
ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) {
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
}
@@ -152,7 +152,15 @@ public final class NotificationDispatchUseCase
}
return switch (outcome.retryDisposition()) {
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
case PARK_BINDING ->
switch (parkResult) {
case PARKED, ALREADY_PARKED ->
NotificationDeliveryStorePort.TerminalState.PARKED_BINDING;
case STALE_GENERATION -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
case NOT_REQUESTED ->
throw new IllegalStateException(
"PARK_BINDING outcome requires an admission park result");
};
case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE;
case NOT_APPLICABLE ->
throw new IllegalArgumentException(
@@ -1,6 +1,10 @@
package dev.caskeleton.application.notification;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@@ -10,6 +14,7 @@ public record NotificationFrozenPlan(
NotificationIntentId intentId,
NotificationKindPolicy policy,
Locale selectedLocale,
BindingSnapshot binding,
NotificationRecipientReference recipient,
NotificationTemplateParameters parameters,
String idempotencyScope,
@@ -24,33 +29,44 @@ public record NotificationFrozenPlan(
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
Objects.requireNonNull(policy, "notification kind policy must be non-null");
selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale);
Objects.requireNonNull(binding, "notification binding snapshot must be non-null");
Objects.requireNonNull(recipient, "notification recipient must be non-null");
Objects.requireNonNull(parameters, "notification template parameters must be non-null");
idempotencyScope =
NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope);
sourceOperationId =
NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId);
Objects.requireNonNull(tenantReference, "tenant reference container must be non-null");
tenantReference = requireOptionalOpaque("tenant reference", tenantReference);
correlationReference =
NotificationIntentId.requireOpaque(
"notification correlation reference", correlationReference);
Objects.requireNonNull(causationReference, "causation reference container must be non-null");
causationReference = requireOptionalOpaque("causation reference", causationReference);
Objects.requireNonNull(notBefore, "notification not-before time must be non-null");
Objects.requireNonNull(expiresAt, "notification expiry time must be non-null");
if (recipient.channel() != policy.channel()) {
throw new IllegalArgumentException("recipient channel must match notification kind channel");
}
if (!expiresAt.isAfter(notBefore)) {
throw new IllegalArgumentException("notification expiry must be after not-before");
if (binding.targets().size() != policy.maxTargetsPerRecipient()) {
throw new IllegalArgumentException(
"frozen binding target count must match the code-owned policy target bound");
}
Duration lifetime = Duration.between(notBefore, expiresAt);
if (lifetime.isZero()
|| lifetime.isNegative()
|| lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) {
throw new IllegalArgumentException(
"notification expiry must be after not-before and within the policy retry horizon");
}
}
public static NotificationFrozenPlan from(NotificationIntentDraft draft, Locale selectedLocale) {
public static NotificationFrozenPlan from(
NotificationIntentDraft draft, Locale selectedLocale, BindingSnapshot binding) {
Objects.requireNonNull(draft, "notification intent draft must be non-null");
return new NotificationFrozenPlan(
draft.intentId(),
draft.policy(),
selectedLocale,
binding,
draft.recipient(),
draft.parameters(),
draft.idempotencyScope(),
@@ -70,6 +86,11 @@ public record NotificationFrozenPlan(
return policy.routeId();
}
private static Optional<String> requireOptionalOpaque(String field, Optional<String> reference) {
Objects.requireNonNull(reference, field + " container must be non-null");
return reference.map(value -> NotificationIntentId.requireOpaque(field, value));
}
@Override
public String toString() {
return "NotificationFrozenPlan[intentId="
@@ -80,10 +101,116 @@ public record NotificationFrozenPlan(
+ policy.policyRevision()
+ ", selectedLocale="
+ selectedLocale.toLanguageTag()
+ ", routeRevision="
+ binding.routeRevision()
+ ", bindingDigest="
+ binding.bindingDigest()
+ ", rendererRevision="
+ binding.rendererRevision()
+ ", targets=<redacted>"
+ ", recipient=<redacted>, parameters=<redacted>, context=<redacted>, notBefore="
+ notBefore
+ ", expiresAt="
+ expiresAt
+ "]";
}
/** Provider-neutral immutable execution graph persisted with the logical intent. */
public record BindingSnapshot(
int routeRevision,
String bindingDigest,
String templateChecksum,
String rendererRevision,
List<FrozenTarget> targets,
boolean receiptRequired,
Duration perAttemptDeadline) {
private static final Duration MAXIMUM_ATTEMPT_DEADLINE = Duration.ofMinutes(5);
public BindingSnapshot {
if (routeRevision < 1 || routeRevision > 1_000_000) {
throw new IllegalArgumentException("frozen route revision must be in 1..1000000");
}
bindingDigest = requireDigest("notification binding digest", bindingDigest);
templateChecksum = requireDigest("notification template checksum", templateChecksum);
rendererRevision =
NotificationIntentId.requireSlug("notification renderer revision", rendererRevision);
Objects.requireNonNull(targets, "frozen notification targets must be non-null");
targets =
targets.stream()
.map(target -> Objects.requireNonNull(target, "frozen target must be non-null"))
.sorted(Comparator.comparingInt(FrozenTarget::ordinal))
.toList();
if (targets.isEmpty() || targets.size() > 16) {
throw new IllegalArgumentException(
"frozen notification targets must contain 1..16 entries");
}
if (new HashSet<>(targets.stream().map(FrozenTarget::targetReference).toList()).size()
!= targets.size()) {
throw new IllegalArgumentException(
"frozen notification targets contain duplicate references");
}
for (int index = 0; index < targets.size(); index++) {
if (targets.get(index).ordinal() != index) {
throw new IllegalArgumentException(
"frozen notification target ordinals must be contiguous from zero");
}
}
Objects.requireNonNull(
perAttemptDeadline, "notification per-attempt deadline must be non-null");
if (perAttemptDeadline.isZero()
|| perAttemptDeadline.isNegative()
|| perAttemptDeadline.compareTo(MAXIMUM_ATTEMPT_DEADLINE) > 0) {
throw new IllegalArgumentException(
"notification per-attempt deadline must be positive and at most five minutes");
}
}
private static String requireDigest(String field, String digest) {
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
}
return digest;
}
}
/** Opaque provider-leg identity; credentials, endpoints and SDK types are deliberately absent. */
public record FrozenTarget(
int ordinal,
String targetReference,
String providerCapabilityReference,
String providerBindingRevision,
String credentialGeneration) {
public FrozenTarget {
if (ordinal < 0 || ordinal > 15) {
throw new IllegalArgumentException("frozen notification target ordinal must be in 0..15");
}
targetReference =
NotificationIntentId.requireOpaque(
"frozen notification target reference", targetReference);
providerCapabilityReference =
NotificationIntentId.requireOpaque(
"frozen provider capability reference", providerCapabilityReference);
providerBindingRevision =
NotificationIntentId.requireOpaque(
"frozen provider binding revision", providerBindingRevision);
credentialGeneration =
NotificationIntentId.requireSlug(
"frozen provider credential generation", credentialGeneration);
}
@Override
public String toString() {
return "FrozenTarget[ordinal="
+ ordinal
+ ", targetReference=<redacted>, providerCapabilityReference="
+ providerCapabilityReference
+ ", providerBindingRevision="
+ providerBindingRevision
+ ", credentialGeneration="
+ credentialGeneration
+ "]";
}
}
}
@@ -8,11 +8,12 @@ public record NotificationMaintenanceCommand(
implements Command {
public NotificationMaintenanceCommand {
long total = (long) maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts;
if (maximumExpiredIntents < 0
|| maximumPayloadRedactions < 0
|| maximumExpiredReceipts < 0
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts < 1
|| maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts > 100) {
|| total < 1
|| total > 100) {
throw new IllegalArgumentException(
"notification maintenance total mutation bound must be in 1..100");
}
@@ -5,7 +5,7 @@ public record NotificationMaintenanceResult(
int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
public NotificationMaintenanceResult {
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
if (expiredIntentCount < 0
|| redactedPayloadCount < 0
|| expiredReceiptCount < 0
@@ -11,7 +11,7 @@ public interface NotificationMaintenanceStorePort {
record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) {
public MutationResult {
int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount;
if (expiredIntentCount < 0
|| redactedPayloadCount < 0
|| expiredReceiptCount < 0
@@ -88,6 +88,29 @@ class NotificationCapabilityCompatibilityValidatorTest {
.hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE");
}
@Test
void reconciliationPolicyRequiresProviderReconciliationCapability() {
NotificationKindPolicy policy = policy();
NotificationProviderCapabilityDescriptor withoutReconciliation =
new NotificationProviderCapabilityDescriptor(
"provider-capability-42",
NotificationChannel.EMAIL,
Set.of(NotificationMode.DURABLE_ASYNC),
true,
false,
true,
16,
1_000_000);
NotificationCapabilityCompatibilityValidator.Compatibility result =
new NotificationCapabilityCompatibilityValidator()
.validate(policy, withoutReconciliation, store(policy), Optional.of(ingress()), true);
assertThat(result.compatible()).isFalse();
assertThat(result.reasonCodes())
.containsExactly(new NotificationReasonCode("PROVIDER_RECONCILIATION_UNSUPPORTED"));
}
private static NotificationKindPolicy policy() {
return new NotificationKindPolicy(
new NotificationKindId("security-alert"),
@@ -151,6 +151,34 @@ class NotificationDispatchUseCaseTest {
assertThat(result.parkedCount()).isEqualTo(1);
}
@Test
void staleParkGenerationRequeuesTheLegInsteadOfStrandingItAsParked() {
List<String> trace = new ArrayList<>();
RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied());
NotificationDispatchResult result =
new NotificationDispatchUseCase(
store,
attempt ->
new ProviderAttemptOutcome(
SubmissionCertainty.DEFINITELY_NOT_APPLIED,
RetryDisposition.PARK_BINDING,
NotificationFaultScope.PROVIDER_BINDING,
new NotificationReasonCode("PROVIDER_AUTH_REJECTED"),
Optional.empty(),
"correlation-42",
Optional.empty()),
request -> NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION,
new TrackingTransactionPort(trace),
Clock.fixed(NOW, ZoneOffset.UTC))
.handle(new NotificationDispatchCommand(1));
assertThat(store.finalization.parkResult())
.isEqualTo(NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION);
assertThat(store.finalization.terminalState())
.isEqualTo(NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED);
assertThat(result.parkedCount()).isZero();
}
@Test
void exactLateResultIsCountedWithoutBlindProviderRetry() {
RecordingStore store =
@@ -251,7 +279,10 @@ class NotificationDispatchUseCaseTest {
Optional.empty(),
NOW,
NOW.plusSeconds(600));
return NotificationFrozenPlan.from(draft, java.util.Locale.ENGLISH);
return NotificationFrozenPlan.from(
draft,
java.util.Locale.ENGLISH,
NotificationTestFixtures.binding(draft.policy().channel()));
}
private static ProviderAttemptOutcome accepted() {
@@ -33,6 +33,17 @@ class NotificationMaintenanceUseCaseTest {
assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1));
assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() -> new NotificationMaintenanceCommand(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationMaintenanceStorePort.MutationResult(
Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() -> new NotificationMaintenanceResult(Integer.MAX_VALUE, Integer.MAX_VALUE, 3))
.isInstanceOf(IllegalArgumentException.class);
}
private static final class TrackingTransactions implements TransactionPort {
@@ -26,7 +26,10 @@ class NotificationPlanningBoundaryTest {
NotificationPlanPort planner =
requested ->
new NotificationPlanningResult.Planned(
NotificationFrozenPlan.from(requested, Locale.ENGLISH));
NotificationFrozenPlan.from(
requested,
Locale.ENGLISH,
NotificationTestFixtures.binding(requested.policy().channel())));
NotificationPlanningResult result = planner.plan(draft);
@@ -46,7 +49,9 @@ class NotificationPlanningBoundaryTest {
"recipient-ref-42",
"source-operation-42",
Instant.parse("2026-07-28T00:00:00Z"));
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.ENGLISH);
NotificationFrozenPlan plan =
NotificationFrozenPlan.from(
draft, Locale.ENGLISH, NotificationTestFixtures.binding(draft.policy().channel()));
AtomicReference<NotificationFrozenPlan> appended = new AtomicReference<>();
AtomicReference<NotificationFrozenPlan> attempted = new AtomicReference<>();
NotificationIntentAppendPort appendPort =
@@ -0,0 +1,26 @@
package dev.caskeleton.application.notification;
import java.time.Duration;
import java.util.List;
final class NotificationTestFixtures {
private NotificationTestFixtures() {}
static NotificationFrozenPlan.BindingSnapshot binding(NotificationChannel channel) {
String capability =
channel == NotificationChannel.EMAIL
? "aws-ses-v2-durable-single-local-sns-v1"
: "slack-web-api-durable-single-local-v1";
return new NotificationFrozenPlan.BindingSnapshot(
3,
"a".repeat(64),
"b".repeat(64),
"renderer-r3",
List.of(
new NotificationFrozenPlan.FrozenTarget(
0, "target-r3", capability, "provider-binding-r3", "credential-r3")),
channel == NotificationChannel.EMAIL,
Duration.ofSeconds(5));
}
}
@@ -193,14 +193,93 @@ class NotificationValueContractTest {
notBefore,
notBefore.plusSeconds(60));
NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.forLanguageTag("en"));
NotificationFrozenPlan plan =
NotificationFrozenPlan.from(
draft,
Locale.forLanguageTag("en"),
NotificationTestFixtures.binding(draft.policy().channel()));
assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH);
assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC);
assertThat(plan.binding().targets()).isUnmodifiable();
assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary"));
assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada");
}
@Test
void publicFrozenPlanConstructorPreservesDraftContextAndRetryHorizonInvariants() {
NotificationKindPolicy policy = durablePolicy(Duration.ofHours(1));
Instant notBefore = Instant.parse("2026-07-28T00:00:00Z");
NotificationFrozenPlan valid =
new NotificationFrozenPlan(
new NotificationIntentId("intent-42"),
policy,
Locale.ENGLISH,
NotificationTestFixtures.binding(policy.channel()),
new EmailRecipientReference("recipient-ref-42"),
new NotificationTemplateParameters(
Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))),
"password-reset",
"source-operation-42",
Optional.empty(),
"correlation-42",
Optional.empty(),
notBefore,
notBefore.plusSeconds(60));
assertThatThrownBy(
() ->
new NotificationFrozenPlan(
valid.intentId(),
policy,
valid.selectedLocale(),
valid.binding(),
valid.recipient(),
valid.parameters(),
valid.idempotencyScope(),
valid.sourceOperationId(),
Optional.of(" "),
valid.correlationReference(),
valid.causationReference(),
notBefore,
notBefore.plusSeconds(60)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationFrozenPlan(
valid.intentId(),
policy,
valid.selectedLocale(),
valid.binding(),
valid.recipient(),
valid.parameters(),
valid.idempotencyScope(),
valid.sourceOperationId(),
valid.tenantReference(),
valid.correlationReference(),
Optional.of("\n"),
notBefore,
notBefore.plusSeconds(60)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new NotificationFrozenPlan(
valid.intentId(),
policy,
valid.selectedLocale(),
valid.binding(),
valid.recipient(),
valid.parameters(),
valid.idempotencyScope(),
valid.sourceOperationId(),
valid.tenantReference(),
valid.correlationReference(),
valid.causationReference(),
notBefore,
notBefore.plus(Duration.ofHours(2))))
.isInstanceOf(IllegalArgumentException.class);
}
private static NotificationKindPolicy durablePolicy(Duration retryHorizon) {
return new NotificationKindPolicy(
new NotificationKindId("password-reset"),
@@ -81,7 +81,16 @@ class ReconcileNotificationDeliveriesUseCaseTest {
new NotificationDeliveryId("delivery-42"),
"reconcile-token-42",
3,
new NotificationRouteId("security-slack"),
3,
"a".repeat(64),
0,
"slack-primary",
"slack-web-api-durable-single-local-v1",
"slack-binding-r1",
"credential-r1",
"provider-message-42",
NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE,
NOW.plusSeconds(30)));
private RecordingStore(List<String> trace) {