The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
10 KiB
Plaintext
129 lines
10 KiB
Plaintext
Sub-scope 05 (notification) provider + core + platform/{provider,observation,reactor} — manifest and probes
|
|
revision=a24ece9cf797f7ea647e33bf846b115208ed1ba5
|
|
generatedAt=2026-08-30T06:52:39+00:00
|
|
|
|
=== OWNED FILES (main) ===
|
|
60b282fdaf571b03713ec3322598cc026599956e main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java 45
|
|
fae64a058a039a7ecc240272827fbac9959d6fdc main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java 31
|
|
1fc3ed6bfcd98beccd79980aa8b35121a09d233d main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java 143
|
|
10a762d66040f26d0f0827ab3bddf691cb44709b main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java 60
|
|
6760f65f4adbb5b0567b0f3694568aebb1533cc6 main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/LoggingNotificationMetrics.java 59
|
|
c710af99e34cf4eab650d0aa7a084fd2bacb16c9 main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java 109
|
|
a41a5704d0da558c81a19daca41e41dd75c2d905 main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthSnapshot.java 31
|
|
861144530bb50837f313e785d0f8cd87504d1cc3 main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationServingThresholds.java 77
|
|
8eabe4b61fa791a15280b5a2740d41c62cc801db main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/EmailAttachments.java 74
|
|
3536d27bf4bb68c4b77de1cdf520d4c128ea781b main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java 100
|
|
579bf96a1b0635342ac56f1550c73256214754ef main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/UnconfiguredAttachmentResolver.java 29
|
|
bc130299645984991cbc7e73499a55161a34f214 main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactiveNotificationOrchestrator.java 27
|
|
4be68bc232781fed6174ddc72dc10741ae8449c7 main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorContextBridge.java 41
|
|
45e34dc34bfe4a170a3f87c3b2f7c8cf9352e072 main/java/dev/caskeleton/adapter/outbound/notification/platform/reactor/ReactorNotificationOrchestrator.java 74
|
|
3a1929a0d2efc4c07a381711ed48365021cf25a2 main/java/dev/caskeleton/adapter/outbound/notification/provider/AttemptCorrelationId.java 34
|
|
8426e14502c0df2e96d7db106d11f379d7f17a64 main/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapter.java 54
|
|
df65ef07819220cb95aa5f2928015ed6868d3561 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapter.java 163
|
|
28f03e15069e1d3ae7431c10c38e9783a962e217 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAttemptContext.java 54
|
|
83b67ce2cc2cb1737a7b0565e7fbb8084bacf1ca main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java 363
|
|
0f67f36f30ebd67abf8366471e9ca81efe8e5407 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java 104
|
|
49fbf9f8d45dde999dc6d49e4081a073c984bfd9 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderRateAdmission.java 35
|
|
90f5abc482ada17ed5dfde249b621d487c866baa main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessProbe.java 12
|
|
3c4eee63e00d175b104bd210e120e12cb25c4b01 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessSnapshot.java 42
|
|
effdc813379835d984da6aff60d04d0cde3cf7ce main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderSecretMaterialProvider.java 10
|
|
a869b5935248b5813d2ea5b04e07936632ddb653 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapter.java 151
|
|
3b4d8e838567ebb873b994f17bf48ff8c02edce2 main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java 93
|
|
157d1b26ff0f939fd34e25a324f9be9407d81e03 main/java/dev/caskeleton/adapter/outbound/notification/provider/PreparedNotificationAttempt.java 31
|
|
a164147837b12da9e75f4d46a4ba49b9730a43ef main/java/dev/caskeleton/adapter/outbound/notification/provider/ProviderMessageReference.java 15
|
|
fdd03f4d009e5c08444b5d0ebf4a43bb9b4e3ac2 main/java/dev/caskeleton/adapter/outbound/notification/provider/ReconciliationLookupMode.java 9
|
|
main_count=29
|
|
=== OWNED FILES (test) ===
|
|
450f4afda7e0ae1af60aab81b48bfed733b91416 test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java 174
|
|
8f120fc7d63f4022d8d7098da4435d3b3082d79d test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java 359
|
|
3357d050c0bd755ad75f726cf38cf43bbe913a8a test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderSandboxSmokeTest.java 37
|
|
ef82853f024be998396b1dec61826b51875a57ad test/java/dev/caskeleton/adapter/outbound/notification/platform/provider/SubmissionCompatibilityTest.java 73
|
|
a2bff37f2be9a3f9ee338391d7bd5955492b2d32 test/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapterTest.java 55
|
|
6e46da9d5aeaf1416b9775292555ceea5791cb62 test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java 195
|
|
a2558bf4bba9b894b767093c046fb2da39da9352 test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptContractTest.java 402
|
|
0fdad560acb5aaa1b0c499fe005339957cd97afd test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java 194
|
|
d2e8ba7b4ac70ae3303ab965c5f33d9ec4e433f6 test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandleTest.java 38
|
|
test_count=9
|
|
|
|
=== 8.1 Retry-After 힌트의 최종 소비처 ===
|
|
$ grep -rn 'ProviderFailure::retryAfter' --include=*.java application-core/src/main
|
|
application-core/src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationDispatchService.java:381: result.failure().flatMap(ProviderFailure::retryAfter),
|
|
exit=0
|
|
|
|
$ grep -n 'chosen\|max' application-core/src/main/java/dev/caskeleton/application/notification/platform/policy/RetryBackoff.java
|
|
11: * the configured maximum: a provider asking for an hour must not silently extend a delivery
|
|
14:public record RetryBackoff(Duration base, Duration max, double multiplier) {
|
|
21: Objects.requireNonNull(max, "max");
|
|
25: if (max.compareTo(base) < 0) {
|
|
26: throw new IllegalArgumentException("max must not be smaller than base");
|
|
41: long ceilingMillis = (long) Math.min(exponential, (double) max.toMillis());
|
|
43: Duration computed = Duration.ofMillis(Math.max(jittered, base.toMillis()));
|
|
44: Duration chosen = retryAfter.filter(hint -> hint.compareTo(computed) > 0).orElse(computed);
|
|
45: return chosen.compareTo(max) > 0 ? max : chosen;
|
|
exit=0
|
|
|
|
=== 8.2 음수 Retry-After: 파서와 생성자의 계약 비교 ===
|
|
$ sed -n '89,99p' adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ProviderResults.java
|
|
/** Parse a {@code Retry-After} header expressed in seconds. */
|
|
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
|
|
return headerValue.flatMap(
|
|
value -> {
|
|
try {
|
|
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
|
|
} catch (NumberFormatException notSeconds) {
|
|
return Optional.empty();
|
|
}
|
|
});
|
|
}
|
|
exit=0
|
|
|
|
$ sed -n '28,35p' application-core/src/main/java/dev/caskeleton/application/notification/platform/provider/ProviderFailure.java
|
|
}
|
|
retryAfter.ifPresent(
|
|
delay -> {
|
|
if (delay.isNegative()) {
|
|
throw new IllegalArgumentException("retryAfter");
|
|
}
|
|
});
|
|
}
|
|
exit=0
|
|
|
|
$ grep -rn 'ProviderResults.retryAfter' --include=*.java adapter/outbound/notification/src/main
|
|
adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java:37: ProviderResults.retryAfter(response.header("retry-after")),
|
|
adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/ses/SesFailureClassifier.java:49: response.statusCode(), ProviderResults.retryAfter(response.header("retry-after")));
|
|
adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/webpush/WebPushFailureClassifier.java:39: status, ProviderResults.retryAfter(response.header("retry-after")));
|
|
adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java:35: ProviderResults.retryAfter(response.header("retry-after")),
|
|
adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/provider/twilio/TwilioFailureClassifier.java:39: response.statusCode(), ProviderResults.retryAfter(response.header("retry-after")));
|
|
exit=0
|
|
|
|
=== 8.3 첨부 무결성: resolve 안에 verify 가 있는가 ===
|
|
$ grep -n 'expectedSize\|expectedDigest\|isEqual' application-core/src/main/java/dev/caskeleton/application/notification/platform/dispatch/AttachmentIntegrityGuard.java
|
|
35: if (attachment.size() != reference.expectedSize()) {
|
|
39: if (!constantTimeEquals(attachment.digest(), reference.expectedDigest())) {
|
|
64: return java.security.MessageDigest.isEqual(left, right);
|
|
exit=0
|
|
|
|
=== 8.4 헬스: 어떤 런타임 상태가 unhealthy 인가 ===
|
|
$ grep -n 'healthy = false' -B 3 adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/observation/NotificationHealthReporter.java
|
|
55- for (ProviderProfileId profileId : monitoredProfiles) {
|
|
56- var runtime = runtimes.find(profileId);
|
|
57- if (runtime.isEmpty()) {
|
|
58: healthy = false;
|
|
--
|
|
63- ProviderRuntimeState state = runtime.get().state();
|
|
64- if (state == ProviderRuntimeState.AUTHENTICATION_FAILED
|
|
65- || state == ProviderRuntimeState.DISABLED) {
|
|
66: healthy = false;
|
|
--
|
|
76- // A platform with providers but no route accepts every request and delivers none. It was
|
|
77- // reported healthy because every runtime was healthy — which was true and beside the point.
|
|
78- if (!monitoredProfiles.isEmpty() && routedChannels.isEmpty()) {
|
|
79: healthy = false;
|
|
--
|
|
81-
|
|
82- NotificationServingState serving = servingState.currentState();
|
|
83- if (thresholds.exceededBy(serving)) {
|
|
84: healthy = false;
|
|
exit=0
|
|
|