feat: add notification production capability

This commit is contained in:
donghyeon-ka
2026-07-31 23:48:58 +09:00
parent b3add0162d
commit ec4bf105c4
199 changed files with 23589 additions and 122 deletions
+20 -2
View File
@@ -12,10 +12,28 @@ Package root: `dev.caskeleton.adapter.outbound.notification`.
## Responsibility
- Implement notification provider routing and provider-specific Slack/email clients behind ports.
- Own provider settings, technical fallback, and provider adaptation.
- Preserve the current raw notification router/provider seams only as the `R0 legacy` compatibility
baseline until the reviewed canonical cutover removes them.
- Implement future provider protocols behind application-owned ports without leaking SDK, transport,
bootstrap, or persistence types.
- Own provider settings, technical fallback mechanics, and provider adaptation; application policy
owns mode, eligibility, retry/fallback decisions and business failure semantics.
- Reuse `adapter:outbound:support` for shared outbound concerns.
## Current R0 freeze
- `RoutingNotifier` performs route-list fan-out over `(Channel, providerId)`.
- `FailOpenNotificationProvider` applies one global fail-open rule.
- `google-email`/`GoogleEmailClient` and `slack-webhook`/`SlackClient` are fake-only extension seams,
not production integrations or qualified provider cards.
- Checked-in provider selector keys drift from the router's `routes` + provider `enabled` grammar.
Preserve and document that drift until the canonical graph replaces it; do not silently reinterpret
the old keys.
- There are no feature/application production consumers and no real-provider, durable, receipt,
security, load, or rotation evidence.
- The exact legacy deletion inventory lives in [README.md](README.md). Do not add behavior to those
classes while building their canonical replacements.
## Boundaries
- Allowed dependency edges come only from the module's
+49 -5
View File
@@ -1,13 +1,34 @@
# adapter:outbound:notification — 설계 결정 참조
# adapter:outbound:notification — R0 legacy truth
> 현재 구현 전체는 교체 전 호환성 기준선인 `R0 legacy`다. `GoogleEmailClient`와
> `SlackClient`는 project-supplied seam일 뿐 실제 Google Mail 또는 Slack 연동이 아니며,
> provider/card qualification evidence도 없다.
알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트:
`dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유
correlation / fail-open 의존성 로깅을 재사용한다.
허용/금지 의존 정책은 `src/build.gradle`
`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직
별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
참조용 기록이다.
허용/금지 의존 정책은 `src/config/architecture/modules.json`
`adapter-outbound-notification` row가 SSOT다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의
근거**와 canonical 구현 전 삭제 대상을 모아둔 참조용 기록이다.
## Task 1 R0 truth table
| 축 | 현재 사실 | 증거와 한계 |
| --- | --- | --- |
| application contract | raw `NotificationPort.notify(Channel, route, Notification)` | mode, transaction, receipt, attempt certainty가 없는 R0 port |
| routing | `(channel, providerId)` registry + route별 provider ID list fan-out | `RoutingNotifierTest`; route list의 모든 provider를 순서대로 호출 |
| provider failure | 모든 provider를 중앙 `FailOpenNotificationProvider`로 감싸고 예외를 삼킴 | global fail-open이며 application kind별 정책이 아님 |
| unbound route | `AdapterDisabledException` fail-fast | `NotificationAdapterTest`; disabled sentinel은 없음 |
| email seam | `google-email` + `GoogleEmailClient` interface | module 안 production client/SDK/credential/protocol 구현 0 |
| Slack seam | `slack-webhook` + `SlackClient` interface | module 안 production client/SDK/credential/protocol 구현 0 |
| configuration | code는 `app.notification.routes.*`와 provider별 `*.enabled`를 읽음 | checked-in `application.yml`/env registry의 `app.notification.{slack,email}.provider` selector와 drift |
| production consumer | feature/application production consumer 0 | main source에는 application contract 선언, adapter 구현과 bootstrap composition만 존재 |
| evidence grade | local fake/contract baseline | real provider, durability, callback, security, load evidence 0; 모든 seam `R0 legacy` |
selector drift는 이 기준선의 일부다. Task 1에서는 고치지 않는다. canonical graph가 준비되고
cutover evidence가 생기기 전까지 기존 key를 새 의미로 재사용하거나 legacy class에 production
동작을 추가하지 않는다.
## 모듈 개요
@@ -32,3 +53,26 @@ client 는 포킹 프로젝트가 채우는 seam 이다.
`channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예:
`GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가
구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다.
## Wave G deletion inventory
다음 surface는 canonical-only cutover와 retained evidence 검증이 끝난 뒤 한 묶음으로 제거한다.
그 전에는 동작을 확장하지 않고 R0 회귀 기준선으로만 유지한다.
- application R0 contract:
`Channel`, `Notification`, raw `NotificationPort`
- router/decorator SPI:
`NotificationConfig`, `NotificationRoutesSettings`, `RoutingNotifier`,
`NotificationProvider`, `FailOpenNotificationProvider`
- fake-only Google email seam:
`GoogleEmailClient`, `GoogleEmailProvider`, `GoogleEmailNotificationAdapterConfig`
- fake-only Slack webhook seam:
`SlackClient`, `SlackWebhookProvider`, `SlackNotificationAdapterConfig`
- legacy configuration/tests:
`app.notification.routes.*`, `app.notification.google-email.enabled`,
`app.notification.slack-webhook.enabled`, drifted
`APP_NOTIFICATION_EMAIL_PROVIDER`/`APP_NOTIFICATION_SLACK_PROVIDER`,
`NotificationAdapterTest`, `RoutingNotifierTest`와 bootstrap legacy gating cases
accepted 또는 indeterminate work를 inventory하지 않은 상태에서 이 목록을 삭제하거나 canonical
provider로 자동 재전송하지 않는다.
@@ -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}}
+10 -1
View File
@@ -63,11 +63,20 @@ adapters implement application/domain ports directly and must not depend on this
| Mode | Propagation | Isolation | Read-only |
|---|---|---|---|
| `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
| `inRootWrite` | `REQUIRED` | `READ_COMMITTED` | `false` |
| `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` |
| `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` |
Pre-built templates are immutable after construction so concurrent callers cannot
observe each other's reconfiguration.
observe each other's reconfiguration. `inRootWrite` reuses the pre-built write template,
but first checks `TransactionSynchronizationManager.isActualTransactionActive()`.
When an actual ambient transaction exists it MUST throw
`NestedRootTransactionRejectedException` before invoking either the action or the
`PlatformTransactionManager`. It MUST NOT use `NEVER` or `REQUIRES_NEW`.
`inRootWrite` returns its action value only after `TransactionTemplate.execute` has
committed. A commit failure propagates the transaction exception and no success value
is returned to the caller.
### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract)
@@ -17,6 +17,19 @@ readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 ra
사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED`
로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract).
### 왜 `inRootWrite`가 별도 템플릿이나 `NEVER` propagation을 만들지 않나
`inRootWrite`의 실행 속성은 `inWrite`와 같은 `WRITE + REQUIRED + READ_COMMITTED`라 기존
write template을 재사용한다. 차이는 실행 전 precondition이다.
`TransactionSynchronizationManager.isActualTransactionActive()``true`이면 action과
`PlatformTransactionManager`를 호출하기 전에
`NestedRootTransactionRejectedException`으로 fail-fast한다. `REQUIRES_NEW`로 suspend해서
"root처럼 보이게" 하지 않으므로 호출자 transaction과 독립 commit되는 silent 의미 변경이 없다.
`TransactionTemplate.execute`는 commit까지 성공한 다음 값을 반환한다. 따라서
`inRootWrite`의 결과는 post-commit에만 호출자에게 보이고, commit 실패는 값 대신 원래 transaction
예외로 전파된다. 이 보장은 action이 외부 객체를 직접 변경하는 것을 되돌리는 보상이 아니라,
경계의 반환값을 성공으로 노출하지 않는 계약이다.
## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort`
### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값)
@@ -18,6 +18,9 @@ dependencies {
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.testcontainers:testcontainers-postgresql'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -10,6 +10,9 @@ com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClass
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=runtimeClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
@@ -41,7 +44,9 @@ com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspat
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
@@ -60,12 +65,14 @@ javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
@@ -102,6 +109,7 @@ org.hibernate.models:hibernate-models:1.0.1=runtimeClasspath,testRuntimeClasspat
org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=runtimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
@@ -130,6 +138,7 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=runtimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
@@ -187,6 +196,11 @@ org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -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);
}
@@ -1,12 +1,14 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import dev.caskeleton.application.transaction.Isolation;
import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.function.Supplier;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
/**
@@ -47,6 +49,14 @@ public class SpringTransactionPort implements TransactionPort {
return writeTemplate.execute(status -> action.get());
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
throw new NestedRootTransactionRejectedException();
}
return writeTemplate.execute(status -> action.get());
}
@Override
public <T> T inRead(Supplier<T> action) {
return readTemplate.execute(status -> action.get());
@@ -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());
}
}
}
@@ -1,18 +1,30 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionSynchronizationManager;
class SpringTransactionPortTest {
@AfterEach
void clearTransactionState() {
TransactionSynchronizationManager.clear();
}
@Test
void inWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() {
RecordingTransactionManager tm = new RecordingTransactionManager();
@@ -48,6 +60,88 @@ class SpringTransactionPortTest {
assertThat(definition.isReadOnly()).isTrue();
}
@Test
void inRootWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() {
RecordingTransactionManager tm = new RecordingTransactionManager();
SpringTransactionPort port = new SpringTransactionPort(tm);
String result = port.inRootWrite(() -> "ok");
assertThat(result).isEqualTo("ok");
assertThat(tm.definitions).hasSize(1);
TransactionDefinition definition = tm.definitions.get(0);
assertThat(definition.getPropagationBehavior())
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED);
assertThat(definition.getIsolationLevel())
.isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED);
assertThat(definition.isReadOnly()).isFalse();
assertThat(tm.commits).isOne();
assertThat(tm.rollbacks).isZero();
}
@Test
void inRootWriteRejectsAmbientActualTransactionBeforeActionOrTransactionManagerSideEffects() {
RecordingTransactionManager tm = new RecordingTransactionManager();
SpringTransactionPort port = new SpringTransactionPort(tm);
AtomicBoolean actionCalled = new AtomicBoolean();
TransactionSynchronizationManager.setActualTransactionActive(true);
assertThatThrownBy(
() ->
port.inRootWrite(
() -> {
actionCalled.set(true);
return "not-visible";
}))
.isInstanceOf(NestedRootTransactionRejectedException.class);
assertThat(actionCalled).isFalse();
assertThat(tm.definitions).isEmpty();
assertThat(tm.commitAttempts).isZero();
assertThat(tm.rollbacks).isZero();
}
@Test
void inRootWriteReturnsOnlyAfterPhysicalCommitCompletes() {
RecordingTransactionManager tm = new RecordingTransactionManager();
SpringTransactionPort port = new SpringTransactionPort(tm);
String result =
port.inRootWrite(
() -> {
tm.lifecycle.add("action");
return "committed";
});
tm.lifecycle.add("returned");
assertThat(result).isEqualTo("committed");
assertThat(tm.lifecycle).containsExactly("begin", "action", "commit", "returned");
}
@Test
void inRootWritePropagatesCommitFailureWithoutPublishingCallerVisibleResult() {
RecordingTransactionManager tm = new RecordingTransactionManager();
tm.failCommit = true;
SpringTransactionPort port = new SpringTransactionPort(tm);
AtomicReference<String> callerVisible = new AtomicReference<>();
assertThatThrownBy(
() ->
callerVisible.set(
port.inRootWrite(
() -> {
tm.lifecycle.add("action");
return "must-not-be-visible";
})))
.isInstanceOf(TransactionSystemException.class)
.hasMessageContaining("commit failed");
assertThat(callerVisible).hasValue(null);
assertThat(tm.commitAttempts).isOne();
assertThat(tm.commits).isZero();
assertThat(tm.lifecycle).containsExactly("begin", "action", "commit-failed");
}
@Test
void inNewUsesRequiresNewPropagationReadCommittedIsolationAndNotReadOnly() {
RecordingTransactionManager tm = new RecordingTransactionManager();
@@ -116,6 +210,9 @@ class SpringTransactionPortTest {
private static final class RecordingTransactionManager implements PlatformTransactionManager {
private final List<TransactionDefinition> definitions = new ArrayList<>();
private final List<String> lifecycle = new ArrayList<>();
private boolean failCommit;
private int commitAttempts;
private int commits;
private int rollbacks;
@@ -123,11 +220,18 @@ class SpringTransactionPortTest {
public TransactionStatus getTransaction(TransactionDefinition definition)
throws TransactionException {
definitions.add(definition);
lifecycle.add("begin");
return new SimpleTransactionStatus(true);
}
@Override
public void commit(TransactionStatus status) throws TransactionException {
commitAttempts++;
if (failCommit) {
lifecycle.add("commit-failed");
throw new TransactionSystemException("commit failed");
}
lifecycle.add("commit");
commits++;
}
@@ -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.violations.application.BulkWriteWithoutWriteAccessUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
@@ -59,6 +60,8 @@ class ArchitectureViolationFixtureTest {
new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class);
private static final JavaClasses APPLICATION_DIAGNOSTIC_FRAMEWORK_FIXTURE_ONLY =
new ClassFileImporter().importClasses(ApplicationDiagnosticFrameworkViolation.class);
private static final JavaClasses ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY =
new ClassFileImporter().importClasses(RootWriteTransactionBoundaryUseCase.class);
// Each WebSocket fixture is imported in ISOLATION so the two package globs in
// NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..")
@@ -261,10 +264,23 @@ class ArchitectureViolationFixtureTest {
.as(
"USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch "
+ "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without "
+ "TransactionPort.inWrite")
+ "TransactionPort.inWrite or TransactionPort.inRootWrite")
.isTrue();
}
@Test
void useCaseCapabilityMatchesTransactionPortBoundaryAllowsRootWriteBoundary() {
EvaluationResult result =
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate(
ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must allow a "
+ "WRITE_REPOSITORY use case that directly calls TransactionPort.inRootWrite")
.isFalse();
}
@Test
void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() {
EvaluationResult result =
@@ -387,7 +387,7 @@ class CleanArchitectureTest {
"feature-domain-feature-onboarding-contract D4: a use case that declares a "
+ "repository-backed transaction capability must call the matching "
+ "TransactionPort boundary directly: READ_REPOSITORY+READ_ONLY -> inRead, "
+ "WRITE_REPOSITORY+WRITE -> inWrite, REQUIRES_NEW -> inNew. "
+ "WRITE_REPOSITORY+WRITE -> inWrite or inRootWrite, REQUIRES_NEW -> inNew. "
+ "RepositoryAccess.NONE may intentionally skip a DB transaction. "
+ "UNSUPPORTED_IMPL_DECISION: static analysis reaches direct calls only; a "
+ "transaction hidden behind a helper remains a code-review concern.")
@@ -583,24 +583,24 @@ class CleanArchitectureTest {
String transactionMode = enumAnnotationValue(annotation, "transactionMode");
String repositoryAccess = enumAnnotationValue(annotation, "repositoryAccess");
String requiredMethod = null;
Set<String> requiredMethods = Set.of();
if ("REQUIRES_NEW".equals(transactionMode)) {
requiredMethod = "inNew";
requiredMethods = Set.of("inNew");
} else if ("WRITE".equals(transactionMode) && "WRITE_REPOSITORY".equals(repositoryAccess)) {
requiredMethod = "inWrite";
requiredMethods = Set.of("inWrite", "inRootWrite");
} else if ("READ_ONLY".equals(transactionMode)
&& "READ_REPOSITORY".equals(repositoryAccess)) {
requiredMethod = "inRead";
requiredMethods = Set.of("inRead");
}
if (requiredMethod == null) {
if (requiredMethods.isEmpty()) {
return;
}
for (JavaMethodCall call : item.getMethodCallsFromSelf()) {
if ("dev.caskeleton.application.transaction.TransactionPort"
.equals(call.getTargetOwner().getFullName())
&& requiredMethod.equals(call.getName())) {
&& requiredMethods.contains(call.getName())) {
return;
}
}
@@ -614,8 +614,8 @@ class CleanArchitectureTest {
+ transactionMode
+ ", repositoryAccess = "
+ repositoryAccess
+ ") but does not directly call TransactionPort."
+ requiredMethod
+ ") but does not directly call one of TransactionPort."
+ requiredMethods
+ "(...)"));
}
};
@@ -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
@@ -1263,7 +1264,7 @@ class CleanArchitectureTest {
"..adapter.inbound.web..",
"..adapter.outbound.persistence.."))
.as(
"B7: outbound adapter public methods must return domain types (or "
"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 "
@@ -1272,6 +1273,17 @@ class CleanArchitectureTest {
+ "response surfaces.")
.allowEmptyShould(true);
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()
@@ -0,0 +1,33 @@
package dev.caskeleton.bootstrap.architecture.fixtures.application;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
/** Positive fixture: a root-only write boundary satisfies the WRITE_REPOSITORY fitness rule. */
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
@RequiresPermission("fixture:root-write")
public final class RootWriteTransactionBoundaryUseCase
implements CommandUseCase<RootWriteTransactionBoundaryUseCase.CommandFixture, String> {
private final TransactionPort transactionPort;
public RootWriteTransactionBoundaryUseCase(TransactionPort transactionPort) {
this.transactionPort = transactionPort;
}
@Override
public String handle(CommandFixture command) {
return transactionPort.inRootWrite(command::value);
}
public record CommandFixture(String value) implements Command {}
}
@@ -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();
}
@@ -8,7 +8,10 @@ import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.usecase.CommandUseCase;
/** Intentional write-use-case violation: declares a write but skips TransactionPort.inWrite. */
/**
* Intentional write-use-case violation: declares a write but skips both TransactionPort.inWrite and
* TransactionPort.inRootWrite.
*/
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
@@ -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();
}
+30 -1
View File
@@ -56,13 +56,37 @@ Package root: `dev.caskeleton.application`.
| `usecase.QueryUseCase<Q extends Query, R>` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. |
| `command.Command` | Marker for write intents. Plain immutable types built from domain values. |
| `query.Query` | Marker for read intents. Plain immutable types built from domain values. |
| `transaction.TransactionPort` | Outbound port for transactional boundaries. Implemented by `adapter-persistence`. |
| `transaction.TransactionPort` | Outbound port for join-capable write/read, physical root-only write, and independent write boundaries. Implemented by `adapter-persistence`. |
| `transaction.NestedRootTransactionRejectedException` | Fail-fast signal raised before action/provider side effects when `inRootWrite` detects an actual ambient transaction. |
| `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. |
| `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. |
| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. |
| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. |
| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. |
## 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.
## Naming convention
- Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit.
@@ -103,10 +127,15 @@ application-core never self-registers with a DI framework.
| Use case shape | `transactionMode` | TransactionPort call | When |
|---|---|---|---|
| Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. |
| Physical-root write command | `WRITE` | `tx.inRootWrite(...)` | Only when orchestration must prove there is no ambient transaction and expose a result after commit. |
| Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. |
| Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. |
`NESTED` and `NEVER` propagation are forbidden.
`inRootWrite` MUST reject an actual ambient transaction before invoking its action or
`PlatformTransactionManager`; it MUST NOT emulate root-only behavior with `REQUIRES_NEW`.
Both `inWrite` and `inRootWrite` satisfy the direct boundary fitness rule for a
`WRITE_REPOSITORY + WRITE` use case. READ and REQUIRES_NEW mappings remain exclusive.
### Callback signature contract (D11)
+38 -4
View File
@@ -71,13 +71,21 @@
- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional`
을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통
`SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다.
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
- 가지 경계:
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
- 가지 경계:
- `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본.
- `inRootWrite` — 물리 root 전용 REQUIRED + read-write, `READ_COMMITTED`. 실제 ambient
transaction 이 하나라도 있으면 action 실행 전에
`NestedRootTransactionRejectedException` 으로 거부한다. 성공 값은 commit 이 끝난 뒤에만
호출자에게 반환되며, commit 실패는 그대로 전파된다.
- `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본.
- `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한
유스케이스(outbox/audit/compensation)에서만 허용.
- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
- **root-only 사용 조건**: `inRootWrite` 는 join 가능한 일반 command 경계의 대체물이 아니다.
외부 효과를 commit 이후에만 시작해야 하는 orchestration처럼 물리 root를 증명해야 하는 경우에만
쓴다. 기존 transaction 안에서 `REQUIRES_NEW` 로 몰래 분리하지 않고 fail-fast하므로, 호출자는
transaction 없는 진입점에서 이 경계를 시작해야 한다.
- **콜백 시그니처(D11)**: 네 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
수 없다. Spring `TransactionCallback<T>` 제약과 동일하다. 그래서 호출자는 도메인 checked
exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`).
`IOException``UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이
@@ -93,7 +101,33 @@
**금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 +
데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라.
- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서
`@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출.
`@Transactional` 직접 사용, `inRootWrite` 의 ambient transaction 진입, `inNew` 의 per-record
루프 호출.
---
## Notification R1 오케스트레이션 경계
`dev.caskeleton.application.notification`은 알림 vendor 구현이 아니라 알림 capability의 순수
애플리케이션 계약이다.
- 입력은 typed recipient/template value와 코드 소유 `NotificationKindPolicy`로 제한한다. feature가
만든 `NotificationIntentDraft`는 `NotificationPlanPort`에서 immutable
`NotificationFrozenPlan`으로 고정되고, append/inline 포트는 이 plan만 소비한다.
- dispatch는 claim → reserve/authorize → provider call → terminal-once finalize 순서다. 짧은 DB
transaction 사이에서 provider를 호출하며, opaque claim/version/execution token으로 stale 결과를
거부한다. submission certainty가 `INDETERMINATE`면 blind retry나 fallback을 하지 않는다.
- receipt reducer는 fact 순서와 무관한 monotonic projection을 만든다. hard bounce/complaint만
technical suppression 후보이고, business consent/unsubscribe는 다른 capability가 소유한다.
- admission/reconciliation/maintenance는 bounded batch와 주입된 `Clock`을 사용한다. scheduler는
이 유스케이스만 호출하며 store/provider 포트를 직접 조율하지 않는다.
- legacy→canonical writer cutover는 exact route/generation/profile registry, root-only commit,
서명된 inventory/quiescence evidence와 closed transition action으로 표현한다. 애플리케이션은
verifier/operation 포트의 입력 계약을 강제하고, 실제 서명 검증·행 잠금·불변 journal·provider
egress 차단은 후속 adapter 구현이 증명해야 한다.
현재 증거 등급은 **R1 application contract with fakes**다. PostgreSQL DDL/locking, provider
protocol, receipt ingress, runtime wiring을 포함한 R2/R3 완료 주장이 아니다.
### TransactionMode
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
import java.util.Objects;
/** Applies one already authenticated and normalized receipt. */
public record ApplyNotificationReceiptCommand(NormalizedNotificationReceiptCommand receipt)
implements Command {
public ApplyNotificationReceiptCommand {
Objects.requireNonNull(receipt, "normalized notification receipt must be non-null");
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Non-sensitive result of reducing one receipt event. */
public record ApplyNotificationReceiptResult(
Status status, NotificationReceiptProjection projection, boolean suppressionApplied) {
public ApplyNotificationReceiptResult {
Objects.requireNonNull(status, "notification receipt apply status must be non-null");
Objects.requireNonNull(projection, "notification receipt projection must be non-null");
if (status == Status.DUPLICATE && suppressionApplied) {
throw new IllegalArgumentException("duplicate receipt cannot repeat technical suppression");
}
}
public enum Status {
APPLIED,
DUPLICATE
}
}
@@ -0,0 +1,80 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.Objects;
/**
* Appends one receipt fact and reduces its delivery projection in one physical root transaction.
*/
@RequiresPermission("notification:receipt")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public final class ApplyNotificationReceiptUseCase
implements CommandUseCase<ApplyNotificationReceiptCommand, ApplyNotificationReceiptResult> {
private final NotificationReceiptStorePort store;
private final NotificationTechnicalSuppressionPort suppression;
private final TransactionPort transactions;
private final Clock clock;
public ApplyNotificationReceiptUseCase(
NotificationReceiptStorePort store,
NotificationTechnicalSuppressionPort suppression,
TransactionPort transactions,
Clock clock) {
this.store = Objects.requireNonNull(store, "notification receipt store must be non-null");
this.suppression =
Objects.requireNonNull(suppression, "notification suppression port must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public ApplyNotificationReceiptResult handle(ApplyNotificationReceiptCommand command) {
Objects.requireNonNull(command, "apply notification receipt command must be non-null");
return transactions.inRootWrite(() -> applyInsideRoot(command.receipt()));
}
private ApplyNotificationReceiptResult applyInsideRoot(
NormalizedNotificationReceiptCommand command) {
NotificationReceiptStorePort.AppendResult appendResult = store.appendIfAbsent(command);
if (appendResult instanceof NotificationReceiptStorePort.Duplicate duplicate) {
return new ApplyNotificationReceiptResult(
ApplyNotificationReceiptResult.Status.DUPLICATE, duplicate.projection(), false);
}
NotificationReceiptStorePort.ReceiptAggregate aggregate =
((NotificationReceiptStorePort.Appended) appendResult).aggregate();
if (!aggregate.deliveryId().equals(command.deliveryId())) {
throw new IllegalStateException(
"receipt aggregate delivery does not match normalized command");
}
NotificationReceiptProjection projection =
NotificationReceiptProjection.reduce(aggregate.facts());
store.saveProjection(aggregate.deliveryId(), projection);
boolean suppressionApplied = shouldSuppress(command.fact());
if (suppressionApplied) {
suppression.suppress(
new NotificationTechnicalSuppressionPort.SuppressionMutation(
aggregate.recipient(), command.fact().reasonCode(), clock.instant()));
}
return new ApplyNotificationReceiptResult(
ApplyNotificationReceiptResult.Status.APPLIED, projection, suppressionApplied);
}
private static boolean shouldSuppress(NotificationReceiptFact fact) {
return fact.type() == NotificationReceiptFact.Type.COMPLAINT
|| (fact.type() == NotificationReceiptFact.Type.BOUNCE
&& fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification;
/** Point at which recipient consent or preference must be established. */
public enum ConsentCheckMode {
SNAPSHOT_AT_APPEND,
RECHECK_BEFORE_EACH_DELIVERY
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.notification;
/** Opaque reference resolved to an email recipient only inside a qualified adapter. */
public record EmailRecipientReference(String reference) implements NotificationRecipientReference {
public EmailRecipientReference {
reference = NotificationIntentId.requireOpaque("email recipient reference", reference);
}
@Override
public NotificationChannel channel() {
return NotificationChannel.EMAIL;
}
@Override
public String toString() {
return "EmailRecipientReference[reference=<redacted>]";
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
import java.util.Objects;
/** Reviewed all-route initialization request; partial route sets are rejected by the use case. */
public record InitializeNotificationWriterFencesCommand(
String operationToken,
NotificationCanonicalWriterRouteSet reviewedRoutes,
String reviewedRouteSetDigest,
String actorReference,
NotificationReasonCode reasonCode)
implements Command {
public InitializeNotificationWriterFencesCommand {
operationToken =
NotificationIntentId.requireOpaque("writer initialization operation token", operationToken);
Objects.requireNonNull(reviewedRoutes, "reviewed writer routes must be non-null");
reviewedRouteSetDigest = requireDigest(reviewedRouteSetDigest);
actorReference =
NotificationIntentId.requireOpaque("writer initialization actor", actorReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
static String requireDigest(String digest) {
if (digest == null || !digest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"writer evidence digest must be 64 lowercase hex characters");
}
return digest;
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
/** Atomic persistence operation for absent-fence and empty-journal initialization. */
@FunctionalInterface
public interface InitializeNotificationWriterFencesOperation {
InitializeNotificationWriterFencesResult initialize(
InitializeNotificationWriterFencesCommand command,
NotificationWriterRouteSet trustedRoutes,
Instant requestedAt);
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Committed all-route fence initialization result. */
public record InitializeNotificationWriterFencesResult(
Status status, int initializedRouteCount, String routeSetDigest) {
public InitializeNotificationWriterFencesResult {
Objects.requireNonNull(status, "writer initialization status must be non-null");
if (initializedRouteCount < 1 || initializedRouteCount > 100) {
throw new IllegalArgumentException("initialized writer route count must be in 1..100");
}
routeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(routeSetDigest);
}
public enum Status {
INITIALIZED,
REPLAYED
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.Objects;
/** Root-commits the complete trusted writer fence and proof registry initialization batch. */
@RequiresPermission("notification:cutover")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
crossTenantAdmin = true)
public final class InitializeNotificationWriterFencesUseCase
implements CommandUseCase<
InitializeNotificationWriterFencesCommand, InitializeNotificationWriterFencesResult> {
private final NotificationWriterRouteSet routes;
private final InitializeNotificationWriterFencesOperation operation;
private final TransactionPort transactions;
private final Clock clock;
public InitializeNotificationWriterFencesUseCase(
NotificationWriterRouteSet routes,
InitializeNotificationWriterFencesOperation operation,
TransactionPort transactions,
Clock clock) {
this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null");
this.operation =
Objects.requireNonNull(operation, "writer initialization operation must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public InitializeNotificationWriterFencesResult handle(
InitializeNotificationWriterFencesCommand command) {
Objects.requireNonNull(command, "writer initialization command must be non-null");
if (!command.reviewedRoutes().equals(routes.canonicalRoutes())) {
throw new IllegalArgumentException(
"reviewed writer routes must exactly equal the trusted all-route set");
}
if (!command.reviewedRouteSetDigest().equals(routes.digest())) {
throw new IllegalArgumentException(
"reviewed writer route-set digest does not match trusted registry digest");
}
return transactions.inRootWrite(() -> operation.initialize(command, routes, clock.instant()));
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.notification;
/**
* Executes one bounded, non-durable inline attempt over a frozen application plan. The caller must
* establish the physical root-write sequencing contract before invoking this port.
*/
@FunctionalInterface
public interface InlineNotificationAttemptPort {
NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan);
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Framework-free receipt normalized by an authenticated inbound adapter. */
public record NormalizedNotificationReceiptCommand(
NotificationReceiptEventId receiptEventId,
NotificationDeliveryId deliveryId,
NotificationReceiptFact fact) {
public NormalizedNotificationReceiptCommand {
Objects.requireNonNull(receiptEventId, "notification receipt event ID must be non-null");
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(fact, "notification receipt fact must be non-null");
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.notification;
/** Code-owned dispatch admission and fairness class. */
public enum NotificationAdmissionClass {
SECURITY_CRITICAL,
TRANSACTIONAL,
BULK_LOW_VALUE
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
import java.util.Objects;
/** Audited operator request to re-probe and resume one shared notification admission gate. */
public record NotificationAdmissionGateCommand(
String operationToken,
NotificationRouteId routeId,
int policyRevision,
NotificationFaultScope faultScope,
String scopeReference,
long expectedGeneration,
int maximumParkedLegs,
String actorReference,
NotificationReasonCode reasonCode)
implements Command {
public NotificationAdmissionGateCommand {
operationToken =
NotificationIntentId.requireOpaque("admission resume operation token", operationToken);
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (policyRevision < 1 || expectedGeneration < 0) {
throw new IllegalArgumentException(
"policy revision must be positive and expected generation non-negative");
}
if (maximumParkedLegs < 1 || maximumParkedLegs > 100) {
throw new IllegalArgumentException("maximum parked legs must be in 1..100");
}
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
if (faultScope == NotificationFaultScope.DELIVERY) {
throw new IllegalArgumentException("operator admission command cannot target DELIVERY scope");
}
scopeReference =
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
actorReference =
NotificationIntentId.requireOpaque("admission resume actor reference", actorReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
NotificationAdmissionReadinessPort.ResumeRequest toResumeRequest() {
return new NotificationAdmissionReadinessPort.ResumeRequest(
operationToken,
routeId,
policyRevision,
faultScope,
scopeReference,
expectedGeneration,
maximumParkedLegs,
actorReference,
reasonCode);
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.Objects;
/** Probes readiness outside a transaction and generation-CAS resumes inside one short write. */
@RequiresPermission("notification:operate")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
externalOutboundAllowed = true,
crossTenantAdmin = true)
public final class NotificationAdmissionGateUseCase
implements CommandUseCase<
NotificationAdmissionGateCommand, NotificationAdmissionGateUseCase.Result> {
private final NotificationAdmissionReadinessPort admission;
private final TransactionPort transactions;
private final Clock clock;
public NotificationAdmissionGateUseCase(
NotificationAdmissionReadinessPort admission, TransactionPort transactions, Clock clock) {
this.admission =
Objects.requireNonNull(admission, "notification admission port must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public Result handle(NotificationAdmissionGateCommand command) {
Objects.requireNonNull(command, "notification admission command must be non-null");
NotificationAdmissionReadinessPort.ResumeRequest request = command.toResumeRequest();
NotificationAdmissionReadinessPort.ReadinessProbe probe =
Objects.requireNonNull(admission.probe(request), "readiness probe must be non-null");
if (!probe.ready()) {
return new Result(Result.Status.NOT_READY, probe.reasonCode());
}
NotificationAdmissionReadinessPort.ResumeResult resumeResult =
Objects.requireNonNull(
transactions.inWrite(() -> admission.resume(request, probe, clock.instant())),
"notification admission resume result must be non-null");
validateResumeResult(request, resumeResult);
return switch (resumeResult.status()) {
case RESUMED -> new Result(Result.Status.RESUMED, probe.reasonCode());
case ALREADY_ACTIVE -> new Result(Result.Status.ALREADY_ACTIVE, probe.reasonCode());
case STALE_GENERATION ->
new Result(
Result.Status.STALE_GENERATION,
new NotificationReasonCode("STALE_ADMISSION_GENERATION"));
};
}
private static void validateResumeResult(
NotificationAdmissionReadinessPort.ResumeRequest request,
NotificationAdmissionReadinessPort.ResumeResult result) {
if (result.processedLegCount() > request.maximumParkedLegs()) {
throw new IllegalArgumentException(
"admission resume processed more parked legs than the requested bound");
}
if (result.status() == NotificationAdmissionReadinessPort.ResumeStatus.RESUMED
&& result.resultingGeneration() != request.expectedGeneration() + 1) {
throw new IllegalArgumentException(
"resumed admission gate must advance the exact expected generation");
}
}
public record Result(Status status, NotificationReasonCode reasonCode) {
public Result {
Objects.requireNonNull(status, "notification admission result status must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
public enum Status {
RESUMED,
ALREADY_ACTIVE,
NOT_READY,
STALE_GENERATION
}
}
}
@@ -0,0 +1,154 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
import java.util.Objects;
/** Persists shared route/provider/account admission readiness with generation-guarded CAS. */
@FunctionalInterface
public interface NotificationAdmissionReadinessPort {
ParkResult park(ParkRequest request);
default ReadinessProbe probe(ResumeRequest request) {
throw new UnsupportedOperationException("notification readiness probe is not implemented");
}
default ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) {
throw new UnsupportedOperationException("notification admission resume is not implemented");
}
record ParkRequest(
NotificationRouteId routeId,
int policyRevision,
NotificationFaultScope faultScope,
String scopeReference,
long expectedGeneration,
NotificationReasonCode reasonCode,
Instant parkedAt) {
public ParkRequest {
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (policyRevision < 1 || expectedGeneration < 0) {
throw new IllegalArgumentException(
"policy revision must be positive and expected generation non-negative");
}
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
if (faultScope == NotificationFaultScope.DELIVERY) {
throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope");
}
scopeReference =
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
Objects.requireNonNull(parkedAt, "admission parked time must be non-null");
}
}
enum ParkResult {
NOT_REQUESTED,
PARKED,
ALREADY_PARKED,
STALE_GENERATION
}
record ResumeRequest(
String operationToken,
NotificationRouteId routeId,
int policyRevision,
NotificationFaultScope faultScope,
String scopeReference,
long expectedGeneration,
int maximumParkedLegs,
String actorReference,
NotificationReasonCode reasonCode) {
public ResumeRequest {
operationToken =
NotificationIntentId.requireOpaque("admission resume operation token", operationToken);
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (policyRevision < 1 || expectedGeneration < 0) {
throw new IllegalArgumentException(
"policy revision must be positive and expected generation non-negative");
}
if (maximumParkedLegs < 1 || maximumParkedLegs > 100) {
throw new IllegalArgumentException("maximum parked legs must be in 1..100");
}
Objects.requireNonNull(faultScope, "notification fault scope must be non-null");
if (faultScope == NotificationFaultScope.DELIVERY) {
throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope");
}
scopeReference =
NotificationIntentId.requireOpaque("admission scope reference", scopeReference);
actorReference =
NotificationIntentId.requireOpaque("admission resume actor reference", actorReference);
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
record ReadinessProbe(boolean ready, NotificationReasonCode reasonCode) {
public ReadinessProbe {
Objects.requireNonNull(reasonCode, "notification readiness reason must be non-null");
}
}
enum ResumeStatus {
RESUMED,
ALREADY_ACTIVE,
STALE_GENERATION
}
/**
* Audited bounded result of rechecking every selected parked leg inside the gate-resume
* transaction. Initial R1 never activates fallback while resuming a binding park.
*/
record ResumeResult(
ResumeStatus status,
long resultingGeneration,
int queuedCount,
int expiredCount,
int cancelledCount,
int technicallySuppressedCount,
int policyRejectedCount,
int activatedFallbackCount) {
public ResumeResult {
Objects.requireNonNull(status, "notification admission resume status must be non-null");
if (resultingGeneration < 0
|| queuedCount < 0
|| expiredCount < 0
|| cancelledCount < 0
|| technicallySuppressedCount < 0
|| policyRejectedCount < 0
|| activatedFallbackCount < 0) {
throw new IllegalArgumentException(
"notification admission resume generation/counts must be non-negative");
}
int processedLegCount =
queuedCount
+ expiredCount
+ cancelledCount
+ technicallySuppressedCount
+ policyRejectedCount;
if (processedLegCount > 100) {
throw new IllegalArgumentException(
"notification admission resume leg count must be bounded by 100");
}
if (activatedFallbackCount != 0) {
throw new IllegalArgumentException(
"binding-park resume must not activate initial fallback legs");
}
if (status != ResumeStatus.RESUMED && processedLegCount != 0) {
throw new IllegalArgumentException(
"non-mutating admission resume status cannot report processed legs");
}
}
public int processedLegCount() {
return queuedCount
+ expiredCount
+ cancelledCount
+ technicallySuppressedCount
+ policyRejectedCount;
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Durable append result; neither appended nor duplicate means provider delivery succeeded. */
public sealed interface NotificationAppendResult
permits NotificationAppendResult.Appended,
NotificationAppendResult.DuplicateExisting,
NotificationAppendResult.Rejected {
record Appended(NotificationIntentId intentId) implements NotificationAppendResult {
public Appended {
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
}
}
record DuplicateExisting(NotificationIntentId intentId) implements NotificationAppendResult {
public DuplicateExisting {
Objects.requireNonNull(intentId, "notification intent ID must be non-null");
}
}
record Rejected(NotificationReasonCode reasonCode) implements NotificationAppendResult {
public Rejected {
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Framework- and provider-neutral application failure carrying only a stable reason code. */
public final class NotificationApplicationException extends RuntimeException {
private final NotificationReasonCode reasonCode;
public NotificationApplicationException(NotificationReasonCode reasonCode, Throwable cause) {
super(
Objects.requireNonNull(reasonCode, "notification reason code must be non-null").value(),
cause);
this.reasonCode = reasonCode;
}
public NotificationReasonCode reasonCode() {
return reasonCode;
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification;
/** Opaque identity of one authorized physical provider attempt. */
public record NotificationAttemptId(String value) {
public NotificationAttemptId {
value = NotificationIntentId.requireOpaque("attemptId", value);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/**
* Internal application collaborator asserting canonical ownership in an existing write boundary.
*/
public final class NotificationCanonicalWriterFenceGuard {
private final NotificationCanonicalWriterFencePort fence;
private final NotificationCanonicalWriterRouteSet routes;
public NotificationCanonicalWriterFenceGuard(
NotificationCanonicalWriterFencePort fence, NotificationCanonicalWriterRouteSet routes) {
this.fence = Objects.requireNonNull(fence, "canonical writer fence port must be non-null");
this.routes = Objects.requireNonNull(routes, "canonical writer route set must be non-null");
}
public void assertCanonical(
NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) {
if (!routes.contains(route)) {
throw new IllegalArgumentException(
"route is outside canonical notification writer route set");
}
NotificationCanonicalWriterFencePort.FenceSnapshot snapshot =
Objects.requireNonNull(
fence.assertCanonicalInCallerTransaction(
new NotificationCanonicalWriterFencePort.FenceRequest(route, expectedGeneration)),
"canonical writer fence snapshot must be non-null");
if (!snapshot.route().equals(route)) {
throw failure("CANONICAL_WRITER_ROUTE_MISMATCH");
}
if (snapshot.owner() != NotificationWriterOwnership.CANONICAL) {
throw failure("CANONICAL_WRITER_NOT_OWNER");
}
if (snapshot.generation() != expectedGeneration) {
throw failure("STALE_CANONICAL_WRITER_GENERATION");
}
}
private static NotificationApplicationException failure(String reason) {
return new NotificationApplicationException(new NotificationReasonCode(reason), null);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/**
* Acquires a transaction-scoped shared fence assertion. The persistence implementation must hold
* the share lock until the caller's physical commit or rollback.
*/
@FunctionalInterface
public interface NotificationCanonicalWriterFencePort {
FenceSnapshot assertCanonicalInCallerTransaction(FenceRequest request);
record FenceRequest(
NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) {
public FenceRequest {
Objects.requireNonNull(route, "notification writer route must be non-null");
if (expectedGeneration < 0) {
throw new IllegalArgumentException("expected writer generation must be non-negative");
}
}
}
record FenceSnapshot(
NotificationCanonicalWriterRouteSet.RouteRevision route,
NotificationWriterOwnership owner,
long generation) {
public FenceSnapshot {
Objects.requireNonNull(route, "notification writer route must be non-null");
Objects.requireNonNull(owner, "notification writer owner must be non-null");
if (generation < 0) {
throw new IllegalArgumentException("writer generation must be non-negative");
}
}
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.application.notification;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Bounded, ordered canonical route-revision set that production admission is allowed to use. */
public record NotificationCanonicalWriterRouteSet(List<RouteRevision> routes) {
public NotificationCanonicalWriterRouteSet {
Objects.requireNonNull(routes, "canonical notification writer routes must be non-null");
routes =
routes.stream()
.map(route -> Objects.requireNonNull(route, "canonical route must be non-null"))
.sorted(
Comparator.comparing((RouteRevision route) -> route.routeId().value())
.thenComparingInt(RouteRevision::routeRevision))
.toList();
if (routes.isEmpty() || routes.size() > 100) {
throw new IllegalArgumentException("canonical writer route set must contain 1..100 routes");
}
if (new HashSet<>(routes).size() != routes.size()) {
throw new IllegalArgumentException("canonical writer route set contains a duplicate route");
}
long distinctRouteKeys = routes.stream().map(RouteRevision::routeId).distinct().count();
if (distinctRouteKeys != routes.size()) {
throw new IllegalArgumentException(
"canonical writer route set contains multiple revisions for one route key");
}
}
public boolean contains(RouteRevision route) {
return routes.contains(route);
}
public String digest() {
MessageDigest digest = sha256();
routes.forEach(
route -> {
update(digest, route.routeId().value());
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(route.routeRevision()).array());
digest.update(
ByteBuffer.allocate(Long.BYTES).putLong(route.predecessorGeneration()).array());
});
return java.util.HexFormat.of().formatHex(digest.digest());
}
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);
}
}
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);
}
public record RouteRevision(
NotificationRouteId routeId, int routeRevision, long predecessorGeneration) {
public RouteRevision {
Objects.requireNonNull(routeId, "notification route ID must be non-null");
if (routeRevision < 1 || routeRevision > 1_000_000 || predecessorGeneration < 0) {
throw new IllegalArgumentException(
"route revision must be in 1..1000000 and predecessor generation non-negative");
}
}
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.application.notification;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Pure validator over application-owned policy and provider/store/ingress capability facts. */
public final class NotificationCapabilityCompatibilityValidator {
public Compatibility validate(
NotificationKindPolicy policy,
NotificationProviderCapabilityDescriptor provider,
NotificationStoreCapabilityDescriptor store,
Optional<NotificationReceiptIngressCapabilityDescriptor> receiptIngress,
boolean receiptRequired) {
Objects.requireNonNull(policy, "notification kind policy must be non-null");
Objects.requireNonNull(provider, "notification provider descriptor must be non-null");
Objects.requireNonNull(store, "notification store descriptor must be non-null");
Objects.requireNonNull(receiptIngress, "receipt ingress container must be non-null");
List<NotificationReasonCode> reasons = new ArrayList<>();
addIf(reasons, provider.channel() != policy.channel(), "PROVIDER_CHANNEL_MISMATCH");
addIf(reasons, !provider.supportedModes().contains(policy.mode()), "PROVIDER_MODE_UNSUPPORTED");
addIf(reasons, !provider.hiddenRetriesControlled(), "PROVIDER_HIDDEN_RETRY_UNCONTROLLED");
addIf(
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,
!store.durableIntentStore() || !store.attemptJournal(),
"DURABLE_STORE_UNAVAILABLE");
}
addIf(
reasons,
!store.availablePolicyRevisions().contains(policy.policyRevision()),
"POLICY_REVISION_UNAVAILABLE");
addIf(
reasons,
!store.availableTemplateRevisions().contains(policy.templateRef()),
"TEMPLATE_REVISION_UNAVAILABLE");
if (receiptRequired) {
addIf(reasons, !provider.receiptSupported(), "PROVIDER_RECEIPT_UNSUPPORTED");
addIf(reasons, !store.receiptInbox(), "RECEIPT_STORE_UNAVAILABLE");
boolean ingressUnavailable =
receiptIngress.isEmpty()
|| !receiptIngress.orElseThrow().enabled()
|| !receiptIngress.orElseThrow().authenticated()
|| receiptIngress.orElseThrow().channel() != policy.channel()
|| receiptIngress.orElseThrow().supportedFactTypes().isEmpty();
addIf(reasons, ingressUnavailable, "RECEIPT_INGRESS_UNAVAILABLE");
}
return new Compatibility(reasons.isEmpty(), reasons);
}
public void requireCompatible(
NotificationKindPolicy policy,
NotificationProviderCapabilityDescriptor provider,
NotificationStoreCapabilityDescriptor store,
Optional<NotificationReceiptIngressCapabilityDescriptor> receiptIngress,
boolean receiptRequired) {
Compatibility compatibility =
validate(policy, provider, store, receiptIngress, receiptRequired);
if (!compatibility.compatible()) {
throw new NotificationApplicationException(
new NotificationReasonCode("NOTIFICATION_CAPABILITY_INCOMPATIBLE"), null);
}
}
private static void addIf(
List<NotificationReasonCode> reasons, boolean condition, String reasonCode) {
if (condition) {
reasons.add(new NotificationReasonCode(reasonCode));
}
}
public record Compatibility(boolean compatible, List<NotificationReasonCode> reasonCodes) {
public Compatibility {
reasonCodes =
List.copyOf(
Objects.requireNonNull(
reasonCodes, "notification compatibility reasons must be non-null"));
if (compatible != reasonCodes.isEmpty()) {
throw new IllegalArgumentException(
"compatible flag must equal an empty incompatibility reason set");
}
}
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.notification;
/** Provider-neutral delivery medium. */
public enum NotificationChannel {
EMAIL,
SLACK
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.notification;
/** Opaque identity of one provider leg for a logical recipient. */
public record NotificationDeliveryId(String value) {
public NotificationDeliveryId {
value = NotificationIntentId.requireOpaque("deliveryId", value);
}
}
@@ -0,0 +1,267 @@
package dev.caskeleton.application.notification;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/** Durable delivery-leg state port; provider I/O is deliberately absent from this contract. */
public interface NotificationDeliveryStorePort {
List<ClaimedDelivery> claimEligible(int maximumClaims, Instant now);
AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now);
FinalizationResult finalizeAttempt(
AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now);
List<ReconciliationClaim> claimForReconciliation(int maximumClaims, Instant now);
ReconciliationFinalizationResult finalizeReconciliation(
ReconciliationClaim claim,
NotificationReconciliationPort.ReconciliationOutcome outcome,
Instant now);
int attachOrphanReceipts(int maximumAttachments, Instant now);
record ClaimedDelivery(
NotificationDeliveryId deliveryId,
NotificationFrozenPlan plan,
int targetOrdinal,
String claimToken,
long rowVersion,
long admissionGeneration) {
public ClaimedDelivery {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) {
throw new IllegalArgumentException("target ordinal is outside the frozen plan bound");
}
claimToken = NotificationIntentId.requireOpaque("claim token", claimToken);
if (rowVersion < 0 || admissionGeneration < 0) {
throw new IllegalArgumentException(
"row version and admission generation must be non-negative");
}
}
}
sealed interface AttemptAuthorization permits Authorized, StaleClaim, NotEligible {}
record Authorized(AuthorizedAttempt attempt) implements AttemptAuthorization {
public Authorized {
Objects.requireNonNull(attempt, "authorized notification attempt must be non-null");
}
}
record StaleClaim(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode)
implements AttemptAuthorization {
public StaleClaim {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
record NotEligible(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode)
implements AttemptAuthorization {
public NotEligible {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(reasonCode, "notification reason code must be non-null");
}
}
record AuthorizedAttempt(
NotificationDeliveryId deliveryId,
NotificationAttemptId attemptId,
NotificationFrozenPlan plan,
int targetOrdinal,
String claimToken,
String executionToken,
long expectedRowVersion,
long admissionGeneration,
String admissionScopeReference,
Instant absoluteDeadline) {
public AuthorizedAttempt {
Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null");
Objects.requireNonNull(attemptId, "notification attempt ID must be non-null");
Objects.requireNonNull(plan, "notification frozen plan must be non-null");
if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) {
throw new IllegalArgumentException("target ordinal is outside the frozen plan bound");
}
claimToken = NotificationIntentId.requireOpaque("claim token", claimToken);
executionToken =
NotificationIntentId.requireOpaque("attempt execution token", executionToken);
if (claimToken.equals(executionToken)) {
throw new IllegalArgumentException(
"attempt execution token must be distinct from the claim token");
}
if (expectedRowVersion < 0 || admissionGeneration < 0) {
throw new IllegalArgumentException(
"row version and admission generation must be non-negative");
}
admissionScopeReference =
NotificationIntentId.requireOpaque("admission scope reference", admissionScopeReference);
Objects.requireNonNull(absoluteDeadline, "absolute attempt deadline must be non-null");
}
@Override
public String toString() {
return "AuthorizedAttempt[deliveryId=<redacted>, attemptId="
+ attemptId
+ ", plan=<redacted>, targetOrdinal="
+ targetOrdinal
+ ", claimToken=<redacted>, executionToken=<redacted>, expectedRowVersion="
+ expectedRowVersion
+ ", admissionGeneration="
+ admissionGeneration
+ ", admissionScopeReference=<redacted>, absoluteDeadline="
+ absoluteDeadline
+ "]";
}
}
record AttemptFinalization(
ProviderAttemptOutcome providerOutcome,
TerminalState terminalState,
boolean fallbackEligible,
NotificationAdmissionReadinessPort.ParkResult parkResult) {
public AttemptFinalization {
Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null");
Objects.requireNonNull(terminalState, "notification terminal state must be non-null");
Objects.requireNonNull(parkResult, "admission park result must be non-null");
if (fallbackEligible
&& providerOutcome.submissionCertainty() != SubmissionCertainty.DEFINITELY_NOT_APPLIED) {
throw new IllegalArgumentException("fallback is eligible only for DEFINITELY_NOT_APPLIED");
}
if (terminalState == TerminalState.TERMINAL_INDETERMINATE
&& providerOutcome.submissionCertainty() != SubmissionCertainty.INDETERMINATE) {
throw new IllegalArgumentException(
"TERMINAL_INDETERMINATE requires an indeterminate provider outcome");
}
if (terminalState == TerminalState.PARKED_BINDING
&& 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");
}
}
}
}
enum TerminalState {
ACCEPTED,
RETRY_SCHEDULED,
PARKED_BINDING,
TERMINAL_FAILURE,
TERMINAL_INDETERMINATE
}
enum FinalizationResult {
APPLIED,
LATE_EXACT_APPLIED,
STALE_EXECUTION_TOKEN,
ALREADY_TERMINAL
}
record ReconciliationClaim(
NotificationDeliveryId deliveryId,
String executionToken,
long expectedRowVersion,
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 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) {
throw new IllegalArgumentException(
"reconciliation row version, route revision and target ordinal are invalid");
}
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(
"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");
}
@Override
public String toString() {
return "ReconciliationClaim[deliveryId=<redacted>, executionToken=<redacted>, "
+ "expectedRowVersion="
+ expectedRowVersion
+ ", 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,
STALE_EXECUTION_TOKEN,
ALREADY_TERMINAL
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.command.Command;
/** Requests one bounded cross-tenant dispatch cycle. */
public record NotificationDispatchCommand(int maximumClaims) implements Command {
public NotificationDispatchCommand {
if (maximumClaims < 1 || maximumClaims > 100) {
throw new IllegalArgumentException("maximum notification claims must be in 1..100");
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.notification;
/** Bounded non-sensitive aggregate outcome of one dispatch cycle. */
public record NotificationDispatchResult(
int claimedCount,
int authorizedCount,
int providerCallCount,
int finalizedCount,
int staleClaimCount,
int indeterminateCount,
int parkedCount) {
public NotificationDispatchResult {
int[] counts = {
claimedCount,
authorizedCount,
providerCallCount,
finalizedCount,
staleClaimCount,
indeterminateCount,
parkedCount
};
for (int count : counts) {
if (count < 0 || count > 100) {
throw new IllegalArgumentException("notification dispatch counts must be in 0..100");
}
}
if (authorizedCount > claimedCount
|| providerCallCount > authorizedCount
|| finalizedCount > providerCallCount
|| staleClaimCount > claimedCount
|| indeterminateCount > providerCallCount
|| parkedCount > providerCallCount) {
throw new IllegalArgumentException("notification dispatch counts are inconsistent");
}
}
}
@@ -0,0 +1,199 @@
package dev.caskeleton.application.notification;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.List;
import java.util.Objects;
/** Coordinates short store transactions around provider I/O for a bounded delivery batch. */
@RequiresPermission("notification:dispatch")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
externalOutboundAllowed = true,
sensitiveRead = true,
crossTenantAdmin = true)
public final class NotificationDispatchUseCase
implements CommandUseCase<NotificationDispatchCommand, NotificationDispatchResult> {
private final NotificationDeliveryStorePort store;
private final NotificationProviderAttemptPort provider;
private final NotificationAdmissionReadinessPort admission;
private final TransactionPort transactions;
private final Clock clock;
public NotificationDispatchUseCase(
NotificationDeliveryStorePort store,
NotificationProviderAttemptPort provider,
NotificationAdmissionReadinessPort admission,
TransactionPort transactions,
Clock clock) {
this.store = Objects.requireNonNull(store, "notification delivery store must be non-null");
this.provider = Objects.requireNonNull(provider, "notification provider port must be non-null");
this.admission =
Objects.requireNonNull(admission, "notification admission port must be non-null");
this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null");
this.clock = Objects.requireNonNull(clock, "clock must be non-null");
}
@Override
public NotificationDispatchResult handle(NotificationDispatchCommand command) {
Objects.requireNonNull(command, "notification dispatch command must be non-null");
List<NotificationDeliveryStorePort.ClaimedDelivery> claimed =
List.copyOf(
transactions.inWrite(
() -> store.claimEligible(command.maximumClaims(), clock.instant())));
if (claimed.size() > command.maximumClaims()) {
throw new IllegalStateException("notification store returned more claims than requested");
}
MutableCounts counts = new MutableCounts(claimed.size());
for (NotificationDeliveryStorePort.ClaimedDelivery delivery : claimed) {
dispatchOne(delivery, counts);
}
return counts.toResult();
}
private void dispatchOne(
NotificationDeliveryStorePort.ClaimedDelivery delivery, MutableCounts counts) {
NotificationDeliveryStorePort.AttemptAuthorization authorization =
transactions.inWrite(() -> store.reserveAndAuthorize(delivery, clock.instant()));
if (authorization instanceof NotificationDeliveryStorePort.StaleClaim) {
counts.staleClaims++;
return;
}
if (authorization instanceof NotificationDeliveryStorePort.NotEligible) {
return;
}
NotificationDeliveryStorePort.AuthorizedAttempt attempt =
((NotificationDeliveryStorePort.Authorized) authorization).attempt();
counts.authorized++;
ProviderAttemptOutcome outcome = invokeProvider(attempt);
counts.providerCalls++;
if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) {
counts.indeterminate++;
}
FinalizationExecution execution =
transactions.inWrite(() -> finalizeInsideTransaction(attempt, outcome));
boolean applied =
execution.result() == NotificationDeliveryStorePort.FinalizationResult.APPLIED
|| execution.result()
== NotificationDeliveryStorePort.FinalizationResult.LATE_EXACT_APPLIED;
if (applied) {
counts.finalized++;
}
if (applied
&& execution.finalization().terminalState()
== NotificationDeliveryStorePort.TerminalState.PARKED_BINDING) {
counts.parked++;
}
}
private ProviderAttemptOutcome invokeProvider(
NotificationDeliveryStorePort.AuthorizedAttempt attempt) {
try {
return Objects.requireNonNull(
provider.attempt(attempt), "provider attempt outcome must be non-null");
} catch (RuntimeException providerFailure) {
return new ProviderAttemptOutcome(
SubmissionCertainty.INDETERMINATE,
RetryDisposition.NOT_APPLICABLE,
NotificationFaultScope.DELIVERY,
new NotificationReasonCode("UNCLASSIFIED_PROVIDER_FAILURE"),
java.util.Optional.empty(),
attempt.executionToken(),
java.util.Optional.empty());
}
}
private FinalizationExecution finalizeInsideTransaction(
NotificationDeliveryStorePort.AuthorizedAttempt attempt, ProviderAttemptOutcome outcome) {
NotificationAdmissionReadinessPort.ParkResult parkResult =
NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED;
if (outcome.retryDisposition() == RetryDisposition.PARK_BINDING) {
parkResult =
admission.park(
new NotificationAdmissionReadinessPort.ParkRequest(
attempt.plan().routeId(),
attempt.plan().policy().policyRevision(),
outcome.faultScope(),
attempt.admissionScopeReference(),
attempt.admissionGeneration(),
outcome.reasonCode(),
clock.instant()));
}
NotificationDeliveryStorePort.AttemptFinalization finalization =
new NotificationDeliveryStorePort.AttemptFinalization(
outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult);
NotificationDeliveryStorePort.FinalizationResult result =
Objects.requireNonNull(
store.finalizeAttempt(attempt, finalization, clock.instant()),
"notification attempt finalization result must be non-null");
return new FinalizationExecution(finalization, result);
}
private static NotificationDeliveryStorePort.TerminalState terminalState(
ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) {
if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) {
return NotificationDeliveryStorePort.TerminalState.ACCEPTED;
}
if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) {
return NotificationDeliveryStorePort.TerminalState.TERMINAL_INDETERMINATE;
}
return switch (outcome.retryDisposition()) {
case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED;
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(
"definitely-not-applied outcome requires an explicit disposition");
};
}
private static boolean fallbackEligible(ProviderAttemptOutcome outcome) {
return outcome.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED
&& outcome.retryDisposition() == RetryDisposition.TERMINAL;
}
private record FinalizationExecution(
NotificationDeliveryStorePort.AttemptFinalization finalization,
NotificationDeliveryStorePort.FinalizationResult result) {}
private static final class MutableCounts {
private final int claimed;
private int authorized;
private int providerCalls;
private int finalized;
private int staleClaims;
private int indeterminate;
private int parked;
private MutableCounts(int claimed) {
this.claimed = claimed;
}
private NotificationDispatchResult toResult() {
return new NotificationDispatchResult(
claimed, authorized, providerCalls, finalized, staleClaims, indeterminate, parked);
}
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/** Historical issuer-key decision retained with accepted signed evidence. */
public record NotificationEvidenceTrustSnapshot(
String catalogRevision, HistoricalKeyStatus historicalKeyStatus, String issuerKeyDigest) {
public NotificationEvidenceTrustSnapshot {
catalogRevision =
NotificationIntentId.requireSlug("evidence trust catalog revision", catalogRevision);
Objects.requireNonNull(historicalKeyStatus, "historical evidence key status must be non-null");
issuerKeyDigest = InitializeNotificationWriterFencesCommand.requireDigest(issuerKeyDigest);
if (historicalKeyStatus == HistoricalKeyStatus.REVOKED) {
throw new IllegalArgumentException("revoked evidence issuer key cannot be accepted");
}
}
public enum HistoricalKeyStatus {
ALLOWED,
RETIRING,
REVOKED
}
}

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