feat: add messaging R2 polling producer

This commit is contained in:
donghyeon-ka
2026-07-31 23:48:43 +09:00
parent b3add0162d
commit e5af291269
93 changed files with 19617 additions and 139 deletions
+41
View File
@@ -15,6 +15,11 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`.
- Implement outbound message publication and broker integration behind application/domain ports.
- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox
publication adaptation.
- Own the closed local contract/destination/capability binding compiler, stable catalog/settings/
schema digests, and deterministic partition-key algorithm. Physical topics and bootstrap servers
stay in deployment settings and compiled outbound bindings.
- Own the deterministic UTF-8 envelope writer and exact-resource Draft 2020-12 registry. Schema
validation is local and precompiled; YAML and runtime network/filesystem resolution are forbidden.
- Own structured rendering of `OutboxRelayFailureReport` through the single unconditional
`Slf4jOutboxRelayFailureReportAdapter` bean.
- Reuse `adapter:outbound:support` for shared technical concerns.
@@ -24,6 +29,42 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`.
- Allowed dependency edges come only from the module's
`src/config/architecture/modules.json` entry.
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency.
- Contract compilation accepts only explicitly supplied application SPI contributions; do not scan,
import sample payload classes, use `Class.forName`, or discover contracts from raw JSON/tree data.
- Snapshot each contribution accessor exactly once. Compiled contracts/publication bindings have
no public constructor and ACTIVE descriptors are created only through the canonical compile path.
- Keep adapter-local compiler/card/binding types off public instance return surfaces. Use
package-private accessors and narrowly scoped public static composition bridges; expose only
primitive/String/core values from public instance methods.
- `DISABLED` with an empty catalog/binding is a zero-resource state. The R1 compiler is intentionally
not wired to legacy `MessagingConfig`; it must not claim Kafka ACK, wire compatibility,
`ACTIVE_READY`, or R2 qualification.
- The local encoder accepts only exact registered final payload records and a closed value
vocabulary. No `Map`, raw JSON/tree, polymorphic type or custom serializer input is allowed.
- Compile and freeze the complete declared payload generic graph before encoding. Allow only the
closed scalar/enum vocabulary, `Optional<T>`, `List<T>`, and exact final nested records; reject
open/raw/wildcard/interface/tree types and runtime record discovery.
- Snapshot every payload record accessor exactly once, encode the payload once, and reuse those
exact trusted bytes for payload-schema validation and envelope embedding. Do not add a raw JSON
parser/generator injection path.
- Apply decimal/list/output admission before unbounded allocation: bound plain decimal digits
before `toPlainString`, check list size before bounded iteration without `toArray`, fail closed on
mutation/concurrency, and enforce output bytes while the generator writes.
- Keep the nine exact checked-in Draft 2020-12 schema/meta bytes and digests synchronized. Startup
compares those bytes, IDs, and NetworkNT runtime schema trees without assuming the validator's
`CodeSource` is a regular JAR. Strict dependency locks and `verifyJsonSchemaRuntimeGraph` own the
NetworkNT 3.0.2 artifact provenance.
- Permit only lowercase exact `urn` root `$id` and absolute `$ref` schemes. Reject every
root-external nested `$id` key regardless of value type, and reject `$dynamicRef`,
`$dynamicAnchor`, deprecated `$recursiveRef`/`$recursiveAnchor`, and `$anchor` anywhere in the
Task 6 closed subset.
- Keep common evidence validation in test/build qualification code: validate exact schema and
generated manifest bytes with the pinned NetworkNT/`LocalJsonSchemaRegistry`, retain schema
meta-validation, and order JSON-only evidence before combined contract evidence so the combined
manifest deterministically owns the shared output.
- The adopted JDK regex engine has no proven execution timeout. Keep regex input and document
budgets finite, retain the adversarial corpus, and preserve the explicit unsupported evidence
claim until a separately reviewed bounded engine or isolation policy exists.
- Do not hide use-case sequencing or business routing policy in broker adapters.
- `OutboxMessagePublishAdapter` is mapping/send-only and emits no dependency log. The confirmed
FAILED/DEAD transition owns the one canonical ERROR; only the general fail-open publisher keeps
+99
View File
@@ -45,6 +45,22 @@ application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에
그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다.
반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
## Legacy R0 characterization
현재 `KafkaSender.send(...)``MessageBroker.send(...)`의 정상 void 반환은 호출이 예외 없이
끝났다는 뜻일 뿐 broker acknowledgement 증거가 아니다. relay는 이 정상 반환 뒤 legacy
`PUBLISHED`를 기록한다. sender 예외는 fail-closed outbox 경로에서 전파되어 relay의
`FAILED/DEAD` 전이를 유발한다.
broker 설정이 blank면 두 포트는 각각 `DisabledMessagePublisher`
`DisabledOutboxMessagePublisher`에 바인딩된다. broker를 선택했지만 project-supplied sender가
없거나 선택 ID와 활성 `MessageBroker.brokerId()`가 다르면 startup이 실패한다.
publisher 정상 반환 뒤 DB mark가 실패하면 row는 `IN_FLIGHT`에 남아 timeout reclaim 후 같은
event가 다시 publish될 수 있다. 현재 consumer/inbox가 없으므로 이 duplicate 가능 구간을
중복 안전 전달로 표현하지 않는다. 또한 legacy FIFO gate는 `occurred_at`의 strict less-than 비교만
사용하므로 동일 aggregate의 timestamp 동률 row는 서로를 gate하지 않는다.
## OutboxRelayFailureReport 구조화 ERROR
`MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의
@@ -58,3 +74,86 @@ ERROR 하나로 렌더링한다.
payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로
붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted
FAILED/DEAD outcome을 바꾸지 않는다.
## Closed local binding compiler R1
`contract`, `destination`, `config` package는 명시적으로 주입된
`IntegrationEventContractContribution` 목록만 사용해 contract catalog와 deployment binding을
로컬에서 컴파일한다. payload record의 exact `Class` token, 선언 순서, schema resource/hash를
검증하며 scan, `Class.forName`, assignable discovery, raw JSON/tree discovery를 하지 않는다.
catalog/settings/schema digest는 정렬된 semantic identity와 length-prefixed UTF-8/raw SHA-256
bytes로 계산한다.
physical topic과 bootstrap server는 `DestinationBindingSettings`
`CompiledPublicationBinding`에만 존재한다. application contract에는 broker, topic, credential
설정이 유출되지 않는다. logical destination과 physical topic은 각각 deployment 안에서 unique다.
compiled publication binding은 package-local compiler만 만들 수 있고 active descriptor는 public
static compile 경로로만 생성한다. compiled integration-event contract도 catalog compiler만
package-local constructor로 만들 수 있다. partition key v1은 canonical non-null tenant scope,
logical destination, aggregate type/id를 strict UTF-8 REPORT + domain-separated
length-prefixed SHA-256으로 계산하고 lowercase hex US-ASCII bytes를 반환한다. malformed
surrogate는 replacement 문자로 바꾸지 않고 거부한다.
adapter 내부 compiler/card/binding 타입은 direct 또는 generic argument 형태의 public instance
response surface로 노출하지 않는다.
cross-package adapter composition이 정확한 compiled identity를 확인해야 할 때만 public static
bridge를 사용하며, instance accessor는 package-private이고 외부에 필요한 상태는 boolean/String/
core value로만 제공한다. 이는 broker SDK뿐 아니라 adapter 자체의 조립 타입도 B7 반환 경계를
통해 application consumer로 새지 않게 한다.
`MessagingCapabilityCardRegistry`는 first R2 tuple의 11개 ID와 각 role을 닫힌 Java vocabulary로
제공할 뿐이다. readiness registry의 `not-implemented` 상태를 승격하거나 R2/`ACTIVE_READY`
주장하지 않는다. `DISABLED + empty catalog/bindings`는 client, thread, scheduler, network,
filesystem resource가 정확히 0인 descriptor다. `ACTIVE`는 empty catalog, missing/duplicate/extra
binding, unknown/mismatched card와 ordering/schema/security 완화를 resource 생성 전에 거부한다.
## Deterministic local JSON Schema encoder candidate
`envelope` package는 exact final `IntegrationPayload` record만 받아 frozen v1 field order로 UTF-8
JSON을 쓴다. `Map`, raw JSON, `JsonNode`, polymorphic typing, custom serializer, assignable search는
입력 경로에 없다. contract compiler가 선언된 generic type graph를 scalar/enum,
`Optional<T>`/`List<T>`/exact final nested record로만 재귀 고정하며 `Object`, interface, raw/wildcard,
generic record와 runtime record discovery를 거부한다. writer는 이 compiled graph를 따라서 각
record accessor를 정확히 한 번 읽은 immutable snapshot을 만들고 payload를 정확히 한 번 encode한다.
schema validation에 건넨 바로 그 payload bytes를 raw JSON API 없이 envelope suffix에 삽입한다.
depth, string character/UTF-8 byte, array, object, number, payload/envelope byte limit는 writer와
parser 양쪽에서 적용한다. output byte limit는 generator가 쓰는 bounded stream에서 allocation 전에
강제하고, list는 size를 먼저 검사한 뒤 bounded iterator로 한 번만 snapshot한다. mutation/concurrent
access와 극단적 `BigDecimal` scale도 큰 배열/String 생성 전에 fail-closed 한다. duplicate key,
malformed UTF-8, trailing data, unpaired surrogate와 non-finite number는 replacement나 coercion
없이 거부한다.
`LocalJsonSchemaRegistry`는 호출자가 명시적으로 제공한 exact bytes와 SHA-256만 startup에서
Draft 2020-12 meta-schema로 검사하고 precompile한다. root `$id`와 absolute `$ref`는 lowercase
exact `urn` scheme만 허용하고 `$ref`는 동일 문서 fragment 또는 제공된 exact URN map으로 닫힌다.
HTTP/HTTPS/file/classpath/resource/jar 및 unknown absolute scheme fetch와 YAML loader는 제공하지
않는다. public validation boundary도 UTF-8 bytes만 받는다. format assertion은 활성이다. 이 Task 6
closed subset은 scope 추적의 모호성을 제거하기 위해 root 외 nested `$id`를 value type과 무관하게
거부하고, `$dynamicRef`, `$dynamicAnchor`, deprecated `$recursiveRef`/`$recursiveAnchor`, `$anchor`
키워드를 어느 위치에서도 지원하지 않는다.
Draft 2020-12 authority는 `draft/2020-12/schema` 1개와 `meta/*` 8개의 exact checked-in bytes 및
digest로 pin한다. registry startup은 9개 digest, 각 `$id`, NetworkNT runtime schema tree를
대조하고 하나라도 다르면 fail-closed 한다. Spring Boot executable/fat/nested JAR 배치를 깨뜨리는
`CodeSource` regular-file/JAR 가정은 하지 않는다. NetworkNT 3.0.2 artifact provenance는 strict
Gradle dependency lock과 `verifyJsonSchemaRuntimeGraph`가 담당한다. 이 검증은 business schema
registry나 runtime remote resolution 경로를 넓히지 않는다.
공통 build evidence manifest는 수동 구조 검사만으로 PASS하지 않는다. test/build 전용
`MessagingEvidenceManifestSchemaValidator`가 exact common schema와 생성된 manifest bytes를 같은
pinned NetworkNT/`LocalJsonSchemaRegistry`로 Draft 2020-12 검증하며, common schema 자체도
meta-schema 검증을 통과해야 한다. `verifyMessagingContracts`는 JSON-only qualification과 그
manifest schema validation을 명시적으로 선행해 CLI task 나열 순서와 무관하게 combined manifest가
`build/messaging-evidence/contracts-schema/manifest.json`의 최종 소유자가 된다.
NetworkNT 3.0.2의 adopted regex 구현은 JDK regex 실행시간을 강제 중단시키는 별도 engine/timeout을
제공하지 않는다. 따라서 현재 후보는 regex 입력 길이와 전체 document 구조를 먼저 제한하고,
pathological pattern의 작은 repository corpus를 회귀 테스트한다. 이는 JSON Schema Test
Suite/Bowtie 전체 호환이나 hostile regex 시간 상한 증명이 아니며 evidence의
`regex-engine-timeout`, `consumer-compatibility-full-suite` unsupported claim으로 남는다. 테스트
runtime은 remote corpus를 내려받지 않는다.
이 Task의 acceptance는 **deterministic local wire contract candidate**다. encoder/catalog는 기존
`MessagingConfig`, runtime append, `KafkaSender` 또는 legacy broker selection에 연결하지 않았다.
Kafka ACK, durable outbox R2, external topic attestation은 아직 구현되지 않았고 R0 runtime authority와
동작은 그대로다.
@@ -4,7 +4,63 @@ dependencies {
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework.boot:spring-boot-starter-json'
implementation('com.networknt:json-schema-validator:3.0.2') {
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
}
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
tasks.withType(Test).configureEach {
systemProperty 'messaging.commonEvidenceSchema',
rootProject.file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath
}
configurations.configureEach {
exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
exclude group: 'org.yaml', module: 'snakeyaml'
exclude group: 'org.snakeyaml', module: 'snakeyaml-engine'
}
tasks.register('verifyJsonSchemaRuntimeGraph') {
group = 'verification'
description = 'Verifies the closed Jackson 3 / NetworkNT graph contains no YAML or Jackson 2 runtime.'
doLast {
Set<String> modules = configurations.runtimeClasspath.incoming.resolutionResult
.allComponents
.findAll { it.moduleVersion != null }
.collect {
"${it.moduleVersion.group}:${it.moduleVersion.name}:${it.moduleVersion.version}"
.toString()
} as Set
List<String> forbidden = modules.findAll { String coordinate ->
String lowered = coordinate.toLowerCase(Locale.ROOT)
lowered.contains('yaml') ||
lowered.startsWith('org.yaml:') ||
lowered.startsWith('org.snakeyaml:') ||
lowered ==~ /com\.fasterxml\.jackson\.core:jackson-(core|databind):.*/
}.sort()
if (!forbidden.isEmpty()) {
throw new GradleException(
"Messaging JSON runtime contains forbidden Jackson 2/YAML modules: ${forbidden}")
}
[
'com.networknt:json-schema-validator:3.0.2',
'tools.jackson.core:jackson-core:3.0.2',
'tools.jackson.core:jackson-databind:3.0.2'
].each { String required ->
if (!modules.contains(required)) {
throw new GradleException(
"Messaging JSON runtime is missing required locked module ${required}")
}
}
// Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a
// Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph.
}
}
tasks.named('check') {
dependsOn tasks.named('verifyJsonSchemaRuntimeGraph')
}
+24 -22
View File
@@ -1,23 +1,24 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
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=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
@@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
@@ -44,7 +46,7 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
@@ -60,9 +62,9 @@ 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
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
@@ -95,10 +97,10 @@ org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
@@ -108,26 +110,27 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=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
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -145,8 +148,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,204 @@
package dev.caskeleton.adapter.outbound.messaging.config;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigest;
import dev.caskeleton.adapter.outbound.messaging.destination.CompiledPublicationBinding;
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompiler;
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingSettings;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Immutable R1 local compilation result; it owns no broker client, thread or external resource. */
public final class CompiledMessagingDescriptor {
private final ActivationMode activationMode;
private final List<CompiledIntegrationEventContract> contracts;
private final List<CompiledPublicationBinding> bindings;
private final Sha256 catalogDigest;
private final Sha256 settingsDigest;
private final Sha256 schemaSetDigest;
private CompiledMessagingDescriptor(
ActivationMode activationMode,
List<CompiledIntegrationEventContract> contracts,
List<CompiledPublicationBinding> bindings,
Sha256 catalogDigest,
Sha256 settingsDigest,
Sha256 schemaSetDigest) {
if (activationMode == null
|| contracts == null
|| bindings == null
|| catalogDigest == null
|| settingsDigest == null
|| schemaSetDigest == null) {
throw new IllegalArgumentException("compiled messaging descriptor fields must not be null");
}
this.activationMode = activationMode;
this.contracts = List.copyOf(contracts);
this.bindings = List.copyOf(bindings);
this.catalogDigest = catalogDigest;
this.settingsDigest = settingsDigest;
this.schemaSetDigest = schemaSetDigest;
if (!catalogDigest.equals(ContractCatalogDigest.compute(this.contracts))) {
throw new IllegalArgumentException("catalog digest must match the exact compiled catalog");
}
Sha256 expectedSchemaSetDigest =
new DestinationBindingCompiler().schemaSetDigest(this.contracts);
if (!schemaSetDigest.equals(expectedSchemaSetDigest)) {
throw new IllegalArgumentException("schema digest must match the exact compiled catalog");
}
if (activationMode == ActivationMode.DISABLED) {
if (!this.contracts.isEmpty() || !this.bindings.isEmpty()) {
throw new IllegalArgumentException(
"DISABLED descriptor requires an empty catalog and empty bindings");
}
} else {
validateActive(this.contracts, this.bindings, settingsDigest, schemaSetDigest);
}
}
public static CompiledMessagingDescriptor compile(
ActivationMode mode,
List<CompiledIntegrationEventContract> contracts,
DestinationBindingSettings settings,
MessagingCapabilityCardRegistry cardRegistry) {
if (mode == null || contracts == null || settings == null || cardRegistry == null) {
throw new IllegalArgumentException("descriptor compiler inputs must not be null");
}
DestinationBindingCompiler bindingCompiler = new DestinationBindingCompiler();
if (mode == ActivationMode.DISABLED) {
if (!contracts.isEmpty()
|| !DestinationBindingSettings.destinationsOf(settings).isEmpty()
|| DestinationBindingSettings.legacyAliasesOf(settings).isPresent()) {
throw new IllegalArgumentException(
"DISABLED descriptor requires an empty catalog and empty bindings");
}
return new CompiledMessagingDescriptor(
mode,
List.of(),
List.of(),
ContractCatalogDigest.compute(List.of()),
bindingCompiler.settingsDigest(settings),
bindingCompiler.schemaSetDigest(List.of()));
}
if (contracts.isEmpty()) {
throw new IllegalArgumentException("ACTIVE descriptor rejects an empty catalog");
}
List<CompiledPublicationBinding> bindings =
DestinationBindingCompiler.compileExact(contracts, settings, cardRegistry);
return new CompiledMessagingDescriptor(
mode,
contracts,
bindings,
ContractCatalogDigest.compute(contracts),
bindings.getFirst().settingsDigest(),
bindings.getFirst().schemaSetDigest());
}
ActivationMode activationMode() {
return activationMode;
}
public boolean active() {
return activationMode == ActivationMode.ACTIVE;
}
public boolean disabled() {
return activationMode == ActivationMode.DISABLED;
}
List<CompiledIntegrationEventContract> contracts() {
return contracts;
}
List<CompiledPublicationBinding> bindings() {
return bindings;
}
/** Narrow immutable composition bridge for the exact compiled catalog. */
public static List<CompiledIntegrationEventContract> contractsOf(
CompiledMessagingDescriptor descriptor) {
return requireDescriptor(descriptor).contracts;
}
/** Narrow immutable composition bridge for the exact compiled bindings. */
public static List<CompiledPublicationBinding> bindingsOf(
CompiledMessagingDescriptor descriptor) {
return requireDescriptor(descriptor).bindings;
}
public Sha256 catalogDigest() {
return catalogDigest;
}
public Sha256 settingsDigest() {
return settingsDigest;
}
public Sha256 schemaSetDigest() {
return schemaSetDigest;
}
/** Task 5 is compilation-only and therefore always allocates zero runtime resources. */
public int resourceCount() {
return 0;
}
private static CompiledMessagingDescriptor requireDescriptor(
CompiledMessagingDescriptor descriptor) {
if (descriptor == null) {
throw new IllegalArgumentException("compiled messaging descriptor must not be null");
}
return descriptor;
}
private static void validateActive(
List<CompiledIntegrationEventContract> contracts,
List<CompiledPublicationBinding> bindings,
Sha256 settingsDigest,
Sha256 schemaSetDigest) {
if (contracts.isEmpty()) {
throw new IllegalArgumentException("ACTIVE descriptor rejects an empty catalog");
}
if (contracts.size() != bindings.size()) {
throw new IllegalArgumentException(
"ACTIVE descriptor requires exactly one binding per contract version");
}
Map<String, CompiledIntegrationEventContract> contractsByKey = new HashMap<>();
for (CompiledIntegrationEventContract contract : contracts) {
if (contractsByKey.putIfAbsent(contract.stableKey(), contract) != null) {
throw new IllegalArgumentException(
"ACTIVE descriptor rejects duplicate contract " + contract.stableKey());
}
}
Map<String, CompiledPublicationBinding> bindingsByKey = new HashMap<>();
for (CompiledPublicationBinding binding : bindings) {
CompiledIntegrationEventContract bindingContract =
CompiledPublicationBinding.contractOf(binding);
String stableKey = bindingContract.stableKey();
CompiledIntegrationEventContract exactContract = contractsByKey.get(stableKey);
if (exactContract == null
|| !exactContract.equals(bindingContract)
|| bindingsByKey.putIfAbsent(stableKey, binding) != null) {
throw new IllegalArgumentException(
"ACTIVE descriptor contains a forged, duplicate or extra binding for " + stableKey);
}
if (!settingsDigest.equals(binding.settingsDigest())
|| !schemaSetDigest.equals(binding.schemaSetDigest())) {
throw new IllegalArgumentException(
"ACTIVE descriptor digest must match every compiled binding");
}
}
if (!bindingsByKey.keySet().equals(contractsByKey.keySet())) {
throw new IllegalArgumentException("ACTIVE descriptor is missing an exact contract binding");
}
}
public enum ActivationMode {
DISABLED,
ACTIVE
}
}
@@ -0,0 +1,235 @@
package dev.caskeleton.adapter.outbound.messaging.config;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Closed code vocabulary for the first producer-only R2 capability tuple. */
public final class MessagingCapabilityCardRegistry {
private static final List<CapabilityCard> FIRST_R2 =
List.of(
new CapabilityCard("messaging-outbox-publish.v1", CardRole.SEMANTIC),
new CapabilityCard("kafka-spring-acknowledged-idempotent.v1", CardRole.PRODUCER),
new CapabilityCard("postgresql-polling-outbox.v2", CardRole.DISPATCH),
new CapabilityCard("postgresql-per-record-jit-claim.v1", CardRole.CLAIM),
new CapabilityCard("json-schema-envelope.v1", CardRole.SERIALIZATION),
new CapabilityCard("external-topic-validated.v1", CardRole.TOPIC),
new CapabilityCard("kafka-sasl-ssl-scram-sha-512.v1", CardRole.SECURITY),
new CapabilityCard("kafka-compression-none.v1", CardRole.COMPRESSION),
new CapabilityCard("per-key-normal-path-sequence-detectable.v1", CardRole.ORDERING),
new CapabilityCard("same-postgresql-transaction-resource.v1", CardRole.TRANSACTION),
new CapabilityCard(
"authenticated-internal-web-disposition.v1", CardRole.OPERATOR_CONTROL));
private static final Map<String, CardRole> SUPPORTED_ROLES = supportedRoles();
private final List<CapabilityCard> cards;
private final Map<String, CapabilityCard> cardsById;
private final ExactSelection selection;
private MessagingCapabilityCardRegistry(List<CapabilityCard> cards) {
this.cards = List.copyOf(cards);
this.cardsById = index(cards);
this.selection = selection(cards);
}
public static MessagingCapabilityCardRegistry exactFirstR2() {
return new MessagingCapabilityCardRegistry(FIRST_R2);
}
/** Static bridge keeps the adapter-local selection type off public instance response surfaces. */
public static ExactSelection exactFirstR2Selection() {
return selection(FIRST_R2);
}
public static MessagingCapabilityCardRegistry compile(List<CapabilityCard> cards) {
if (cards == null) {
throw new IllegalArgumentException("capability cards must not be null");
}
return new MessagingCapabilityCardRegistry(new ArrayList<>(cards));
}
List<CapabilityCard> cards() {
return cards;
}
ExactSelection selection() {
return selection;
}
/** Selection vocabulary is not readiness evidence and never claims release eligibility. */
public boolean claimsReleaseEligibility() {
return false;
}
public void validate(ExactSelection candidate) {
if (candidate == null) {
throw new IllegalArgumentException("capability selection must not be null");
}
require(candidate.semanticCardId(), CardRole.SEMANTIC);
require(candidate.producerCardId(), CardRole.PRODUCER);
require(candidate.dispatchCardId(), CardRole.DISPATCH);
require(candidate.claimCardId(), CardRole.CLAIM);
require(candidate.serializationCardId(), CardRole.SERIALIZATION);
require(candidate.topicCardId(), CardRole.TOPIC);
require(candidate.securityCardId(), CardRole.SECURITY);
require(candidate.compressionCardId(), CardRole.COMPRESSION);
require(candidate.orderingCardId(), CardRole.ORDERING);
require(candidate.transactionCardId(), CardRole.TRANSACTION);
require(candidate.operatorControlCardId(), CardRole.OPERATOR_CONTROL);
}
private void require(String cardId, CardRole expectedRole) {
CapabilityCard card = cardsById.get(cardId);
if (card == null) {
throw new IllegalArgumentException("unknown capability card: " + cardId);
}
if (card.role() != expectedRole) {
throw new IllegalArgumentException(
"capability card role mismatch: "
+ cardId
+ " is "
+ card.role()
+ ", expected "
+ expectedRole);
}
}
private static Map<String, CapabilityCard> index(List<CapabilityCard> cards) {
Set<String> observedIds = new HashSet<>();
for (CapabilityCard card : cards) {
if (card != null && !observedIds.add(card.cardId())) {
throw new IllegalArgumentException("duplicate capability card: " + card.cardId());
}
}
if (cards.size() != FIRST_R2.size()) {
throw new IllegalArgumentException("registry must contain exactly the first R2 card tuple");
}
Map<String, CapabilityCard> result = new HashMap<>();
Set<CardRole> roles = new HashSet<>();
for (CapabilityCard card : cards) {
if (card == null) {
throw new IllegalArgumentException("capability card must not be null");
}
CardRole supportedRole = SUPPORTED_ROLES.get(card.cardId());
if (supportedRole == null) {
throw new IllegalArgumentException("unknown capability card: " + card.cardId());
}
if (supportedRole != card.role()) {
throw new IllegalArgumentException("capability card role mismatch: " + card.cardId());
}
if (result.putIfAbsent(card.cardId(), card) != null) {
throw new IllegalArgumentException("duplicate capability card: " + card.cardId());
}
if (!roles.add(card.role())) {
throw new IllegalArgumentException("duplicate capability card role: " + card.role());
}
}
if (!result.keySet().equals(SUPPORTED_ROLES.keySet())) {
throw new IllegalArgumentException("registry must contain exactly the first R2 card tuple");
}
return Map.copyOf(result);
}
private static ExactSelection selection(List<CapabilityCard> cards) {
EnumMap<CardRole, String> byRole = new EnumMap<>(CardRole.class);
cards.forEach(card -> byRole.put(card.role(), card.cardId()));
return new ExactSelection(
byRole.get(CardRole.SEMANTIC),
byRole.get(CardRole.PRODUCER),
byRole.get(CardRole.DISPATCH),
byRole.get(CardRole.CLAIM),
byRole.get(CardRole.SERIALIZATION),
byRole.get(CardRole.TOPIC),
byRole.get(CardRole.SECURITY),
byRole.get(CardRole.COMPRESSION),
byRole.get(CardRole.ORDERING),
byRole.get(CardRole.TRANSACTION),
byRole.get(CardRole.OPERATOR_CONTROL));
}
private static Map<String, CardRole> supportedRoles() {
Map<String, CardRole> result = new HashMap<>();
FIRST_R2.forEach(card -> result.put(card.cardId(), card.role()));
return Map.copyOf(result);
}
public enum CardRole {
SEMANTIC,
PRODUCER,
DISPATCH,
CLAIM,
SERIALIZATION,
TOPIC,
SECURITY,
COMPRESSION,
ORDERING,
TRANSACTION,
OPERATOR_CONTROL
}
public static final class CapabilityCard {
private final String cardId;
private final CardRole role;
public CapabilityCard(String cardId, CardRole role) {
if (cardId == null || cardId.isBlank() || role == null) {
throw new IllegalArgumentException("capability card id and role must not be blank");
}
this.cardId = cardId;
this.role = role;
}
public String cardId() {
return cardId;
}
CardRole role() {
return role;
}
public String roleName() {
return role.name();
}
}
public record ExactSelection(
String semanticCardId,
String producerCardId,
String dispatchCardId,
String claimCardId,
String serializationCardId,
String topicCardId,
String securityCardId,
String compressionCardId,
String orderingCardId,
String transactionCardId,
String operatorControlCardId) {
public ExactSelection {
String[] selectedIds = {
semanticCardId,
producerCardId,
dispatchCardId,
claimCardId,
serializationCardId,
topicCardId,
securityCardId,
compressionCardId,
orderingCardId,
transactionCardId,
operatorControlCardId
};
if (java.util.Arrays.stream(selectedIds)
.anyMatch(value -> value == null || value.isBlank())) {
throw new IllegalArgumentException("selected capability card ids must not be blank");
}
}
}
}
@@ -0,0 +1,263 @@
package dev.caskeleton.adapter.outbound.messaging.contract;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.RecordComponent;
import java.util.HashSet;
import java.util.List;
/** Immutable, provider-neutral result of compiling one exact integration-event contract version. */
public final class CompiledIntegrationEventContract {
private final ContractId contractId;
private final int payloadVersion;
private final Class<? extends IntegrationPayload> exactPayloadRecordType;
private final List<String> canonicalRecordComponentOrder;
private final SchemaResourceId payloadSchemaResource;
private final Sha256 payloadSchemaHash;
private final ContractDescriptor descriptor;
private final PayloadShape payloadShape;
CompiledIntegrationEventContract(
ContractId contractId,
int payloadVersion,
Class<? extends IntegrationPayload> exactPayloadRecordType,
List<String> canonicalRecordComponentOrder,
SchemaResourceId payloadSchemaResource,
Sha256 payloadSchemaHash,
ContractDescriptor descriptor) {
this(
contractId,
payloadVersion,
exactPayloadRecordType,
canonicalRecordComponentOrder,
payloadSchemaResource,
payloadSchemaHash,
descriptor,
ContractCatalogCompiler.compilePayloadShape(exactPayloadRecordType));
}
CompiledIntegrationEventContract(
ContractId contractId,
int payloadVersion,
Class<? extends IntegrationPayload> exactPayloadRecordType,
List<String> canonicalRecordComponentOrder,
SchemaResourceId payloadSchemaResource,
Sha256 payloadSchemaHash,
ContractDescriptor descriptor,
PayloadShape payloadShape) {
if (contractId == null
|| exactPayloadRecordType == null
|| canonicalRecordComponentOrder == null
|| payloadSchemaResource == null
|| payloadSchemaHash == null
|| descriptor == null
|| payloadShape == null) {
throw new IllegalArgumentException("compiled contract fields must not be null");
}
if (payloadVersion <= 0) {
throw new IllegalArgumentException("payloadVersion must be positive");
}
List<String> copiedComponentOrder = List.copyOf(canonicalRecordComponentOrder);
if (!exactPayloadRecordType.isRecord()
|| !Modifier.isFinal(exactPayloadRecordType.getModifiers())
|| !IntegrationPayload.class.isAssignableFrom(exactPayloadRecordType)) {
throw new IllegalArgumentException(
"compiled exact payload type must be a final IntegrationPayload record");
}
if (copiedComponentOrder.stream()
.anyMatch(component -> component == null || component.isBlank())
|| new HashSet<>(copiedComponentOrder).size() != copiedComponentOrder.size()) {
throw new IllegalArgumentException(
"compiled canonical component order must contain unique non-blank names");
}
List<String> reflectedComponentOrder =
java.util.Arrays.stream(exactPayloadRecordType.getRecordComponents())
.map(RecordComponent::getName)
.toList();
if (!copiedComponentOrder.equals(reflectedComponentOrder)) {
throw new IllegalArgumentException(
"compiled canonical component order must match the exact record type");
}
if (payloadShape.kind != PayloadKind.RECORD
|| payloadShape.exactJavaType != exactPayloadRecordType
|| !payloadShape.components.stream()
.map(component -> component.name)
.toList()
.equals(copiedComponentOrder)) {
throw new IllegalArgumentException(
"compiled payload shape must match the exact root record declaration");
}
this.contractId = contractId;
this.payloadVersion = payloadVersion;
this.exactPayloadRecordType = exactPayloadRecordType;
this.canonicalRecordComponentOrder = copiedComponentOrder;
this.payloadSchemaResource = payloadSchemaResource;
this.payloadSchemaHash = payloadSchemaHash;
this.descriptor = descriptor;
this.payloadShape = payloadShape;
}
public ContractId contractId() {
return contractId;
}
public int payloadVersion() {
return payloadVersion;
}
public Class<? extends IntegrationPayload> exactPayloadRecordType() {
return exactPayloadRecordType;
}
public List<String> canonicalRecordComponentOrder() {
return canonicalRecordComponentOrder;
}
public SchemaResourceId payloadSchemaResource() {
return payloadSchemaResource;
}
public Sha256 payloadSchemaHash() {
return payloadSchemaHash;
}
public ContractDescriptor descriptor() {
return descriptor;
}
/** Stable semantic identity independent of Java class names and physical destinations. */
public String stableKey() {
return contractId.value() + ":v" + payloadVersion;
}
/** Adapter-internal bridge used by the deterministic writer without runtime type discovery. */
public static PayloadShape payloadShapeOf(CompiledIntegrationEventContract contract) {
if (contract == null) {
throw new IllegalArgumentException("compiled contract must not be null");
}
return contract.payloadShape;
}
/**
* Invokes the exact accessor frozen by the catalog compiler without exposing reflection types.
*/
public static Object readPayloadComponentOnce(PayloadComponent component, Object record) {
PayloadComponent required = PayloadComponent.requireComponent(component);
if (record == null || record.getClass() != required.accessor.getDeclaringClass()) {
throw new IllegalArgumentException(
"payload component record must match the exact compiled declaring class");
}
try {
return required.accessor.invoke(record);
} catch (IllegalAccessException | InvocationTargetException exception) {
throw new IllegalArgumentException(
"record component accessor could not be invoked: " + required.name, exception);
}
}
/** Closed kinds accepted by the contract compiler. */
public enum PayloadKind {
RECORD,
OPTIONAL,
LIST,
STRING,
BOOLEAN,
INTEGRAL,
DECIMAL,
ENUM
}
/** Immutable declared type node. Public only as an adapter-package bridge; it has no mutators. */
public static final class PayloadShape {
private final PayloadKind kind;
private final Class<?> exactJavaType;
private final List<PayloadComponent> components;
private final PayloadShape elementShape;
PayloadShape(
PayloadKind kind,
Class<?> exactJavaType,
List<PayloadComponent> components,
PayloadShape elementShape) {
if (kind == null || exactJavaType == null || components == null) {
throw new IllegalArgumentException("compiled payload shape fields must not be null");
}
this.kind = kind;
this.exactJavaType = exactJavaType;
this.components = List.copyOf(components);
this.elementShape = elementShape;
}
public static PayloadKind kindOf(PayloadShape shape) {
return requireShape(shape).kind;
}
public static Class<?> exactJavaTypeOf(PayloadShape shape) {
return requireShape(shape).exactJavaType;
}
public static List<PayloadComponent> componentsOf(PayloadShape shape) {
return requireShape(shape).components;
}
public static PayloadShape elementShapeOf(PayloadShape shape) {
PayloadShape required = requireShape(shape);
if (required.elementShape == null) {
throw new IllegalArgumentException("compiled payload shape has no element shape");
}
return required.elementShape;
}
private static PayloadShape requireShape(PayloadShape shape) {
if (shape == null) {
throw new IllegalArgumentException("compiled payload shape must not be null");
}
return shape;
}
}
/** Immutable record component node with its exact compiled accessor and declared child shape. */
public static final class PayloadComponent {
private final String name;
private final Method accessor;
private final PayloadShape shape;
PayloadComponent(String name, Method accessor, PayloadShape shape) {
if (name == null || name.isBlank() || accessor == null || shape == null) {
throw new IllegalArgumentException("compiled payload component fields must not be null");
}
if (!accessor.trySetAccessible()) {
throw new IllegalArgumentException(
"compiled payload component accessor must be locally invocable");
}
this.name = name;
this.accessor = accessor;
this.shape = shape;
}
public static String nameOf(PayloadComponent component) {
return requireComponent(component).name;
}
public static PayloadShape shapeOf(PayloadComponent component) {
return requireComponent(component).shape;
}
private static PayloadComponent requireComponent(PayloadComponent component) {
if (component == null) {
throw new IllegalArgumentException("compiled payload component must not be null");
}
return component;
}
}
}
@@ -0,0 +1,274 @@
package dev.caskeleton.adapter.outbound.messaging.contract;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** Fail-fast compiler for an explicitly supplied, closed integration-event contribution list. */
public final class ContractCatalogCompiler {
List<CompiledIntegrationEventContract> compile(
List<? extends IntegrationEventContractContribution<?>> contributions) {
if (contributions == null) {
throw new IllegalArgumentException("contract contributions must not be null");
}
Set<String> stableKeys = new HashSet<>();
Set<SchemaResourceId> schemaResources = new HashSet<>();
Set<Class<?>> exactPayloadTypes = new HashSet<>();
Map<ContractId, LogicalDestinationId> destinationsByContract = new HashMap<>();
List<CompiledIntegrationEventContract> compiled = new ArrayList<>(contributions.size());
for (IntegrationEventContractContribution<?> contribution : contributions) {
if (contribution == null) {
throw new IllegalArgumentException("contract contribution must not be null");
}
compiled.add(
compileOne(
contribution,
stableKeys,
schemaResources,
exactPayloadTypes,
destinationsByContract));
}
compiled.sort(
java.util.Comparator.comparing(
(CompiledIntegrationEventContract contract) -> contract.contractId().value())
.thenComparingInt(CompiledIntegrationEventContract::payloadVersion));
return List.copyOf(compiled);
}
/** Narrow immutable composition bridge for callers in sibling messaging adapter packages. */
public static List<CompiledIntegrationEventContract> compileExact(
List<? extends IntegrationEventContractContribution<?>> contributions) {
return new ContractCatalogCompiler().compile(contributions);
}
private static CompiledIntegrationEventContract compileOne(
IntegrationEventContractContribution<?> contribution,
Set<String> stableKeys,
Set<SchemaResourceId> schemaResources,
Set<Class<?>> exactPayloadTypes,
Map<ContractId, LogicalDestinationId> destinationsByContract) {
ContractId contractId = contribution.contractId();
int payloadVersion = contribution.payloadVersion();
Class<? extends IntegrationPayload> payloadType = contribution.exactPayloadRecordType();
List<String> declaredComponentOrder = contribution.canonicalRecordComponentOrder();
SchemaResourceId schemaResource = contribution.payloadSchemaResource();
Sha256 schemaHash = contribution.payloadSchemaHash();
ContractDescriptor descriptor = contribution.descriptor();
if (contractId == null) {
throw new IllegalArgumentException("contractId must not be null");
}
if (payloadVersion <= 0) {
throw new IllegalArgumentException("payload version must be positive");
}
String stableKey = contractId.value() + ":v" + payloadVersion;
if (!stableKeys.add(stableKey)) {
throw new IllegalArgumentException("duplicate contract identity: " + stableKey);
}
if (schemaResource == null) {
throw new IllegalArgumentException("payload schema resource must not be null");
}
if (!schemaResources.add(schemaResource)) {
throw new IllegalArgumentException("duplicate schema resource: " + schemaResource.value());
}
if (schemaHash == null) {
throw new IllegalArgumentException("payload schema hash must not be null");
}
validateExactPayloadType(payloadType);
if (!exactPayloadTypes.add(payloadType)) {
throw new IllegalArgumentException(
"duplicate exact payload record type: " + payloadType.getName());
}
List<String> componentOrder = validateComponentOrder(payloadType, declaredComponentOrder);
CompiledIntegrationEventContract.PayloadShape payloadShape = compilePayloadShape(payloadType);
if (descriptor == null) {
throw new IllegalArgumentException("contract descriptor must not be null");
}
LogicalDestinationId logicalDestination = descriptor.logicalDestination();
LogicalDestinationId existing =
destinationsByContract.putIfAbsent(contractId, logicalDestination);
if (existing != null && !existing.equals(logicalDestination)) {
throw new IllegalArgumentException(
"logical destination drift across versions of contract " + contractId.value());
}
return new CompiledIntegrationEventContract(
contractId,
payloadVersion,
payloadType,
componentOrder,
schemaResource,
schemaHash,
descriptor,
payloadShape);
}
private static void validateExactPayloadType(Class<? extends IntegrationPayload> payloadType) {
if (payloadType == null) {
throw new IllegalArgumentException("exact payload record type must not be null");
}
if (!payloadType.isRecord() || !Modifier.isFinal(payloadType.getModifiers())) {
throw new IllegalArgumentException("exact payload type must be a final Java record");
}
if (!IntegrationPayload.class.isAssignableFrom(payloadType)) {
throw new IllegalArgumentException("exact payload record must implement IntegrationPayload");
}
}
private static List<String> validateComponentOrder(
Class<? extends IntegrationPayload> payloadType, List<String> declared) {
if (declared == null) {
throw new IllegalArgumentException("canonical record component order must not be null");
}
List<String> copied = List.copyOf(declared);
if (copied.stream().anyMatch(value -> value == null || value.isBlank())
|| new HashSet<>(copied).size() != copied.size()) {
throw new IllegalArgumentException(
"canonical record component order must contain unique non-blank names");
}
List<String> actual =
java.util.Arrays.stream(payloadType.getRecordComponents())
.map(RecordComponent::getName)
.toList();
if (!copied.equals(actual)) {
throw new IllegalArgumentException(
"canonical record component order must exactly match record declaration order");
}
return copied;
}
static CompiledIntegrationEventContract.PayloadShape compilePayloadShape(
Class<? extends IntegrationPayload> payloadType) {
validateExactPayloadType(payloadType);
return compileDeclaredType(payloadType, new ArrayDeque<>());
}
private static CompiledIntegrationEventContract.PayloadShape compileDeclaredType(
Type declaredType, ArrayDeque<Class<?>> recordStack) {
if (declaredType instanceof WildcardType
|| declaredType instanceof TypeVariable<?>
|| declaredType instanceof GenericArrayType) {
throw unsupportedDeclaredType(declaredType);
}
if (declaredType instanceof ParameterizedType parameterized) {
Type rawType = parameterized.getRawType();
Type[] arguments = parameterized.getActualTypeArguments();
if (arguments.length != 1 || !(rawType instanceof Class<?> rawClass)) {
throw unsupportedDeclaredType(declaredType);
}
if (rawClass == Optional.class) {
return new CompiledIntegrationEventContract.PayloadShape(
CompiledIntegrationEventContract.PayloadKind.OPTIONAL,
Optional.class,
List.of(),
compileDeclaredType(arguments[0], recordStack));
}
if (rawClass == List.class) {
return new CompiledIntegrationEventContract.PayloadShape(
CompiledIntegrationEventContract.PayloadKind.LIST,
List.class,
List.of(),
compileDeclaredType(arguments[0], recordStack));
}
throw unsupportedDeclaredType(declaredType);
}
if (!(declaredType instanceof Class<?> declaredClass)
|| declaredClass.isArray()
|| declaredClass.getTypeParameters().length != 0) {
throw unsupportedDeclaredType(declaredType);
}
CompiledIntegrationEventContract.PayloadKind scalarKind = scalarKind(declaredClass);
if (scalarKind != null) {
return new CompiledIntegrationEventContract.PayloadShape(
scalarKind, declaredClass, List.of(), null);
}
if (declaredClass == Optional.class || declaredClass == List.class) {
throw new IllegalArgumentException(
"raw Optional/List payload component types are unsupported");
}
if (!declaredClass.isRecord() || !Modifier.isFinal(declaredClass.getModifiers())) {
throw unsupportedDeclaredType(declaredType);
}
if (recordStack.contains(declaredClass)) {
throw new IllegalArgumentException("cyclic declared payload record graph is unsupported");
}
recordStack.addLast(declaredClass);
List<CompiledIntegrationEventContract.PayloadComponent> components = new ArrayList<>();
for (RecordComponent component : declaredClass.getRecordComponents()) {
components.add(
new CompiledIntegrationEventContract.PayloadComponent(
component.getName(),
component.getAccessor(),
compileDeclaredType(component.getGenericType(), recordStack)));
}
recordStack.removeLast();
return new CompiledIntegrationEventContract.PayloadShape(
CompiledIntegrationEventContract.PayloadKind.RECORD, declaredClass, components, null);
}
private static CompiledIntegrationEventContract.PayloadKind scalarKind(Class<?> type) {
if (type == String.class) {
return CompiledIntegrationEventContract.PayloadKind.STRING;
}
if (type == boolean.class || type == Boolean.class) {
return CompiledIntegrationEventContract.PayloadKind.BOOLEAN;
}
if (type == byte.class
|| type == Byte.class
|| type == short.class
|| type == Short.class
|| type == int.class
|| type == Integer.class
|| type == long.class
|| type == Long.class
|| type == BigInteger.class) {
return CompiledIntegrationEventContract.PayloadKind.INTEGRAL;
}
if (type == float.class
|| type == Float.class
|| type == double.class
|| type == Double.class
|| type == BigDecimal.class) {
return CompiledIntegrationEventContract.PayloadKind.DECIMAL;
}
if (type.isEnum()) {
return CompiledIntegrationEventContract.PayloadKind.ENUM;
}
return null;
}
private static IllegalArgumentException unsupportedDeclaredType(Type type) {
return new IllegalArgumentException(
"unsupported declared payload component type: " + type.getTypeName());
}
}
@@ -0,0 +1,91 @@
package dev.caskeleton.adapter.outbound.messaging.contract;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.List;
/** Stable SHA-256 digest of closed contract semantics, independent of caller collection order. */
public final class ContractCatalogDigest {
private static final byte[] DOMAIN =
"ca-skeleton.messaging.contract-catalog-digest.v1".getBytes(StandardCharsets.UTF_8);
private ContractCatalogDigest() {}
public static Sha256 compute(List<CompiledIntegrationEventContract> contracts) {
if (contracts == null || contracts.stream().anyMatch(java.util.Objects::isNull)) {
throw new IllegalArgumentException("compiled contracts must not be null");
}
MessageDigest digest = sha256();
digest.update(DOMAIN);
digest.update((byte) 0);
contracts.stream()
.sorted(
Comparator.comparing(
(CompiledIntegrationEventContract contract) -> contract.contractId().value())
.thenComparingInt(CompiledIntegrationEventContract::payloadVersion))
.forEach(contract -> updateContract(digest, contract));
return new Sha256(digest.digest());
}
private static void updateContract(
MessageDigest digest, CompiledIntegrationEventContract contract) {
field(digest, "contract", contract.stableKey());
field(digest, "contractId", contract.contractId().value());
field(digest, "payloadVersion", Integer.toString(contract.payloadVersion()));
field(digest, "payloadType", contract.exactPayloadRecordType().getName());
for (int index = 0; index < contract.canonicalRecordComponentOrder().size(); index++) {
field(
digest, "component[" + index + "]", contract.canonicalRecordComponentOrder().get(index));
}
field(digest, "schemaResource", contract.payloadSchemaResource().value());
rawField(digest, "schemaHash", contract.payloadSchemaHash().bytes());
ContractDescriptor descriptor = contract.descriptor();
field(digest, "ownerModule", descriptor.ownerModule());
field(digest, "logicalDestination", descriptor.logicalDestination().value());
field(digest, "serializerId", descriptor.serializerId());
field(digest, "orderingRequired", Boolean.toString(descriptor.orderingRequired()));
field(digest, "maximumPayloadBytes", Integer.toString(descriptor.maximumPayloadBytes()));
field(digest, "maximumEnvelopeBytes", Integer.toString(descriptor.maximumEnvelopeBytes()));
field(digest, "sensitivity", descriptor.sensitivityClassification().name());
field(
digest,
"requeueHorizonSeconds",
Long.toString(descriptor.sameEventRequeueHorizon().toSeconds()));
field(
digest,
"requeueHorizonNanos",
Integer.toString(descriptor.sameEventRequeueHorizon().toNanosPart()));
}
private static void field(MessageDigest digest, String tag, String value) {
rawField(digest, tag, value.getBytes(StandardCharsets.UTF_8));
}
private static void rawField(MessageDigest digest, String tag, byte[] value) {
byte[] tagBytes = tag.getBytes(StandardCharsets.UTF_8);
digest.update(unsignedLength(tagBytes.length));
digest.update(tagBytes);
digest.update(unsignedLength(value.length));
digest.update(value);
}
private static byte[] unsignedLength(int length) {
return ByteBuffer.allocate(Integer.BYTES).putInt(length).array();
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
}
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.outbound.messaging.destination;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.util.HashSet;
import java.util.List;
/** Immutable exact contract/version to physical deployment binding compiled without I/O. */
public final class CompiledPublicationBinding {
private final CompiledIntegrationEventContract contract;
private final LogicalDestinationId logicalDestination;
private final String physicalTopic;
private final List<String> bootstrapServers;
private final int effectiveMaximumRecordBytes;
private final MessagingCapabilityCardRegistry.ExactSelection selectedCards;
private final Sha256 settingsDigest;
private final Sha256 schemaSetDigest;
CompiledPublicationBinding(
CompiledIntegrationEventContract contract,
LogicalDestinationId logicalDestination,
String physicalTopic,
List<String> bootstrapServers,
int effectiveMaximumRecordBytes,
MessagingCapabilityCardRegistry.ExactSelection selectedCards,
Sha256 settingsDigest,
Sha256 schemaSetDigest) {
if (contract == null
|| logicalDestination == null
|| !logicalDestination.equals(contract.descriptor().logicalDestination())
|| !DestinationBindingSettings.DestinationBinding.validPhysicalTopic(physicalTopic)
|| bootstrapServers == null
|| bootstrapServers.isEmpty()
|| bootstrapServers.stream()
.anyMatch(
server ->
server == null
|| !DestinationBindingSettings.DestinationBinding.validBootstrapServer(
server))
|| new HashSet<>(bootstrapServers).size() != bootstrapServers.size()
|| effectiveMaximumRecordBytes <= 0
|| effectiveMaximumRecordBytes > contract.descriptor().maximumEnvelopeBytes()
|| selectedCards == null
|| settingsDigest == null
|| schemaSetDigest == null) {
throw new IllegalArgumentException("compiled publication binding fields must be canonical");
}
MessagingCapabilityCardRegistry.exactFirstR2().validate(selectedCards);
this.contract = contract;
this.logicalDestination = logicalDestination;
this.physicalTopic = physicalTopic;
this.bootstrapServers = List.copyOf(bootstrapServers);
this.effectiveMaximumRecordBytes = effectiveMaximumRecordBytes;
this.selectedCards = selectedCards;
this.settingsDigest = settingsDigest;
this.schemaSetDigest = schemaSetDigest;
}
CompiledIntegrationEventContract contract() {
return contract;
}
/** Static bridge for adapter-internal composition without an instance response surface. */
public static CompiledIntegrationEventContract contractOf(CompiledPublicationBinding binding) {
if (binding == null) {
throw new IllegalArgumentException("compiled publication binding must not be null");
}
return binding.contract;
}
public LogicalDestinationId logicalDestination() {
return logicalDestination;
}
public String physicalTopic() {
return physicalTopic;
}
public List<String> bootstrapServers() {
return bootstrapServers;
}
public int effectiveMaximumRecordBytes() {
return effectiveMaximumRecordBytes;
}
MessagingCapabilityCardRegistry.ExactSelection selectedCards() {
return selectedCards;
}
public Sha256 settingsDigest() {
return settingsDigest;
}
public Sha256 schemaSetDigest() {
return schemaSetDigest;
}
public String stableKey() {
return contract.stableKey() + "@" + logicalDestination.value();
}
}
@@ -0,0 +1,248 @@
package dev.caskeleton.adapter.outbound.messaging.destination;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
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.Set;
/** Pure local compiler joining exact contracts, deployment destinations and closed cards. */
public final class DestinationBindingCompiler {
List<CompiledPublicationBinding> compile(
List<CompiledIntegrationEventContract> contracts,
DestinationBindingSettings settings,
MessagingCapabilityCardRegistry cardRegistry) {
if (contracts == null || settings == null || cardRegistry == null) {
throw new IllegalArgumentException("binding compiler inputs must not be null");
}
if (contracts.stream().anyMatch(java.util.Objects::isNull)) {
throw new IllegalArgumentException("compiled contracts must not contain null");
}
cardRegistry.validate(settings.selectedCards());
validateRequiredProfiles(settings);
if (!settings.destinations().isEmpty() && settings.legacyAliases().isPresent()) {
throw new IllegalArgumentException(
"legacy and canonical destination configuration must not be simultaneous");
}
Map<String, CompiledIntegrationEventContract> contractsByKey = indexContracts(contracts);
Map<LogicalDestinationId, DestinationBindingSettings.DestinationBinding> destinations =
indexDestinations(settings.destinations());
Map<String, DestinationBindingSettings.DestinationBinding> bindingByContract =
indexContractBindings(destinations.values(), contractsByKey);
for (CompiledIntegrationEventContract contract : contracts) {
if (!bindingByContract.containsKey(contract.stableKey())) {
throw new IllegalArgumentException(
"missing exact destination binding for contract " + contract.stableKey());
}
}
Sha256 settingsDigest = settingsDigest(settings);
Sha256 schemaSetDigest = schemaSetDigest(contracts);
List<CompiledPublicationBinding> result = new ArrayList<>(contracts.size());
contracts.stream()
.sorted(Comparator.comparing(CompiledIntegrationEventContract::stableKey))
.forEach(
contract -> {
DestinationBindingSettings.DestinationBinding destination =
bindingByContract.get(contract.stableKey());
int effectiveMaximumRecordBytes =
Math.min(
contract.descriptor().maximumEnvelopeBytes(),
destination.maximumRecordBytes());
if (effectiveMaximumRecordBytes <= 0) {
throw new IllegalArgumentException(
"effective maximum record bytes must be positive");
}
result.add(
new CompiledPublicationBinding(
contract,
destination.logicalDestination(),
destination.physicalTopic(),
destination.bootstrapServers(),
effectiveMaximumRecordBytes,
settings.selectedCards(),
settingsDigest,
schemaSetDigest));
});
return List.copyOf(result);
}
/** Narrow immutable composition bridge for callers in sibling messaging adapter packages. */
public static List<CompiledPublicationBinding> compileExact(
List<CompiledIntegrationEventContract> contracts,
DestinationBindingSettings settings,
MessagingCapabilityCardRegistry cardRegistry) {
return new DestinationBindingCompiler().compile(contracts, settings, cardRegistry);
}
public Sha256 settingsDigest(DestinationBindingSettings settings) {
if (settings == null) {
throw new IllegalArgumentException("destination settings must not be null");
}
MessageDigest digest = digest("ca-skeleton.messaging.destination-settings-digest.v1");
field(digest, "orderingRequired", Boolean.toString(settings.orderingRequired()));
field(
digest, "schemaValidationRequired", Boolean.toString(settings.schemaValidationRequired()));
field(digest, "secureTransportRequired", Boolean.toString(settings.secureTransportRequired()));
updateSelection(digest, settings.selectedCards());
settings.destinations().stream()
.sorted(Comparator.comparing(destination -> destination.logicalDestination().value()))
.forEach(destination -> updateDestination(digest, destination));
settings
.legacyAliases()
.ifPresent(
legacy -> {
field(digest, "legacyBroker", legacy.brokerAlias().orElse(""));
field(digest, "legacyTopic", legacy.topicAlias().orElse(""));
legacy.bootstrapServerAliases().stream()
.sorted()
.forEach(value -> field(digest, "legacyBootstrap", value));
});
return new Sha256(digest.digest());
}
public Sha256 schemaSetDigest(List<CompiledIntegrationEventContract> contracts) {
if (contracts == null || contracts.stream().anyMatch(java.util.Objects::isNull)) {
throw new IllegalArgumentException("compiled contracts must not be null");
}
MessageDigest digest = digest("ca-skeleton.messaging.schema-set-digest.v1");
contracts.stream()
.sorted(Comparator.comparing(CompiledIntegrationEventContract::stableKey))
.forEach(
contract -> {
field(digest, "contract", contract.stableKey());
field(digest, "schemaResource", contract.payloadSchemaResource().value());
rawField(digest, "schemaHash", contract.payloadSchemaHash().bytes());
});
return new Sha256(digest.digest());
}
private static void validateRequiredProfiles(DestinationBindingSettings settings) {
if (!settings.orderingRequired()) {
throw new IllegalArgumentException("ordering requirement cannot be relaxed");
}
if (!settings.schemaValidationRequired()) {
throw new IllegalArgumentException("schema validation requirement cannot be relaxed");
}
if (!settings.secureTransportRequired()) {
throw new IllegalArgumentException("security requirement cannot be relaxed");
}
}
private static Map<String, CompiledIntegrationEventContract> indexContracts(
List<CompiledIntegrationEventContract> contracts) {
Map<String, CompiledIntegrationEventContract> result = new HashMap<>();
for (CompiledIntegrationEventContract contract : contracts) {
if (result.putIfAbsent(contract.stableKey(), contract) != null) {
throw new IllegalArgumentException("duplicate compiled contract: " + contract.stableKey());
}
}
return result;
}
private static Map<LogicalDestinationId, DestinationBindingSettings.DestinationBinding>
indexDestinations(List<DestinationBindingSettings.DestinationBinding> destinations) {
Map<LogicalDestinationId, DestinationBindingSettings.DestinationBinding> result =
new HashMap<>();
for (DestinationBindingSettings.DestinationBinding destination : destinations) {
if (result.putIfAbsent(destination.logicalDestination(), destination) != null) {
throw new IllegalArgumentException(
"duplicate destination binding: " + destination.logicalDestination().value());
}
}
return result;
}
private static Map<String, DestinationBindingSettings.DestinationBinding> indexContractBindings(
java.util.Collection<DestinationBindingSettings.DestinationBinding> destinations,
Map<String, CompiledIntegrationEventContract> contractsByKey) {
Map<String, DestinationBindingSettings.DestinationBinding> result = new HashMap<>();
Set<LogicalDestinationId> usedDestinations = new HashSet<>();
for (DestinationBindingSettings.DestinationBinding destination : destinations) {
for (DestinationBindingSettings.ContractVersion reference : destination.contracts()) {
CompiledIntegrationEventContract contract = contractsByKey.get(reference.stableKey());
if (contract == null) {
throw new IllegalArgumentException(
"extra contract binding is not in the closed catalog: " + reference.stableKey());
}
if (!contract.descriptor().logicalDestination().equals(destination.logicalDestination())) {
throw new IllegalArgumentException(
"contract/destination identity mismatch for " + reference.stableKey());
}
if (result.putIfAbsent(reference.stableKey(), destination) != null) {
throw new IllegalArgumentException(
"duplicate exact contract binding: " + reference.stableKey());
}
usedDestinations.add(destination.logicalDestination());
}
}
if (usedDestinations.size() != destinations.size()) {
throw new IllegalArgumentException("extra destination binding has no closed contract");
}
return result;
}
private static void updateDestination(
MessageDigest digest, DestinationBindingSettings.DestinationBinding destination) {
field(digest, "logicalDestination", destination.logicalDestination().value());
destination.contracts().stream()
.sorted(Comparator.comparing(DestinationBindingSettings.ContractVersion::stableKey))
.forEach(contract -> field(digest, "contract", contract.stableKey()));
field(digest, "physicalTopic", destination.physicalTopic());
destination.bootstrapServers().stream()
.sorted()
.forEach(server -> field(digest, "bootstrapServer", server));
field(digest, "maximumRecordBytes", Integer.toString(destination.maximumRecordBytes()));
}
private static void updateSelection(
MessageDigest digest, MessagingCapabilityCardRegistry.ExactSelection selection) {
field(digest, "semanticCard", selection.semanticCardId());
field(digest, "producerCard", selection.producerCardId());
field(digest, "dispatchCard", selection.dispatchCardId());
field(digest, "claimCard", selection.claimCardId());
field(digest, "serializationCard", selection.serializationCardId());
field(digest, "topicCard", selection.topicCardId());
field(digest, "securityCard", selection.securityCardId());
field(digest, "compressionCard", selection.compressionCardId());
field(digest, "orderingCard", selection.orderingCardId());
field(digest, "transactionCard", selection.transactionCardId());
field(digest, "operatorControlCard", selection.operatorControlCardId());
}
private static MessageDigest digest(String domain) {
try {
MessageDigest result = MessageDigest.getInstance("SHA-256");
result.update(domain.getBytes(StandardCharsets.UTF_8));
result.update((byte) 0);
return result;
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
}
}
private static void field(MessageDigest digest, String tag, String value) {
rawField(digest, tag, value.getBytes(StandardCharsets.UTF_8));
}
private static void rawField(MessageDigest digest, String tag, byte[] value) {
byte[] tagBytes = tag.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(tagBytes.length).array());
digest.update(tagBytes);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array());
digest.update(value);
}
}
@@ -0,0 +1,238 @@
package dev.caskeleton.adapter.outbound.messaging.destination;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Deployment-owned, bounded canonical destination binding inputs without credential values. */
public final class DestinationBindingSettings {
private final List<DestinationBinding> destinations;
private final MessagingCapabilityCardRegistry.ExactSelection selectedCards;
private final boolean orderingRequired;
private final boolean schemaValidationRequired;
private final boolean secureTransportRequired;
private final Optional<LegacyAliases> legacyAliases;
public DestinationBindingSettings(
List<DestinationBinding> destinations,
MessagingCapabilityCardRegistry.ExactSelection selectedCards,
boolean orderingRequired,
boolean schemaValidationRequired,
boolean secureTransportRequired,
Optional<LegacyAliases> legacyAliases) {
if (destinations == null || selectedCards == null || legacyAliases == null) {
throw new IllegalArgumentException("destination binding settings fields must not be null");
}
if (destinations.stream().anyMatch(java.util.Objects::isNull)) {
throw new IllegalArgumentException("destination binding must not be null");
}
List<DestinationBinding> copiedDestinations = List.copyOf(destinations);
Set<LogicalDestinationId> logicalDestinations = new HashSet<>();
Set<String> physicalTopics = new HashSet<>();
for (DestinationBinding destination : copiedDestinations) {
if (!logicalDestinations.add(destination.logicalDestination())) {
throw new IllegalArgumentException("duplicate destination binding");
}
if (!physicalTopics.add(destination.physicalTopic())) {
throw new IllegalArgumentException("duplicate physical topic binding");
}
}
this.destinations = copiedDestinations;
this.selectedCards = selectedCards;
this.orderingRequired = orderingRequired;
this.schemaValidationRequired = schemaValidationRequired;
this.secureTransportRequired = secureTransportRequired;
this.legacyAliases = legacyAliases;
}
List<DestinationBinding> destinations() {
return destinations;
}
MessagingCapabilityCardRegistry.ExactSelection selectedCards() {
return selectedCards;
}
public boolean orderingRequired() {
return orderingRequired;
}
public boolean schemaValidationRequired() {
return schemaValidationRequired;
}
public boolean secureTransportRequired() {
return secureTransportRequired;
}
Optional<LegacyAliases> legacyAliases() {
return legacyAliases;
}
/** Narrow immutable composition bridge for sibling messaging adapter packages. */
public static List<DestinationBinding> destinationsOf(DestinationBindingSettings settings) {
return requireSettings(settings).destinations;
}
/** Narrow immutable composition bridge for sibling messaging adapter packages. */
public static Optional<LegacyAliases> legacyAliasesOf(DestinationBindingSettings settings) {
return requireSettings(settings).legacyAliases;
}
private static DestinationBindingSettings requireSettings(DestinationBindingSettings settings) {
if (settings == null) {
throw new IllegalArgumentException("destination binding settings must not be null");
}
return settings;
}
public record ContractVersion(ContractId contractId, int payloadVersion) {
public ContractVersion {
if (contractId == null || payloadVersion <= 0) {
throw new IllegalArgumentException(
"contract binding identity and positive version required");
}
}
public String stableKey() {
return contractId.value() + ":v" + payloadVersion;
}
}
public static final class DestinationBinding {
private static final int MAXIMUM_BOOTSTRAP_SERVERS = 32;
private static final String TOPIC_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._-]{0,248}";
private static final String BOOTSTRAP_GRAMMAR =
"[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:[1-9][0-9]{0,4}";
private final LogicalDestinationId logicalDestination;
private final List<ContractVersion> contracts;
private final String physicalTopic;
private final List<String> bootstrapServers;
private final int maximumRecordBytes;
public DestinationBinding(
LogicalDestinationId logicalDestination,
List<ContractVersion> contracts,
String physicalTopic,
List<String> bootstrapServers,
int maximumRecordBytes) {
if (logicalDestination == null
|| contracts == null
|| contracts.isEmpty()
|| physicalTopic == null
|| !physicalTopic.matches(TOPIC_GRAMMAR)
|| bootstrapServers == null
|| bootstrapServers.isEmpty()
|| bootstrapServers.size() > MAXIMUM_BOOTSTRAP_SERVERS
|| maximumRecordBytes <= 0) {
throw new IllegalArgumentException("invalid bounded canonical destination binding");
}
contracts = List.copyOf(contracts);
if (new HashSet<>(contracts).size() != contracts.size()) {
throw new IllegalArgumentException("destination contract identities must be unique");
}
if (bootstrapServers.stream()
.anyMatch(server -> server == null || !validBootstrapServer(server))) {
throw new IllegalArgumentException(
"bootstrap servers must be bounded host:port values without secrets");
}
if (new HashSet<>(bootstrapServers).size() != bootstrapServers.size()) {
throw new IllegalArgumentException("bootstrap servers must be unique");
}
this.logicalDestination = logicalDestination;
this.contracts = contracts;
this.physicalTopic = physicalTopic;
this.bootstrapServers = bootstrapServers.stream().sorted().toList();
this.maximumRecordBytes = maximumRecordBytes;
}
public LogicalDestinationId logicalDestination() {
return logicalDestination;
}
List<ContractVersion> contracts() {
return contracts;
}
public String physicalTopic() {
return physicalTopic;
}
public List<String> bootstrapServers() {
return bootstrapServers;
}
public int maximumRecordBytes() {
return maximumRecordBytes;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof DestinationBinding that)) {
return false;
}
return maximumRecordBytes == that.maximumRecordBytes
&& logicalDestination.equals(that.logicalDestination)
&& contracts.equals(that.contracts)
&& physicalTopic.equals(that.physicalTopic)
&& bootstrapServers.equals(that.bootstrapServers);
}
@Override
public int hashCode() {
return Objects.hash(
logicalDestination, contracts, physicalTopic, bootstrapServers, maximumRecordBytes);
}
static boolean validPhysicalTopic(String topic) {
return topic != null && topic.matches(TOPIC_GRAMMAR);
}
static boolean validBootstrapServer(String server) {
if (!server.matches(BOOTSTRAP_GRAMMAR)) {
return false;
}
int port = Integer.parseInt(server.substring(server.lastIndexOf(':') + 1));
return port <= 65_535;
}
}
/** Read-only migration aliases used solely to reject simultaneous legacy and canonical inputs. */
public record LegacyAliases(
Optional<String> brokerAlias,
Optional<String> topicAlias,
List<String> bootstrapServerAliases) {
public LegacyAliases {
if (brokerAlias == null || topicAlias == null || bootstrapServerAliases == null) {
throw new IllegalArgumentException("legacy aliases must not be null");
}
if (brokerAlias.filter(value -> !value.matches("[a-z][a-z0-9-]{0,95}")).isPresent()
|| topicAlias.filter(value -> !DestinationBinding.validPhysicalTopic(value)).isPresent()
|| bootstrapServerAliases.stream()
.anyMatch(
alias -> alias == null || !DestinationBinding.validBootstrapServer(alias))) {
throw new IllegalArgumentException(
"legacy aliases must be canonical identifiers without secrets");
}
bootstrapServerAliases = List.copyOf(bootstrapServerAliases);
if (new HashSet<>(bootstrapServerAliases).size() != bootstrapServerAliases.size()) {
throw new IllegalArgumentException("legacy bootstrap aliases must be unique");
}
if (brokerAlias.isEmpty() && topicAlias.isEmpty() && bootstrapServerAliases.isEmpty()) {
throw new IllegalArgumentException("at least one legacy alias must be present");
}
}
}
}
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.messaging.destination;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.event.AggregateIdentity;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
/**
* Exact domain-separated partition-key v1 algorithm shared by polling and future dispatch cards.
*/
public final class PartitionKeyV1 {
private static final byte[] DOMAIN =
"ca-skeleton.messaging.partition-key.v1".getBytes(StandardCharsets.UTF_8);
private PartitionKeyV1() {}
public static Value derive(
LogicalDestinationId logicalDestination, AggregateIdentity aggregateIdentity) {
if (aggregateIdentity == null) {
throw new IllegalArgumentException("aggregate identity must not be null");
}
return deriveCanonicalComponents(
aggregateIdentity.tenantScope(),
logicalDestination,
aggregateIdentity.aggregateType(),
aggregateIdentity.aggregateId());
}
/**
* Cross-language vector entry point. Callers retain ownership of their canonical component
* grammar; this method never substitutes a missing tenant scope.
*/
public static Value deriveCanonicalComponents(
String tenantScope,
LogicalDestinationId logicalDestination,
String aggregateType,
String aggregateId) {
requireBounded("tenantScope", tenantScope, 96);
if (logicalDestination == null) {
throw new IllegalArgumentException("logicalDestination must not be null");
}
requireBounded("aggregateType", aggregateType, 64);
requireBounded("aggregateId", aggregateId, 160);
MessageDigest digest = sha256();
digest.update(DOMAIN);
digest.update((byte) 0);
updateLengthPrefixed(digest, strictUtf8("tenantScope", tenantScope));
updateLengthPrefixed(digest, strictUtf8("logicalDestination", logicalDestination.value()));
updateLengthPrefixed(digest, strictUtf8("aggregateType", aggregateType));
updateLengthPrefixed(digest, strictUtf8("aggregateId", aggregateId));
String text = HexFormat.of().formatHex(digest.digest());
return new Value(text, text.getBytes(StandardCharsets.US_ASCII));
}
private static void updateLengthPrefixed(MessageDigest digest, byte[] bytes) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
digest.update(bytes);
}
private static byte[] strictUtf8(String field, String value) {
try {
ByteBuffer encoded =
StandardCharsets.UTF_8
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
byte[] bytes = new byte[encoded.remaining()];
encoded.get(bytes);
return bytes;
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException(field + " must contain strict valid UTF-8", exception);
}
}
private static void requireBounded(String field, String value, int maximumLength) {
if (value == null || value.isBlank() || value.length() > maximumLength) {
throw new IllegalArgumentException(
field + " must be a canonical non-blank value of at most " + maximumLength + " chars");
}
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
}
}
@SuppressWarnings("ArrayRecordComponent")
public record Value(String text, byte[] bytes) {
public Value {
if (text == null
|| !text.matches("[0-9a-f]{64}")
|| bytes == null
|| !Arrays.equals(bytes, text.getBytes(StandardCharsets.US_ASCII))) {
throw new IllegalArgumentException("partition key must be exact lower-case hex ASCII");
}
bytes = bytes.clone();
}
@Override
public byte[] bytes() {
return bytes.clone();
}
@Override
public boolean equals(Object other) {
return this == other
|| (other instanceof Value that
&& text.equals(that.text)
&& Arrays.equals(bytes, that.bytes));
}
@Override
public int hashCode() {
return 31 * Objects.hash(text) + Arrays.hashCode(bytes);
}
}
}
@@ -0,0 +1,545 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract.PayloadComponent;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract.PayloadKind;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract.PayloadShape;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.StreamWriteConstraints;
import tools.jackson.core.json.JsonFactory;
/**
* Closed declared-shape writer for envelope v1.
*
* <p>Every record accessor is read exactly once into an immutable snapshot. The payload is encoded
* exactly once and those exact trusted bytes are then embedded in the envelope without any raw JSON
* parser or generator API.
*/
final class DeterministicEnvelopeWriter {
private static final byte[] PAYLOAD_PROPERTY =
",\"payload\":".getBytes(StandardCharsets.US_ASCII);
private final EnvelopeAdmissionLimits limits;
private final JsonFactory jsonFactory;
DeterministicEnvelopeWriter(EnvelopeAdmissionLimits limits) {
if (limits == null) {
throw new IllegalArgumentException("envelope admission limits must not be null");
}
this.limits = limits;
this.jsonFactory =
JsonFactory.builder()
.streamWriteConstraints(
StreamWriteConstraints.builder().maxNestingDepth(limits.maximumDepth()).build())
.build();
}
EncodedEnvelope write(IntegrationEventDraft<?> draft, CompiledIntegrationEventContract contract) {
if (draft == null || contract == null) {
throw new IllegalArgumentException("draft and compiled contract must not be null");
}
IntegrationPayload payload = draft.featurePayload();
if (payload.getClass() != contract.exactPayloadRecordType()) {
throw new IllegalArgumentException(
"draft payload must have the exact registered final record class");
}
if (!draft.contractId().equals(contract.contractId())
|| draft.payloadVersion() != contract.payloadVersion()) {
throw new IllegalArgumentException("draft identity must match the exact compiled contract");
}
SnapshotValue payloadSnapshot =
snapshot(CompiledIntegrationEventContract.payloadShapeOf(contract), payload, 1);
byte[] payloadBytes =
generate(
limits.maximumPayloadBytes(), generator -> writeSnapshot(generator, payloadSnapshot));
byte[] metadataBytes =
generate(
limits.maximumEnvelopeBytes(), generator -> writeEnvelopeMetadata(generator, draft));
byte[] envelopeBytes = embedExactPayload(metadataBytes, payloadBytes);
return new EncodedEnvelope(envelopeBytes, payloadBytes);
}
private SnapshotValue snapshot(PayloadShape shape, Object value, int depth) {
requireDepth(depth);
PayloadKind kind = PayloadShape.kindOf(shape);
return switch (kind) {
case RECORD -> snapshotRecord(shape, value, depth);
case OPTIONAL -> snapshotOptional(shape, value, depth);
case LIST -> snapshotList(shape, value, depth);
case STRING -> snapshotString(shape, value);
case BOOLEAN -> snapshotBoolean(shape, value);
case INTEGRAL -> snapshotIntegral(shape, value);
case DECIMAL -> snapshotDecimal(shape, value);
case ENUM -> snapshotEnum(shape, value);
};
}
private SnapshotValue snapshotRecord(PayloadShape shape, Object value, int depth) {
Class<?> exactType = PayloadShape.exactJavaTypeOf(shape);
if (value == null || value.getClass() != exactType) {
throw new IllegalArgumentException("exact declared final record value required");
}
List<PayloadComponent> components = PayloadShape.componentsOf(shape);
if (components.size() > limits.maximumObjectProperties()) {
throw new IllegalArgumentException("record exceeds object properties admission limit");
}
List<String> names = new ArrayList<>(components.size());
List<SnapshotValue> values = new ArrayList<>(components.size());
for (PayloadComponent component : components) {
String name = PayloadComponent.nameOf(component);
requireString("record component name", name);
names.add(name);
values.add(
snapshot(
PayloadComponent.shapeOf(component),
CompiledIntegrationEventContract.readPayloadComponentOnce(component, value),
depth + 1));
}
return SnapshotValue.object(names, values);
}
private SnapshotValue snapshotOptional(PayloadShape shape, Object value, int depth) {
if (value == null || value.getClass() != Optional.class) {
throw new IllegalArgumentException("a non-null exact Optional value is required");
}
Optional<?> optional = (Optional<?>) value;
return optional.isEmpty()
? SnapshotValue.nullValue()
: snapshot(PayloadShape.elementShapeOf(shape), optional.get(), depth);
}
private SnapshotValue snapshotList(PayloadShape shape, Object value, int depth) {
if (!(value instanceof List<?> list)) {
throw new IllegalArgumentException("a value declared as List is required");
}
int declaredSize = boundedListSize(list);
List<SnapshotValue> snapshots = new ArrayList<>(declaredSize);
PayloadShape elementShape = PayloadShape.elementShapeOf(shape);
Iterator<?> iterator = listIterator(list);
for (int index = 0; index < declaredSize; index++) {
if (!hasNext(iterator)) {
throw mutatedList();
}
snapshots.add(snapshot(elementShape, next(iterator), depth + 1));
}
if (hasNext(iterator)) {
throw mutatedList();
}
return SnapshotValue.array(snapshots);
}
private int boundedListSize(List<?> list) {
int size;
try {
size = list.size();
} catch (RuntimeException exception) {
throw mutatedList(exception);
}
if (size < 0 || size > limits.maximumArrayItems()) {
throw new IllegalArgumentException("list exceeds array items admission limit");
}
return size;
}
private static Iterator<?> listIterator(List<?> list) {
try {
return list.iterator();
} catch (RuntimeException exception) {
throw mutatedList(exception);
}
}
private static boolean hasNext(Iterator<?> iterator) {
try {
return iterator.hasNext();
} catch (RuntimeException exception) {
throw mutatedList(exception);
}
}
private static Object next(Iterator<?> iterator) {
try {
return iterator.next();
} catch (RuntimeException exception) {
throw mutatedList(exception);
}
}
private static IllegalArgumentException mutatedList() {
return new IllegalArgumentException("list mutated or failed during bounded snapshot");
}
private static IllegalArgumentException mutatedList(RuntimeException cause) {
return new IllegalArgumentException("list mutated or failed during bounded snapshot", cause);
}
private SnapshotValue snapshotString(PayloadShape shape, Object value) {
requireExactRuntimeType(shape, value);
String text = (String) value;
requireString("string value", text);
return SnapshotValue.string(text);
}
private SnapshotValue snapshotBoolean(PayloadShape shape, Object value) {
requireExactRuntimeType(shape, value);
return SnapshotValue.bool((Boolean) value);
}
private SnapshotValue snapshotIntegral(PayloadShape shape, Object value) {
requireExactRuntimeType(shape, value);
String text = value.toString();
requireNumberDigits(text);
return SnapshotValue.number(text);
}
private SnapshotValue snapshotDecimal(PayloadShape shape, Object value) {
requireExactRuntimeType(shape, value);
BigDecimal decimal;
if (value instanceof BigDecimal exact) {
decimal = exact;
} else if (value instanceof Double floating) {
if (!Double.isFinite(floating)) {
throw new IllegalArgumentException("decimal values must be finite");
}
decimal = BigDecimal.valueOf(floating);
} else if (value instanceof Float floating) {
if (!Float.isFinite(floating)) {
throw new IllegalArgumentException("decimal values must be finite");
}
decimal = new BigDecimal(Float.toString(floating));
} else {
throw new IllegalArgumentException("unsupported declared decimal runtime class");
}
BigDecimal canonical = canonicalDecimal(decimal);
String text = canonical.toPlainString();
requireNumberDigits(text);
return SnapshotValue.number(text);
}
private SnapshotValue snapshotEnum(PayloadShape shape, Object value) {
requireExactRuntimeType(shape, value);
String name = ((Enum<?>) value).name();
requireString("enum value", name);
return SnapshotValue.string(name);
}
private static void requireExactRuntimeType(PayloadShape shape, Object value) {
Class<?> declared = PayloadShape.exactJavaTypeOf(shape);
Class<?> runtimeType = wrapperType(declared);
if (value == null || value.getClass() != runtimeType) {
throw new IllegalArgumentException("payload scalar must match its exact declared type");
}
}
private static Class<?> wrapperType(Class<?> type) {
if (!type.isPrimitive()) {
return type;
}
if (type == boolean.class) {
return Boolean.class;
}
if (type == byte.class) {
return Byte.class;
}
if (type == short.class) {
return Short.class;
}
if (type == int.class) {
return Integer.class;
}
if (type == long.class) {
return Long.class;
}
if (type == float.class) {
return Float.class;
}
if (type == double.class) {
return Double.class;
}
throw new IllegalArgumentException("unsupported primitive payload component");
}
private void writeSnapshot(JsonGenerator generator, SnapshotValue snapshot) {
switch (snapshot.kind()) {
case NULL -> generator.writeNull();
case STRING -> generator.writeString((String) snapshot.scalar());
case BOOLEAN -> generator.writeBoolean((Boolean) snapshot.scalar());
case NUMBER -> generator.writeNumber((String) snapshot.scalar());
case ARRAY -> {
generator.writeStartArray();
for (SnapshotValue value : snapshot.values()) {
writeSnapshot(generator, value);
}
generator.writeEndArray();
}
case OBJECT -> {
generator.writeStartObject();
for (int index = 0; index < snapshot.names().size(); index++) {
generator.writeName(snapshot.names().get(index));
writeSnapshot(generator, snapshot.values().get(index));
}
generator.writeEndObject();
}
default -> throw new IllegalStateException("unknown immutable payload snapshot kind");
}
}
private void writeEnvelopeMetadata(JsonGenerator generator, IntegrationEventDraft<?> draft) {
if (10 > limits.maximumObjectProperties()) {
throw new IllegalArgumentException("envelope exceeds object properties admission limit");
}
generator.writeStartObject();
generator.writeNumberProperty("envelopeVersion", 1);
writeStringProperty(generator, "eventId", draft.eventId().value());
writeStringProperty(generator, "contractId", draft.contractId().value());
generator.writeNumberProperty("payloadVersion", draft.payloadVersion());
writeStringProperty(generator, "logicalDestination", draft.destinationId().value());
generator.writeObjectPropertyStart("aggregate");
writeStringProperty(generator, "type", draft.aggregate().aggregateType());
writeStringProperty(generator, "id", draft.aggregate().aggregateId());
generator.writeNumberProperty("sequence", draft.order().sequence());
generator.writeNumberProperty("eventIndex", draft.order().eventIndex());
generator.writeEndObject();
writeStringProperty(generator, "occurredAt", draft.occurredAt().toString());
writeStringProperty(generator, "correlationId", draft.correlationId());
writeStringProperty(generator, "contentType", "application/json");
generator.writeEndObject();
}
private void writeStringProperty(JsonGenerator generator, String name, String value) {
requireString(name, value);
generator.writeStringProperty(name, value);
}
private byte[] embedExactPayload(byte[] metadataBytes, byte[] payloadBytes) {
if (metadataBytes.length < 2 || metadataBytes[metadataBytes.length - 1] != (byte) '}') {
throw new IllegalStateException("generated envelope metadata is not a JSON object");
}
long exactLength =
(long) metadataBytes.length - 1L + PAYLOAD_PROPERTY.length + payloadBytes.length + 1L;
if (exactLength > limits.maximumEnvelopeBytes()) {
throw new IllegalArgumentException("encoded JSON exceeds byte admission limit");
}
byte[] envelope = new byte[(int) exactLength];
int offset = metadataBytes.length - 1;
System.arraycopy(metadataBytes, 0, envelope, 0, offset);
System.arraycopy(PAYLOAD_PROPERTY, 0, envelope, offset, PAYLOAD_PROPERTY.length);
offset += PAYLOAD_PROPERTY.length;
System.arraycopy(payloadBytes, 0, envelope, offset, payloadBytes.length);
envelope[envelope.length - 1] = (byte) '}';
return envelope;
}
private byte[] generate(int maximumBytes, GeneratorAction action) {
ByteArrayOutputStream output = new BoundedByteArrayOutputStream(maximumBytes);
try (JsonGenerator generator = jsonFactory.createGenerator(output)) {
action.write(generator);
}
byte[] result = output.toByteArray();
if (result.length > maximumBytes) {
throw new IllegalArgumentException("encoded JSON exceeds byte admission limit");
}
return result;
}
private void requireDepth(int depth) {
if (depth > limits.maximumDepth()) {
throw new IllegalArgumentException("payload exceeds depth admission limit");
}
}
private void requireString(String field, String value) {
if (value == null) {
throw new IllegalArgumentException(field + " must not be null");
}
if (value.codePointCount(0, value.length()) > limits.maximumStringCharacters()) {
throw new IllegalArgumentException(field + " exceeds string character admission limit");
}
byte[] bytes = strictUtf8(field, value);
if (bytes.length > limits.maximumStringUtf8Bytes()) {
throw new IllegalArgumentException(field + " exceeds UTF-8 byte admission limit");
}
}
private static byte[] strictUtf8(String field, String value) {
try {
ByteBuffer encoded =
StandardCharsets.UTF_8
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
byte[] bytes = new byte[encoded.remaining()];
encoded.get(bytes);
return bytes;
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException(field + " contains an unpaired surrogate", exception);
}
}
private void requireNumberDigits(String text) {
long digits = text.codePoints().filter(Character::isDigit).count();
if (digits > limits.maximumNumberDigits()) {
throw new IllegalArgumentException("number exceeds digits admission limit");
}
}
private BigDecimal canonicalDecimal(BigDecimal decimal) {
if (decimal == null) {
throw new IllegalArgumentException("decimal must not be null");
}
BigDecimal stripped = decimal.stripTrailingZeros();
long precision = stripped.precision();
long scale = stripped.scale();
long plainDigits = scale >= precision ? 1L + scale : precision + Math.max(0L, -scale);
if (plainDigits > limits.maximumNumberDigits()) {
throw new IllegalArgumentException("number exceeds digits admission limit");
}
return stripped.scale() < 0 ? stripped.setScale(0) : stripped;
}
@SuppressWarnings("ArrayRecordComponent")
record EncodedEnvelope(byte[] envelopeBytes, byte[] payloadBytes) {
EncodedEnvelope {
if (envelopeBytes == null
|| envelopeBytes.length == 0
|| payloadBytes == null
|| payloadBytes.length == 0) {
throw new IllegalArgumentException("encoded envelope and payload bytes are required");
}
envelopeBytes = envelopeBytes.clone();
payloadBytes = payloadBytes.clone();
}
@Override
public byte[] envelopeBytes() {
return envelopeBytes.clone();
}
@Override
public byte[] payloadBytes() {
return payloadBytes.clone();
}
}
private enum SnapshotKind {
NULL,
STRING,
BOOLEAN,
NUMBER,
ARRAY,
OBJECT
}
private static final class SnapshotValue {
private final SnapshotKind kind;
private final Object scalar;
private final List<String> names;
private final List<SnapshotValue> values;
private SnapshotValue(
SnapshotKind kind, Object scalar, List<String> names, List<SnapshotValue> values) {
this.kind = kind;
this.scalar = scalar;
this.names = List.copyOf(names);
this.values = List.copyOf(values);
}
private SnapshotKind kind() {
return kind;
}
private Object scalar() {
return scalar;
}
private List<String> names() {
return names;
}
private List<SnapshotValue> values() {
return values;
}
private static SnapshotValue nullValue() {
return new SnapshotValue(SnapshotKind.NULL, null, List.of(), List.of());
}
private static SnapshotValue string(String value) {
return new SnapshotValue(SnapshotKind.STRING, value, List.of(), List.of());
}
private static SnapshotValue bool(boolean value) {
return new SnapshotValue(SnapshotKind.BOOLEAN, value, List.of(), List.of());
}
private static SnapshotValue number(String value) {
return new SnapshotValue(SnapshotKind.NUMBER, value, List.of(), List.of());
}
private static SnapshotValue array(List<SnapshotValue> values) {
return new SnapshotValue(SnapshotKind.ARRAY, null, List.of(), values);
}
private static SnapshotValue object(List<String> names, List<SnapshotValue> values) {
return new SnapshotValue(SnapshotKind.OBJECT, null, names, values);
}
}
private static final class BoundedByteArrayOutputStream extends ByteArrayOutputStream {
private final int maximumBytes;
private BoundedByteArrayOutputStream(int maximumBytes) {
super(Math.min(maximumBytes, 8192));
if (maximumBytes <= 0) {
throw new IllegalArgumentException("encoded JSON byte admission limit must be positive");
}
this.maximumBytes = maximumBytes;
}
@Override
public synchronized void write(int value) {
requireCapacity(1);
super.write(value);
}
@Override
public synchronized void write(byte[] value, int offset, int length) {
if (value == null) {
throw new IllegalArgumentException("encoded JSON bytes must not be null");
}
java.util.Objects.checkFromIndexSize(offset, length, value.length);
requireCapacity(length);
super.write(value, offset, length);
}
private void requireCapacity(int additionalBytes) {
if ((long) count + additionalBytes > maximumBytes) {
throw new IllegalArgumentException("encoded JSON exceeds byte admission limit");
}
}
}
@FunctionalInterface
private interface GeneratorAction {
void write(JsonGenerator generator);
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
/**
* Closed, finite admission budgets shared by the deterministic writer and local schema validator.
*/
public record EnvelopeAdmissionLimits(
int maximumDepth,
int maximumStringCharacters,
int maximumStringUtf8Bytes,
int maximumArrayItems,
int maximumObjectProperties,
int maximumNumberDigits,
int maximumPayloadBytes,
int maximumEnvelopeBytes,
int maximumValidationErrors,
int maximumRegexInputCharacters,
int maximumReferenceDepth) {
private static final int HARD_MAXIMUM_DEPTH = 128;
private static final int HARD_MAXIMUM_STRING_CHARACTERS = 1_000_000;
private static final int HARD_MAXIMUM_STRING_BYTES = 4_000_000;
private static final int HARD_MAXIMUM_COLLECTION_ITEMS = 100_000;
private static final int HARD_MAXIMUM_NUMBER_DIGITS = 10_000;
private static final int HARD_MAXIMUM_DOCUMENT_BYTES = 100_000_000;
private static final int HARD_MAXIMUM_VALIDATION_ERRORS = 1_000;
private static final int HARD_MAXIMUM_REFERENCE_DEPTH = 64;
public EnvelopeAdmissionLimits {
requireFinite("maximumDepth", maximumDepth, HARD_MAXIMUM_DEPTH);
requireFinite(
"maximumStringCharacters", maximumStringCharacters, HARD_MAXIMUM_STRING_CHARACTERS);
requireFinite("maximumStringUtf8Bytes", maximumStringUtf8Bytes, HARD_MAXIMUM_STRING_BYTES);
requireFinite("maximumArrayItems", maximumArrayItems, HARD_MAXIMUM_COLLECTION_ITEMS);
requireFinite(
"maximumObjectProperties", maximumObjectProperties, HARD_MAXIMUM_COLLECTION_ITEMS);
requireFinite("maximumNumberDigits", maximumNumberDigits, HARD_MAXIMUM_NUMBER_DIGITS);
requireFinite("maximumPayloadBytes", maximumPayloadBytes, HARD_MAXIMUM_DOCUMENT_BYTES);
requireFinite("maximumEnvelopeBytes", maximumEnvelopeBytes, HARD_MAXIMUM_DOCUMENT_BYTES);
requireFinite(
"maximumValidationErrors", maximumValidationErrors, HARD_MAXIMUM_VALIDATION_ERRORS);
requireFinite(
"maximumRegexInputCharacters", maximumRegexInputCharacters, HARD_MAXIMUM_STRING_CHARACTERS);
requireFinite("maximumReferenceDepth", maximumReferenceDepth, HARD_MAXIMUM_REFERENCE_DEPTH);
if (maximumEnvelopeBytes < maximumPayloadBytes) {
throw new IllegalArgumentException(
"maximumEnvelopeBytes must be at least maximumPayloadBytes");
}
}
private static void requireFinite(String field, int value, int hardMaximum) {
if (value <= 0 || value > hardMaximum || value == Integer.MAX_VALUE) {
throw new IllegalArgumentException(
field + " must be a positive finite bound at most " + hardMaximum);
}
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/** Domain-separated exact-byte hash for the immutable v1 envelope. */
public final class EnvelopeHashV1 {
private static final byte[] DOMAIN =
"ca-skeleton.messaging.envelope.v1".getBytes(StandardCharsets.UTF_8);
private EnvelopeHashV1() {}
public static Sha256 compute(byte[] exactEnvelopeBytes) {
if (exactEnvelopeBytes == null || exactEnvelopeBytes.length == 0) {
throw new IllegalArgumentException("exact envelope bytes must not be null or empty");
}
MessageDigest digest = sha256();
digest.update(DOMAIN);
digest.update((byte) 0);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(exactEnvelopeBytes.length).array());
digest.update(exactEnvelopeBytes);
return new Sha256(digest.digest());
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
}
}
}
@@ -0,0 +1,232 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigest;
import dev.caskeleton.adapter.outbound.messaging.destination.CompiledPublicationBinding;
import dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1;
import dev.caskeleton.application.messaging.contract.Sha256;
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
import dev.caskeleton.application.messaging.event.IntegrationEventEncoderPort;
import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Pure local encoder joining exact compiled contracts, bindings and precompiled schemas. */
public final class JsonSchemaIntegrationEventEncoder implements IntegrationEventEncoderPort {
public static final String ENVELOPE_SCHEMA_RESOURCE =
"contracts/messaging/envelope/v1.schema.json";
private static final byte[] SCHEMA_SET_DOMAIN =
"ca-skeleton.messaging.schema-set.v1".getBytes(StandardCharsets.UTF_8);
private final Map<String, Entry> entries;
private final LocalJsonSchemaRegistry registry;
private final DeterministicEnvelopeWriter writer;
private final EnvelopeAdmissionLimits limits;
private final String envelopeSchemaId;
private final Sha256 envelopeSchemaHash;
private final String catalogRevision;
public JsonSchemaIntegrationEventEncoder(
List<CompiledIntegrationEventContract> contracts,
List<CompiledPublicationBinding> bindings,
LocalJsonSchemaRegistry registry,
EnvelopeAdmissionLimits limits) {
if (contracts == null
|| contracts.isEmpty()
|| bindings == null
|| registry == null
|| limits == null) {
throw new IllegalArgumentException(
"closed contracts, bindings, registry and limits are required");
}
this.registry = registry;
this.limits = limits;
this.writer = new DeterministicEnvelopeWriter(limits);
this.envelopeSchemaId = registry.schemaId(ENVELOPE_SCHEMA_RESOURCE);
this.envelopeSchemaHash = registry.schemaHash(ENVELOPE_SCHEMA_RESOURCE);
this.catalogRevision = ContractCatalogDigest.compute(contracts).toString();
Map<String, CompiledPublicationBinding> bindingByKey = new HashMap<>();
for (CompiledPublicationBinding binding : bindings) {
if (binding == null
|| bindingByKey.putIfAbsent(
CompiledPublicationBinding.contractOf(binding).stableKey(), binding)
!= null) {
throw new IllegalArgumentException("bindings must be unique and non-null");
}
}
Map<String, Entry> indexed = new HashMap<>();
for (CompiledIntegrationEventContract contract : contracts) {
if (contract == null) {
throw new IllegalArgumentException("compiled contract must not be null");
}
CompiledPublicationBinding binding = bindingByKey.remove(contract.stableKey());
if (binding == null || CompiledPublicationBinding.contractOf(binding) != contract) {
throw new IllegalArgumentException(
"each exact compiled contract requires its canonical compiled binding");
}
Sha256 registeredPayloadHash = registry.schemaHash(contract.payloadSchemaResource().value());
if (!registeredPayloadHash.equals(contract.payloadSchemaHash())) {
throw new IllegalArgumentException(
"compiled contract payload schema hash differs from the exact registry bytes");
}
String payloadSchemaId = registry.schemaId(contract.payloadSchemaResource().value());
Entry entry =
new Entry(
contract,
binding,
payloadSchemaId,
registeredPayloadHash,
schemaSetHash(envelopeSchemaHash, registeredPayloadHash));
if (indexed.putIfAbsent(contract.stableKey(), entry) != null) {
throw new IllegalArgumentException("duplicate compiled contract identity");
}
}
if (!bindingByKey.isEmpty()) {
throw new IllegalArgumentException("extra publication binding outside the closed catalog");
}
this.entries = Map.copyOf(indexed);
}
@Override
public ValidatedIntegrationEvent encode(IntegrationEventDraft<?> draft) {
if (draft == null) {
throw new IllegalArgumentException("integration event draft must not be null");
}
String stableKey = draft.contractId().value() + ":v" + draft.payloadVersion();
Entry entry = entries.get(stableKey);
if (entry == null) {
throw new IllegalArgumentException("unknown exact contract and payload version");
}
if (!draft.destinationId().equals(entry.contract().descriptor().logicalDestination())
|| !draft.destinationId().equals(entry.binding().logicalDestination())) {
throw new IllegalArgumentException("draft logical destination does not match binding");
}
if (draft.featurePayload().getClass() != entry.contract().exactPayloadRecordType()) {
throw new IllegalArgumentException("draft payload must have the exact registered type");
}
DeterministicEnvelopeWriter.EncodedEnvelope encoded = writer.write(draft, entry.contract());
byte[] payloadBytes = encoded.payloadBytes();
byte[] envelopeBytes = encoded.envelopeBytes();
int payloadBound =
Math.min(limits.maximumPayloadBytes(), entry.contract().descriptor().maximumPayloadBytes());
int envelopeBound =
Math.min(
Math.min(
limits.maximumEnvelopeBytes(),
entry.contract().descriptor().maximumEnvelopeBytes()),
entry.binding().effectiveMaximumRecordBytes());
if (payloadBytes.length > payloadBound) {
throw new IllegalArgumentException("payload exceeds effective code/deployment byte bound");
}
if (envelopeBytes.length > envelopeBound) {
throw new IllegalArgumentException("envelope exceeds effective code/deployment byte bound");
}
List<String> payloadErrors = registry.validate(entry.payloadSchemaId(), payloadBytes);
if (!payloadErrors.isEmpty()) {
throw new IllegalArgumentException(
"payload schema validation failed: " + String.join(",", payloadErrors));
}
List<String> envelopeErrors = registry.validate(envelopeSchemaId, envelopeBytes);
if (!envelopeErrors.isEmpty()) {
throw new IllegalArgumentException(
"envelope schema validation failed: " + String.join(",", envelopeErrors));
}
PartitionKeyV1.Value partitionKey =
PartitionKeyV1.derive(draft.destinationId(), draft.aggregate());
return new ValidatedIntegrationEvent(
draft.eventId(),
draft.contractId(),
1,
draft.payloadVersion(),
draft.destinationId(),
draft.aggregate(),
draft.order(),
draft.occurredAt(),
draft.correlationId(),
draft.causationId(),
partitionKey.text(),
partitionKey.bytes(),
envelopeBytes,
"application/json",
entry.schemaSetHash(),
EnvelopeHashV1.compute(envelopeBytes),
envelopeSchemaHash,
entry.payloadSchemaHash(),
catalogRevision,
entry.binding().settingsDigest().toString());
}
private static Sha256 schemaSetHash(Sha256 envelope, Sha256 payload) {
MessageDigest digest = sha256();
digest.update(SCHEMA_SET_DOMAIN);
digest.update((byte) 0);
updateLengthPrefixed(digest, envelope.bytes());
updateLengthPrefixed(digest, payload.bytes());
return new Sha256(digest.digest());
}
private static void updateLengthPrefixed(MessageDigest digest, byte[] value) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array());
digest.update(value);
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
}
}
private static final class Entry {
private final CompiledIntegrationEventContract contract;
private final CompiledPublicationBinding binding;
private final String payloadSchemaId;
private final Sha256 payloadSchemaHash;
private final Sha256 schemaSetHash;
private Entry(
CompiledIntegrationEventContract contract,
CompiledPublicationBinding binding,
String payloadSchemaId,
Sha256 payloadSchemaHash,
Sha256 schemaSetHash) {
this.contract = contract;
this.binding = binding;
this.payloadSchemaId = payloadSchemaId;
this.payloadSchemaHash = payloadSchemaHash;
this.schemaSetHash = schemaSetHash;
}
private CompiledIntegrationEventContract contract() {
return contract;
}
private CompiledPublicationBinding binding() {
return binding;
}
private String payloadSchemaId() {
return payloadSchemaId;
}
private Sha256 payloadSchemaHash() {
return payloadSchemaHash;
}
private Sha256 schemaSetHash() {
return schemaSetHash;
}
}
}
@@ -0,0 +1,701 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import com.networknt.schema.Error;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaLocation;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SchemaRegistryConfig;
import com.networknt.schema.SpecificationVersion;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import tools.jackson.core.StreamReadConstraints;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Immutable, startup-compiled Draft 2020-12 registry backed only by explicitly supplied bytes.
*
* <p>Every reference is checked before NetworkNT compilation. After construction this type exposes
* no loader, URL, file or classpath fetch operation.
*/
public final class LocalJsonSchemaRegistry {
public static final String DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
private static final Set<String> KNOWN_VOCABULARIES =
Set.of(
"https://json-schema.org/draft/2020-12/vocab/core",
"https://json-schema.org/draft/2020-12/vocab/applicator",
"https://json-schema.org/draft/2020-12/vocab/unevaluated",
"https://json-schema.org/draft/2020-12/vocab/validation",
"https://json-schema.org/draft/2020-12/vocab/meta-data",
"https://json-schema.org/draft/2020-12/vocab/format-annotation",
"https://json-schema.org/draft/2020-12/vocab/format-assertion",
"https://json-schema.org/draft/2020-12/vocab/content");
private static final Set<String> UNSUPPORTED_CLOSED_SUBSET_KEYWORDS =
Set.of("$anchor", "$dynamicRef", "$dynamicAnchor", "$recursiveRef", "$recursiveAnchor");
private static final String PINNED_AUTHORITY_ROOT = "contracts/messaging/meta/draft-2020-12/";
private static final String PINNED_AUTHORITY_MANIFEST =
PINNED_AUTHORITY_ROOT + "authority.sha256";
private static final byte[] PINNED_AUTHORITY_DOMAIN =
"ca-skeleton.messaging.draft-2020-12-authority.v1".getBytes(StandardCharsets.UTF_8);
private static final Map<String, String> PINNED_META_IDS =
Map.ofEntries(
Map.entry("draft/2020-12/schema", DRAFT_2020_12),
Map.entry(
"draft/2020-12/meta/applicator",
"https://json-schema.org/draft/2020-12/meta/applicator"),
Map.entry(
"draft/2020-12/meta/content", "https://json-schema.org/draft/2020-12/meta/content"),
Map.entry("draft/2020-12/meta/core", "https://json-schema.org/draft/2020-12/meta/core"),
Map.entry(
"draft/2020-12/meta/format-annotation",
"https://json-schema.org/draft/2020-12/meta/format-annotation"),
Map.entry(
"draft/2020-12/meta/format-assertion",
"https://json-schema.org/draft/2020-12/meta/format-assertion"),
Map.entry(
"draft/2020-12/meta/meta-data",
"https://json-schema.org/draft/2020-12/meta/meta-data"),
Map.entry(
"draft/2020-12/meta/unevaluated",
"https://json-schema.org/draft/2020-12/meta/unevaluated"),
Map.entry(
"draft/2020-12/meta/validation",
"https://json-schema.org/draft/2020-12/meta/validation"));
private final EnvelopeAdmissionLimits limits;
private final ObjectMapper mapper;
private final Map<String, CompiledSchema> schemasById;
private final Map<String, CompiledSchema> schemasByResource;
private final Sha256 pinnedDraft202012AuthorityHash;
public LocalJsonSchemaRegistry(
Map<String, SchemaSource> exactSources, EnvelopeAdmissionLimits limits) {
if (exactSources == null || exactSources.isEmpty() || limits == null) {
throw new IllegalArgumentException(
"an explicit non-empty schema source map and limits are required");
}
this.limits = limits;
this.mapper = strictMapper(limits);
PinnedAuthority pinnedAuthority =
loadPinnedAuthority(mapper, LocalJsonSchemaRegistry.class.getClassLoader());
Map<String, ParsedSource> parsedById = new LinkedHashMap<>();
Map<String, ParsedSource> parsedByResource = new LinkedHashMap<>();
exactSources.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(
entry -> {
SchemaSource source = entry.getValue();
if (source == null || !entry.getKey().equals(source.resourcePath())) {
throw new IllegalArgumentException(
"schema resource map key must equal its non-null exact source path");
}
ParsedSource parsed = parseAndCheck(source);
if (parsedByResource.putIfAbsent(source.resourcePath(), parsed) != null) {
throw new IllegalArgumentException(
"duplicate schema resource " + source.resourcePath());
}
if (parsedById.putIfAbsent(parsed.schemaId(), parsed) != null) {
throw new IllegalArgumentException("duplicate schema $id " + parsed.schemaId());
}
});
validateAllReferences(parsedById);
SchemaRegistry registry = compileRegistry(parsedById);
validatePinnedAuthorityAgainstRuntime(registry, pinnedAuthority);
validateAgainstBundledMetaSchema(registry, parsedById.values());
Map<String, CompiledSchema> byId = new LinkedHashMap<>();
Map<String, CompiledSchema> byResource = new LinkedHashMap<>();
parsedById.values().stream()
.sorted(Comparator.comparing(ParsedSource::schemaId))
.forEach(
parsed -> {
Schema compiled;
try {
compiled = registry.getSchema(SchemaLocation.of(parsed.schemaId()));
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"schema compilation failed for " + parsed.resourcePath(), exception);
}
CompiledSchema value =
new CompiledSchema(
parsed.resourcePath(),
parsed.schemaId(),
parsed.exactBytes(),
parsed.hash(),
compiled);
byId.put(parsed.schemaId(), value);
byResource.put(parsed.resourcePath(), value);
});
this.schemasById = Map.copyOf(byId);
this.schemasByResource = Map.copyOf(byResource);
this.pinnedDraft202012AuthorityHash = pinnedAuthority.authorityHash();
}
public List<String> validate(String schemaId, byte[] exactUtf8Bytes) {
if (exactUtf8Bytes == null || exactUtf8Bytes.length == 0) {
throw new IllegalArgumentException("exact UTF-8 JSON bytes must not be null or empty");
}
if (exactUtf8Bytes.length > limits.maximumEnvelopeBytes()) {
throw new IllegalArgumentException("JSON document exceeds envelope byte admission limit");
}
JsonNode node;
try {
node = mapper.readTree(exactUtf8Bytes);
} catch (RuntimeException exception) {
String message;
if (!strictUtf8Decodable(exactUtf8Bytes)) {
message = "malformed UTF-8";
} else if (exception.getMessage() != null
&& exception.getMessage().toLowerCase(java.util.Locale.ROOT).contains("string")) {
message = "JSON string exceeds parser admission limit";
} else {
message = "invalid bounded JSON document";
}
throw new IllegalArgumentException(message, exception);
}
return validate(schemaId, node);
}
private List<String> validate(String schemaId, JsonNode instance) {
CompiledSchema schema = schemasById.get(schemaId);
if (schema == null || instance == null) {
throw new IllegalArgumentException("unknown schema ID or null JSON tree");
}
enforceInstanceLimits(instance, 1);
List<Error> errors;
try {
errors = schema.compiled().validate(instance);
} catch (RuntimeException exception) {
throw new IllegalArgumentException("local schema validation failed", exception);
}
return errors.stream()
.sorted(
Comparator.comparing((Error error) -> error.getInstanceLocation().toString())
.thenComparing(Error::getKeyword))
.limit(limits.maximumValidationErrors())
.map(
error ->
error.getInstanceLocation()
+ ":"
+ (error.getKeyword() == null ? "schema" : error.getKeyword()))
.toList();
}
public String schemaId(String resourcePath) {
return requireResource(resourcePath).schemaId();
}
public Sha256 schemaHash(String resourcePath) {
return requireResource(resourcePath).hash();
}
public byte[] exactSchemaBytes(String resourcePath) {
return requireResource(resourcePath).exactBytes();
}
public Sha256 pinnedDraft202012AuthorityHash() {
return pinnedDraft202012AuthorityHash;
}
private CompiledSchema requireResource(String resourcePath) {
CompiledSchema schema = schemasByResource.get(resourcePath);
if (schema == null) {
throw new IllegalArgumentException("unknown schema resource");
}
return schema;
}
private ParsedSource parseAndCheck(SchemaSource source) {
byte[] bytes = source.exactBytes();
Sha256 actual = sha256(bytes);
if (!actual.equals(source.expectedSha256())) {
throw new IllegalArgumentException("schema checksum mismatch for " + source.resourcePath());
}
JsonNode root;
try {
root = mapper.readTree(bytes);
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"schema is not a strict bounded JSON document: " + source.resourcePath(), exception);
}
if (!root.isObject()) {
throw new IllegalArgumentException("schema root must be an object");
}
JsonNode dialect = root.get("$schema");
if (dialect == null || !dialect.isTextual() || !DRAFT_2020_12.equals(dialect.textValue())) {
throw new IllegalArgumentException("schema dialect must explicitly be Draft 2020-12");
}
JsonNode identifier = root.get("$id");
if (identifier == null || !identifier.isTextual()) {
throw new IllegalArgumentException("schema requires an explicit absolute immutable $id");
}
String schemaId = requireAbsoluteImmutableId(identifier.textValue());
rejectUnsupportedSchemaLocations(root, true);
validateVocabulary(root.get("$vocabulary"));
return new ParsedSource(source.resourcePath(), schemaId, source.exactBytes(), actual, root);
}
private static String requireAbsoluteImmutableId(String value) {
try {
URI id = new URI(value);
if (!id.isAbsolute() || !"urn".equals(id.getScheme()) || id.getFragment() != null) {
throw new IllegalArgumentException(
"schema $id must use the exact urn scheme and must not contain a fragment");
}
return id.toASCIIString();
} catch (URISyntaxException exception) {
throw new IllegalArgumentException("schema $id must be an absolute immutable URI", exception);
}
}
private static void validateVocabulary(JsonNode vocabulary) {
if (vocabulary == null) {
return;
}
if (!vocabulary.isObject()) {
throw new IllegalArgumentException("$vocabulary must be an object");
}
for (Map.Entry<String, JsonNode> entry : vocabulary.properties()) {
if (!KNOWN_VOCABULARIES.contains(entry.getKey())) {
throw new IllegalArgumentException("unknown JSON Schema vocabulary");
}
if (!entry.getValue().isBoolean()) {
throw new IllegalArgumentException("vocabulary declarations must be boolean");
}
}
}
private static void rejectUnsupportedSchemaLocations(JsonNode node, boolean root) {
if (node.isObject()) {
if (!root && node.has("$id")) {
throw new IllegalArgumentException(
"nested $id is unsupported by the closed local schema subset");
}
for (String keyword : UNSUPPORTED_CLOSED_SUBSET_KEYWORDS) {
if (node.has(keyword)) {
throw new IllegalArgumentException(
keyword + " is unsupported by the closed local schema subset");
}
}
node.properties().forEach(entry -> rejectUnsupportedSchemaLocations(entry.getValue(), false));
} else if (node.isArray()) {
node.forEach(child -> rejectUnsupportedSchemaLocations(child, false));
}
}
private void validateAllReferences(Map<String, ParsedSource> parsedById) {
for (ParsedSource source : parsedById.values()) {
List<ReferenceTarget> roots = collectReferences(source.schemaId(), source.root());
for (ReferenceTarget target : roots) {
validateReferenceChain(target, parsedById, new ArrayDeque<>(), 1);
}
}
}
private void validateReferenceChain(
ReferenceTarget target,
Map<String, ParsedSource> sources,
ArrayDeque<ReferenceTarget> stack,
int depth) {
if (depth > limits.maximumReferenceDepth() || stack.contains(target)) {
throw new IllegalArgumentException("schema reference cycle/depth exceeds supported depth");
}
ParsedSource source = sources.get(target.schemaId());
if (source == null) {
throw new IllegalArgumentException("schema reference is not in the exact supplied $id map");
}
JsonNode referenced = resolveFragment(source.root(), target.fragment());
if (referenced == null) {
throw new IllegalArgumentException("schema reference fragment does not exist");
}
stack.addLast(target);
for (ReferenceTarget nested : collectReferences(source.schemaId(), referenced)) {
validateReferenceChain(nested, sources, stack, depth + 1);
}
stack.removeLast();
}
private static List<ReferenceTarget> collectReferences(String ownerId, JsonNode node) {
List<ReferenceTarget> result = new ArrayList<>();
collectReferences(ownerId, node, result);
return result;
}
private static void collectReferences(
String ownerId, JsonNode node, List<ReferenceTarget> result) {
if (node.isObject()) {
JsonNode ref = node.get("$ref");
if (ref != null) {
if (!ref.isTextual()) {
throw new IllegalArgumentException("$ref must be a string");
}
result.add(parseReference(ownerId, ref.textValue()));
}
node.properties().forEach(entry -> collectReferences(ownerId, entry.getValue(), result));
} else if (node.isArray()) {
node.forEach(child -> collectReferences(ownerId, child, result));
}
}
private static ReferenceTarget parseReference(String ownerId, String value) {
try {
URI reference = new URI(value);
if (reference.isAbsolute() && !"urn".equals(reference.getScheme())) {
throw new IllegalArgumentException(
"absolute schema reference $ref must use the exact urn scheme");
}
String fragment = reference.getRawFragment();
if (fragment != null && !fragment.isEmpty() && !fragment.startsWith("/")) {
throw new IllegalArgumentException("only JSON Pointer schema fragments are supported");
}
if (!reference.isAbsolute() && !value.startsWith("#")) {
throw new IllegalArgumentException("relative external schema reference forbidden");
}
String base =
value.startsWith("#")
? ownerId
: new URI(reference.getScheme(), reference.getSchemeSpecificPart(), null)
.toASCIIString();
return new ReferenceTarget(base, fragment == null ? "" : fragment);
} catch (URISyntaxException exception) {
throw new IllegalArgumentException("invalid schema reference", exception);
}
}
private static JsonNode resolveFragment(JsonNode root, String fragment) {
if (fragment.isEmpty()) {
return root;
}
JsonNode resolved = root.at(fragment);
return resolved.isMissingNode() ? null : resolved;
}
private static SchemaRegistry compileRegistry(Map<String, ParsedSource> parsedById) {
Map<String, String> exactSchemas = new HashMap<>();
parsedById.forEach(
(id, source) ->
exactSchemas.put(id, new String(source.exactBytes(), StandardCharsets.UTF_8)));
SchemaRegistryConfig config =
SchemaRegistryConfig.builder()
.formatAssertionsEnabled(true)
.typeLoose(false)
.failFast(false)
.cacheRefs(true)
.build();
try {
return SchemaRegistry.withDefaultDialect(
SpecificationVersion.DRAFT_2020_12,
builder -> builder.schemaRegistryConfig(config).schemas(exactSchemas));
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"local Draft 2020-12 registry compilation failed", exception);
}
}
private static void validateAgainstBundledMetaSchema(
SchemaRegistry registry, java.util.Collection<ParsedSource> sources) {
Schema metaSchema;
try {
metaSchema = registry.getSchema(SchemaLocation.of(DRAFT_2020_12));
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"bundled Draft 2020-12 meta-schema is unavailable", exception);
}
for (ParsedSource source : sources) {
List<Error> errors = metaSchema.validate(source.root());
if (!errors.isEmpty()) {
throw new IllegalArgumentException(
"schema fails bundled Draft 2020-12 meta-schema validation: " + source.resourcePath());
}
}
}
private static PinnedAuthority loadPinnedAuthority(ObjectMapper mapper, ClassLoader classLoader) {
byte[] manifestBytes = readRequiredClasspathBytes(classLoader, PINNED_AUTHORITY_MANIFEST);
String manifest;
try {
manifest =
StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(manifestBytes))
.toString();
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority manifest is not strict UTF-8", exception);
}
Map<String, Sha256> expectedByPath = new LinkedHashMap<>();
for (String line : manifest.lines().toList()) {
if (!line.matches("[a-f0-9]{64} draft/2020-12/(schema|meta/[a-z-]+)")) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority manifest has an invalid entry");
}
String path = line.substring(66);
Sha256 expected = new Sha256(HexFormat.of().parseHex(line.substring(0, 64)));
if (expectedByPath.putIfAbsent(path, expected) != null) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority manifest contains a duplicate path");
}
}
if (!expectedByPath.keySet().equals(PINNED_META_IDS.keySet())) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority manifest has an incomplete resource set");
}
MessageDigest authorityDigest = sha256Digest();
authorityDigest.update(PINNED_AUTHORITY_DOMAIN);
authorityDigest.update((byte) 0);
Map<String, JsonNode> nodesById = new LinkedHashMap<>();
expectedByPath.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(
entry -> {
String path = entry.getKey();
byte[] pinnedBytes =
readRequiredClasspathBytes(classLoader, PINNED_AUTHORITY_ROOT + path);
if (!sha256(pinnedBytes).equals(entry.getValue())) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority checksum mismatch for " + path);
}
JsonNode node;
try {
node = mapper.readTree(pinnedBytes);
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority is not bounded strict JSON", exception);
}
String expectedId = PINNED_META_IDS.get(path);
JsonNode id = node.get("$id");
if (id == null || !id.isTextual() || !expectedId.equals(id.textValue())) {
throw new IllegalArgumentException(
"pinned Draft 2020-12 authority has an unexpected $id for " + path);
}
nodesById.put(expectedId, node);
updateLengthPrefixed(authorityDigest, path.getBytes(StandardCharsets.UTF_8));
updateLengthPrefixed(authorityDigest, pinnedBytes);
});
return new PinnedAuthority(Map.copyOf(nodesById), new Sha256(authorityDigest.digest()));
}
private static void validatePinnedAuthorityAgainstRuntime(
SchemaRegistry registry, PinnedAuthority authority) {
for (Map.Entry<String, JsonNode> entry : authority.nodesById().entrySet()) {
Schema runtimeSchema;
try {
runtimeSchema = registry.getSchema(SchemaLocation.of(entry.getKey()));
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"NetworkNT runtime is missing pinned Draft 2020-12 authority " + entry.getKey(),
exception);
}
if (!entry.getValue().equals(runtimeSchema.getSchemaNode())) {
throw new IllegalArgumentException(
"NetworkNT runtime Draft 2020-12 authority tree differs from pinned exact bytes");
}
}
}
private static byte[] readRequiredClasspathBytes(ClassLoader classLoader, String path) {
try (InputStream input = classLoader.getResourceAsStream(path)) {
if (input == null) {
throw new IllegalArgumentException(
"required pinned Draft 2020-12 classpath resource is missing: " + path);
}
return input.readAllBytes();
} catch (IOException exception) {
throw new IllegalArgumentException(
"required pinned Draft 2020-12 classpath resource could not be read: " + path, exception);
}
}
private static void updateLengthPrefixed(MessageDigest digest, byte[] value) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array());
digest.update(value);
}
private void enforceInstanceLimits(JsonNode node, int depth) {
if (depth > limits.maximumDepth()) {
throw new IllegalArgumentException("JSON tree exceeds depth admission limit");
}
if (node.isObject()) {
if (node.size() > limits.maximumObjectProperties()) {
throw new IllegalArgumentException("JSON object exceeds properties admission limit");
}
for (Map.Entry<String, JsonNode> entry : node.properties()) {
enforceString(entry.getKey());
enforceInstanceLimits(entry.getValue(), depth + 1);
}
} else if (node.isArray()) {
if (node.size() > limits.maximumArrayItems()) {
throw new IllegalArgumentException("JSON array exceeds items admission limit");
}
node.forEach(child -> enforceInstanceLimits(child, depth + 1));
} else if (node.isTextual()) {
enforceString(node.textValue());
if (node.textValue().codePointCount(0, node.textValue().length())
> limits.maximumRegexInputCharacters()) {
throw new IllegalArgumentException("JSON string exceeds regex input admission limit");
}
} else if (node.isNumber()) {
long digits = node.toString().codePoints().filter(Character::isDigit).count();
if (digits > limits.maximumNumberDigits()) {
throw new IllegalArgumentException("JSON number exceeds digits admission limit");
}
if (node.isFloatingPointNumber() && !Double.isFinite(node.doubleValue())) {
throw new IllegalArgumentException("JSON number must be finite");
}
}
}
private void enforceString(String value) {
if (value.codePointCount(0, value.length()) > limits.maximumStringCharacters()) {
throw new IllegalArgumentException("JSON string exceeds character admission limit");
}
byte[] bytes = strictUtf8(value);
if (bytes.length > limits.maximumStringUtf8Bytes()) {
throw new IllegalArgumentException("JSON string exceeds UTF-8 byte admission limit");
}
}
private static boolean strictUtf8Decodable(byte[] bytes) {
try {
StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes));
return true;
} catch (CharacterCodingException exception) {
return false;
}
}
private static byte[] strictUtf8(String value) {
try {
ByteBuffer encoded =
StandardCharsets.UTF_8
.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
byte[] result = new byte[encoded.remaining()];
encoded.get(result);
return result;
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException("JSON string contains an unpaired surrogate", exception);
}
}
private static ObjectMapper strictMapper(EnvelopeAdmissionLimits limits) {
JsonFactory factory =
JsonFactory.builder()
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(limits.maximumDepth())
.maxDocumentLength(limits.maximumEnvelopeBytes())
.maxNumberLength(limits.maximumNumberDigits())
.maxStringLength(limits.maximumStringCharacters())
.maxNameLength(limits.maximumStringCharacters())
.build())
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.build();
return JsonMapper.builder(factory)
.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.build();
}
private static Sha256 sha256(byte[] bytes) {
return new Sha256(sha256Digest().digest(bytes));
}
private static MessageDigest sha256Digest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
}
}
@SuppressWarnings("ArrayRecordComponent")
public record SchemaSource(String resourcePath, byte[] exactBytes, Sha256 expectedSha256) {
public SchemaSource {
if (resourcePath == null
|| resourcePath.isBlank()
|| resourcePath.startsWith("/")
|| resourcePath.contains("..")
|| exactBytes == null
|| exactBytes.length == 0
|| expectedSha256 == null) {
throw new IllegalArgumentException("exact local schema source fields are required");
}
exactBytes = exactBytes.clone();
}
@Override
public byte[] exactBytes() {
return exactBytes.clone();
}
}
@SuppressWarnings("ArrayRecordComponent")
private record ParsedSource(
String resourcePath, String schemaId, byte[] exactBytes, Sha256 hash, JsonNode root) {
private ParsedSource {
exactBytes = exactBytes.clone();
}
@Override
public byte[] exactBytes() {
return exactBytes.clone();
}
}
@SuppressWarnings("ArrayRecordComponent")
private record CompiledSchema(
String resourcePath, String schemaId, byte[] exactBytes, Sha256 hash, Schema compiled) {
private CompiledSchema {
exactBytes = exactBytes.clone();
}
@Override
public byte[] exactBytes() {
return exactBytes.clone();
}
}
private record PinnedAuthority(Map<String, JsonNode> nodesById, Sha256 authorityHash) {}
private record ReferenceTarget(String schemaId, String fragment) {}
}
@@ -0,0 +1,9 @@
41da76f5afb7ce062d248f762463a92f7ca47e4e0f905b224ba6afeef91ded0f draft/2020-12/schema
c4a6e4147b91fef7fea6dc058cb1bf93402f7414b76578a8b16aaf1dad6aacef draft/2020-12/meta/applicator
08343747764e4a5814262793cf4d652057a7913863c5950d43297e8e1fdac5b6 draft/2020-12/meta/content
c2d12a8e4dd11d336dfc83a3f663aa4c69f0b49b3beb094ffeb25b5316f4803d draft/2020-12/meta/core
abc775adfefd89d22358170d9bf93f4ebd2349563bbbedd60f02bef7c812bcc0 draft/2020-12/meta/format-annotation
6a5a8e13c605e3eff51f9bf8da18078880d81ff1634e391760ccc2e16ee2146f draft/2020-12/meta/format-assertion
8f76d6e14f41b9b92ef933b708cdc5144c8b5268651ad11918485fb1754f1c76 draft/2020-12/meta/meta-data
2dbfbcb73994b670b0976492adee1fffb46c21682784d2f5a4ca561f9e2d0cb4 draft/2020-12/meta/unevaluated
7010a31e541f32d2be721e2de348df75c9b36876a3ed304877fc0abda1d37a58 draft/2020-12/meta/validation
@@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/applicator",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/applicator": true
},
"$dynamicAnchor": "meta",
"title": "Applicator vocabulary meta-schema",
"type": ["object", "boolean"],
"properties": {
"prefixItems": { "$ref": "#/$defs/schemaArray" },
"items": { "$dynamicRef": "#meta" },
"contains": { "$dynamicRef": "#meta" },
"additionalProperties": { "$dynamicRef": "#meta" },
"properties": {
"type": "object",
"additionalProperties": { "$dynamicRef": "#meta" },
"default": {}
},
"patternProperties": {
"type": "object",
"additionalProperties": { "$dynamicRef": "#meta" },
"propertyNames": { "format": "regex" },
"default": {}
},
"dependentSchemas": {
"type": "object",
"additionalProperties": { "$dynamicRef": "#meta" },
"default": {}
},
"propertyNames": { "$dynamicRef": "#meta" },
"if": { "$dynamicRef": "#meta" },
"then": { "$dynamicRef": "#meta" },
"else": { "$dynamicRef": "#meta" },
"allOf": { "$ref": "#/$defs/schemaArray" },
"anyOf": { "$ref": "#/$defs/schemaArray" },
"oneOf": { "$ref": "#/$defs/schemaArray" },
"not": { "$dynamicRef": "#meta" }
},
"$defs": {
"schemaArray": {
"type": "array",
"minItems": 1,
"items": { "$dynamicRef": "#meta" }
}
}
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/content",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/content": true
},
"$dynamicAnchor": "meta",
"title": "Content vocabulary meta-schema",
"type": ["object", "boolean"],
"properties": {
"contentEncoding": { "type": "string" },
"contentMediaType": { "type": "string" },
"contentSchema": { "$dynamicRef": "#meta" }
}
}
@@ -0,0 +1,51 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/core",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true
},
"$dynamicAnchor": "meta",
"title": "Core vocabulary meta-schema",
"type": ["object", "boolean"],
"properties": {
"$id": {
"$ref": "#/$defs/uriReferenceString",
"$comment": "Non-empty fragments not allowed.",
"pattern": "^[^#]*#?$"
},
"$schema": { "$ref": "#/$defs/uriString" },
"$ref": { "$ref": "#/$defs/uriReferenceString" },
"$anchor": { "$ref": "#/$defs/anchorString" },
"$dynamicRef": { "$ref": "#/$defs/uriReferenceString" },
"$dynamicAnchor": { "$ref": "#/$defs/anchorString" },
"$vocabulary": {
"type": "object",
"propertyNames": { "$ref": "#/$defs/uriString" },
"additionalProperties": {
"type": "boolean"
}
},
"$comment": {
"type": "string"
},
"$defs": {
"type": "object",
"additionalProperties": { "$dynamicRef": "#meta" }
}
},
"$defs": {
"anchorString": {
"type": "string",
"pattern": "^[A-Za-z_][-A-Za-z0-9._]*$"
},
"uriString": {
"type": "string",
"format": "uri"
},
"uriReferenceString": {
"type": "string",
"format": "uri-reference"
}
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/format-annotation",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/format-annotation": true
},
"$dynamicAnchor": "meta",
"title": "Format vocabulary meta-schema for annotation results",
"type": ["object", "boolean"],
"properties": {
"format": { "type": "string" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/format-assertion",
"$dynamicAnchor": "meta",
"title": "Format vocabulary meta-schema for assertion results",
"type": ["object", "boolean"],
"properties": {
"format": { "type": "string" }
}
}
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/meta-data",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/meta-data": true
},
"$dynamicAnchor": "meta",
"title": "Meta-data vocabulary meta-schema",
"type": ["object", "boolean"],
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"default": true,
"deprecated": {
"type": "boolean",
"default": false
},
"readOnly": {
"type": "boolean",
"default": false
},
"writeOnly": {
"type": "boolean",
"default": false
},
"examples": {
"type": "array",
"items": true
}
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/unevaluated",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/unevaluated": true
},
"$dynamicAnchor": "meta",
"title": "Unevaluated applicator vocabulary meta-schema",
"type": ["object", "boolean"],
"properties": {
"unevaluatedItems": { "$dynamicRef": "#meta" },
"unevaluatedProperties": { "$dynamicRef": "#meta" }
}
}
@@ -0,0 +1,98 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/meta/validation",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/validation": true
},
"$dynamicAnchor": "meta",
"title": "Validation vocabulary meta-schema",
"type": ["object", "boolean"],
"properties": {
"type": {
"anyOf": [
{ "$ref": "#/$defs/simpleTypes" },
{
"type": "array",
"items": { "$ref": "#/$defs/simpleTypes" },
"minItems": 1,
"uniqueItems": true
}
]
},
"const": true,
"enum": {
"type": "array",
"items": true
},
"multipleOf": {
"type": "number",
"exclusiveMinimum": 0
},
"maximum": {
"type": "number"
},
"exclusiveMaximum": {
"type": "number"
},
"minimum": {
"type": "number"
},
"exclusiveMinimum": {
"type": "number"
},
"maxLength": { "$ref": "#/$defs/nonNegativeInteger" },
"minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" },
"pattern": {
"type": "string",
"format": "regex"
},
"maxItems": { "$ref": "#/$defs/nonNegativeInteger" },
"minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" },
"uniqueItems": {
"type": "boolean",
"default": false
},
"maxContains": { "$ref": "#/$defs/nonNegativeInteger" },
"minContains": {
"$ref": "#/$defs/nonNegativeInteger",
"default": 1
},
"maxProperties": { "$ref": "#/$defs/nonNegativeInteger" },
"minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" },
"required": { "$ref": "#/$defs/stringArray" },
"dependentRequired": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/stringArray"
}
}
},
"$defs": {
"nonNegativeInteger": {
"type": "integer",
"minimum": 0
},
"nonNegativeIntegerDefault0": {
"$ref": "#/$defs/nonNegativeInteger",
"default": 0
},
"simpleTypes": {
"enum": [
"array",
"boolean",
"integer",
"null",
"number",
"object",
"string"
]
},
"stringArray": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true,
"default": []
}
}
}
@@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.org/draft/2020-12/schema",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true,
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
"https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
"https://json-schema.org/draft/2020-12/vocab/validation": true,
"https://json-schema.org/draft/2020-12/vocab/meta-data": true,
"https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
"https://json-schema.org/draft/2020-12/vocab/content": true
},
"$dynamicAnchor": "meta",
"title": "Core and Validation specifications meta-schema",
"allOf": [
{"$ref": "meta/core"},
{"$ref": "meta/applicator"},
{"$ref": "meta/unevaluated"},
{"$ref": "meta/validation"},
{"$ref": "meta/meta-data"},
{"$ref": "meta/format-annotation"},
{"$ref": "meta/content"}
],
"type": ["object", "boolean"],
"$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.",
"properties": {
"definitions": {
"$comment": "\"definitions\" has been replaced by \"$defs\".",
"type": "object",
"additionalProperties": { "$dynamicRef": "#meta" },
"deprecated": true,
"default": {}
},
"dependencies": {
"$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.",
"type": "object",
"additionalProperties": {
"anyOf": [
{ "$dynamicRef": "#meta" },
{ "$ref": "meta/validation#/$defs/stringArray" }
]
},
"deprecated": true,
"default": {}
},
"$recursiveAnchor": {
"$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".",
"$ref": "meta/core#/$defs/anchorString",
"deprecated": true
},
"$recursiveRef": {
"$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".",
"$ref": "meta/core#/$defs/uriReferenceString",
"deprecated": true
}
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.adapter.outbound.messaging;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender;
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
class MessagingConfigTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withBean(FailOpenDependencyLogger.class, FailOpenDependencyLogger::new)
.withUserConfiguration(MessagingConfig.class);
@Test
void blankBrokerBindsDisabledSentinelsCharacterization() {
runner
.withPropertyValues("app.messaging.broker=")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(MessagePublisher.class))
.isInstanceOf(DisabledMessagePublisher.class);
assertThat(context.getBean(OutboxMessagePublishPort.class))
.isInstanceOf(DisabledOutboxMessagePublisher.class);
});
}
@Test
void selectedKafkaBrokerWithoutProjectSenderFailsStartupCharacterization() {
runner
.withUserConfiguration(KafkaAdapterConfig.class)
.withPropertyValues(
"app.messaging.broker=kafka", "app.messaging.kafka.brokers=localhost:9092")
.run(
context -> {
assertThat(context).hasFailed();
assertThat(rootCause(context.getStartupFailure()))
.isInstanceOf(NoSuchBeanDefinitionException.class)
.hasMessageContaining(KafkaSender.class.getName());
});
}
@Test
void selectedBrokerIdMismatchFailsStartupCharacterization() {
runner
.withBean(MessageBroker.class, () -> brokerReporting("other"))
.withPropertyValues("app.messaging.broker=kafka")
.run(
context ->
assertThat(context)
.hasFailed()
.getFailure()
.hasRootCauseMessage(
"app.messaging.broker=kafka but the active MessageBroker reports brokerId"
+ " 'other'"));
}
private static MessageBroker brokerReporting(String brokerId) {
return new MessageBroker() {
@Override
public String brokerId() {
return brokerId;
}
@Override
public void send(
dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage ignoredMessage) {}
};
}
private static Throwable rootCause(Throwable failure) {
Throwable current = failure;
while (current.getCause() != null) {
current = current.getCause();
}
return current;
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.adapter.outbound.messaging.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry.CapabilityCard;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry.CardRole;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class MessagingCapabilityCardRegistryTest {
@Test
void exposesExactlyTheFirstR2TupleWithOneClosedCardPerRole() {
MessagingCapabilityCardRegistry registry = MessagingCapabilityCardRegistry.exactFirstR2();
assertThat(registry.cards())
.extracting(CapabilityCard::cardId)
.containsExactly(
"messaging-outbox-publish.v1",
"kafka-spring-acknowledged-idempotent.v1",
"postgresql-polling-outbox.v2",
"postgresql-per-record-jit-claim.v1",
"json-schema-envelope.v1",
"external-topic-validated.v1",
"kafka-sasl-ssl-scram-sha-512.v1",
"kafka-compression-none.v1",
"per-key-normal-path-sequence-detectable.v1",
"same-postgresql-transaction-resource.v1",
"authenticated-internal-web-disposition.v1");
assertThat(registry.cards())
.extracting(CapabilityCard::role)
.containsExactlyInAnyOrder(
CardRole.SEMANTIC,
CardRole.PRODUCER,
CardRole.DISPATCH,
CardRole.CLAIM,
CardRole.SERIALIZATION,
CardRole.TOPIC,
CardRole.SECURITY,
CardRole.COMPRESSION,
CardRole.ORDERING,
CardRole.TRANSACTION,
CardRole.OPERATOR_CONTROL);
assertThat(registry.selection().producerCardId())
.isEqualTo("kafka-spring-acknowledged-idempotent.v1");
assertThat(registry.selection().serializationCardId()).isEqualTo("json-schema-envelope.v1");
assertThat(registry.claimsReleaseEligibility()).isFalse();
assertThatThrownBy(() -> registry.cards().add(registry.cards().getFirst()))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void rejectsNullUnknownConsumerCdcEosSchemaRegistryAndDuplicateCards() {
List<CapabilityCard> exact =
new ArrayList<>(MessagingCapabilityCardRegistry.exactFirstR2().cards());
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(null))
.isInstanceOf(IllegalArgumentException.class);
assertRejected(exact, new CapabilityCard("future-provider.v1", CardRole.PRODUCER), "unknown");
assertRejected(
exact, new CapabilityCard("messaging-inbox-consume.v1", CardRole.SEMANTIC), "unknown");
assertRejected(
exact, new CapabilityCard("messaging-cdc-dispatch.v1", CardRole.DISPATCH), "unknown");
assertRejected(
exact, new CapabilityCard("kafka-exactly-once.v1", CardRole.PRODUCER), "unknown");
assertRejected(
exact, new CapabilityCard("json-schema-registry.v1", CardRole.SERIALIZATION), "unknown");
exact.add(exact.getFirst());
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(exact))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate");
}
@Test
void rejectsRoleSubstitutionEvenWhenTheCardIdItselfIsKnown() {
List<CapabilityCard> cards =
new ArrayList<>(MessagingCapabilityCardRegistry.exactFirstR2().cards());
cards.set(
1, new CapabilityCard("kafka-spring-acknowledged-idempotent.v1", CardRole.SERIALIZATION));
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(cards))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("role");
}
private static void assertRejected(
List<CapabilityCard> exact, CapabilityCard unsupported, String message) {
List<CapabilityCard> cards = new ArrayList<>(exact);
cards.set(1, unsupported);
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(cards))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(message);
}
}
@@ -0,0 +1,564 @@
package dev.caskeleton.adapter.outbound.messaging.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class ContractCatalogCompilerTest {
private static final ContractCatalogCompiler COMPILER = new ContractCatalogCompiler();
@Test
void compilesAnImmutableClosedCatalogFromExactRecordTypeTokens() {
ArrayList<String> componentOrder = new ArrayList<>(List.of("eventId", "count"));
Contribution<ValidPayload> contribution =
contribution(
"fixture.event.created",
1,
ValidPayload.class,
componentOrder,
"contracts/messaging/fixture.event.created/v1.schema.json",
hash(1),
descriptor("fixture-events", true, 256));
List<CompiledIntegrationEventContract> catalog = COMPILER.compile(List.of(contribution));
componentOrder.set(0, "mutated");
assertThat(catalog).hasSize(1);
CompiledIntegrationEventContract compiled = catalog.getFirst();
assertThat(compiled.stableKey()).isEqualTo("fixture.event.created:v1");
assertThat(compiled.exactPayloadRecordType()).isEqualTo(ValidPayload.class);
assertThat(compiled.exactPayloadRecordType().isRecord()).isTrue();
assertThat(Modifier.isFinal(compiled.exactPayloadRecordType().getModifiers())).isTrue();
assertThat(compiled.canonicalRecordComponentOrder()).containsExactly("eventId", "count");
assertThat(compiled.descriptor().logicalDestination())
.isEqualTo(new LogicalDestinationId("fixture-events"));
assertThatThrownBy(() -> catalog.add(compiled))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> compiled.canonicalRecordComponentOrder().add("another"))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void rejectsNullInputsDuplicateStableSchemaAndExactPayloadIdentities() {
Contribution<ValidPayload> first = validContribution("fixture.event.created", 1);
assertThatThrownBy(() -> COMPILER.compile(null)).isInstanceOf(IllegalArgumentException.class);
List<Contribution<ValidPayload>> withNull = new ArrayList<>();
withNull.add(first);
withNull.add(null);
assertThatThrownBy(() -> COMPILER.compile(withNull))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contribution");
assertThatThrownBy(
() -> COMPILER.compile(List.of(first, validContribution("fixture.event.created", 1))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contract");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
first,
contribution(
"fixture.other.created",
1,
OtherPayload.class,
List.of("value"),
first.payloadSchemaResource().value(),
hash(2),
descriptor("fixture-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("schema");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
first,
contribution(
"fixture.other.created",
1,
ValidPayload.class,
List.of("eventId", "count"),
"contracts/messaging/fixture.other.created/v1.schema.json",
hash(2),
descriptor("fixture-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payload");
}
@Test
void rejectsNonPositiveVersionAndInvalidExactPayloadKinds() {
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
contribution(
"fixture.event.created",
0,
ValidPayload.class,
List.of("eventId", "count"),
"contracts/messaging/fixture.event.created/v1.schema.json",
hash(1),
descriptor("fixture-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("version");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
contribution(
"fixture.event.created",
1,
MutablePayload.class,
List.of(),
"contracts/messaging/fixture.event.created/v1.schema.json",
hash(1),
descriptor("fixture-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("record");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
contribution(
"fixture.event.created",
1,
null,
List.of(),
"contracts/messaging/fixture.event.created/v1.schema.json",
hash(1),
descriptor("fixture-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payload");
}
@Test
void rejectsNullBlankDuplicateAndReflectionMismatchedComponentOrders() {
assertInvalidOrder(null);
assertInvalidOrder(List.of("eventId", " "));
assertInvalidOrder(List.of("eventId", "eventId"));
assertInvalidOrder(List.of("count", "eventId"));
assertInvalidOrder(List.of("eventId"));
assertInvalidOrder(List.of("eventId", "count", "unknown"));
}
@Test
void rejectsMissingDescriptorsAndLogicalDestinationDriftAcrossPayloadVersions() {
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
contribution(
"fixture.event.created",
1,
ValidPayload.class,
List.of("eventId", "count"),
"contracts/messaging/fixture.event.created/v1.schema.json",
hash(1),
null))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("descriptor");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
validContribution("fixture.event.created", 1),
contribution(
"fixture.event.created",
2,
OtherPayload.class,
List.of("value"),
"contracts/messaging/fixture.event.created/v2.schema.json",
hash(2),
descriptor("other-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("logical destination");
}
@Test
void compiledContractCannotBeConstructedWithAnInvalidExactTypeOrComponentOrder() {
Contribution<ValidPayload> contribution = validContribution("fixture.event.created", 1);
assertThatThrownBy(
() ->
new CompiledIntegrationEventContract(
contribution.contractId(),
contribution.payloadVersion(),
MutablePayload.class,
List.of(),
contribution.payloadSchemaResource(),
contribution.payloadSchemaHash(),
contribution.descriptor()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("record");
assertThatThrownBy(
() ->
new CompiledIntegrationEventContract(
contribution.contractId(),
contribution.payloadVersion(),
ValidPayload.class,
List.of("count", "eventId"),
contribution.payloadSchemaResource(),
contribution.payloadSchemaHash(),
contribution.descriptor()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("component");
}
@Test
void compiledContractUsesOnlyAStaticPublicCompositionBridgeWithoutReflectionLeak()
throws NoSuchMethodException {
assertThat(Modifier.isFinal(CompiledIntegrationEventContract.class.getModifiers())).isTrue();
assertThat(CompiledIntegrationEventContract.class.isRecord()).isFalse();
assertThat(CompiledIntegrationEventContract.class.getConstructors()).isEmpty();
assertThat(CompiledIntegrationEventContract.class.getDeclaredConstructors())
.allSatisfy(
constructor -> assertThat(Modifier.isPublic(constructor.getModifiers())).isFalse());
assertThat(
Modifier.isPublic(
ContractCatalogCompiler.class
.getDeclaredMethod("compile", List.class)
.getModifiers()))
.isFalse();
assertThat(
ContractCatalogCompiler.class
.getDeclaredMethod("compileExact", List.class)
.getModifiers())
.satisfies(
modifiers -> {
assertThat(Modifier.isPublic(modifiers)).isTrue();
assertThat(Modifier.isStatic(modifiers)).isTrue();
});
assertThat(
java.util.Arrays.stream(CompiledIntegrationEventContract.class.getMethods())
.map(java.lang.reflect.Method::getReturnType))
.doesNotContain(java.lang.reflect.Method.class);
}
@Test
void snapshotsEveryContributionAccessorExactlyOnceIncludingAStatefulSchemaHash() {
StatefulContribution<ValidPayload> contribution =
new StatefulContribution<>(
new ContractId("fixture.event.created"),
1,
ValidPayload.class,
List.of("eventId", "count"),
new SchemaResourceId("contracts/messaging/fixture.event.created/v1.schema.json"),
List.of(hash(1), hash(2)),
List.of(descriptor("fixture-events", true, 256)));
CompiledIntegrationEventContract compiled = COMPILER.compile(List.of(contribution)).getFirst();
assertThat(compiled.payloadSchemaHash()).isEqualTo(hash(1));
assertThat(contribution.callCounts()).containsOnly(1);
}
@Test
void statefulDescriptorCannotBypassCrossVersionLogicalDestinationDrift() {
StatefulContribution<OtherPayload> statefulSecondVersion =
new StatefulContribution<>(
new ContractId("fixture.event.created"),
2,
OtherPayload.class,
List.of("value"),
new SchemaResourceId("contracts/messaging/fixture.event.created/v2.schema.json"),
List.of(hash(2)),
List.of(
descriptor("other-events", true, 256),
descriptor("fixture-events", true, 256),
descriptor("other-events", true, 256)));
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(validContribution("fixture.event.created", 1), statefulSecondVersion)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("logical destination");
}
@Test
void recursivelyFreezesOnlyTheClosedDeclaredGenericPayloadGraph() {
Contribution<ClosedGraphPayload> contribution =
contribution(
"fixture.closed.graph",
1,
ClosedGraphPayload.class,
List.of("name", "count", "amount", "state", "nested"),
"contracts/messaging/fixture.closed.graph/v1.schema.json",
hash(7),
descriptor("fixture-events", true, 256));
CompiledIntegrationEventContract contract = COMPILER.compile(List.of(contribution)).getFirst();
CompiledIntegrationEventContract.PayloadShape root =
CompiledIntegrationEventContract.payloadShapeOf(contract);
List<CompiledIntegrationEventContract.PayloadComponent> components =
CompiledIntegrationEventContract.PayloadShape.componentsOf(root);
assertThat(CompiledIntegrationEventContract.PayloadShape.kindOf(root))
.isEqualTo(CompiledIntegrationEventContract.PayloadKind.RECORD);
assertThat(CompiledIntegrationEventContract.PayloadShape.exactJavaTypeOf(root))
.isEqualTo(ClosedGraphPayload.class);
assertThat(components.stream().map(CompiledIntegrationEventContract.PayloadComponent::nameOf))
.containsExactly("name", "count", "amount", "state", "nested");
CompiledIntegrationEventContract.PayloadShape optional =
CompiledIntegrationEventContract.PayloadComponent.shapeOf(components.getLast());
assertThat(CompiledIntegrationEventContract.PayloadShape.kindOf(optional))
.isEqualTo(CompiledIntegrationEventContract.PayloadKind.OPTIONAL);
CompiledIntegrationEventContract.PayloadShape list =
CompiledIntegrationEventContract.PayloadShape.elementShapeOf(optional);
assertThat(CompiledIntegrationEventContract.PayloadShape.kindOf(list))
.isEqualTo(CompiledIntegrationEventContract.PayloadKind.LIST);
assertThat(
CompiledIntegrationEventContract.PayloadShape.exactJavaTypeOf(
CompiledIntegrationEventContract.PayloadShape.elementShapeOf(list)))
.isEqualTo(ClosedNested.class);
assertThatThrownBy(() -> components.add(components.getFirst()))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void rejectsOpenRawWildcardMapJsonTreeInterfaceAndGenericRecordGraphs() {
assertUnsupportedGraph(ObjectPayload.class, List.of("value"));
assertUnsupportedGraph(InterfacePayload.class, List.of("value"));
assertUnsupportedGraph(MapPayload.class, List.of("value"));
assertUnsupportedGraph(RawListPayload.class, List.of("value"));
assertUnsupportedGraph(WildcardListPayload.class, List.of("value"));
assertUnsupportedGraph(JsonTreePayload.class, List.of("value"));
assertUnsupportedGraph(GenericRecordPayload.class, List.of("value"));
}
private static void assertInvalidOrder(List<String> order) {
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(
contribution(
"fixture.event.created",
1,
ValidPayload.class,
order,
"contracts/messaging/fixture.event.created/v1.schema.json",
hash(1),
descriptor("fixture-events", true, 256)))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("component");
}
@SuppressWarnings({"rawtypes", "unchecked"})
private static void assertUnsupportedGraph(
Class<? extends IntegrationPayload> type, List<String> order) {
IntegrationEventContractContribution<?> contribution =
contribution(
"fixture.unsupported.graph",
1,
(Class) type,
order,
"contracts/messaging/fixture.unsupported.graph/v1.schema.json",
hash(9),
descriptor("fixture-events", true, 256));
assertThatThrownBy(() -> COMPILER.compile(List.of(contribution)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageMatching(".*(unsupported declared|raw Optional/List).*");
}
private static Contribution<ValidPayload> validContribution(String contractId, int version) {
return contribution(
contractId,
version,
ValidPayload.class,
List.of("eventId", "count"),
"contracts/messaging/" + contractId + "/v" + version + ".schema.json",
hash(version),
descriptor("fixture-events", true, 256));
}
private static ContractDescriptor descriptor(
String destination, boolean orderingRequired, int maximumEnvelopeBytes) {
return new ContractDescriptor(
"adapter-outbound-messaging",
new LogicalDestinationId(destination),
"json-schema-envelope-v1",
orderingRequired,
128,
maximumEnvelopeBytes,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7));
}
private static Sha256 hash(int seed) {
return new Sha256(HexFormat.of().parseHex(String.format("%064x", seed)));
}
private static <P extends IntegrationPayload> Contribution<P> contribution(
String contractId,
int version,
Class<P> type,
List<String> componentOrder,
String schemaResource,
Sha256 schemaHash,
ContractDescriptor descriptor) {
return new Contribution<>(
new ContractId(contractId),
version,
type,
componentOrder,
new SchemaResourceId(schemaResource),
schemaHash,
descriptor);
}
private record Contribution<P extends IntegrationPayload>(
ContractId contractId,
int payloadVersion,
Class<P> exactPayloadRecordType,
List<String> canonicalRecordComponentOrder,
SchemaResourceId payloadSchemaResource,
Sha256 payloadSchemaHash,
ContractDescriptor descriptor)
implements IntegrationEventContractContribution<P> {}
private static final class StatefulContribution<P extends IntegrationPayload>
implements IntegrationEventContractContribution<P> {
private final ContractId contractId;
private final int payloadVersion;
private final Class<P> exactPayloadRecordType;
private final List<String> canonicalRecordComponentOrder;
private final SchemaResourceId payloadSchemaResource;
private final List<Sha256> payloadSchemaHashes;
private final List<ContractDescriptor> descriptors;
private int contractIdCalls;
private int payloadVersionCalls;
private int exactPayloadRecordTypeCalls;
private int canonicalRecordComponentOrderCalls;
private int payloadSchemaResourceCalls;
private int payloadSchemaHashCalls;
private int descriptorCalls;
private StatefulContribution(
ContractId contractId,
int payloadVersion,
Class<P> exactPayloadRecordType,
List<String> canonicalRecordComponentOrder,
SchemaResourceId payloadSchemaResource,
List<Sha256> payloadSchemaHashes,
List<ContractDescriptor> descriptors) {
this.contractId = contractId;
this.payloadVersion = payloadVersion;
this.exactPayloadRecordType = exactPayloadRecordType;
this.canonicalRecordComponentOrder = canonicalRecordComponentOrder;
this.payloadSchemaResource = payloadSchemaResource;
this.payloadSchemaHashes = payloadSchemaHashes;
this.descriptors = descriptors;
}
@Override
public ContractId contractId() {
contractIdCalls++;
return contractId;
}
@Override
public int payloadVersion() {
payloadVersionCalls++;
return payloadVersion;
}
@Override
public Class<P> exactPayloadRecordType() {
exactPayloadRecordTypeCalls++;
return exactPayloadRecordType;
}
@Override
public List<String> canonicalRecordComponentOrder() {
canonicalRecordComponentOrderCalls++;
return canonicalRecordComponentOrder;
}
@Override
public SchemaResourceId payloadSchemaResource() {
payloadSchemaResourceCalls++;
return payloadSchemaResource;
}
@Override
public Sha256 payloadSchemaHash() {
int index = Math.min(payloadSchemaHashCalls++, payloadSchemaHashes.size() - 1);
return payloadSchemaHashes.get(index);
}
@Override
public ContractDescriptor descriptor() {
int index = Math.min(descriptorCalls++, descriptors.size() - 1);
return descriptors.get(index);
}
private List<Integer> callCounts() {
return List.of(
contractIdCalls,
payloadVersionCalls,
exactPayloadRecordTypeCalls,
canonicalRecordComponentOrderCalls,
payloadSchemaResourceCalls,
payloadSchemaHashCalls,
descriptorCalls);
}
}
private record ValidPayload(String eventId, int count) implements IntegrationPayload {}
private record OtherPayload(String value) implements IntegrationPayload {}
private record ClosedGraphPayload(
String name,
long count,
BigDecimal amount,
GraphState state,
Optional<List<ClosedNested>> nested)
implements IntegrationPayload {}
private record ClosedNested(String code, BigInteger rank) {}
private enum GraphState {
READY
}
private record ObjectPayload(Object value) implements IntegrationPayload {}
private record InterfacePayload(CharSequence value) implements IntegrationPayload {}
private record MapPayload(Map<String, String> value) implements IntegrationPayload {}
@SuppressWarnings("rawtypes")
private record RawListPayload(List value) implements IntegrationPayload {}
private record WildcardListPayload(List<?> value) implements IntegrationPayload {}
private record JsonTreePayload(tools.jackson.databind.JsonNode value)
implements IntegrationPayload {}
private record GenericRecordPayload(GenericNested<String> value) implements IntegrationPayload {}
private record GenericNested<T>(T value) {}
private static final class MutablePayload implements IntegrationPayload {}
}
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.outbound.messaging.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import org.junit.jupiter.api.Test;
class ContractCatalogDigestTest {
@Test
void digestIsDeterministicForEmptyAndInputOrderIndependentForNonEmptyCatalogs() {
CompiledIntegrationEventContract first = contract("fixture.a.created", 1, hash(1), 256);
CompiledIntegrationEventContract second = contract("fixture.b.created", 2, hash(2), 512);
assertThat(ContractCatalogDigest.compute(List.of()).toString())
.isEqualTo("a86e4f7991482c9ee798a606da37ae379e36d81ca2fa3c2ea190ba2b0cee1da0");
assertThat(ContractCatalogDigest.compute(List.of(first, second)))
.isEqualTo(ContractCatalogDigest.compute(List.of(second, first)));
}
@Test
void digestChangesForSchemaHashDescriptorAndCanonicalComponentOrderSemantics() {
CompiledIntegrationEventContract baseline = contract("fixture.a.created", 1, hash(1), 256);
CompiledIntegrationEventContract schemaChanged = contract("fixture.a.created", 1, hash(2), 256);
CompiledIntegrationEventContract descriptorChanged =
contract("fixture.a.created", 1, hash(1), 512);
CompiledIntegrationEventContract orderChanged =
compile(
contribution(
"fixture.a.created",
1,
ReorderedDigestPayload.class,
List.of("count", "eventId"),
hash(1),
256));
Sha256 digest = ContractCatalogDigest.compute(List.of(baseline));
assertThat(ContractCatalogDigest.compute(List.of(schemaChanged))).isNotEqualTo(digest);
assertThat(ContractCatalogDigest.compute(List.of(descriptorChanged))).isNotEqualTo(digest);
assertThat(ContractCatalogDigest.compute(List.of(orderChanged))).isNotEqualTo(digest);
}
private static CompiledIntegrationEventContract contract(
String contractId, int version, Sha256 schemaHash, int maximumEnvelopeBytes) {
return compile(
contribution(
contractId,
version,
DigestPayload.class,
List.of("eventId", "count"),
schemaHash,
maximumEnvelopeBytes));
}
private static CompiledIntegrationEventContract compile(
IntegrationEventContractContribution<?> contribution) {
return new ContractCatalogCompiler().compile(List.of(contribution)).getFirst();
}
private static <P extends IntegrationPayload> DigestContribution<P> contribution(
String contractId,
int version,
Class<P> payloadType,
List<String> componentOrder,
Sha256 schemaHash,
int maximumEnvelopeBytes) {
return new DigestContribution<>(
new ContractId(contractId),
version,
payloadType,
componentOrder,
new SchemaResourceId("contracts/messaging/" + contractId + "/v" + version + ".schema.json"),
schemaHash,
new ContractDescriptor(
"adapter-outbound-messaging",
new LogicalDestinationId("fixture-events"),
"json-schema-envelope-v1",
true,
128,
maximumEnvelopeBytes,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7)));
}
private static Sha256 hash(int seed) {
return new Sha256(HexFormat.of().parseHex(String.format("%064x", seed)));
}
private record DigestPayload(String eventId, int count) implements IntegrationPayload {}
private record ReorderedDigestPayload(int count, String eventId) implements IntegrationPayload {}
private record DigestContribution<P extends IntegrationPayload>(
ContractId contractId,
int payloadVersion,
Class<P> exactPayloadRecordType,
List<String> canonicalRecordComponentOrder,
SchemaResourceId payloadSchemaResource,
Sha256 payloadSchemaHash,
ContractDescriptor descriptor)
implements IntegrationEventContractContribution<P> {}
}
@@ -0,0 +1,648 @@
package dev.caskeleton.adapter.outbound.messaging.destination;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.messaging.config.CompiledMessagingDescriptor;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompiler;
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigest;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.lang.reflect.Modifier;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class DestinationBindingCompilerTest {
private static final MessagingCapabilityCardRegistry CARDS =
MessagingCapabilityCardRegistry.exactFirstR2();
private static final DestinationBindingCompiler COMPILER = new DestinationBindingCompiler();
@Test
void compilesExactContractBindingWithCodeDeploymentByteIntersectionAndStableDigests() {
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
DestinationBindingSettings first =
settings(
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(contract))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
DestinationBindingSettings shuffledBootstrap =
settings(
List.of(
binding(
"fixture-events",
"fixture.events.v1",
512,
List.of("broker-b:9093", "broker-a:9093"),
ref(contract))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
List<CompiledPublicationBinding> result = COMPILER.compile(List.of(contract), first, CARDS);
assertThat(result).hasSize(1);
CompiledPublicationBinding compiled = result.getFirst();
assertThat(compiled.contract().stableKey()).isEqualTo("fixture.event.created:v1");
assertThat(compiled.logicalDestination()).isEqualTo(contract.descriptor().logicalDestination());
assertThat(compiled.physicalTopic()).isEqualTo("fixture.events.v1");
assertThat(compiled.bootstrapServers()).containsExactly("broker-a:9093", "broker-b:9093");
assertThat(compiled.effectiveMaximumRecordBytes()).isEqualTo(256);
assertThat(compiled.settingsDigest())
.isEqualTo(
COMPILER
.compile(List.of(contract), shuffledBootstrap, CARDS)
.getFirst()
.settingsDigest());
assertThat(compiled.schemaSetDigest()).isNotNull();
assertThatThrownBy(() -> compiled.bootstrapServers().add("broker-c:9093"))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void deploymentMaximumBelowCodeMaximumWinsAndMustRemainPositive() {
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
DestinationBindingSettings settings =
settings(
List.of(binding("fixture-events", "fixture.events.v1", 192, ref(contract))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
assertThat(
COMPILER
.compile(List.of(contract), settings, CARDS)
.getFirst()
.effectiveMaximumRecordBytes())
.isEqualTo(192);
assertThatThrownBy(
() ->
new DestinationBindingSettings.DestinationBinding(
new LogicalDestinationId("fixture-events"),
List.of(ref(contract)),
"fixture.events.v1",
List.of("broker-a:9093"),
0))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void settingsRejectDuplicateLogicalDestinationsInvalidPortsAndSecretShapedLegacyAliases() {
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
DestinationBindingSettings.DestinationBinding exact =
binding("fixture-events", "fixture.events.v1", 512, ref(contract));
assertThatThrownBy(
() ->
settings(
List.of(exact, exact),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate destination");
assertThatThrownBy(
() ->
binding(
"fixture-events",
"fixture.events.v1",
512,
List.of("broker-a:99999"),
ref(contract)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("bootstrap");
assertThatThrownBy(
() ->
new DestinationBindingSettings.LegacyAliases(
Optional.of("user:password"), Optional.empty(), List.of("broker-a:9093")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("legacy");
}
@Test
void settingsRejectNullDestinationAndDuplicatePhysicalTopicAcrossLogicalDestinations() {
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
DestinationBindingSettings.DestinationBinding first =
binding("fixture-events", "shared.events.v1", 512, ref(contract));
DestinationBindingSettings.DestinationBinding second =
binding(
"other-events",
"shared.events.v1",
512,
new DestinationBindingSettings.ContractVersion(
new ContractId("fixture.other.created"), 1));
assertThatThrownBy(
() ->
settings(
List.of(first, second),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("physical topic");
List<DestinationBindingSettings.DestinationBinding> withNull = new ArrayList<>();
withNull.add(null);
assertThatThrownBy(
() ->
settings(
withNull,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("destination");
}
@Test
void rejectsMissingDuplicateExtraAndContractDestinationMismatchedBindings() {
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
DestinationBindingSettings.DestinationBinding exact =
binding("fixture-events", "fixture.events.v1", 512, ref(contract));
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(contract),
settings(
List.of(),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()),
CARDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("missing");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(contract),
settings(
List.of(exact, exact),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()),
CARDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate destination");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(contract),
settings(
List.of(
exact,
binding(
"unused-events",
"unused.events.v1",
512,
new DestinationBindingSettings.ContractVersion(
new ContractId("fixture.unknown.created"), 1))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()),
CARDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("extra");
assertThatThrownBy(
() ->
COMPILER.compile(
List.of(contract),
settings(
List.of(binding("other-events", "other.events.v1", 512, ref(contract))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty()),
CARDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("identity");
}
@Test
void rejectsUnknownCardsRoleMismatchLegacyConflictAndRelaxedRequirements() {
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
List<DestinationBindingSettings.DestinationBinding> bindings =
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(contract)));
assertRejected(
contract,
settings(
bindings,
selectionWithProducer("future-consumer.v1"),
true,
true,
true,
Optional.empty()),
"unknown");
assertRejected(
contract,
settings(
bindings,
selectionWithProducer(
MessagingCapabilityCardRegistry.exactFirstR2Selection().serializationCardId()),
true,
true,
true,
Optional.empty()),
"role");
assertRejected(
contract,
settings(
bindings,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.of(
new DestinationBindingSettings.LegacyAliases(
Optional.of("kafka"), Optional.of("legacy.topic"), List.of("legacy:9092")))),
"legacy");
assertRejected(
contract,
settings(
bindings,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
false,
true,
true,
Optional.empty()),
"ordering");
assertRejected(
contract,
settings(
bindings,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
false,
true,
Optional.empty()),
"schema");
assertRejected(
contract,
settings(
bindings,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
false,
Optional.empty()),
"security");
}
@Test
void disabledEmptyCatalogIsZeroResourceWhileActiveRequiresExactCatalogAndBindings() {
DestinationBindingSettings emptySettings =
settings(
List.of(),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
CompiledMessagingDescriptor disabled =
CompiledMessagingDescriptor.compile(
CompiledMessagingDescriptor.ActivationMode.DISABLED, List.of(), emptySettings, CARDS);
assertThat(CompiledMessagingDescriptor.contractsOf(disabled)).isEmpty();
assertThat(CompiledMessagingDescriptor.bindingsOf(disabled)).isEmpty();
assertThat(disabled.resourceCount()).isZero();
assertThatThrownBy(
() ->
CompiledMessagingDescriptor.compile(
CompiledMessagingDescriptor.ActivationMode.ACTIVE,
List.of(),
emptySettings,
CARDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("empty catalog");
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
DestinationBindingSettings activeSettings =
settings(
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(contract))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
CompiledMessagingDescriptor active =
CompiledMessagingDescriptor.compile(
CompiledMessagingDescriptor.ActivationMode.ACTIVE,
List.of(contract),
activeSettings,
CARDS);
assertThat(CompiledMessagingDescriptor.bindingsOf(active)).hasSize(1);
assertThat(active.catalogDigest()).isEqualTo(ContractCatalogDigest.compute(List.of(contract)));
assertThat(active.settingsDigest())
.isEqualTo(CompiledMessagingDescriptor.bindingsOf(active).getFirst().settingsDigest());
assertThat(active.schemaSetDigest())
.isEqualTo(CompiledMessagingDescriptor.bindingsOf(active).getFirst().schemaSetDigest());
assertThat(CompiledMessagingDescriptor.bindingsOf(active).getFirst().contract())
.isSameAs(CompiledMessagingDescriptor.contractsOf(active).getFirst());
assertThat(active.resourceCount()).isZero();
}
@Test
void compiledArtifactsExposeOnlyNarrowStaticImmutableCompositionBridges()
throws NoSuchMethodException {
assertThat(Modifier.isFinal(CompiledPublicationBinding.class.getModifiers())).isTrue();
assertThat(Modifier.isFinal(CompiledMessagingDescriptor.class.getModifiers())).isTrue();
assertThat(Modifier.isFinal(CompiledIntegrationEventContract.class.getModifiers())).isTrue();
assertThat(CompiledPublicationBinding.class.isRecord()).isFalse();
assertThat(CompiledMessagingDescriptor.class.isRecord()).isFalse();
assertThat(CompiledIntegrationEventContract.class.isRecord()).isFalse();
assertThat(CompiledPublicationBinding.class.getConstructors()).isEmpty();
assertThat(CompiledMessagingDescriptor.class.getConstructors()).isEmpty();
assertThat(CompiledIntegrationEventContract.class.getConstructors()).isEmpty();
assertThat(CompiledPublicationBinding.class.getDeclaredConstructors())
.allSatisfy(
constructor -> assertThat(Modifier.isPublic(constructor.getModifiers())).isFalse());
assertThat(CompiledMessagingDescriptor.class.getDeclaredConstructors())
.allSatisfy(
constructor -> assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue());
assertPackagePrivateInstanceMethod(
DestinationBindingCompiler.class,
"compile",
List.class,
DestinationBindingSettings.class,
MessagingCapabilityCardRegistry.class);
assertPublicStaticMethod(
DestinationBindingCompiler.class,
"compileExact",
List.class,
DestinationBindingSettings.class,
MessagingCapabilityCardRegistry.class);
assertPackagePrivateInstanceMethod(CompiledMessagingDescriptor.class, "contracts");
assertPackagePrivateInstanceMethod(CompiledMessagingDescriptor.class, "bindings");
assertPublicStaticMethod(
CompiledMessagingDescriptor.class, "contractsOf", CompiledMessagingDescriptor.class);
assertPublicStaticMethod(
CompiledMessagingDescriptor.class, "bindingsOf", CompiledMessagingDescriptor.class);
assertPackagePrivateInstanceMethod(MessagingCapabilityCardRegistry.class, "cards");
assertPackagePrivateInstanceMethod(DestinationBindingSettings.class, "destinations");
assertPackagePrivateInstanceMethod(DestinationBindingSettings.class, "legacyAliases");
assertPackagePrivateInstanceMethod(
DestinationBindingSettings.DestinationBinding.class, "contracts");
BindingContribution<BindingPayload> firstContribution =
contribution(
"fixture.event.created",
BindingPayload.class,
"contracts/messaging/fixture.event.created/v1.schema.json");
BindingContribution<AlternateBindingPayload> duplicateSchemaContribution =
contribution(
"fixture.other.created",
AlternateBindingPayload.class,
"contracts/messaging/fixture.event.created/v1.schema.json");
BindingContribution<BindingPayload> duplicateExactTypeContribution =
contribution(
"fixture.other.created",
BindingPayload.class,
"contracts/messaging/fixture.other.created/v1.schema.json");
assertThatThrownBy(
() ->
ContractCatalogCompiler.compileExact(
List.of(firstContribution, duplicateSchemaContribution)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("schema");
assertThatThrownBy(
() ->
ContractCatalogCompiler.compileExact(
List.of(firstContribution, duplicateExactTypeContribution)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payload");
CompiledIntegrationEventContract first =
ContractCatalogCompiler.compileExact(List.of(firstContribution)).getFirst();
DestinationBindingSettings settings =
settings(
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(first))),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
CompiledMessagingDescriptor active =
CompiledMessagingDescriptor.compile(
CompiledMessagingDescriptor.ActivationMode.ACTIVE, List.of(first), settings, CARDS);
assertThat(CompiledMessagingDescriptor.contractsOf(active).getFirst()).isSameAs(first);
assertThat(CompiledMessagingDescriptor.bindingsOf(active).getFirst().contract())
.isSameAs(first);
assertThatThrownBy(
() ->
new DestinationBindingSettings.DestinationBinding(
new LogicalDestinationId("fixture-events"),
List.of(ref(first)),
" ",
List.of("broker-a:9093"),
512))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new DestinationBindingSettings.DestinationBinding(
new LogicalDestinationId("fixture-events"),
List.of(ref(first)),
"fixture.events.v1",
List.of("user:secret@broker-a:9093"),
512))
.isInstanceOf(IllegalArgumentException.class);
Sha256 digest = ContractCatalogDigest.compute(List.of(first));
assertThatThrownBy(
() ->
new CompiledPublicationBinding(
first,
new LogicalDestinationId("other-events"),
"fixture.events.v1",
List.of("broker-a:9093"),
256,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
digest,
digest))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new CompiledPublicationBinding(
first,
first.descriptor().logicalDestination(),
"fixture.events.v1",
List.of("broker-a:9093"),
257,
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
digest,
digest))
.isInstanceOf(IllegalArgumentException.class);
}
private static void assertPackagePrivateInstanceMethod(
Class<?> owner, String methodName, Class<?>... parameterTypes) throws NoSuchMethodException {
int modifiers = owner.getDeclaredMethod(methodName, parameterTypes).getModifiers();
assertThat(Modifier.isPublic(modifiers)).isFalse();
assertThat(Modifier.isStatic(modifiers)).isFalse();
}
private static void assertPublicStaticMethod(
Class<?> owner, String methodName, Class<?>... parameterTypes) throws NoSuchMethodException {
int modifiers = owner.getDeclaredMethod(methodName, parameterTypes).getModifiers();
assertThat(Modifier.isPublic(modifiers)).isTrue();
assertThat(Modifier.isStatic(modifiers)).isTrue();
}
private static void assertRejected(
CompiledIntegrationEventContract contract,
DestinationBindingSettings settings,
String message) {
assertThatThrownBy(() -> COMPILER.compile(List.of(contract), settings, CARDS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(message);
}
private static DestinationBindingSettings settings(
List<DestinationBindingSettings.DestinationBinding> bindings,
MessagingCapabilityCardRegistry.ExactSelection selection,
boolean ordering,
boolean schema,
boolean security,
Optional<DestinationBindingSettings.LegacyAliases> legacy) {
return new DestinationBindingSettings(bindings, selection, ordering, schema, security, legacy);
}
private static DestinationBindingSettings.DestinationBinding binding(
String logicalDestination,
String physicalTopic,
int maximumRecordBytes,
DestinationBindingSettings.ContractVersion... contracts) {
return binding(
logicalDestination,
physicalTopic,
maximumRecordBytes,
List.of("broker-a:9093", "broker-b:9093"),
contracts);
}
private static DestinationBindingSettings.DestinationBinding binding(
String logicalDestination,
String physicalTopic,
int maximumRecordBytes,
List<String> bootstrapServers,
DestinationBindingSettings.ContractVersion... contracts) {
return new DestinationBindingSettings.DestinationBinding(
new LogicalDestinationId(logicalDestination),
List.of(contracts),
physicalTopic,
bootstrapServers,
maximumRecordBytes);
}
private static DestinationBindingSettings.ContractVersion ref(
CompiledIntegrationEventContract contract) {
return new DestinationBindingSettings.ContractVersion(
contract.contractId(), contract.payloadVersion());
}
private static MessagingCapabilityCardRegistry.ExactSelection selectionWithProducer(
String producer) {
MessagingCapabilityCardRegistry.ExactSelection selected =
MessagingCapabilityCardRegistry.exactFirstR2Selection();
return new MessagingCapabilityCardRegistry.ExactSelection(
selected.semanticCardId(),
producer,
selected.dispatchCardId(),
selected.claimCardId(),
selected.serializationCardId(),
selected.topicCardId(),
selected.securityCardId(),
selected.compressionCardId(),
selected.orderingCardId(),
selected.transactionCardId(),
selected.operatorControlCardId());
}
private static CompiledIntegrationEventContract contract(
String contractId, int version, int maximumEnvelopeBytes) {
BindingContribution<BindingPayload> contribution =
new BindingContribution<>(
new ContractId(contractId),
version,
BindingPayload.class,
List.of("eventId"),
new SchemaResourceId(
"contracts/messaging/" + contractId + "/v" + version + ".schema.json"),
new Sha256(HexFormat.of().parseHex(String.format("%064x", version))),
descriptor("fixture-events", maximumEnvelopeBytes));
return ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
}
private static <P extends IntegrationPayload> BindingContribution<P> contribution(
String contractId, Class<P> payloadType, String schemaResource) {
return new BindingContribution<>(
new ContractId(contractId),
1,
payloadType,
List.of("eventId"),
new SchemaResourceId(schemaResource),
new Sha256(HexFormat.of().parseHex(String.format("%064x", contractId.hashCode()))),
descriptor("fixture-events", 256));
}
private static ContractDescriptor descriptor(String destination, int maximumEnvelopeBytes) {
return new ContractDescriptor(
"adapter-outbound-messaging",
new LogicalDestinationId(destination),
"json-schema-envelope-v1",
true,
128,
maximumEnvelopeBytes,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7));
}
private record BindingPayload(String eventId) implements IntegrationPayload {}
private record AlternateBindingPayload(String eventId) implements IntegrationPayload {}
private record BindingContribution<P extends IntegrationPayload>(
ContractId contractId,
int payloadVersion,
Class<P> exactPayloadRecordType,
List<String> canonicalRecordComponentOrder,
SchemaResourceId payloadSchemaResource,
Sha256 payloadSchemaHash,
ContractDescriptor descriptor)
implements IntegrationEventContractContribution<P> {}
}
@@ -0,0 +1,104 @@
package dev.caskeleton.adapter.outbound.messaging.destination;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.event.AggregateIdentity;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class PartitionKeyV1Test {
private static final LogicalDestinationId DESTINATION =
new LogicalDestinationId("portfolio-domain-events");
@Test
void freezesTenantScopedGoldenVectorAsLowercaseHexAndExactAsciiBytes() {
PartitionKeyV1.Value key =
PartitionKeyV1.derive(
DESTINATION, new AggregateIdentity("tenant-a", "worklog", "worklog-42"));
assertThat(key.text())
.isEqualTo("0e5feab14824293a301c3e8509f23363f38a585cbcba5895c8f3a3655ca1550a")
.matches("[0-9a-f]{64}");
assertThat(key.bytes()).hasSize(64);
assertThat(key.bytes()).isEqualTo(key.text().getBytes(StandardCharsets.US_ASCII));
byte[] callerCopy = key.bytes();
callerCopy[0] = 'f';
assertThat(key.bytes()).isEqualTo(key.text().getBytes(StandardCharsets.US_ASCII));
}
@Test
void tenantDisabledCallerMustSupplyTheCanonicalNonNullSystemScope() {
assertThat(
PartitionKeyV1.derive(
DESTINATION, new AggregateIdentity("system", "worklog", "worklog-42"))
.text())
.isEqualTo("449f3054afd8ba5234c160fdeca359497645ef198b166c9a380e20702748b3a6");
assertThatThrownBy(() -> PartitionKeyV1.derive(DESTINATION, null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
PartitionKeyV1.deriveCanonicalComponents(
null, DESTINATION, "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
PartitionKeyV1.deriveCanonicalComponents(" ", DESTINATION, "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void nonAsciiAggregateIdGoldenVectorUsesUtf8ByteLengthNotCharacterCount() {
assertThat(
PartitionKeyV1.deriveCanonicalComponents("tenant-a", DESTINATION, "worklog", "작업-42")
.text())
.isEqualTo("367eee4ab006e7f16324abdd026009c257e81ece048abb62f69ccbbfe18631df");
}
@Test
void malformedUtf8SurrogatesAreRejectedInsteadOfCollidingWithLiteralQuestionMark() {
PartitionKeyV1.Value literalQuestionMark =
PartitionKeyV1.deriveCanonicalComponents("tenant-a", DESTINATION, "worklog", "?");
assertThat(literalQuestionMark.text()).matches("[0-9a-f]{64}");
assertThatThrownBy(
() -> PartitionKeyV1.deriveCanonicalComponents("\uD800", DESTINATION, "worklog", "?"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("UTF-8");
assertThatThrownBy(
() -> PartitionKeyV1.deriveCanonicalComponents("tenant-a", DESTINATION, "\uDC00", "?"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("UTF-8");
assertThatThrownBy(
() ->
PartitionKeyV1.deriveCanonicalComponents(
"tenant-a", DESTINATION, "worklog", "\uD800"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("UTF-8");
}
@Test
void canonicalComponentsApplyTask3BoundsBeforeEncoding() {
assertThatThrownBy(
() ->
PartitionKeyV1.deriveCanonicalComponents(
"t".repeat(97), DESTINATION, "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("tenantScope");
assertThatThrownBy(
() ->
PartitionKeyV1.deriveCanonicalComponents(
"tenant-a", DESTINATION, "w".repeat(65), "worklog-42"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("aggregateType");
assertThatThrownBy(
() ->
PartitionKeyV1.deriveCanonicalComponents(
"tenant-a", DESTINATION, "worklog", "i".repeat(161)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("aggregateId");
}
}
@@ -0,0 +1,334 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompiler;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import dev.caskeleton.application.messaging.event.AggregateIdentity;
import dev.caskeleton.application.messaging.event.AggregateOrder;
import dev.caskeleton.application.messaging.event.EventId;
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.AbstractList;
import java.util.ConcurrentModificationException;
import java.util.HexFormat;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/**
* Small adopted corpus: JSON-Schema-Test-Suite draft2020-12 type/required/additionalProperties and
* Bowtie-style malformed-instance scenarios. This is bounded repository evidence, not a full
* implementation compatibility claim.
*/
class EnvelopeAdversarialCorpusTest {
private static final EnvelopeAdmissionLimits LIMITS =
new EnvelopeAdmissionLimits(8, 64, 128, 4, 10, 8, 1024, 2048, 16, 64, 4);
private static final DeterministicEnvelopeWriter WRITER = new DeterministicEnvelopeWriter(LIMITS);
@Test
void limitsRejectZeroNegativeAndUnlimitedSentinels() {
assertThatThrownBy(() -> new EnvelopeAdmissionLimits(0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() -> new EnvelopeAdmissionLimits(Integer.MAX_VALUE, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("finite");
}
@Test
void writerRejectsMapRawTreeUnsupportedClassNonfiniteAndUnpairedSurrogate() {
assertRejected(new MapPayload(Map.of("key", "value")), "unsupported");
assertRejected(new ObjectPayload(new Object()), "unsupported");
assertRejected(new DoublePayload(Double.NaN), "finite");
assertRejected(new DoublePayload(Double.POSITIVE_INFINITY), "finite");
assertRejected(new StringPayload("\ud800"), "surrogate");
}
@Test
void writerEnforcesStringUtf8ArrayObjectNumberAndDepthBudgets() {
assertRejected(new StringPayload("a".repeat(65)), "string");
assertRejected(new StringPayload("".repeat(50)), "UTF-8");
assertRejected(new IntegerListPayload(List.of(1, 2, 3, 4, 5)), "array");
assertRejected(
new ManyPropertiesPayload(
new ManyProperties("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11")),
"properties");
assertRejected(new BigIntegerPayload(new BigInteger("123456789")), "digits");
assertRejected(
new DepthPayload(
new Depth1(
new Depth2(
new Depth3(new Depth4(new Depth5(new Depth6(new Depth7(new Depth8("x"))))))))),
"depth");
}
@Test
void rejectsExtremePositiveDecimalScaleBeforePlainStringAllocation() {
assertRejected(
new BigDecimalPayload(new BigDecimal(BigInteger.ONE, Integer.MAX_VALUE)), "digits");
}
@Test
void boundsJsonOutputDuringWritesInsteadOfOnlyInspectingTheCompletedBuffer() {
assertThat(
java.util.Arrays.stream(DeterministicEnvelopeWriter.class.getDeclaredClasses())
.map(Class::getSimpleName))
.contains("BoundedByteArrayOutputStream");
EnvelopeAdmissionLimits tinyOutput =
new EnvelopeAdmissionLimits(8, 64, 128, 4, 10, 8, 16, 64, 16, 64, 4);
DeterministicEnvelopeWriter tinyWriter = new DeterministicEnvelopeWriter(tinyOutput);
assertThatThrownBy(
() ->
tinyWriter.write(
draft(new StringPayload("1234567890")), contract(StringPayload.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("byte admission");
}
@Test
void checksListSizeBeforeIterationAndFailsClosedOnMutationOrConcurrency() {
assertRejected(new IntegerListPayload(new OversizedNoArrayList()), "array");
assertRejected(new IntegerListPayload(new SizeDriftingList()), "mutated");
assertRejected(new IntegerListPayload(new ConcurrentFailureList()), "mutated");
}
@Test
void writerUsesExactRecordOrderUtf8AndCanonicalScalarRendering() {
OrderedPayload payload =
new OrderedPayload(
"한글", new BigDecimal("12.5000"), 7, true, Optional.empty(), List.of("β", "alpha"));
DeterministicEnvelopeWriter.EncodedEnvelope encoded =
WRITER.write(draft(payload), contract(OrderedPayload.class));
String json = new String(encoded.envelopeBytes(), java.nio.charset.StandardCharsets.UTF_8);
assertThat(json)
.contains(
"\"payload\":{\"text\":\"한글\",\"decimal\":12.5,\"integral\":7,\"flag\":true,\"optional\":null,\"list\":[\"β\",\"alpha\"]}");
assertThat(encoded.payloadBytes())
.isEqualTo(
"{\"text\":\"한글\",\"decimal\":12.5,\"integral\":7,\"flag\":true,\"optional\":null,\"list\":[\"β\",\"alpha\"]}"
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
@Test
void exactPayloadClassIsRequiredAndNoAssignableTypeSearchOccurs() {
CompiledIntegrationEventContract exact = contract(OrderedPayload.class);
StringPayload other = new StringPayload("value");
assertThatThrownBy(() -> WRITER.write(draft(other), exact))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("exact");
}
@SuppressWarnings("unchecked")
private static <P extends IntegrationPayload> void assertRejected(P payload, String message) {
Class<P> exactType = (Class<P>) payload.getClass();
assertThatThrownBy(() -> WRITER.write(draft(payload), contract(exactType)))
.isInstanceOf(IllegalArgumentException.class)
.satisfies(exception -> assertThat(exception.getMessage()).containsIgnoringCase(message));
}
private static <P extends IntegrationPayload> IntegrationEventDraft<P> draft(P payload) {
return new IntegrationEventDraft<>(
new EventId("event-adversarial"),
new ContractId("test.adversarial"),
1,
new LogicalDestinationId("test-events"),
new AggregateIdentity("tenant-a", "worklog", "W-1"),
new AggregateOrder(1, 0),
Instant.parse("2026-07-29T00:00:00Z"),
"corr-adversarial",
Optional.empty(),
payload);
}
private static <P extends IntegrationPayload> CompiledIntegrationEventContract contract(
Class<P> type) {
List<String> order =
java.util.Arrays.stream(type.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName)
.toList();
IntegrationEventContractContribution<P> contribution =
new IntegrationEventContractContribution<>() {
@Override
public ContractId contractId() {
return new ContractId("test.adversarial");
}
@Override
public int payloadVersion() {
return 1;
}
@Override
public Class<P> exactPayloadRecordType() {
return type;
}
@Override
public List<String> canonicalRecordComponentOrder() {
return order;
}
@Override
public SchemaResourceId payloadSchemaResource() {
return new SchemaResourceId("contracts/messaging/test.adversarial/v1.schema.json");
}
@Override
public Sha256 payloadSchemaHash() {
return new Sha256(HexFormat.of().parseHex("11".repeat(32)));
}
@Override
public ContractDescriptor descriptor() {
return new ContractDescriptor(
"adapter-outbound-messaging",
new LogicalDestinationId("test-events"),
"json-schema-envelope-v1",
true,
1024,
2048,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(1));
}
};
return ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
}
private record ObjectPayload(Object value) implements IntegrationPayload {}
private record MapPayload(Map<String, String> value) implements IntegrationPayload {}
private record DoublePayload(double value) implements IntegrationPayload {}
private record StringPayload(String value) implements IntegrationPayload {}
private record IntegerListPayload(List<Integer> value) implements IntegrationPayload {}
private record ManyPropertiesPayload(ManyProperties value) implements IntegrationPayload {}
private record BigIntegerPayload(BigInteger value) implements IntegrationPayload {}
private record BigDecimalPayload(BigDecimal value) implements IntegrationPayload {}
private record DepthPayload(Depth1 value) implements IntegrationPayload {}
private record OrderedPayload(
String text,
BigDecimal decimal,
long integral,
boolean flag,
Optional<String> optional,
List<String> list)
implements IntegrationPayload {}
private record ManyProperties(
String a,
String b,
String c,
String d,
String e,
String f,
String g,
String h,
String i,
String j,
String k) {}
private record Depth1(Depth2 value) {}
private record Depth2(Depth3 value) {}
private record Depth3(Depth4 value) {}
private record Depth4(Depth5 value) {}
private record Depth5(Depth6 value) {}
private record Depth6(Depth7 value) {}
private record Depth7(Depth8 value) {}
private record Depth8(String value) {}
private static final class OversizedNoArrayList extends AbstractList<Integer> {
@Override
public Integer get(int index) {
throw new AssertionError("oversized list must be rejected before element access");
}
@Override
public int size() {
return 5;
}
@Override
public Object[] toArray() {
throw new AssertionError("writer must not duplicate the list through toArray");
}
}
private static final class SizeDriftingList extends AbstractList<Integer> {
@Override
public Integer get(int index) {
return List.of(1, 2).get(index);
}
@Override
public int size() {
return 1;
}
@Override
public Iterator<Integer> iterator() {
return List.of(1, 2).iterator();
}
}
private static final class ConcurrentFailureList extends AbstractList<Integer> {
@Override
public Integer get(int index) {
return 1;
}
@Override
public int size() {
return 1;
}
@Override
public Iterator<Integer> iterator() {
return new Iterator<>() {
@Override
public boolean hasNext() {
throw new ConcurrentModificationException("fixture");
}
@Override
public Integer next() {
return 1;
}
};
}
}
}
@@ -0,0 +1,413 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompiler;
import dev.caskeleton.adapter.outbound.messaging.destination.CompiledPublicationBinding;
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompiler;
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingSettings;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import dev.caskeleton.application.messaging.event.AggregateIdentity;
import dev.caskeleton.application.messaging.event.AggregateOrder;
import dev.caskeleton.application.messaging.event.EventId;
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class JsonSchemaIntegrationEventEncoderTest {
private static final String ENVELOPE_RESOURCE = "contracts/messaging/envelope/v1.schema.json";
private static final String PAYLOAD_RESOURCE = "contracts/messaging/test.event/v1.schema.json";
private static final EnvelopeAdmissionLimits LIMITS =
new EnvelopeAdmissionLimits(16, 256, 1024, 16, 64, 64, 4096, 8192, 32, 256, 8);
@Test
void writesAndValidatesExactCanonicalUtf8EnvelopeWithStableHashesAndRevisions() {
Fixture fixture = fixture();
IntegrationEventDraft<TestPayload> draft = fixture.validDraft();
ValidatedIntegrationEvent first = fixture.encoder().encode(draft);
ValidatedIntegrationEvent second = fixture.encoder().encode(draft);
String expected =
"""
{"envelopeVersion":1,"eventId":"event-1","contractId":"test.event","payloadVersion":1,"logicalDestination":"test-events","aggregate":{"type":"worklog","id":"W-1","sequence":3,"eventIndex":0},"occurredAt":"2026-07-29T01:02:03.123Z","correlationId":"corr-1","contentType":"application/json","payload":{"name":"정확한-UTF8","count":7,"enabled":true,"amount":12.5,"status":"READY","note":null,"tags":["alpha","β"],"nested":{"code":"N1"}}}\
""";
assertThat(first.envelopeBytes()).isEqualTo(expected.getBytes(StandardCharsets.UTF_8));
assertThat(second).isEqualTo(first);
assertThat(first.partitionKeyText()).matches("[0-9a-f]{64}");
assertThat(first.schemaSetHash().toString()).matches("[0-9a-f]{64}");
assertThat(first.envelopeSha256()).isEqualTo(EnvelopeHashV1.compute(first.envelopeBytes()));
assertThat(first.envelopeSchemaHash())
.isEqualTo(fixture.registry().schemaHash(ENVELOPE_RESOURCE));
assertThat(first.payloadSchemaHash())
.isEqualTo(fixture.registry().schemaHash(PAYLOAD_RESOURCE));
assertThat(first.contractCatalogRevision()).matches("[0-9a-f]{64}");
assertThat(first.destinationBindingRevision()).matches("[0-9a-f]{64}");
byte[] returned = first.envelopeBytes();
returned[0] = 0;
assertThat(first.envelopeBytes()[0]).isEqualTo((byte) '{');
}
@Test
void rejectsUnknownVersionDestinationExactTypeAndSchemaInvalidPayload() {
Fixture fixture = fixture();
IntegrationEventDraft<TestPayload> valid = fixture.validDraft();
assertThatThrownBy(
() ->
fixture
.encoder()
.encode(
new IntegrationEventDraft<>(
valid.eventId(),
valid.contractId(),
2,
valid.destinationId(),
valid.aggregate(),
valid.order(),
valid.occurredAt(),
valid.correlationId(),
valid.causationId(),
valid.featurePayload())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contract");
assertThatThrownBy(
() ->
fixture
.encoder()
.encode(
new IntegrationEventDraft<>(
valid.eventId(),
valid.contractId(),
1,
new LogicalDestinationId("other-events"),
valid.aggregate(),
valid.order(),
valid.occurredAt(),
valid.correlationId(),
valid.causationId(),
valid.featurePayload())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("destination");
assertThatThrownBy(
() ->
fixture
.encoder()
.encode(
new IntegrationEventDraft<>(
valid.eventId(),
valid.contractId(),
1,
valid.destinationId(),
valid.aggregate(),
valid.order(),
valid.occurredAt(),
valid.correlationId(),
valid.causationId(),
new OtherPayload("wrong"))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("exact");
assertThatThrownBy(
() ->
fixture
.encoder()
.encode(
new IntegrationEventDraft<>(
valid.eventId(),
valid.contractId(),
1,
valid.destinationId(),
valid.aggregate(),
valid.order(),
valid.occurredAt(),
valid.correlationId(),
valid.causationId(),
new TestPayload(
"",
7,
true,
new BigDecimal("12.50"),
Status.READY,
Optional.empty(),
List.of(),
new NestedPayload("N1")))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payload schema");
}
@Test
void exactEnvelopeHashHasDomainSeparatedGoldenVectorAndDefensiveShaValue() {
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
Sha256 hash = EnvelopeHashV1.compute(bytes);
assertThat(hash.toString())
.isEqualTo("34c6935b92898e650d376a5180b6f14eeaf3c5231277629d29688d2da5caf45b");
bytes[0] = 'z';
assertThat(hash.toString())
.isEqualTo("34c6935b92898e650d376a5180b6f14eeaf3c5231277629d29688d2da5caf45b");
}
@Test
void snapshotsStatefulMutablePayloadAccessorsOnceAndEmbedsThoseExactBytes() {
StatefulPayload.reset();
byte[] schemaBytes = resource(PAYLOAD_RESOURCE);
StatefulContribution contribution = new StatefulContribution(sha256(schemaBytes));
CompiledIntegrationEventContract contract =
ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
IntegrationEventDraft<StatefulPayload> draft =
new IntegrationEventDraft<>(
new EventId("event-1"),
new ContractId("test.event"),
1,
new LogicalDestinationId("test-events"),
new AggregateIdentity("tenant-a", "worklog", "W-1"),
new AggregateOrder(3, 0),
Instant.parse("2026-07-29T01:02:03.123Z"),
"corr-1",
Optional.empty(),
new StatefulPayload(List.of("first")));
DeterministicEnvelopeWriter.EncodedEnvelope encoded =
new DeterministicEnvelopeWriter(LIMITS).write(draft, contract);
String payload = new String(encoded.payloadBytes(), StandardCharsets.UTF_8);
String envelope = new String(encoded.envelopeBytes(), StandardCharsets.UTF_8);
assertThat(StatefulPayload.accessorCalls()).isEqualTo(1);
assertThat(payload).isEqualTo("{\"tags\":[\"first\"]}");
assertThat(envelope).endsWith("\"payload\":" + payload + "}");
}
private static Fixture fixture() {
byte[] payloadSchema = resource(PAYLOAD_RESOURCE);
TestContribution contribution = new TestContribution(sha256(payloadSchema));
CompiledIntegrationEventContract contract =
ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
DestinationBindingSettings settings =
new DestinationBindingSettings(
List.of(
new DestinationBindingSettings.DestinationBinding(
new LogicalDestinationId("test-events"),
List.of(
new DestinationBindingSettings.ContractVersion(
new ContractId("test.event"), 1)),
"test.events.v1",
List.of("broker-a:9093"),
8192)),
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
true,
true,
true,
Optional.empty());
CompiledPublicationBinding binding =
DestinationBindingCompiler.compileExact(
List.of(contract), settings, MessagingCapabilityCardRegistry.exactFirstR2())
.getFirst();
LocalJsonSchemaRegistry registry =
new LocalJsonSchemaRegistry(
Map.of(
ENVELOPE_RESOURCE, source(ENVELOPE_RESOURCE),
PAYLOAD_RESOURCE, source(PAYLOAD_RESOURCE)),
LIMITS);
return new Fixture(
registry,
new JsonSchemaIntegrationEventEncoder(
List.of(contract), List.of(binding), registry, LIMITS));
}
private static LocalJsonSchemaRegistry.SchemaSource source(String path) {
byte[] bytes = resource(path);
return new LocalJsonSchemaRegistry.SchemaSource(path, bytes, sha256(bytes));
}
private static byte[] resource(String path) {
try (InputStream input =
JsonSchemaIntegrationEventEncoderTest.class.getClassLoader().getResourceAsStream(path)) {
if (input == null) {
throw new IllegalStateException("missing test resource " + path);
}
return input.readAllBytes();
} catch (IOException exception) {
throw new IllegalStateException(exception);
}
}
private static Sha256 sha256(byte[] bytes) {
try {
return new Sha256(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private record Fixture(
LocalJsonSchemaRegistry registry, JsonSchemaIntegrationEventEncoder encoder) {
private IntegrationEventDraft<TestPayload> validDraft() {
return new IntegrationEventDraft<>(
new EventId("event-1"),
new ContractId("test.event"),
1,
new LogicalDestinationId("test-events"),
new AggregateIdentity("tenant-a", "worklog", "W-1"),
new AggregateOrder(3, 0),
Instant.parse("2026-07-29T01:02:03.123Z"),
"corr-1",
Optional.of("cause-1"),
new TestPayload(
"정확한-UTF8",
7,
true,
new BigDecimal("12.50"),
Status.READY,
Optional.empty(),
List.of("alpha", "β"),
new NestedPayload("N1")));
}
}
private record TestContribution(Sha256 payloadSchemaHash)
implements IntegrationEventContractContribution<TestPayload> {
@Override
public ContractId contractId() {
return new ContractId("test.event");
}
@Override
public int payloadVersion() {
return 1;
}
@Override
public Class<TestPayload> exactPayloadRecordType() {
return TestPayload.class;
}
@Override
public List<String> canonicalRecordComponentOrder() {
return List.of("name", "count", "enabled", "amount", "status", "note", "tags", "nested");
}
@Override
public SchemaResourceId payloadSchemaResource() {
return new SchemaResourceId(PAYLOAD_RESOURCE);
}
@Override
public ContractDescriptor descriptor() {
return new ContractDescriptor(
"adapter-outbound-messaging",
new LogicalDestinationId("test-events"),
"json-schema-envelope-v1",
true,
4096,
8192,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7));
}
}
private record TestPayload(
String name,
int count,
boolean enabled,
BigDecimal amount,
Status status,
Optional<String> note,
List<String> tags,
NestedPayload nested)
implements IntegrationPayload {}
private record OtherPayload(String value) implements IntegrationPayload {}
private record StatefulContribution(Sha256 payloadSchemaHash)
implements IntegrationEventContractContribution<StatefulPayload> {
@Override
public ContractId contractId() {
return new ContractId("test.event");
}
@Override
public int payloadVersion() {
return 1;
}
@Override
public Class<StatefulPayload> exactPayloadRecordType() {
return StatefulPayload.class;
}
@Override
public List<String> canonicalRecordComponentOrder() {
return List.of("tags");
}
@Override
public SchemaResourceId payloadSchemaResource() {
return new SchemaResourceId(PAYLOAD_RESOURCE);
}
@Override
public ContractDescriptor descriptor() {
return new ContractDescriptor(
"adapter-outbound-messaging",
new LogicalDestinationId("test-events"),
"json-schema-envelope-v1",
true,
4096,
8192,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7));
}
}
private record StatefulPayload(List<String> tags) implements IntegrationPayload {
private static final AtomicInteger CALLS = new AtomicInteger();
@Override
@SuppressWarnings("UnusedMethod")
public List<String> tags() {
return CALLS.incrementAndGet() == 1 ? tags : List.of("mutated");
}
private static void reset() {
CALLS.set(0);
}
private static int accessorCalls() {
return CALLS.get();
}
}
private record NestedPayload(String code) {}
private enum Status {
READY,
DONE
}
}
@@ -0,0 +1,378 @@
package dev.caskeleton.adapter.outbound.messaging.envelope;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class LocalJsonSchemaRegistryTest {
private static final String ENVELOPE_RESOURCE = "contracts/messaging/envelope/v1.schema.json";
private static final String PAYLOAD_RESOURCE = "contracts/messaging/test.event/v1.schema.json";
private static final String PAYLOAD_ID = "urn:dev-caskeleton:contracts:messaging:test.event:v1";
private static final String ENVELOPE_ID = "urn:dev-caskeleton:contracts:messaging:envelope:v1";
private static final EnvelopeAdmissionLimits LIMITS =
new EnvelopeAdmissionLimits(16, 256, 1024, 16, 64, 64, 4096, 8192, 32, 256, 8);
@Test
void precompilesOnlyCheckedDraft202012ResourcesAndValidatesGoldenVectors() {
LocalJsonSchemaRegistry registry = registry();
assertThat(
registry.validate(PAYLOAD_ID, resource(PAYLOAD_RESOURCE.replace(".schema", ".valid"))))
.isEmpty();
assertThat(
registry.validate(
PAYLOAD_ID, resource(PAYLOAD_RESOURCE.replace(".schema", ".invalid"))))
.isNotEmpty();
assertThat(registry.schemaId(PAYLOAD_RESOURCE)).isEqualTo(PAYLOAD_ID);
assertThat(registry.schemaHash(PAYLOAD_RESOURCE)).isEqualTo(sha256(resource(PAYLOAD_RESOURCE)));
assertThat(registry.exactSchemaBytes(PAYLOAD_RESOURCE))
.containsExactly(resource(PAYLOAD_RESOURCE));
assertThat(registry.pinnedDraft202012AuthorityHash().toString())
.isEqualTo("8a9c3b75ebf53edb639da470d4f213a5210a1f62015f4a705d428e8ba3649efb");
}
@Test
void startupAuthorityDoesNotDependOnARegularNetworkNtCodeSourceJar() {
byte[] implementation = classBytes(LocalJsonSchemaRegistry.class);
String constantPool = new String(implementation, StandardCharsets.ISO_8859_1);
assertThat(constantPool)
.doesNotContain("getProtectionDomain")
.doesNotContain("java/util/jar/JarFile")
.doesNotContain("networknt-3.0.2.jar.sha256");
assertThat(
LocalJsonSchemaRegistryTest.class
.getClassLoader()
.getResource("contracts/messaging/meta/draft-2020-12/networknt-3.0.2.jar.sha256"))
.isNull();
assertThat(registry().pinnedDraft202012AuthorityHash().toString())
.isEqualTo("8a9c3b75ebf53edb639da470d4f213a5210a1f62015f4a705d428e8ba3649efb");
}
@Test
void rejectsMissingRequiredNullForbiddenAndInvalidEnvelopeInstances() {
LocalJsonSchemaRegistry registry = registry();
byte[] nullForbidden =
"""
{"name":null,"count":0,"enabled":true,"amount":1,"status":"READY",
"note":null,"tags":[],"nested":{"code":"N1"}}
"""
.getBytes(StandardCharsets.UTF_8);
assertThat(registry.validate(PAYLOAD_ID, "{}".getBytes(StandardCharsets.UTF_8)))
.anyMatch(error -> error.contains("required"));
assertThat(registry.validate(PAYLOAD_ID, nullForbidden))
.anyMatch(error -> error.contains("type"));
assertThat(registry.validate(ENVELOPE_ID, "{}".getBytes(StandardCharsets.UTF_8)))
.anyMatch(error -> error.contains("required"));
}
@Test
void parserRejectsUnpairedSurrogateNonfiniteDepthAndNumberBoundsBeforeValidation() {
LocalJsonSchemaRegistry registry = registry();
String tooDeep = "[".repeat(17) + "0" + "]".repeat(17);
String tooLongNumber = "1".repeat(65);
assertThatThrownBy(
() -> registry.validate(PAYLOAD_ID, "\"\\uD800\"".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("surrogate");
assertThatThrownBy(() -> registry.validate(PAYLOAD_ID, "NaN".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("JSON");
assertThatThrownBy(
() -> registry.validate(PAYLOAD_ID, tooDeep.getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("JSON");
assertThatThrownBy(
() -> registry.validate(PAYLOAD_ID, tooLongNumber.getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("JSON");
}
@Test
void exposesOnlyTheExactByteValidationBoundary() {
assertThat(
java.util.Arrays.stream(LocalJsonSchemaRegistry.class.getMethods())
.filter(method -> method.getName().equals("validate"))
.map(method -> List.of(method.getParameterTypes()))
.toList())
.containsExactly(List.of(String.class, byte[].class));
}
@Test
void rejectsChecksumMismatchDuplicateIdUnknownDialectAndRequiredVocabulary() {
byte[] payload = resource(PAYLOAD_RESOURCE);
assertThatThrownBy(
() ->
new LocalJsonSchemaRegistry(
Map.of(
PAYLOAD_RESOURCE,
new LocalJsonSchemaRegistry.SchemaSource(
PAYLOAD_RESOURCE,
payload,
sha256("different".getBytes(StandardCharsets.UTF_8)))),
LIMITS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("checksum");
Map<String, LocalJsonSchemaRegistry.SchemaSource> duplicate = new LinkedHashMap<>();
duplicate.put(PAYLOAD_RESOURCE, source(PAYLOAD_RESOURCE));
duplicate.put(
"contracts/messaging/duplicate/v1.schema.json",
new LocalJsonSchemaRegistry.SchemaSource(
"contracts/messaging/duplicate/v1.schema.json", payload, sha256(payload)));
assertThatThrownBy(() -> new LocalJsonSchemaRegistry(duplicate, LIMITS))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate");
assertRejectedSchema(
"""
{"$schema":"https://example.invalid/draft","$id":"urn:test:unknown","type":"object"}
""",
"dialect");
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:vocabulary",
"$vocabulary":{"https://example.invalid/required":true},
"type":"object"}
""",
"vocabulary");
}
@Test
void rejectsInvalidSchemaRemoteReferenceAndReferenceGraphBeyondConfiguredDepth() {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:invalid","type":37}
""",
"schema");
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:remote","$ref":"https://example.invalid/secret-schema"}
""",
"reference");
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:cycle","$defs":{"a":{"$ref":"#/$defs/b"},"b":{"$ref":"#/$defs/a"}},
"$ref":"#/$defs/a"}
""",
"depth");
}
@Test
void rejectsRelativeAndAbsoluteNestedSchemaIdentifiersInTheClosedSubset() {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:nested-relative",
"$defs":{"nested":{"$id":"child","type":"string"}},
"type":"object"}
""",
"nested $id");
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:nested-absolute",
"$defs":{"nested":{"$id":"urn:test:nested-child","type":"string"}},
"type":"object"}
""",
"nested $id");
}
@Test
void rejectsNestedSchemaIdentifierKeysRegardlessOfValueType() {
for (String nonTextIdentifier : List.of("37", "null", "{}", "false")) {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:nested-non-text",
"$defs":{"nested":{"$id":%s,"type":"string"}},
"type":"object"}
"""
.formatted(nonTextIdentifier),
"nested $id");
}
}
@Test
void rejectsDynamicRecursiveAndAnchorKeywordsEverywhereInTheClosedSubset() {
for (String keyword :
List.of("$dynamicRef", "$dynamicAnchor", "$recursiveRef", "$recursiveAnchor", "$anchor")) {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:unsupported-keyword",
"$defs":{"nested":{"%s":"resource:external","type":"string"}},
"type":"object"}
"""
.formatted(keyword),
keyword);
}
for (String externalTarget : List.of("classpath:external", "resource:external")) {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:dynamic-ref",
"$dynamicRef":"%s",
"type":"object"}
"""
.formatted(externalTarget),
"$dynamicRef");
}
}
@Test
void acceptsOnlyExactUrnSchemeForRootIdentifiersAndAbsoluteReferences() {
for (String identifier :
List.of("classpath:root", "resource:root", "jar:file:test", "URN:test")) {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"%s","type":"object"}
"""
.formatted(identifier),
"exact urn");
}
for (String reference :
List.of(
"classpath:external",
"resource:external",
"jar:file:test",
"https://example.invalid/schema",
"file:/tmp/schema",
"unknown:external")) {
assertRejectedSchema(
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:absolute-reference",
"$ref":"%s"}
"""
.formatted(reference),
"exact urn");
}
}
@Test
void enablesFormatAssertionsAndReturnsDeterministicPayloadFreeErrors() {
String schema =
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:format","type":"object","required":["when"],
"properties":{"when":{"type":"string","format":"date-time"}},
"unevaluatedProperties":false}
""";
LocalJsonSchemaRegistry registry =
registryWith("contracts/messaging/format/v1.schema.json", schema);
List<String> first =
registry.validate(
"urn:test:format",
"{\"when\":\"not-a-time\",\"secret\":\"do-not-log\"}".getBytes(StandardCharsets.UTF_8));
List<String> second =
registry.validate(
"urn:test:format",
"{\"when\":\"not-a-time\",\"secret\":\"do-not-log\"}".getBytes(StandardCharsets.UTF_8));
assertThat(first).isEqualTo(second).isNotEmpty();
assertThat(String.join(" ", first)).doesNotContain("do-not-log");
}
@Test
void rejectsDuplicateKeysMalformedUtf8TrailingGarbageAndOverBudgetRegexInputs() {
LocalJsonSchemaRegistry registry = registry();
assertThatThrownBy(
() ->
registry.validate(
PAYLOAD_ID, "{\"name\":\"a\",\"name\":\"b\"}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("JSON");
assertThatThrownBy(() -> registry.validate(PAYLOAD_ID, new byte[] {(byte) 0xc3, 0x28}))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("UTF-8");
assertThatThrownBy(
() -> registry.validate(PAYLOAD_ID, "{} trailing".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("JSON");
String regexSchema =
"""
{"$schema":"https://json-schema.org/draft/2020-12/schema",
"$id":"urn:test:regex","type":"string","pattern":"^(a+)+$"}
""";
LocalJsonSchemaRegistry bounded =
registryWith("contracts/messaging/regex/v1.schema.json", regexSchema);
assertThatThrownBy(
() ->
bounded.validate(
"urn:test:regex",
("\"" + "a".repeat(300) + "!\"").getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("string");
}
private static LocalJsonSchemaRegistry registry() {
return new LocalJsonSchemaRegistry(
Map.of(
ENVELOPE_RESOURCE, source(ENVELOPE_RESOURCE),
PAYLOAD_RESOURCE, source(PAYLOAD_RESOURCE)),
LIMITS);
}
private static LocalJsonSchemaRegistry registryWith(String path, String schema) {
byte[] bytes = schema.getBytes(StandardCharsets.UTF_8);
return new LocalJsonSchemaRegistry(
Map.of(path, new LocalJsonSchemaRegistry.SchemaSource(path, bytes, sha256(bytes))), LIMITS);
}
private static void assertRejectedSchema(String schema, String message) {
assertThatThrownBy(() -> registryWith("contracts/messaging/rejected/v1.schema.json", schema))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(message);
}
private static LocalJsonSchemaRegistry.SchemaSource source(String path) {
byte[] bytes = resource(path);
return new LocalJsonSchemaRegistry.SchemaSource(path, bytes, sha256(bytes));
}
private static byte[] resource(String path) {
try (InputStream input =
LocalJsonSchemaRegistryTest.class.getClassLoader().getResourceAsStream(path)) {
if (input == null) {
throw new IllegalStateException("missing test resource " + path);
}
return input.readAllBytes();
} catch (IOException exception) {
throw new IllegalStateException(exception);
}
}
private static byte[] classBytes(Class<?> type) {
return resource(type.getName().replace('.', '/') + ".class");
}
private static Sha256 sha256(byte[] bytes) {
try {
return new Sha256(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -77,7 +77,7 @@ class OutboxMessagePublishAdapterTest {
class SuccessPath {
@Test
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() {
void legacyVoidSenderNormalReturnCompletesWithoutBrokerConfirmationCharacterization() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
@@ -128,7 +128,7 @@ class OutboxMessagePublishAdapterTest {
class FailClosedPath {
@Test
void publishFailurePropagatesAsRuntimeException() {
void senderExceptionPropagatesAsRuntimeExceptionCharacterization() {
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.messaging.qualification;
import dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdmissionLimits;
import dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistry;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
/**
* Build/test-only validator for the common Messaging evidence manifest.
*
* <p>This class deliberately owns the file reads outside production runtime code. Validation uses
* the same pinned Draft 2020-12 registry as the Messaging adapter and consumes the exact schema and
* manifest bytes supplied by the build.
*/
public final class MessagingEvidenceManifestSchemaValidator {
private static final String SCHEMA_ID = "urn:dev.caskeleton:messaging:build-evidence-manifest:v1";
private static final String SCHEMA_RESOURCE =
"config/messaging/evidence/build-evidence-manifest-v1.schema.json";
private static final EnvelopeAdmissionLimits LIMITS =
new EnvelopeAdmissionLimits(
64, 4_096, 16_384, 10_000, 1_000, 1_000, 1_000_000, 1_000_000, 100, 4_096, 32);
private MessagingEvidenceManifestSchemaValidator() {}
public static void main(String[] arguments) {
if (arguments.length != 2) {
throw new IllegalArgumentException(
"expected exact common-schema path and generated-manifest path");
}
try {
validate(
Files.readAllBytes(Path.of(arguments[0])), Files.readAllBytes(Path.of(arguments[1])));
} catch (IOException exception) {
throw new IllegalStateException(
"cannot read exact Messaging evidence qualification bytes", exception);
}
}
static void validate(byte[] exactSchemaBytes, byte[] exactManifestBytes) {
LocalJsonSchemaRegistry registry =
new LocalJsonSchemaRegistry(
Map.of(
SCHEMA_RESOURCE,
new LocalJsonSchemaRegistry.SchemaSource(
SCHEMA_RESOURCE, exactSchemaBytes, sha256(exactSchemaBytes))),
LIMITS);
List<String> errors = registry.validate(SCHEMA_ID, exactManifestBytes);
if (!errors.isEmpty()) {
throw new IllegalArgumentException(
"generated Messaging evidence fails the common Draft 2020-12 schema: " + errors);
}
}
private static Sha256 sha256(byte[] bytes) {
try {
return new Sha256(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.messaging.qualification;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class MessagingEvidenceManifestSchemaValidatorTest {
private static final String SHA256 = "sha256:" + "0".repeat(64);
private static final String VALID_MANIFEST =
"""
{
"schemaVersion": 1,
"sourceDigest": "%1$s",
"artifactDigest": "%1$s",
"producerTask": "verifyMessagingContracts",
"scenarioIds": ["scenario.one"],
"counts": {"executed": 1, "passed": 1, "failed": 0, "skipped": 0},
"command": "./gradlew verifyMessagingContracts",
"generatedAt": "2026-07-29T00:00:00Z",
"hashes": {
"profile": "%1$s",
"catalog": "%1$s",
"schema": "%1$s",
"settings": "%1$s"
},
"failures": [],
"skips": [],
"unsupportedClaims": []
}
"""
.formatted(SHA256);
@Test
void validatesAConformingManifestAgainstTheExactCommonSchema() throws IOException {
assertThatCode(
() ->
MessagingEvidenceManifestSchemaValidator.validate(
commonSchemaBytes(), VALID_MANIFEST.getBytes(StandardCharsets.UTF_8)))
.doesNotThrowAnyException();
}
@Test
void rejectsManifestDriftThatTheCommonSchemaForbids() throws IOException {
String drifted = VALID_MANIFEST.replace("\n}", ",\n \"unexpected\": true\n}");
assertThatThrownBy(
() ->
MessagingEvidenceManifestSchemaValidator.validate(
commonSchemaBytes(), drifted.getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("common Draft 2020-12 schema");
}
@Test
void rejectsCommonSchemaDriftThatFailsDraft202012MetaValidation() throws IOException {
String invalidSchema =
new String(commonSchemaBytes(), StandardCharsets.UTF_8)
.replaceFirst("\"type\"\\s*:\\s*\"object\"", "\"type\": 37");
assertThatThrownBy(
() ->
MessagingEvidenceManifestSchemaValidator.validate(
invalidSchema.getBytes(StandardCharsets.UTF_8),
VALID_MANIFEST.getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(IllegalArgumentException.class);
}
private static byte[] commonSchemaBytes() throws IOException {
String configured = System.getProperty("messaging.commonEvidenceSchema");
if (configured == null || configured.isBlank()) {
throw new IllegalStateException("messaging.commonEvidenceSchema test path is required");
}
return Files.readAllBytes(Path.of(configured));
}
}
@@ -0,0 +1,13 @@
{
"name": "",
"count": -1,
"enabled": true,
"amount": 12.50,
"status": "UNKNOWN",
"note": null,
"tags": [],
"nested": {
"code": "N1"
},
"unknown": "closed-schema"
}
@@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:dev-caskeleton:contracts:messaging:test.event:v1",
"title": "Task 6 deterministic encoder test payload v1",
"type": "object",
"required": [
"name",
"count",
"enabled",
"amount",
"status",
"note",
"tags",
"nested"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"count": {
"type": "integer",
"minimum": 0,
"maximum": 999999
},
"enabled": {
"type": "boolean"
},
"amount": {
"type": "number"
},
"status": {
"enum": [
"READY",
"DONE"
]
},
"note": {
"type": [
"string",
"null"
],
"maxLength": 64
},
"tags": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 32
}
},
"nested": {
"type": "object",
"required": [
"code"
],
"properties": {
"code": {
"type": "string",
"minLength": 1,
"maxLength": 16
}
},
"unevaluatedProperties": false
}
},
"unevaluatedProperties": false
}
@@ -0,0 +1,15 @@
{
"name": "정확한-UTF8",
"count": 7,
"enabled": true,
"amount": 12.50,
"status": "READY",
"note": null,
"tags": [
"alpha",
"β"
],
"nested": {
"code": "N1"
}
}
+17
View File
@@ -816,6 +816,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결
BOM 이 관리.
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
loggers / loggers 쓰기 거부)를 검증한다.
- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity,
wildcard-free compatibility, evidence task/scenario/runbook 선언을
`MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며
모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다.
### Messaging qualification scaffold
`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다.
현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer,
CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과
`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest
validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와
무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*`
root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed
guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는
다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread,
network 또는 다른 런타임 resource를 생성하지 않는다.
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
+2
View File
@@ -96,6 +96,8 @@ dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
// test-only: jackson-databind for the deserialization-policy boundary test. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-json'
// test-only: parses checked-in Messaging capability registries for the fail-closed drift gate.
testImplementation 'org.yaml:snakeyaml'
// sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
sampleFixture project(':sample-portfolio')
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README.
+3 -1
View File
@@ -9,6 +9,7 @@ ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,r
ch.qos.logback:logback-core:1.5.34=sampleFixture
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -57,6 +58,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOf
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
@@ -346,7 +348,7 @@ org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClas
org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -7,6 +7,8 @@ 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.allowed.application.CleanProjectionQueryPort;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging.GenericTypeLeakingAdapterFixture;
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
@@ -85,6 +87,10 @@ class ArchitectureViolationFixtureTest {
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class);
private static final JavaClasses GENERIC_ADAPTER_LEAK_ONLY =
new ClassFileImporter()
.importClasses(GenericTypeLeakingAdapterFixture.class, RawExternalResponseFixture.class);
// feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation
// branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a
// silently broken branch cannot pass vacuously via the other one in the shared pool.
@@ -397,6 +403,20 @@ class ArchitectureViolationFixtureTest {
.isTrue();
}
@Test
void outboundAdapterMethodReturnsOnlyDomainCatchesGenericAdapterLeakInIsolation() {
EvaluationResult result =
CleanArchitectureTest
.MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS
.evaluate(GENERIC_ADAPTER_LEAK_ONLY);
assertThat(result.hasViolation())
.as(
"MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS "
+ "must recursively catch List<RawExternalResponseFixture> (B7)")
.isTrue();
}
@Test
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
EvaluationResult result =
@@ -1272,6 +1272,62 @@ class CleanArchitectureTest {
+ "response surfaces.")
.allowEmptyShould(true);
@ArchTest
static final ArchRule
MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS =
methods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..adapter.outbound.messaging..")
.and()
.areDeclaredInClassesThat()
.areNotAnnotatedWith("org.springframework.context.annotation.Configuration")
.and()
.areDeclaredInClassesThat()
.areNotAnnotatedWith(
"org.springframework.boot.context.properties.ConfigurationProperties")
.and()
.arePublic()
.and()
.areNotStatic()
.should(
notReturnAdapterTypesIncludingGenericArguments(
"..adapter.outbound..",
"..adapter.inbound.web..",
"..adapter.outbound.persistence.."))
.as(
"B7 messaging hardening: every public non-static messaging method must keep "
+ "adapter-local types out of direct and recursive generic return positions; "
+ "narrow public static composition bridges are the only allowed adapter-local "
+ "return seam")
.allowEmptyShould(true);
private static ArchCondition<JavaMethod> notReturnAdapterTypesIncludingGenericArguments(
String... forbiddenPackages) {
DescribedPredicate<JavaClass> forbidden =
JavaClass.Predicates.resideInAnyPackage(forbiddenPackages);
return new ArchCondition<>(
"not return adapter-local types directly or through generic arguments") {
@Override
public void check(JavaMethod method, ConditionEvents events) {
for (JavaClass involved : method.getReturnType().getAllInvolvedRawTypes()) {
if (forbidden.test(involved)) {
events.add(
SimpleConditionEvent.violated(
method,
"Method "
+ method.getFullName()
+ " leaks "
+ involved.getName()
+ " through its return type (directly or as a generic argument)"
+ " — B7 permits only narrow static composition bridges for "
+ "adapter-local types"));
}
}
}
};
}
@ArchTest
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
classes()
@@ -0,0 +1,14 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
import java.util.List;
/**
* Negative B7 fixture proving adapter-local types cannot escape through generic return arguments.
*/
public class GenericTypeLeakingAdapterFixture {
public List<RawExternalResponseFixture> leakGeneric() {
return List.of(new RawExternalResponseFixture());
}
}
@@ -0,0 +1,510 @@
package dev.caskeleton.bootstrap.contract.messaging;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.error.YAMLException;
class MessagingCapabilityRegistryContractTest {
private static final Set<String> FIRST_TUPLE_CARD_IDS =
Set.of(
"messaging-outbox-publish.v1",
"kafka-spring-acknowledged-idempotent.v1",
"postgresql-polling-outbox.v2",
"postgresql-per-record-jit-claim.v1",
"json-schema-envelope.v1",
"external-topic-validated.v1",
"kafka-sasl-ssl-scram-sha-512.v1",
"kafka-compression-none.v1",
"per-key-normal-path-sequence-detectable.v1",
"same-postgresql-transaction-resource.v1",
"authenticated-internal-web-disposition.v1");
private static final Set<String> LEGAL_MATURITY =
Set.of("not-implemented", "implemented-candidate", "release-eligible");
private static final Set<String> REQUIRED_VERIFICATION_TASKS =
Set.of(
"verifyMessagingContracts",
"verifyMessagingJsonSchemaV1",
"verifyMessagingPollingOutboxR2",
"verifyMessagingKafkaProducerR2",
"verifyMessagingSecurityR2",
"verifyMessagingReleaseProfile",
"verifyMessagingTargetBindingPreflight",
"verifyMessagingTargetBinding",
"verifyMessagingDeploymentCutover",
"verifyMessagingCleanupTargetBinding",
"verifyMessagingFinalR2Profile");
private static final Set<String> FORBIDDEN_EXTENSION_TOKENS =
Set.of(
"consumer",
"inbox",
"cdc",
"debezium",
"connect",
"eos",
"exactly-once",
"schema-registry",
"schema_registry",
"avro",
"protobuf");
private static final List<String> COMPATIBILITY_SELECTOR_FIELDS =
List.of(
"semanticContractId",
"dispatchProfile",
"claimProfile",
"serializationProfile",
"producerProfile",
"securityProfile",
"topicProfile",
"compressionProfile",
"orderingProfile",
"transactionProfile",
"operatorControlProfile");
@Test
void yamlLoaderRejectsDuplicateKeys() {
String duplicateKeyFixture =
"""
schemaVersion: 1
cards:
- cardId: first.v1
maturity: not-implemented
maturity: release-eligible
""";
assertThatThrownBy(() -> readYaml(duplicateKeyFixture))
.isInstanceOf(YAMLException.class)
.hasMessageContaining("duplicate");
}
@Test
void readinessCardsContainExactlyTheFirstPollingProducerTuple() throws Exception {
Map<String, Object> registry = readYaml(requiredConfig("readiness-cards.yaml"));
List<Map<String, Object>> cards = rows(registry, "cards");
Set<String> cardIds = stringValues(cards, "cardId");
assertThat(cardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
assertThat(cards).hasSize(FIRST_TUPLE_CARD_IDS.size());
assertThat(cardIds).hasSameSizeAs(cards);
}
@Test
void onlyTheQualifiedJsonSchemaCardIsAnImplementedCandidate() throws Exception {
List<Map<String, Object>> cards =
rows(readYaml(requiredConfig("readiness-cards.yaml")), "cards");
for (Map<String, Object> card : cards) {
assertThat(card.get("maturity")).isIn(LEGAL_MATURITY);
assertThat(card.get("evidenceFingerprint")).isEqualTo("");
if ("json-schema-envelope.v1".equals(card.get("cardId"))) {
assertThat(card.get("maturity")).isEqualTo("implemented-candidate");
assertThat(card.get("schemaSetHash"))
.isEqualTo("sha256:42040504d5c204f9ee0e01bfa17fd9a03182db13f8f99e31b63ec79d5ecc40d0");
assertThat(card.get("settingsDigest"))
.isEqualTo("sha256:fcd849322d43a8d88c926a43160296339cbc6e945db81e46c513ab45d185b924");
} else {
assertThat(card.get("maturity")).isEqualTo("not-implemented");
assertThat(card.get("schemaSetHash")).isEqualTo("");
assertThat(card.get("settingsDigest")).isEqualTo("");
}
assertNonEmptyStringList(card, "evidenceTasks");
assertThat(stringList(card, "evidenceTasks"))
.as("evidenceTasks on %s must reference declared root tasks", card.get("cardId"))
.allMatch(REQUIRED_VERIFICATION_TASKS::contains);
assertNonEmptyStringList(card, "requiredScenarios");
assertNonEmptyStringList(card, "runbookIds");
}
}
@Test
void compatibilityProfilesAreExactUniqueAndWildcardFree() throws Exception {
Map<String, Object> registry = readYaml(requiredConfig("profile-compatibility.yaml"));
List<Map<String, Object>> profiles = rows(registry, "profiles");
assertThat(profiles).isNotEmpty();
assertUnique(profiles, "profileId");
assertThat(flattenStrings(registry))
.allSatisfy(
value -> {
assertThat(value).doesNotContain("*");
assertThat(value).doesNotContain("?");
});
for (Map<String, Object> profile : profiles) {
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("profileId"));
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
assertThat(selectedCardIds).allMatch(FIRST_TUPLE_CARD_IDS::contains);
for (String selectorField : COMPATIBILITY_SELECTOR_FIELDS) {
assertThat(profile.get(selectorField))
.as("%s must select one of selectedCardIds", selectorField)
.isIn(selectedCardIds);
}
assertNonEmptyStringList(profile, "requiredScenarios");
assertListIntegrityIfPresent(profile, "runbookIds");
}
}
@Test
void releaseAssertionsDeclareUniqueTasksScenariosRunbooksAndFailClosedEvidencePolicy()
throws Exception {
Map<String, Object> registry = readYaml(requiredConfig("release-profile-assertions.yaml"));
List<Map<String, Object>> profiles = rows(registry, "releaseProfiles");
assertThat(profiles).isNotEmpty();
assertUnique(profiles, "releaseProfileId");
Set<String> compatibilityProfileIds =
stringValues(
rows(readYaml(requiredConfig("profile-compatibility.yaml")), "profiles"), "profileId");
for (Map<String, Object> profile : profiles) {
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("releaseProfileId"));
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
assertThat(profile.get("compatibilityProfileId")).isIn(compatibilityProfileIds);
assertThat(profile.get("requiredCardMaturity")).isEqualTo("release-eligible");
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
assertListIntegrity(
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
assertThat(requiredEvidenceTasks)
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
assertNonEmptyStringList(profile, "requiredScenarios");
assertNonEmptyStringList(profile, "runbookIds");
Map<String, Object> evidencePolicy = map(profile, "evidencePolicy");
assertThat(evidencePolicy)
.containsEntry("rejectMissing", true)
.containsEntry("rejectSkipped", true)
.containsEntry("rejectStale", true)
.containsEntry("rejectWrongSource", true)
.containsEntry("rejectMismatchedProfile", true);
}
}
@Test
void registriesDoNotPredeclareFutureConsumerCdcEosOrSchemaRegistryRows() throws Exception {
Map<String, Object> readiness = readYaml(requiredConfig("readiness-cards.yaml"));
Map<String, Object> compatibility = readYaml(requiredConfig("profile-compatibility.yaml"));
Map<String, Object> releases = readYaml(requiredConfig("release-profile-assertions.yaml"));
List<String> rowIdentities = new ArrayList<>();
rowIdentities.addAll(stringValues(rows(readiness, "cards"), "cardId"));
rowIdentities.addAll(stringValues(rows(compatibility, "profiles"), "profileId"));
rowIdentities.addAll(stringValues(rows(releases, "releaseProfiles"), "releaseProfileId"));
for (Map<String, Object> profile : rows(compatibility, "profiles")) {
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
}
for (Map<String, Object> profile : rows(releases, "releaseProfiles")) {
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
}
for (String identity : rowIdentities) {
String normalized = identity.toLowerCase(java.util.Locale.ROOT);
assertThat(FORBIDDEN_EXTENSION_TOKENS)
.noneSatisfy(
token ->
assertThat(normalized)
.as(
"machine row '%s' must not predeclare future extension token '%s'",
identity, token)
.contains(token));
}
}
@Test
void commonEvidenceSchemaIsPayloadFreeAndKeepsAllFailClosedFields() throws Exception {
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
Map<String, Object> root =
new ObjectMapper().readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
assertThat(root.get("$schema")).isEqualTo("https://json-schema.org/draft/2020-12/schema");
assertThat(root).containsEntry("type", "object").containsEntry("additionalProperties", false);
assertThat(stringList(root, "required"))
.containsExactlyInAnyOrder(
"schemaVersion",
"sourceDigest",
"artifactDigest",
"producerTask",
"scenarioIds",
"counts",
"command",
"generatedAt",
"hashes",
"failures",
"skips",
"unsupportedClaims");
String schemaText = Files.readString(schema).toLowerCase(java.util.Locale.ROOT);
assertThat(schemaText)
.doesNotContain("\"payload\"")
.contains("\"profile\"")
.contains("\"catalog\"")
.contains("\"schema\"")
.contains("\"settings\"");
}
@Test
void commonEvidenceSchemaClosesNestedObjectsAndPreservesCoreFieldConstraints() throws Exception {
Map<String, Object> root = readJsonSchema();
Map<String, Object> properties = map(root, "properties");
Map<String, Object> definitions = map(root, "$defs");
assertThat(map(properties, "schemaVersion"))
.containsEntry("type", "integer")
.containsEntry("const", 1);
assertThat(map(properties, "sourceDigest")).containsEntry("$ref", "#/$defs/sha256");
assertThat(map(properties, "artifactDigest")).containsEntry("$ref", "#/$defs/sha256");
Map<String, Object> producerTask = map(properties, "producerTask");
assertThat(producerTask).containsEntry("type", "string");
assertThat(stringList(producerTask, "enum"))
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
assertArrayOfReference(
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
assertClosedObject(
map(properties, "counts"), Set.of("executed", "passed", "failed", "skipped"));
Map<String, Object> countProperties = map(map(properties, "counts"), "properties");
assertIntegerMinimum(countProperties, "executed", 1);
assertIntegerMinimum(countProperties, "passed", 0);
assertIntegerMinimum(countProperties, "failed", 0);
assertIntegerMinimum(countProperties, "skipped", 0);
assertThat(map(properties, "command")).containsEntry("type", "string");
assertThat(map(properties, "generatedAt"))
.containsEntry("type", "string")
.containsEntry("format", "date-time");
Map<String, Object> hashes = map(properties, "hashes");
assertClosedObject(hashes, Set.of("profile", "catalog", "schema", "settings"));
Map<String, Object> hashProperties = map(hashes, "properties");
for (String hashName : List.of("profile", "catalog", "schema", "settings")) {
assertThat(map(hashProperties, hashName)).containsEntry("$ref", "#/$defs/sha256");
}
assertArrayOfReference(map(properties, "failures"), "#/$defs/result", false, null);
assertArrayOfReference(map(properties, "skips"), "#/$defs/result", false, null);
assertArrayOfReference(map(properties, "unsupportedClaims"), "#/$defs/identifier", true, null);
assertThat(map(definitions, "sha256"))
.containsEntry("type", "string")
.containsEntry("pattern", "^sha256:[a-f0-9]{64}$");
assertThat(map(definitions, "identifier"))
.containsEntry("type", "string")
.containsEntry("pattern", "^[A-Za-z0-9][A-Za-z0-9._:-]*$");
Map<String, Object> result = map(definitions, "result");
assertClosedObject(result, Set.of("scenarioId", "reason"));
Map<String, Object> resultProperties = map(result, "properties");
assertThat(map(resultProperties, "scenarioId")).containsEntry("$ref", "#/$defs/identifier");
assertThat(map(resultProperties, "reason"))
.containsEntry("type", "string")
.containsEntry("minLength", 1);
}
@Test
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
assertThat(build).contains("messagingFailClosedEvidenceGuard");
assertThat(build)
.contains(
"messagingVerificationSkeletons.each",
"tasks.register(taskName)",
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)",
"qualification producer/tests and common-schema validator are not implemented",
"missing evidence",
"contains skipped evidence",
"is stale or future-dated",
"has wrong source digest",
"has mismatched profile hash");
for (String taskName : REQUIRED_VERIFICATION_TASKS) {
assertThat(build).contains("'" + taskName + "'");
}
}
@Test
void rootBuildSchemaValidatesEvidenceAndDeterministicallyLeavesCombinedEvidenceLast()
throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
assertThat(build)
.contains(
"MessagingEvidenceManifestSchemaValidator",
"validateMessagingJsonSchemaV1EvidenceManifestSchema",
"validateMessagingContractsEvidenceManifestSchema",
"verifyMessagingJsonSchemaV1.configure",
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
"verifyMessagingContracts.configure",
"finalizedBy validateMessagingContractsEvidenceManifestSchema");
}
private static Path requiredConfig(String relativePath) {
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
assertThat(path).as("required Messaging configuration %s", path).isRegularFile();
return path;
}
private static Path repositorySrcRoot() {
Path directory = Path.of("").toAbsolutePath();
for (int depth = 0; depth < 8 && directory != null; depth++) {
if (Files.isRegularFile(directory.resolve("settings.gradle"))
&& Files.isRegularFile(directory.resolve("config/architecture/modules.json"))) {
return directory;
}
directory = directory.getParent();
}
throw new IllegalStateException(
"Could not locate repository src root from test working directory");
}
@SuppressWarnings("unchecked")
private static Map<String, Object> readYaml(Path path) throws Exception {
try (InputStream input = Files.newInputStream(path)) {
return readYaml(input);
}
}
private static Map<String, Object> readYaml(String yaml) {
return readYaml((Object) yaml);
}
private static Map<String, Object> readYaml(InputStream yaml) {
return readYaml((Object) yaml);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> readYaml(Object yamlInput) {
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setAllowDuplicateKeys(false);
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions));
Object value =
yamlInput instanceof InputStream input ? yaml.load(input) : yaml.load((String) yamlInput);
assertThat(value).isInstanceOf(Map.class);
return (Map<String, Object>) value;
}
private static Map<String, Object> readJsonSchema() throws Exception {
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
return new ObjectMapper()
.readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> rows(Map<String, Object> root, String key) {
assertThat(root.get(key)).as("top-level '%s' rows", key).isInstanceOf(List.class);
return (List<Map<String, Object>>) root.get(key);
}
private static Set<String> stringValues(List<Map<String, Object>> rows, String key) {
Set<String> values = new LinkedHashSet<>();
for (Map<String, Object> row : rows) {
assertThat(row.get(key)).isInstanceOf(String.class);
values.add((String) row.get(key));
}
return values;
}
private static void assertUnique(List<Map<String, Object>> rows, String key) {
Set<String> values = stringValues(rows, key);
assertThat(values).hasSameSizeAs(rows);
}
private static void assertNonEmptyStringList(Map<String, Object> row, String key) {
assertListIntegrity(stringList(row, key), key, row);
}
private static void assertListIntegrityIfPresent(Map<String, Object> row, String key) {
if (row.containsKey(key)) {
assertListIntegrity(stringList(row, key), key, row);
}
}
private static void assertListIntegrity(List<String> values, String key, Object owner) {
assertThat(values)
.as("%s on %s", key, owner)
.isNotEmpty()
.doesNotHaveDuplicates()
.allSatisfy(value -> assertThat(value).isNotBlank());
}
@SuppressWarnings("unchecked")
private static List<String> stringList(Map<String, Object> row, String key) {
assertThat(row.get(key)).as("required list '%s' on %s", key, row).isInstanceOf(List.class);
List<Object> values = (List<Object>) row.get(key);
assertThat(values).allMatch(String.class::isInstance);
return values.stream().map(String.class::cast).toList();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Map<String, Object> row, String key) {
assertThat(row.get(key)).as("required map '%s' on %s", key, row).isInstanceOf(Map.class);
return (Map<String, Object>) row.get(key);
}
private static void assertClosedObject(Map<String, Object> schema, Set<String> required) {
assertThat(schema).containsEntry("type", "object").containsEntry("additionalProperties", false);
assertThat(stringList(schema, "required")).containsExactlyInAnyOrderElementsOf(required);
}
private static void assertArrayOfReference(
Map<String, Object> schema, String reference, boolean unique, Integer minimumItems) {
assertThat(schema).containsEntry("type", "array");
assertThat(map(schema, "items")).containsEntry("$ref", reference);
if (unique) {
assertThat(schema).containsEntry("uniqueItems", true);
}
if (minimumItems != null) {
assertThat(schema).containsEntry("minItems", minimumItems);
}
}
private static void assertIntegerMinimum(
Map<String, Object> properties, String field, int minimum) {
assertThat(map(properties, field))
.containsEntry("type", "integer")
.containsEntry("minimum", minimum);
}
private static List<String> flattenStrings(Object value) {
List<String> strings = new ArrayList<>();
flattenStrings(value, strings, new HashSet<>());
return strings;
}
private static void flattenStrings(
Object value, List<String> destination, Set<Object> visitedContainers) {
if (value instanceof String string) {
destination.add(string);
} else if (value instanceof Map<?, ?> map && visitedContainers.add(value)) {
map.forEach(
(key, nested) -> {
destination.add(String.valueOf(key));
flattenStrings(nested, destination, visitedContainers);
});
} else if (value instanceof Iterable<?> iterable && visitedContainers.add(value)) {
iterable.forEach(nested -> flattenStrings(nested, destination, visitedContainers));
}
}
}
@@ -53,7 +53,7 @@ class OutboxAppendTransactionalContractTest {
}
@Test
void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() {
void sameTransactionAppendRollbackRemovesOutboxRowCharacterization() {
Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
try (AnnotationConfigApplicationContext ctx =
@@ -318,6 +318,43 @@ class OutboxRowLifecycleContractTest {
}
}
@Test
void equalTimestampRowsAreClaimedTogetherLegacyFifoLimitationCharacterization() {
Instant occurredAt = Instant.now();
Clock clock = Clock.fixed(occurredAt.plusSeconds(1), ZoneOffset.UTC);
try (AnnotationConfigApplicationContext ctx =
OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) {
TransactionPort tx = ctx.getBean(TransactionPort.class);
OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class);
String aggregateId = "agg-fifo-tie-" + System.nanoTime();
String firstId = "fifo-tie-first-" + System.nanoTime();
String secondId = "fifo-tie-second-" + System.nanoTime();
tx.inWrite(
() -> {
append.append(newEventAt(firstId, aggregateId, occurredAt));
append.append(newEventAt(secondId, aggregateId, occurredAt));
return null;
});
OutboxRelayResult cycle =
OutboxContainerTestSupport.relayUseCase(ctx)
.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(cycle.outcomes())
.as(
"legacy FIFO compares only occurred_at with '<', so equal timestamps do not gate"
+ " either row")
.filteredOn(
outcome -> outcome.eventId().equals(firstId) || outcome.eventId().equals(secondId))
.extracting(OutboxRelayResult.EventOutcome::eventId)
.containsExactlyInAnyOrder(firstId, secondId);
}
}
// =========================================================================
// FIFO gate blocking scenarios (plan Task E)
//
+11
View File
@@ -21,6 +21,8 @@ Package root: `dev.caskeleton.application`.
- Own application transaction boundaries through the `TransactionPort` abstraction.
- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own
MDC or other concrete storage.
- Own the framework-free semantic integration-event draft, validated-event value contract and
exact typed payload contribution SPI. This is the messaging semantic contract R1 boundary only.
## Allowed
@@ -46,6 +48,8 @@ Package root: `dev.caskeleton.application`.
- Persistence-layer transaction annotations of any kind inside this module.
- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express
diagnostic intent through a specific outbound `*Port`; adapters own rendering.
- Messaging provider/runtime types: physical topic, Kafka record or metadata, JSON tree/raw JSON
payload, serializer/schema-validator implementation, security topology and publication epoch.
## Contract types
@@ -62,6 +66,10 @@ Package root: `dev.caskeleton.application`.
| `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`. |
| `messaging.contract.IntegrationEventContractContribution<P>` | Closed exact-record payload type, canonical component order, local schema identity/hash and provider-neutral descriptor contribution. |
| `messaging.event.IntegrationEventDraft<P>` | Typed semantic event before local encoding; never JSON, Kafka or persistence state. |
| `messaging.event.ValidatedIntegrationEvent` | Stable semantic identities plus immutable exact encoded bytes and hashes, ready for a later durable append boundary. |
| `messaging.event.IntegrationEventEncoderPort` | Framework-free local draft-to-validated-event boundary implemented by an outbound adapter. |
## Naming convention
@@ -189,3 +197,6 @@ cd src
./gradlew :application-core:test
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'
```
The messaging types above establish semantic contract R1 only. They do not claim JSON Schema
qualification, Kafka publication, durable outbox persistence or any messaging R2 capability.
+28
View File
@@ -16,6 +16,34 @@
---
## 메시징 semantic contract R1
`messaging.contract``messaging.event`는 feature가 integration event를 동적 JSON이나 provider
타입으로 넘기지 않게 만드는 application 경계다.
- `IntegrationPayload` 구현은 feature가 소유한 불변 typed record다.
- `IntegrationEventContractContribution`은 contract ID와 payload version을 분리하고, exact final
record type token, 실제 record component 순서, repository-local schema resource/hash와
provider-neutral `ContractDescriptor`만 기여한다. assignable-type 탐색, `Class.forName`, Java
class-name routing, `Map`, raw JSON string/tree는 이 SPI에 들어오지 않는다.
- `IntegrationEventDraft`는 canonical event/aggregate/order/correlation identity와 typed payload를
보유한다. tenant가 없는 모드도 null 대신 canonical system tenant scope를
`AggregateIdentity`에 넣어 dedupe/order identity가 PostgreSQL nullable uniqueness에 기대지
않게 한다.
- `IntegrationEventEncoderPort` 뒤의 adapter가 deterministic encoding과 schema validation을
수행하고 `ValidatedIntegrationEvent`를 돌려준다. 결과는 logical destination, exact US-ASCII
partition key, exact encoded envelope bytes, schema/envelope hash와 catalog/binding revision을
defensive copy로 보존한다.
- `ContractDescriptor`는 owner module, logical destination, serializer ID, ordering requirement,
payload/envelope byte limit, sensitivity classification, same-event requeue horizon만 표현한다.
physical topic, Kafka cluster/security topology는 deployment binding의 책임이다.
이 단계의 완성 범위는 **framework-free semantic contract R1**이다. JSON Schema validator와
deterministic writer, Kafka ACK producer, PostgreSQL outbox append/relay는 후속 R2 작업이며 여기서
구현되었거나 검증됐다고 주장하지 않는다.
---
## 유스케이스 계약 (usecase / command / query / capability)
### UseCase / CommandUseCase / QueryUseCase
@@ -0,0 +1,57 @@
package dev.caskeleton.application.messaging.contract;
import java.time.Duration;
/** Provider-neutral semantic metadata for one integration-event contract. */
public record ContractDescriptor(
String ownerModule,
LogicalDestinationId logicalDestination,
String serializerId,
boolean orderingRequired,
int maximumPayloadBytes,
int maximumEnvelopeBytes,
SensitivityClassification sensitivityClassification,
Duration sameEventRequeueHorizon) {
private static final String SEMANTIC_ID = "[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*";
private static final Duration MAXIMUM_REQUEUE_HORIZON = Duration.ofDays(365);
public ContractDescriptor {
if (ownerModule == null
|| ownerModule.length() > 96
|| !ownerModule.matches("[a-z][a-z0-9]*(?:-[a-z0-9]+)*")) {
throw new IllegalArgumentException("ownerModule must be a canonical module identifier");
}
if (logicalDestination == null) {
throw new IllegalArgumentException("logicalDestination must not be null");
}
if (serializerId == null || serializerId.length() > 96 || !serializerId.matches(SEMANTIC_ID)) {
throw new IllegalArgumentException("serializerId must be a canonical semantic identifier");
}
if (maximumPayloadBytes <= 0) {
throw new IllegalArgumentException("maximumPayloadBytes must be positive");
}
if (maximumEnvelopeBytes <= 0 || maximumEnvelopeBytes < maximumPayloadBytes) {
throw new IllegalArgumentException(
"maximumEnvelopeBytes must be positive and at least maximumPayloadBytes");
}
if (sensitivityClassification == null) {
throw new IllegalArgumentException("sensitivityClassification must not be null");
}
if (sameEventRequeueHorizon == null
|| sameEventRequeueHorizon.isZero()
|| sameEventRequeueHorizon.isNegative()
|| sameEventRequeueHorizon.compareTo(MAXIMUM_REQUEUE_HORIZON) > 0) {
throw new IllegalArgumentException(
"sameEventRequeueHorizon must be positive and at most 365 days");
}
}
/** Closed vocabulary for payload handling policy. */
public enum SensitivityClassification {
PUBLIC,
INTERNAL,
CONFIDENTIAL,
RESTRICTED
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.messaging.contract;
/** Stable semantic contract identity. Payload versions are represented separately. */
public record ContractId(String value) {
private static final int MAXIMUM_LENGTH = 160;
private static final String SEGMENT = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
private static final String GRAMMAR = SEGMENT + "(?:\\." + SEGMENT + ")+";
public ContractId {
if (value == null
|| value.length() > MAXIMUM_LENGTH
|| !value.matches(GRAMMAR)
|| value.matches(".*(?:\\.|-)v[0-9]+$")) {
throw new IllegalArgumentException(
"contractId must be a version-free canonical lower-case semantic identifier");
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.messaging.contract;
import java.util.List;
/**
* Framework-free contribution to the closed integration-event contract catalog.
*
* @param <P> exact feature-owned payload record type
*/
public interface IntegrationEventContractContribution<P extends IntegrationPayload> {
ContractId contractId();
int payloadVersion();
Class<P> exactPayloadRecordType();
List<String> canonicalRecordComponentOrder();
SchemaResourceId payloadSchemaResource();
Sha256 payloadSchemaHash();
ContractDescriptor descriptor();
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.messaging.contract;
/** Marker for a feature-owned, immutable integration-event payload record. */
public interface IntegrationPayload {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.messaging.contract;
/** Logical delivery-class identity; never a physical topic, cluster or Java class name. */
public record LogicalDestinationId(String value) {
private static final String GRAMMAR = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
public LogicalDestinationId {
if (value == null
|| value.length() > 96
|| !value.matches(GRAMMAR)
|| value.matches(".*-v[0-9]+$")) {
throw new IllegalArgumentException(
"logicalDestinationId must be a version-free canonical lower-case semantic identifier");
}
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.messaging.contract;
/** Canonical repository-local JSON Schema resource identity. */
public record SchemaResourceId(String value) {
private static final String GRAMMAR =
"contracts/messaging/[a-z][a-z0-9]*(?:[-.][a-z0-9]+)*/v[1-9][0-9]*\\.schema\\.json";
public SchemaResourceId {
if (value == null || value.length() > 256 || !value.matches(GRAMMAR)) {
throw new IllegalArgumentException(
"schemaResourceId must identify a versioned local contracts/messaging JSON Schema");
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.messaging.contract;
import java.util.Arrays;
import java.util.HexFormat;
/** Immutable SHA-256 digest value. */
@SuppressWarnings("ArrayRecordComponent")
public record Sha256(byte[] bytes) {
public static final int BYTE_LENGTH = 32;
public Sha256 {
if (bytes == null || bytes.length != BYTE_LENGTH) {
throw new IllegalArgumentException("SHA-256 digest must contain exactly 32 bytes");
}
bytes = bytes.clone();
}
@Override
public byte[] bytes() {
return bytes.clone();
}
@Override
public boolean equals(Object other) {
return this == other || (other instanceof Sha256 sha256 && Arrays.equals(bytes, sha256.bytes));
}
@Override
public int hashCode() {
return Arrays.hashCode(bytes);
}
@Override
public String toString() {
return HexFormat.of().formatHex(bytes);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.messaging.event;
/** Canonical non-null tenant and aggregate ordering identity. */
public record AggregateIdentity(String tenantScope, String aggregateType, String aggregateId) {
public AggregateIdentity {
if (tenantScope == null
|| tenantScope.length() > 96
|| !tenantScope.matches("[a-z0-9]+(?:[._:-][a-z0-9]+)*")) {
throw new IllegalArgumentException(
"tenantScope must be a canonical non-null lower-case identifier");
}
if (aggregateType == null
|| aggregateType.length() > 64
|| !aggregateType.matches("[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*")) {
throw new IllegalArgumentException("aggregateType must be a canonical lower-case identifier");
}
if (aggregateId == null
|| aggregateId.length() > 160
|| !aggregateId.matches("[A-Za-z0-9][A-Za-z0-9._:-]*")) {
throw new IllegalArgumentException("aggregateId must be a canonical bounded identifier");
}
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.messaging.event;
/** Total order within one aggregate identity. */
public record AggregateOrder(long sequence, int eventIndex) {
public AggregateOrder {
if (sequence <= 0) {
throw new IllegalArgumentException("aggregate sequence must be positive");
}
if (eventIndex < 0) {
throw new IllegalArgumentException("eventIndex must be non-negative");
}
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.messaging.event;
/** Canonical integration-event identity. */
public record EventId(String value) {
private static final String GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
public EventId {
if (value == null || value.length() > 96 || !value.matches(GRAMMAR)) {
throw new IllegalArgumentException(
"eventId must be 1-96 US-ASCII characters matching " + "[A-Za-z0-9][A-Za-z0-9._:-]*");
}
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.messaging.event;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import java.time.Instant;
import java.util.Optional;
/** Feature-owned semantic event before local encoding and schema validation. */
public record IntegrationEventDraft<P extends IntegrationPayload>(
EventId eventId,
ContractId contractId,
int payloadVersion,
LogicalDestinationId destinationId,
AggregateIdentity aggregate,
AggregateOrder order,
Instant occurredAt,
String correlationId,
Optional<String> causationId,
P featurePayload) {
private static final String CORRELATION_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
public IntegrationEventDraft {
if (eventId == null
|| contractId == null
|| destinationId == null
|| aggregate == null
|| order == null) {
throw new IllegalArgumentException("event identities and order must not be null");
}
if (payloadVersion <= 0) {
throw new IllegalArgumentException("payloadVersion must be positive");
}
if (occurredAt == null) {
throw new IllegalArgumentException("occurredAt must not be null");
}
requireCanonicalCorrelationIdentity("correlationId", correlationId);
if (causationId == null) {
throw new IllegalArgumentException("causationId Optional must not be null");
}
causationId.ifPresent(value -> requireCanonicalCorrelationIdentity("causationId", value));
if (featurePayload == null) {
throw new IllegalArgumentException("featurePayload must not be null");
}
}
private static void requireCanonicalCorrelationIdentity(String field, String value) {
if (value == null || value.length() > 96 || !value.matches(CORRELATION_GRAMMAR)) {
throw new IllegalArgumentException(field + " must be a canonical bounded US-ASCII identity");
}
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.messaging.event;
/** Local, framework-free boundary for deterministic encoding and contract validation. */
public interface IntegrationEventEncoderPort {
ValidatedIntegrationEvent encode(IntegrationEventDraft<?> draft);
}
@@ -0,0 +1,162 @@
package dev.caskeleton.application.messaging.event;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
/** Immutable, locally encoded integration event ready for durable append. */
@SuppressWarnings("ArrayRecordComponent")
public record ValidatedIntegrationEvent(
EventId eventId,
ContractId contractId,
int envelopeVersion,
int payloadVersion,
LogicalDestinationId logicalDestinationId,
AggregateIdentity aggregate,
AggregateOrder order,
Instant occurredAt,
String correlationId,
Optional<String> causationId,
String partitionKeyText,
byte[] partitionKeyBytes,
byte[] envelopeBytes,
String contentType,
Sha256 schemaSetHash,
Sha256 envelopeSha256,
Sha256 envelopeSchemaHash,
Sha256 payloadSchemaHash,
String contractCatalogRevision,
String destinationBindingRevision) {
private static final String CORRELATION_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
private static final String REVISION_GRAMMAR = "[a-z0-9][a-z0-9._:-]{0,95}";
private static final String CONTENT_TYPE_GRAMMAR = "[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+";
public ValidatedIntegrationEvent {
if (eventId == null
|| contractId == null
|| logicalDestinationId == null
|| aggregate == null
|| order == null) {
throw new IllegalArgumentException("stable event identities and order must not be null");
}
if (envelopeVersion <= 0 || payloadVersion <= 0) {
throw new IllegalArgumentException("envelopeVersion and payloadVersion must be positive");
}
if (occurredAt == null) {
throw new IllegalArgumentException("occurredAt must not be null");
}
requireCanonicalIdentity("correlationId", correlationId, CORRELATION_GRAMMAR);
if (causationId == null) {
throw new IllegalArgumentException("causationId Optional must not be null");
}
causationId.ifPresent(
value -> requireCanonicalIdentity("causationId", value, CORRELATION_GRAMMAR));
if (partitionKeyText == null || !partitionKeyText.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
"partitionKeyText must contain exactly 64 lower-case hexadecimal characters");
}
if (partitionKeyBytes == null
|| !Arrays.equals(
partitionKeyBytes, partitionKeyText.getBytes(StandardCharsets.US_ASCII))) {
throw new IllegalArgumentException(
"partitionKeyBytes must exactly equal the US-ASCII partitionKeyText bytes");
}
if (envelopeBytes == null || envelopeBytes.length == 0) {
throw new IllegalArgumentException("envelopeBytes must not be null or empty");
}
if (contentType == null
|| contentType.length() > 96
|| !contentType.matches(CONTENT_TYPE_GRAMMAR)) {
throw new IllegalArgumentException("contentType must be a canonical bounded media type");
}
if (schemaSetHash == null
|| envelopeSha256 == null
|| envelopeSchemaHash == null
|| payloadSchemaHash == null) {
throw new IllegalArgumentException("validated schema and envelope hashes must not be null");
}
requireCanonicalIdentity("contractCatalogRevision", contractCatalogRevision, REVISION_GRAMMAR);
requireCanonicalIdentity(
"destinationBindingRevision", destinationBindingRevision, REVISION_GRAMMAR);
partitionKeyBytes = partitionKeyBytes.clone();
envelopeBytes = envelopeBytes.clone();
}
@Override
public byte[] partitionKeyBytes() {
return partitionKeyBytes.clone();
}
@Override
public byte[] envelopeBytes() {
return envelopeBytes.clone();
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof ValidatedIntegrationEvent that)) {
return false;
}
return envelopeVersion == that.envelopeVersion
&& payloadVersion == that.payloadVersion
&& eventId.equals(that.eventId)
&& contractId.equals(that.contractId)
&& logicalDestinationId.equals(that.logicalDestinationId)
&& aggregate.equals(that.aggregate)
&& order.equals(that.order)
&& occurredAt.equals(that.occurredAt)
&& correlationId.equals(that.correlationId)
&& causationId.equals(that.causationId)
&& partitionKeyText.equals(that.partitionKeyText)
&& Arrays.equals(partitionKeyBytes, that.partitionKeyBytes)
&& Arrays.equals(envelopeBytes, that.envelopeBytes)
&& contentType.equals(that.contentType)
&& schemaSetHash.equals(that.schemaSetHash)
&& envelopeSha256.equals(that.envelopeSha256)
&& envelopeSchemaHash.equals(that.envelopeSchemaHash)
&& payloadSchemaHash.equals(that.payloadSchemaHash)
&& contractCatalogRevision.equals(that.contractCatalogRevision)
&& destinationBindingRevision.equals(that.destinationBindingRevision);
}
@Override
public int hashCode() {
int result =
Objects.hash(
eventId,
contractId,
envelopeVersion,
payloadVersion,
logicalDestinationId,
aggregate,
order,
occurredAt,
correlationId,
causationId,
partitionKeyText,
contentType,
schemaSetHash,
envelopeSha256,
envelopeSchemaHash,
payloadSchemaHash,
contractCatalogRevision,
destinationBindingRevision);
result = 31 * result + Arrays.hashCode(partitionKeyBytes);
return 31 * result + Arrays.hashCode(envelopeBytes);
}
private static void requireCanonicalIdentity(String field, String value, String grammar) {
if (value == null || !value.matches(grammar)) {
throw new IllegalArgumentException(field + " must be a canonical bounded US-ASCII identity");
}
}
}
@@ -0,0 +1,217 @@
package dev.caskeleton.application.messaging.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.lang.reflect.Modifier;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
class IntegrationEventContractContributionTest {
@Test
void contributionUsesAnExactFinalRecordAndCanonicalComponentOrder() {
TestContribution contribution = new TestContribution();
assertThat(contribution.exactPayloadRecordType()).isEqualTo(TestPayload.class);
assertThat(contribution.exactPayloadRecordType().isRecord()).isTrue();
assertThat(Modifier.isFinal(contribution.exactPayloadRecordType().getModifiers())).isTrue();
assertThat(contribution.canonicalRecordComponentOrder())
.containsExactlyElementsOf(
Arrays.stream(TestPayload.class.getRecordComponents())
.map(component -> component.getName())
.toList());
assertThat(contribution.contractId()).isEqualTo(new ContractId("test.event.created"));
assertThat(contribution.payloadVersion()).isEqualTo(1);
assertThat(contribution.payloadSchemaResource())
.isEqualTo(new SchemaResourceId("contracts/messaging/test.event.created/v1.schema.json"));
assertThat(contribution.payloadSchemaHash()).isEqualTo(new Sha256(new byte[32]));
}
@Test
void contributionSpiIsClosedAndDoesNotExposeDynamicPayloadOrRoutingApis() {
Set<String> methods =
Arrays.stream(IntegrationEventContractContribution.class.getDeclaredMethods())
.map(method -> method.getName())
.collect(Collectors.toSet());
assertThat(methods)
.containsExactlyInAnyOrder(
"contractId",
"payloadVersion",
"exactPayloadRecordType",
"canonicalRecordComponentOrder",
"payloadSchemaResource",
"payloadSchemaHash",
"descriptor");
assertThat(
Arrays.stream(IntegrationEventContractContribution.class.getDeclaredMethods())
.filter(method -> method.isDefault()))
.isEmpty();
assertThat(
Arrays.stream(IntegrationEventContractContribution.class.getDeclaredMethods())
.flatMap(
method ->
java.util.stream.Stream.concat(
java.util.stream.Stream.of(method.getReturnType()),
Arrays.stream(method.getParameterTypes()))))
.doesNotContain(Map.class, String.class);
assertThat(methods)
.noneMatch(
name ->
name.contains("assignable")
|| name.contains("className")
|| name.contains("json")
|| name.contains("tree"));
}
@Test
void contractAndDestinationIdentifiersAreStableSemanticIdsWithSeparateVersions() {
assertThat(new ContractId("portfolio.worklog.reserved").value())
.isEqualTo("portfolio.worklog.reserved");
assertThat(new LogicalDestinationId("portfolio-domain-events").value())
.isEqualTo("portfolio-domain-events");
assertThatThrownBy(() -> new ContractId("portfolio.worklog.reserved.v1"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new ContractId("dev.caskeleton.WorkLogReservedPayload"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new ContractId("portfolio-.worklog.reserved"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new ContractId("kafka://portfolio.domain-events.v1"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LogicalDestinationId("portfolio.domain-events.v1"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LogicalDestinationId("WorkLogReservedPayload"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LogicalDestinationId("portfolio-domain-events-v1"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void schemaResourceIsAClosedLocalContractResource() {
assertThat(
new SchemaResourceId("contracts/messaging/test.event.created/v1.schema.json").value())
.isEqualTo("contracts/messaging/test.event.created/v1.schema.json");
assertThatThrownBy(() -> new SchemaResourceId("https://schemas.example/test.schema.json"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new SchemaResourceId("../test.schema.json"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new SchemaResourceId("contracts/messaging/test..event/v1.schema.json"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new SchemaResourceId("dev.caskeleton.TestPayload"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void sha256HasFixedLengthContentEqualityAndDefensiveCopies() {
byte[] bytes = new byte[32];
bytes[0] = 42;
Sha256 hash = new Sha256(bytes);
Sha256 equalHash = new Sha256(bytes.clone());
bytes[0] = 0;
byte[] exposed = hash.bytes();
exposed[0] = 0;
assertThat(hash).isEqualTo(equalHash);
assertThat(hash.hashCode()).isEqualTo(equalHash.hashCode());
assertThat(hash.bytes()[0]).isEqualTo((byte) 42);
assertThatThrownBy(() -> new Sha256(new byte[31])).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new Sha256(null)).isInstanceOf(IllegalArgumentException.class);
}
@Test
void descriptorContainsOnlyBoundedProviderNeutralContractMetadata() {
ContractDescriptor descriptor = descriptor();
assertThat(descriptor.ownerModule()).isEqualTo("sample-portfolio");
assertThat(descriptor.logicalDestination())
.isEqualTo(new LogicalDestinationId("portfolio-domain-events"));
assertThat(descriptor.maximumPayloadBytes()).isEqualTo(64 * 1024);
assertThat(descriptor.maximumEnvelopeBytes()).isEqualTo(128 * 1024);
assertThat(descriptor.sameEventRequeueHorizon()).isEqualTo(Duration.ofDays(7));
assertThat(
Arrays.stream(ContractDescriptor.class.getRecordComponents())
.map(component -> component.getName()))
.noneMatch(
name ->
name.toLowerCase(Locale.ROOT).contains("topic")
|| name.toLowerCase(Locale.ROOT).contains("kafka")
|| name.toLowerCase(Locale.ROOT).contains("bootstrap")
|| name.toLowerCase(Locale.ROOT).contains("security"));
assertThatThrownBy(
() ->
new ContractDescriptor(
"sample-portfolio",
new LogicalDestinationId("portfolio-domain-events"),
"json-schema-envelope-v1",
true,
0,
128 * 1024,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7)))
.isInstanceOf(IllegalArgumentException.class);
}
private static ContractDescriptor descriptor() {
return new ContractDescriptor(
"sample-portfolio",
new LogicalDestinationId("portfolio-domain-events"),
"json-schema-envelope-v1",
true,
64 * 1024,
128 * 1024,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7));
}
private record TestPayload(String workLogId, long revision) implements IntegrationPayload {}
private static final class TestContribution
implements IntegrationEventContractContribution<TestPayload> {
@Override
public ContractId contractId() {
return new ContractId("test.event.created");
}
@Override
public int payloadVersion() {
return 1;
}
@Override
public Class<TestPayload> exactPayloadRecordType() {
return TestPayload.class;
}
@Override
public List<String> canonicalRecordComponentOrder() {
return List.of("workLogId", "revision");
}
@Override
public SchemaResourceId payloadSchemaResource() {
return new SchemaResourceId("contracts/messaging/test.event.created/v1.schema.json");
}
@Override
public Sha256 payloadSchemaHash() {
return new Sha256(new byte[32]);
}
@Override
public ContractDescriptor descriptor() {
return IntegrationEventContractContributionTest.descriptor();
}
}
}
@@ -0,0 +1,119 @@
package dev.caskeleton.application.messaging.event;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class IntegrationEventDraftTest {
@Test
void eventIdAcceptsOnlyBoundedCanonicalUsAscii() {
assertThat(new EventId("A0:event.id-1").value()).isEqualTo("A0:event.id-1");
assertThatThrownBy(() -> new EventId("")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EventId("-event")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EventId("event 한글")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new EventId("e".repeat(97)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void aggregateIdentityRequiresCanonicalNonNullTenantAndAggregateValues() {
AggregateIdentity identity = new AggregateIdentity("tenant-a", "worklog", "worklog-42");
assertThat(identity.tenantScope()).isEqualTo("tenant-a");
assertThatThrownBy(() -> new AggregateIdentity(null, "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new AggregateIdentity(" ", "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new AggregateIdentity("Tenant A", "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new AggregateIdentity("tenant-", "worklog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new AggregateIdentity("tenant-a", "WorkLog", "worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new AggregateIdentity("tenant-a", "worklog", " worklog-42"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void aggregateOrderRequiresPositiveSequenceAndNonNegativeEventIndex() {
assertThat(new AggregateOrder(1, 0)).isEqualTo(new AggregateOrder(1, 0));
assertThatThrownBy(() -> new AggregateOrder(0, 0)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new AggregateOrder(1, -1))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void draftRequiresPositiveVersionTimeCanonicalCorrelationAndTypedPayload() {
TestPayload payload = new TestPayload("worklog-42");
IntegrationEventDraft<TestPayload> draft = draft(payload, Optional.of("cause-1"));
assertThat(draft.featurePayload()).isSameAs(payload);
assertThat(draft.occurredAt()).isEqualTo(Instant.parse("2026-07-28T05:10:30.123Z"));
assertThatThrownBy(
() ->
new IntegrationEventDraft<>(
new EventId("event-1"),
new ContractId("portfolio.worklog.reserved"),
0,
new LogicalDestinationId("portfolio-domain-events"),
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
new AggregateOrder(17, 0),
Instant.parse("2026-07-28T05:10:30.123Z"),
"corr-1",
Optional.empty(),
payload))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new IntegrationEventDraft<>(
new EventId("event-1"),
new ContractId("portfolio.worklog.reserved"),
1,
new LogicalDestinationId("portfolio-domain-events"),
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
new AggregateOrder(17, 0),
null,
"corr-1",
Optional.empty(),
payload))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> draft(payload, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> draft(null, Optional.empty()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void encoderPortOwnsOnlyTheProviderNeutralDraftToValidatedBoundary() throws Exception {
assertThat(
IntegrationEventEncoderPort.class
.getDeclaredMethod("encode", IntegrationEventDraft.class)
.getReturnType())
.isEqualTo(ValidatedIntegrationEvent.class);
assertThat(IntegrationEventEncoderPort.class.getDeclaredMethods()).hasSize(1);
}
private static IntegrationEventDraft<TestPayload> draft(
TestPayload payload, Optional<String> causationId) {
return new IntegrationEventDraft<>(
new EventId("event-1"),
new ContractId("portfolio.worklog.reserved"),
1,
new LogicalDestinationId("portfolio-domain-events"),
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
new AggregateOrder(17, 0),
Instant.parse("2026-07-28T05:10:30.123Z"),
"corr-1",
causationId,
payload);
}
private record TestPayload(String workLogId) implements IntegrationPayload {}
}
@@ -0,0 +1,158 @@
package dev.caskeleton.application.messaging.event;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class ValidatedIntegrationEventTest {
@Test
void validatedEventOwnsExactEncodedBytesThroughDefensiveCopies() {
byte[] keyBytes = "a".repeat(64).getBytes(StandardCharsets.US_ASCII);
byte[] envelopeBytes = "{\"envelopeVersion\":1}".getBytes(StandardCharsets.UTF_8);
ValidatedIntegrationEvent event = event(keyBytes, envelopeBytes);
keyBytes[0] = 'b';
envelopeBytes[0] = 'x';
byte[] exposedKey = event.partitionKeyBytes();
byte[] exposedEnvelope = event.envelopeBytes();
exposedKey[0] = 'c';
exposedEnvelope[0] = 'y';
assertThat(event.partitionKeyBytes())
.containsExactly("a".repeat(64).getBytes(StandardCharsets.US_ASCII));
assertThat(event.envelopeBytes())
.containsExactly("{\"envelopeVersion\":1}".getBytes(StandardCharsets.UTF_8));
}
@Test
void validatedEventValueEqualityUsesEncodedByteContents() {
ValidatedIntegrationEvent first =
event(
"a".repeat(64).getBytes(StandardCharsets.US_ASCII),
"{}".getBytes(StandardCharsets.UTF_8));
ValidatedIntegrationEvent equal =
event(
"a".repeat(64).getBytes(StandardCharsets.US_ASCII),
"{}".getBytes(StandardCharsets.UTF_8));
assertThat(first).isEqualTo(equal);
assertThat(first.hashCode()).isEqualTo(equal.hashCode());
}
@Test
void validatedEventRequiresPositiveVersionsAndMatchingCanonicalAsciiPartitionKey() {
byte[] envelope = "{}".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(
() ->
event(
"b".repeat(64).getBytes(StandardCharsets.US_ASCII),
envelope,
"a".repeat(64),
0,
1))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
event(
"b".repeat(64).getBytes(StandardCharsets.US_ASCII),
envelope,
"a".repeat(64),
1,
0))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
event(
"b".repeat(64).getBytes(StandardCharsets.US_ASCII),
envelope,
"a".repeat(64),
1,
1))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
event(
"é".repeat(64).getBytes(StandardCharsets.UTF_8),
envelope,
"é".repeat(64),
1,
1))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void validatedEventCarriesStableSemanticIdentityAndProviderNeutralEvidenceOnly() {
ValidatedIntegrationEvent event =
event(
"a".repeat(64).getBytes(StandardCharsets.US_ASCII),
"{}".getBytes(StandardCharsets.UTF_8));
assertThat(event.eventId()).isEqualTo(new EventId("event-1"));
assertThat(event.contractId()).isEqualTo(new ContractId("portfolio.worklog.reserved"));
assertThat(event.logicalDestinationId())
.isEqualTo(new LogicalDestinationId("portfolio-domain-events"));
assertThat(event.aggregate())
.isEqualTo(new AggregateIdentity("tenant-a", "worklog", "worklog-42"));
assertThat(event.order()).isEqualTo(new AggregateOrder(17, 0));
assertThat(event.contentType()).isEqualTo("application/json");
assertThat(event.envelopeSha256()).isEqualTo(new Sha256(new byte[32]));
assertThat(event.contractCatalogRevision()).isEqualTo("catalog-r1");
assertThat(event.destinationBindingRevision()).isEqualTo("binding-r1");
assertThat(
Arrays.stream(ValidatedIntegrationEvent.class.getRecordComponents())
.map(component -> component.getName()))
.noneMatch(
name ->
name.toLowerCase(Locale.ROOT).contains("topic")
|| name.toLowerCase(Locale.ROOT).contains("kafka")
|| name.toLowerCase(Locale.ROOT).contains("metadata")
|| name.toLowerCase(Locale.ROOT).contains("publicationepoch")
|| name.toLowerCase(Locale.ROOT).contains("validator"));
}
private static ValidatedIntegrationEvent event(byte[] keyBytes, byte[] envelopeBytes) {
return event(keyBytes, envelopeBytes, "a".repeat(64), 1, 1);
}
private static ValidatedIntegrationEvent event(
byte[] keyBytes,
byte[] envelopeBytes,
String keyText,
int envelopeVersion,
int payloadVersion) {
Sha256 hash = new Sha256(new byte[32]);
return new ValidatedIntegrationEvent(
new EventId("event-1"),
new ContractId("portfolio.worklog.reserved"),
envelopeVersion,
payloadVersion,
new LogicalDestinationId("portfolio-domain-events"),
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
new AggregateOrder(17, 0),
Instant.parse("2026-07-28T05:10:30.123Z"),
"corr-1",
Optional.of("cause-1"),
keyText,
keyBytes,
envelopeBytes,
"application/json",
hash,
hash,
hash,
hash,
"catalog-r1",
"binding-r1");
}
}
@@ -7,6 +7,7 @@ import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -61,7 +62,7 @@ class PublishPendingOutboxEventsUseCaseTest {
// ---- success path ----
@Test
void successfulPublishTransitionsEventToPublished() {
void legacyVoidPublisherNormalReturnTransitionsEventToPublishedCharacterization() {
OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(event);
@@ -93,7 +94,7 @@ class PublishPendingOutboxEventsUseCaseTest {
// ---- transient failure path ----
@Test
void transientFailureTransitionsEventToFailedWithBackoff() {
void senderExceptionTransitionsEventToFailedWithBackoffCharacterization() {
OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(event);
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
@@ -122,7 +123,7 @@ class PublishPendingOutboxEventsUseCaseTest {
}
@Test
void failureOnThirdAttemptTransitionsEventToDead() {
void senderExceptionAtRetryLimitTransitionsEventToDeadCharacterization() {
// attemptCount=3 means this is the 3rd attempt — next failure should DEAD
OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
store.addClaimable(event);
@@ -202,8 +203,8 @@ class PublishPendingOutboxEventsUseCaseTest {
// ---- markPublished failure — spec §엣지·실패·의존 semantics ----
/**
* When {@code store.markPublished} throws after a SUCCESSFUL publish, the exception must
* propagate out of {@code handle()} rather than being caught and misclassified as a publish
* When {@code store.markPublished} throws after a normal void publisher return, the exception
* must propagate out of {@code handle()} rather than being caught and misclassified as a publish
* failure (which would trigger FAILED/DEAD state machine and potentially dead-letter a
* successfully-delivered event).
*
@@ -213,19 +214,21 @@ class PublishPendingOutboxEventsUseCaseTest {
* <li>The exception propagates — it is NOT swallowed inside {@code publishOne}.
* <li>{@code markFailed} is NOT called for the event (no misclassification).
* <li>{@code markDead} is NOT called for the event (no misclassification).
* <li>{@code publishPort.publish} was called exactly once.
* <li>The row remains IN_FLIGHT and is recovered via the orphan visibility-timeout reclaim path
* on the next tick — re-published → duplicate absorbed by consumer dedupe (at-least-once).
* <li>{@code publishPort.publish} is not called again before the in-flight timeout.
* <li>The row remains IN_FLIGHT and can be recovered via the orphan visibility-timeout reclaim
* path on a later tick. Because the prior void publisher return is not broker
* acknowledgement evidence, a later publish can be a duplicate.
* </ul>
*/
@Test
void markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure() {
void legacyMarkPublishedFailureLeavesInFlightAndDuplicatePossibleCharacterization() {
OutboxEvent event =
makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
ThrowingOnMarkPublishedStorePort throwingStore =
new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished"));
throwingStore.addClaimable(event);
MutableClock mutableClock = new MutableClock(NOW, ZoneOffset.UTC);
TimeoutAwareThrowingOnMarkPublishedStorePort throwingStore =
new TimeoutAwareThrowingOnMarkPublishedStorePort(
event, NOW, new RuntimeException("DB down on markPublished"));
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
@@ -234,7 +237,7 @@ class PublishPendingOutboxEventsUseCaseTest {
reporter,
tx,
backoffPolicy,
clock,
mutableClock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
@@ -244,17 +247,36 @@ class PublishPendingOutboxEventsUseCaseTest {
.isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markPublished");
// publish was called exactly once — the broker call succeeded.
// A normal void return records only that the legacy publisher call completed.
assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail");
assertThat(throwingStore.currentStatus()).isEqualTo(OutboxEventStatus.IN_FLIGHT);
assertThat(throwingStore.attemptCount()).isEqualTo(1);
assertThat(throwingStore.nextAttemptAt()).isEqualTo(NOW.plus(IN_FLIGHT_TIMEOUT));
// No misclassification: the event must NOT be marked FAILED or DEAD.
assertThat(throwingStore.failedEvents)
.as("markFailed must NOT be called when only markPublished fails")
.doesNotContainKey("evt-store-fail");
assertThat(throwingStore.deadEvents)
.as("markDead must NOT be called when only markPublished fails")
.doesNotContain("evt-store-fail");
assertThat(throwingStore.markFailedCalls()).isZero();
assertThat(throwingStore.markDeadCalls()).isZero();
assertThat(reporter.reports).isEmpty();
mutableClock.advance(IN_FLIGHT_TIMEOUT.minusNanos(1));
OutboxRelayResult beforeTimeout =
useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(beforeTimeout.claimedCount()).isZero();
assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail");
assertThat(throwingStore.attemptCount()).isEqualTo(1);
// Move strictly past the timeout. The orphan becomes eligible and the same event is sent again
// because no PUBLISHED transition was persisted after the first normal void return.
mutableClock.advance(Duration.ofNanos(2));
assertThatThrownBy(
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markPublished");
assertThat(throwingStore.currentStatus()).isEqualTo(OutboxEventStatus.IN_FLIGHT);
assertThat(throwingStore.attemptCount()).isEqualTo(2);
assertThat(publishPort.publishedEvents)
.as("mark failure leaves a duplicate-possible retry window")
.containsExactly("evt-store-fail", "evt-store-fail");
}
/**
@@ -487,6 +509,95 @@ class PublishPendingOutboxEventsUseCaseTest {
}
}
static final class TimeoutAwareThrowingOnMarkPublishedStorePort implements OutboxStorePort {
private final OutboxEvent event;
private final RuntimeException markPublishedException;
private OutboxEventStatus currentStatus = OutboxEventStatus.PENDING;
private int attemptCount;
private Instant nextAttemptAt;
private int markFailedCalls;
private int markDeadCalls;
TimeoutAwareThrowingOnMarkPublishedStorePort(
OutboxEvent event, Instant firstEligibleAt, RuntimeException markPublishedException) {
this.event = event;
this.nextAttemptAt = firstEligibleAt;
this.markPublishedException = markPublishedException;
}
@Override
public List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout) {
if (batchSize == 0
|| currentStatus == OutboxEventStatus.PUBLISHED
|| currentStatus == OutboxEventStatus.DEAD
|| nextAttemptAt.isAfter(now)) {
return List.of();
}
currentStatus = OutboxEventStatus.IN_FLIGHT;
attemptCount++;
nextAttemptAt = now.plus(inFlightTimeout);
return List.of(
new OutboxEvent(
event.eventId(),
event.eventType(),
event.aggregateId(),
event.payload(),
event.occurredAt(),
event.correlationId(),
event.idempotencyKey(),
currentStatus,
attemptCount));
}
@Override
public void markPublished(String eventId) {
throw markPublishedException;
}
@Override
public void markFailed(String eventId, Instant retryAt) {
markFailedCalls++;
currentStatus = OutboxEventStatus.FAILED;
nextAttemptAt = retryAt;
}
@Override
public void markDead(String eventId) {
markDeadCalls++;
currentStatus = OutboxEventStatus.DEAD;
}
@Override
public Map<OutboxEventStatus, Long> countByStatus() {
return Map.of(currentStatus, 1L);
}
@Override
public Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now) {
return Map.of();
}
OutboxEventStatus currentStatus() {
return currentStatus;
}
int attemptCount() {
return attemptCount;
}
Instant nextAttemptAt() {
return nextAttemptAt;
}
int markFailedCalls() {
return markFailedCalls;
}
int markDeadCalls() {
return markDeadCalls;
}
}
static final class ThrowingTransitionStorePort extends FakeOutboxStorePort {
private final RuntimeException markFailedException;
private final RuntimeException markDeadException;
@@ -561,6 +672,35 @@ class PublishPendingOutboxEventsUseCaseTest {
}
}
static final class MutableClock extends Clock {
private Instant current;
private final ZoneId zone;
MutableClock(Instant current, ZoneId zone) {
this.current = current;
this.zone = zone;
}
void advance(Duration duration) {
current = current.plus(duration);
}
@Override
public ZoneId getZone() {
return zone;
}
@Override
public Clock withZone(ZoneId requestedZone) {
return new MutableClock(current, requestedZone);
}
@Override
public Instant instant() {
return current;
}
}
/** Deterministic RandomGenerator that always returns 0 — produces zero jitter. */
static final class ZeroRandom implements RandomGenerator {
@Override
+502
View File
@@ -1,4 +1,9 @@
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.xml.XmlSlurper
import java.security.MessageDigest
import java.time.Duration
import java.time.Instant
import org.gradle.api.artifacts.dsl.LockMode
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.tasks.bundling.AbstractArchiveTask
@@ -45,6 +50,115 @@ ext.releaseVersion = releaseVersion
ext.sourceRevision = sourceRevision
ext.traceableVersion = traceableVersion
// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed.
// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid,
// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass.
Map<String, List<String>> messagingVerificationSkeletons = [
'verifyMessagingPollingOutboxR2': [
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json'
],
'verifyMessagingKafkaProducerR2': [
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json'
],
'verifyMessagingSecurityR2': [
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
],
'verifyMessagingReleaseProfile': [
'build/messaging-evidence/contracts-schema/manifest.json',
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json',
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
],
'verifyMessagingTargetBindingPreflight': [
'app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json'
],
'verifyMessagingTargetBinding': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json'
],
'verifyMessagingDeploymentCutover': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
'app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json'
],
'verifyMessagingCleanupTargetBinding': [
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json'
],
'verifyMessagingFinalR2Profile': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json',
'build/messaging-evidence/final-r2-profile/manifest.json'
]
]
Closure<Void> messagingFailClosedEvidenceGuard = { String taskName, List<String> relativePaths ->
List<File> evidenceFiles = relativePaths.collect { rootProject.file(it) }
List<String> violations = evidenceFiles.findAll { !it.isFile() }.collect {
"missing evidence ${rootProject.relativePath(it)}"
}
String expectedSourceDigest = providers.gradleProperty('messagingSourceDigest').getOrElse('')
String expectedProfileHash = providers.gradleProperty('messagingProfileHash').getOrElse('')
if (expectedSourceDigest.isBlank()) {
violations << 'missing -PmessagingSourceDigest=sha256:<exact-source-digest>'
}
if (expectedProfileHash.isBlank()) {
violations << 'missing -PmessagingProfileHash=sha256:<exact-profile-hash>'
}
evidenceFiles.findAll { it.isFile() }.each { File evidenceFile ->
try {
def manifest = new JsonSlurper().parse(evidenceFile)
if (manifest.sourceDigest != expectedSourceDigest) {
violations << "${rootProject.relativePath(evidenceFile)} has wrong source digest"
}
if (manifest.hashes?.profile != expectedProfileHash) {
violations << "${rootProject.relativePath(evidenceFile)} has mismatched profile hash"
}
if ((manifest.counts?.skipped ?: 0) != 0 || !(manifest.skips instanceof List) ||
!manifest.skips.isEmpty()) {
violations << "${rootProject.relativePath(evidenceFile)} contains skipped evidence"
}
if ((manifest.counts?.failed ?: 0) != 0 || !(manifest.failures instanceof List) ||
!manifest.failures.isEmpty()) {
violations << "${rootProject.relativePath(evidenceFile)} contains failed evidence"
}
try {
Instant generatedAt = Instant.parse(manifest.generatedAt as String)
if (generatedAt.isBefore(Instant.now().minus(Duration.ofHours(24))) ||
generatedAt.isAfter(Instant.now().plus(Duration.ofMinutes(5)))) {
violations << "${rootProject.relativePath(evidenceFile)} is stale or future-dated"
}
} catch (RuntimeException ignored) {
violations << "${rootProject.relativePath(evidenceFile)} has invalid generatedAt"
}
} catch (RuntimeException ignored) {
violations << "${rootProject.relativePath(evidenceFile)} is not valid JSON evidence"
}
}
// Task 2 intentionally has no matching qualification Test tasks or complete schema validator.
// This unconditional violation prevents hand-written evidence from manufacturing an R2 PASS.
violations << 'qualification producer/tests and common-schema validator are not implemented'
throw new GradleException(
"${taskName}: FAIL_CLOSED — no R2 claim is available:\n ${violations.join('\n ')}")
}
messagingVerificationSkeletons.each { String taskName, List<String> evidencePaths ->
tasks.register(taskName) {
group = 'verification'
description = "Fail-closed Messaging qualification skeleton for ${taskName}."
inputs.files(evidencePaths.collect { rootProject.file(it) }).optional()
outputs.upToDateWhen { false }
doLast {
messagingFailClosedEvidenceGuard(taskName, evidencePaths)
}
}
}
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
@@ -287,6 +401,394 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
}
}
// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes.
// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties
// and every selected Task 3-6 test have passed in the current invocation.
def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence')
def registerMessagingQualificationTest = {
Project owner, String taskName, List<String> patterns, String resultDirectory ->
owner.tasks.register(taskName, Test) {
group = 'verification'
description = 'Runs exact Messaging Task 3-6 qualification tests without broad discovery.'
testClassesDirs = owner.sourceSets.test.output.classesDirs
classpath = owner.sourceSets.test.runtimeClasspath
useJUnitPlatform()
filter {
patterns.each { includeTestsMatching(it) }
failOnNoMatchingTests = true
}
failOnNoDiscoveredTests = true
reports.junitXml.required = true
reports.junitXml.outputLocation =
messagingEvidenceResultRoot.map { it.dir(resultDirectory) }
reports.html.required = false
binaryResultsDirectory =
layout.buildDirectory.dir("test-results/messaging-evidence-binary/${resultDirectory}")
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
}
def messagingApplicationQualification = registerMessagingQualificationTest(
project(':application-core'),
'messagingApplicationContractQualificationTest',
[
'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest',
'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest',
'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest'
],
'application')
def messagingSharedQualification = registerMessagingQualificationTest(
project(':shared-contract'),
'messagingSharedSchemaQualificationTest',
['dev.caskeleton.shared.contract.messaging.MessagingEnvelopeSchemaResourceTest'],
'shared')
def messagingSampleQualification = registerMessagingQualificationTest(
project(':sample-portfolio'),
'messagingSampleContractQualificationTest',
['dev.caskeleton.sample.portfolio.application.event.WorkLogReservedContractContributionTest'],
'sample')
def messagingCompiledQualification = registerMessagingQualificationTest(
project(':adapter:outbound:messaging'),
'messagingCompiledContractsQualificationTest',
[
'dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistryTest',
'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompilerTest',
'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigestTest',
'dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompilerTest',
'dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1Test'
],
'compiled')
def messagingJsonSchemaQualification = registerMessagingQualificationTest(
project(':adapter:outbound:messaging'),
'messagingJsonSchemaV1QualificationTest',
[
'dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistryTest',
'dev.caskeleton.adapter.outbound.messaging.envelope.JsonSchemaIntegrationEventEncoderTest',
'dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdversarialCorpusTest',
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidatorTest'
],
'json-schema')
def messagingEvidenceFile = layout.buildDirectory.file(
'messaging-evidence/contracts-schema/manifest.json')
def messagingProfileFile = file('config/messaging/profile-compatibility.yaml')
def messagingDigestProperty = { String propertyName ->
String value = providers.gradleProperty(propertyName).getOrElse('')
if (!(value ==~ /sha256:[a-f0-9]{64}/)) {
throw new GradleException(
"-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.")
}
value
}
def messagingSha256Bytes = { byte[] bytes ->
'sha256:' + java.util.HexFormat.of().formatHex(
MessageDigest.getInstance('SHA-256').digest(bytes))
}
def messagingSha256FileSet = { String domain, List<File> files ->
MessageDigest digest = MessageDigest.getInstance('SHA-256')
digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8))
digest.update((byte) 0)
files.sort { rootProject.relativePath(it) }.each { File input ->
if (!input.isFile()) {
throw new GradleException(
"Messaging evidence input is missing: ${rootProject.relativePath(input)}")
}
byte[] path = rootProject.relativePath(input)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)
byte[] content = input.bytes
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array())
digest.update(path)
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array())
digest.update(content)
}
'sha256:' + java.util.HexFormat.of().formatHex(digest.digest())
}
def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') {
group = 'verification'
outputs.upToDateWhen { false }
doLast {
File output = messagingEvidenceFile.get().asFile
if (output.exists() && !output.delete()) {
throw new GradleException("Could not delete stale Messaging evidence ${output}")
}
messagingDigestProperty('messagingSourceDigest')
messagingDigestProperty('messagingArtifactDigest')
String suppliedProfile = messagingDigestProperty('messagingProfileHash')
String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes)
if (suppliedProfile != exactProfile) {
throw new GradleException(
"messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.")
}
}
}
[
messagingApplicationQualification,
messagingSharedQualification,
messagingSampleQualification,
messagingCompiledQualification,
messagingJsonSchemaQualification
].each {
it.configure {
dependsOn prepareMessagingContractEvidence
}
}
def messagingEvidenceFromXml = { List<String> resultDirectories ->
List<Map<String, String>> cases = []
resultDirectories.each { String directory ->
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
fileTree(resultDirectory).matching { include 'TEST-*.xml' }.files.sort().each { File xml ->
def suite = new XmlSlurper(false, false).parse(xml)
suite.testcase.each { testCase ->
boolean failed = !testCase.failure.isEmpty() || !testCase.error.isEmpty()
boolean skipped = !testCase.skipped.isEmpty()
String simpleClass = testCase.@classname.text().tokenize('.').last()
String rawId = "${simpleClass}.${testCase.@name.text()}"
String scenarioId = rawId
.replace('()', '')
.replaceAll('[^A-Za-z0-9._:-]', '-')
.replaceAll('-+', '-')
cases << [id: scenarioId, failed: failed.toString(), skipped: skipped.toString()]
}
}
}
if (cases.isEmpty()) {
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
}
List<String> scenarioIds = cases.collect { it.id }.sort()
if (scenarioIds.toSet().size() != scenarioIds.size()) {
throw new GradleException('Messaging qualification scenario IDs are not unique.')
}
int failed = cases.count { it.failed == 'true' }
int skipped = cases.count { it.skipped == 'true' }
[
scenarioIds: scenarioIds,
counts: [
executed: cases.size(),
passed: cases.size() - failed - skipped,
failed: failed,
skipped: skipped
]
]
}
def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer ->
Set<String> exactRootKeys = [
'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds',
'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips',
'unsupportedClaims'
] as Set
Set<String> exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set
Set<String> exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set
List<String> violations = []
if (manifest.keySet() != exactRootKeys) {
violations << 'root fields do not match the common manifest schema'
}
if (manifest.schemaVersion != 1 || manifest.producerTask != expectedProducer) {
violations << 'schemaVersion or producerTask is wrong'
}
['sourceDigest', 'artifactDigest'].each { String field ->
if (!(manifest[field] instanceof String) ||
!(manifest[field] ==~ /sha256:[a-f0-9]{64}/)) {
violations << "${field} is not a canonical SHA-256"
}
}
if (!(manifest.scenarioIds instanceof List) || manifest.scenarioIds.isEmpty() ||
manifest.scenarioIds.toSet().size() != manifest.scenarioIds.size() ||
manifest.scenarioIds.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'scenarioIds violate the common schema'
}
if (!(manifest.counts instanceof Map) || manifest.counts.keySet() != exactCountKeys ||
!(manifest.counts.executed instanceof Integer) || manifest.counts.executed < 1 ||
manifest.counts.values().any { !(it instanceof Integer) || it < 0 } ||
manifest.counts.executed !=
manifest.counts.passed + manifest.counts.failed + manifest.counts.skipped) {
violations << 'counts are invalid or inconsistent'
}
if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 ||
manifest.failures != [] || manifest.skips != []) {
violations << 'failed or skipped qualification cannot produce PASS evidence'
}
if (!(manifest.hashes instanceof Map) || manifest.hashes.keySet() != exactHashKeys ||
manifest.hashes.values().any {
!(it instanceof String) || !(it ==~ /sha256:[a-f0-9]{64}/)
}) {
violations << 'hashes violate the common schema'
}
if (!(manifest.command instanceof String) || manifest.command.isBlank() ||
manifest.command.length() > 2048) {
violations << 'command is missing or unbounded'
}
try {
Instant.parse(manifest.generatedAt as String)
} catch (RuntimeException ignored) {
violations << 'generatedAt is not UTC date-time evidence'
}
if (!(manifest.unsupportedClaims instanceof List) ||
manifest.unsupportedClaims.toSet().size() != manifest.unsupportedClaims.size() ||
manifest.unsupportedClaims.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'unsupportedClaims violate the common schema'
}
if (!violations.isEmpty()) {
throw new GradleException(
"Messaging evidence fails the common schema structural validator:\n " +
violations.join('\n '))
}
}
def writeMessagingEvidence = {
String producerTask, List<String> resultDirectories, List<String> commandTasks ->
Map result = messagingEvidenceFromXml(resultDirectories)
Map manifest = [
schemaVersion: 1,
sourceDigest: messagingDigestProperty('messagingSourceDigest'),
artifactDigest: messagingDigestProperty('messagingArtifactDigest'),
producerTask: producerTask,
scenarioIds: result.scenarioIds,
counts: result.counts,
command: './gradlew ' + commandTasks.join(' ') +
' -PmessagingSourceDigest=<sha256> -PmessagingArtifactDigest=<sha256> ' +
'-PmessagingProfileHash=<exact-sha256> --console=plain',
generatedAt: Instant.now().toString(),
hashes: [
profile: messagingSha256Bytes(messagingProfileFile.bytes),
catalog: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.catalog.v1',
[file('config/messaging/readiness-cards.yaml')]),
schema: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.schema-set.v1',
[
file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'),
file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json')
] + fileTree(
'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12'
).files.toList()),
settings: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.settings.v1',
[
file('adapter/outbound/messaging/build.gradle'),
file('adapter/outbound/messaging/gradle.lockfile')
])
],
failures: [],
skips: [],
unsupportedClaims: [
'consumer-compatibility-full-suite',
'durable-outbox-r2',
'kafka-acknowledged-r2',
'regex-engine-timeout',
'remote-schema-resolution'
]
]
validateMessagingEvidenceStructure(manifest, producerTask)
File commonSchema =
file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
if (!commonSchema.isFile()) {
throw new GradleException('Common Messaging evidence schema is missing.')
}
File output = messagingEvidenceFile.get().asFile
output.parentFile.mkdirs()
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
Map reloaded = new JsonSlurper().parse(output) as Map
validateMessagingEvidenceStructure(reloaded, producerTask)
logger.lifecycle(
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
}
def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') {
group = 'verification'
description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.'
dependsOn messagingJsonSchemaQualification
dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph')
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingJsonSchemaV1',
['json-schema'],
[':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingJsonSchemaV1'])
}
}
def validateMessagingJsonSchemaV1EvidenceManifestSchema =
tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingJsonSchemaV1
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingJsonSchemaV1.configure {
finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema
}
def verifyMessagingContracts = tasks.register('verifyMessagingContracts') {
group = 'verification'
description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.'
dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema
dependsOn messagingApplicationQualification
dependsOn messagingSharedQualification
dependsOn messagingSampleQualification
dependsOn messagingCompiledQualification
dependsOn messagingJsonSchemaQualification
dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph')
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingContracts',
['application', 'shared', 'sample', 'compiled', 'json-schema'],
[
':application-core:messagingApplicationContractQualificationTest',
':shared-contract:messagingSharedSchemaQualificationTest',
':sample-portfolio:messagingSampleContractQualificationTest',
':adapter:outbound:messaging:messagingCompiledContractsQualificationTest',
':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingContracts'
])
}
}
def validateMessagingContractsEvidenceManifestSchema =
tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingContracts
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingContracts.configure {
finalizedBy validateMessagingContractsEvidenceManifestSchema
}
// One explicit command regenerates every module's Gradle-default lockfile.
tasks.register('resolveAndLockAll') {
group = 'build setup'
@@ -0,0 +1,170 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:dev.caskeleton:messaging:build-evidence-manifest:v1",
"title": "Messaging build evidence manifest v1",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"sourceDigest",
"artifactDigest",
"producerTask",
"scenarioIds",
"counts",
"command",
"generatedAt",
"hashes",
"failures",
"skips",
"unsupportedClaims"
],
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"sourceDigest": {
"$ref": "#/$defs/sha256"
},
"artifactDigest": {
"$ref": "#/$defs/sha256"
},
"producerTask": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^verifyMessaging[A-Za-z0-9]+$",
"enum": [
"verifyMessagingContracts",
"verifyMessagingJsonSchemaV1",
"verifyMessagingPollingOutboxR2",
"verifyMessagingKafkaProducerR2",
"verifyMessagingSecurityR2",
"verifyMessagingReleaseProfile",
"verifyMessagingTargetBindingPreflight",
"verifyMessagingTargetBinding",
"verifyMessagingDeploymentCutover",
"verifyMessagingCleanupTargetBinding",
"verifyMessagingFinalR2Profile"
]
},
"scenarioIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/identifier"
}
},
"counts": {
"type": "object",
"additionalProperties": false,
"required": [
"executed",
"passed",
"failed",
"skipped"
],
"properties": {
"executed": {
"type": "integer",
"minimum": 1
},
"passed": {
"type": "integer",
"minimum": 0
},
"failed": {
"type": "integer",
"minimum": 0
},
"skipped": {
"type": "integer",
"minimum": 0
}
}
},
"command": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"generatedAt": {
"type": "string",
"format": "date-time"
},
"hashes": {
"type": "object",
"additionalProperties": false,
"required": [
"profile",
"catalog",
"schema",
"settings"
],
"properties": {
"profile": {
"$ref": "#/$defs/sha256"
},
"catalog": {
"$ref": "#/$defs/sha256"
},
"schema": {
"$ref": "#/$defs/sha256"
},
"settings": {
"$ref": "#/$defs/sha256"
}
}
},
"failures": {
"type": "array",
"items": {
"$ref": "#/$defs/result"
}
},
"skips": {
"type": "array",
"items": {
"$ref": "#/$defs/result"
}
},
"unsupportedClaims": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/$defs/identifier"
}
}
},
"$defs": {
"sha256": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"identifier": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"scenarioId",
"reason"
],
"properties": {
"scenarioId": {
"$ref": "#/$defs/identifier"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 512
}
}
}
}
}
@@ -0,0 +1,30 @@
schemaVersion: 1
profiles:
- profileId: messaging-first-r2-polling-producer.v1
semanticContractId: messaging-outbox-publish.v1
selectedCardIds:
- messaging-outbox-publish.v1
- kafka-spring-acknowledged-idempotent.v1
- postgresql-polling-outbox.v2
- postgresql-per-record-jit-claim.v1
- json-schema-envelope.v1
- external-topic-validated.v1
- kafka-sasl-ssl-scram-sha-512.v1
- kafka-compression-none.v1
- per-key-normal-path-sequence-detectable.v1
- same-postgresql-transaction-resource.v1
- authenticated-internal-web-disposition.v1
dispatchProfile: postgresql-polling-outbox.v2
claimProfile: postgresql-per-record-jit-claim.v1
serializationProfile: json-schema-envelope.v1
producerProfile: kafka-spring-acknowledged-idempotent.v1
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topicProfile: external-topic-validated.v1
compressionProfile: kafka-compression-none.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
transactionProfile: same-postgresql-transaction-resource.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
requiredScenarios:
- messaging.interaction.append-encode-claim-ack.v1
- messaging.interaction.indeterminate-hold-disposition.v1
- messaging.interaction.shutdown-drain.v1
+381
View File
@@ -0,0 +1,381 @@
schemaVersion: 1
cards:
- cardId: messaging-outbox-publish.v1
cardVersion: 1
phase: P1
semanticContractIds:
- messaging-outbox-publish.v1
providerId: provider-neutral
providerVersion: 1
maturity: not-implemented
guarantees:
- transactional-append-before-publication
- explicit-terminal-or-operator-disposition
explicitNonGuarantees:
- consumer-deduplication
- global-ordering
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: bounded-first-r2.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingContracts
requiredScenarios:
- messaging.contract.transactional-append.v1
- messaging.contract.explicit-disposition.v1
runbookIds:
- runbook.messaging-terminal-delivery-disposition.v1
owner: application-core
- cardId: kafka-spring-acknowledged-idempotent.v1
cardVersion: 1
phase: P3
semanticContractIds:
- messaging-outbox-publish.v1
providerId: spring-kafka
providerVersion: 4
maturity: not-implemented
guarantees:
- broker-acknowledgement-observed
- finite-producer-settings
explicitNonGuarantees:
- duplicate-free-delivery
- global-ordering
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: per-record-jit-bounded.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingKafkaProducerR2
requiredScenarios:
- messaging.kafka.ack-metadata.v1
- messaging.kafka.indeterminate-outcome.v1
runbookIds:
- runbook.messaging-producer-unavailable-or-unauthorized.v1
owner: adapter-outbound-messaging
- cardId: postgresql-polling-outbox.v2
cardVersion: 2
phase: P2
semanticContractIds:
- messaging-outbox-publish.v1
providerId: postgresql-polling
providerVersion: 2
maturity: not-implemented
guarantees:
- immutable-event-mutable-delivery-split
- fenced-publication-authority
explicitNonGuarantees:
- zero-duplicate-delivery
- global-ordering
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: per-record-jit-bounded.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingPollingOutboxR2
requiredScenarios:
- messaging.polling.delivery-lifecycle.v2
- messaging.polling.authority-fence.v1
runbookIds:
- runbook.messaging-outbox-backlog-and-stale-lease.v1
owner: adapter-outbound-persistence-jpa
- cardId: postgresql-per-record-jit-claim.v1
cardVersion: 1
phase: P2
semanticContractIds:
- messaging-outbox-publish.v1
providerId: postgresql-jit-claim
providerVersion: 1
maturity: not-implemented
guarantees:
- one-record-claim-after-local-admission
- token-and-unexpired-lease-cas
explicitNonGuarantees:
- distributed-strict-fifo
- duplicate-free-delivery
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: admitted-record-upper-bound-one.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingPollingOutboxR2
requiredScenarios:
- messaging.polling.jit-claim.v1
- messaging.polling.stale-token-rejected.v1
runbookIds:
- runbook.messaging-outbox-backlog-and-stale-lease.v1
owner: adapter-outbound-persistence-jpa
- cardId: json-schema-envelope.v1
cardVersion: 1
phase: P1
semanticContractIds:
- messaging-outbox-publish.v1
providerId: json-schema-envelope
providerVersion: 1
maturity: implemented-candidate
guarantees:
- deterministic-utf8-envelope
- closed-offline-schema-catalog
explicitNonGuarantees:
- alternate-wire-format
- dynamic-contract-discovery
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: bounded-envelope-and-payload.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: "sha256:42040504d5c204f9ee0e01bfa17fd9a03182db13f8f99e31b63ec79d5ecc40d0"
settingsDigest: "sha256:fcd849322d43a8d88c926a43160296339cbc6e945db81e46c513ab45d185b924"
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingContracts
- verifyMessagingJsonSchemaV1
requiredScenarios:
- messaging.schema.envelope-valid.v1
- messaging.schema.adversarial-bounds.v1
runbookIds:
- runbook.messaging-schema-poison-or-record-too-large.v1
owner: adapter-outbound-messaging
- cardId: external-topic-validated.v1
cardVersion: 1
phase: P4
semanticContractIds:
- messaging-outbox-publish.v1
providerId: external-kafka-topic
providerVersion: 1
maturity: not-implemented
guarantees:
- external-topic-policy-attested
- exact-destination-binding
explicitNonGuarantees:
- automatic-topic-creation
- multi-cluster-failover
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: finite-topic-catalog.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingSecurityR2
requiredScenarios:
- messaging.topic.policy-attestation.v1
- messaging.topic.below-min-isr-rejection.v1
runbookIds:
- runbook.messaging-topic-policy-or-partition-change.v1
owner: adapter-outbound-messaging
- cardId: kafka-sasl-ssl-scram-sha-512.v1
cardVersion: 1
phase: P4
semanticContractIds:
- messaging-outbox-publish.v1
providerId: kafka-sasl-ssl-scram-sha-512
providerVersion: 1
maturity: not-implemented
guarantees:
- authenticated-encrypted-broker-transport
- least-privilege-producer-principal
explicitNonGuarantees:
- encryption-at-rest
- credential-zero-downtime-without-drill
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: bounded-secret-refresh.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingSecurityR2
requiredScenarios:
- messaging.security.sasl-ssl-scram.v1
- messaging.security.least-privilege-negative-probe.v1
runbookIds:
- runbook.messaging-shutdown-deploy-and-secret-rotation.v1
owner: adapter-outbound-messaging
- cardId: kafka-compression-none.v1
cardVersion: 1
phase: P3
semanticContractIds:
- messaging-outbox-publish.v1
providerId: kafka-compression-none
providerVersion: 1
maturity: not-implemented
guarantees:
- exact-uncompressed-producer-profile
explicitNonGuarantees:
- compression-ratio
- compression-throughput
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: bounded-record-size.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingKafkaProducerR2
requiredScenarios:
- messaging.kafka.compression-none-effective.v1
runbookIds:
- runbook.messaging-producer-unavailable-or-unauthorized.v1
owner: adapter-outbound-messaging
- cardId: per-key-normal-path-sequence-detectable.v1
cardVersion: 1
phase: P2
semanticContractIds:
- messaging-outbox-publish.v1
providerId: aggregate-sequence-header
providerVersion: 1
maturity: not-implemented
guarantees:
- per-key-sequence-gap-detectable
explicitNonGuarantees:
- strict-fifo-through-failure
- cross-key-ordering
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: bounded-sequence-metadata.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingPollingOutboxR2
requiredScenarios:
- messaging.ordering.per-key-sequence.v1
runbookIds:
- runbook.messaging-delivery-indeterminate-and-duplicate-burst.v1
owner: application-core
- cardId: same-postgresql-transaction-resource.v1
cardVersion: 1
phase: P2
semanticContractIds:
- messaging-outbox-publish.v1
providerId: same-postgresql-transaction-resource
providerVersion: 1
maturity: not-implemented
guarantees:
- business-write-and-outbox-append-atomic
explicitNonGuarantees:
- cross-database-atomicity
- broker-transaction-atomicity
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: one-transaction-resource.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingPollingOutboxR2
requiredScenarios:
- messaging.persistence.same-resource-rollback.v1
runbookIds:
- runbook.messaging-outbox-backlog-and-stale-lease.v1
owner: app-bootstrap
- cardId: authenticated-internal-web-disposition.v1
cardVersion: 1
phase: P3
semanticContractIds:
- messaging-outbox-publish.v1
providerId: authenticated-internal-web-disposition
providerVersion: 1
maturity: not-implemented
guarantees:
- authenticated-operator-disposition
- audited-generation-cas
explicitNonGuarantees:
- raw-status-update
- unauthenticated-operator-access
outcomeTaxonomyVersion: outbox-publication-outcome.v1
orderingProfile: per-key-normal-path-sequence-detectable.v1
resourceBounds: bounded-operator-request.v1
automaticPublicationAge: finite-required
sameEventRequeueHorizon: finite-required
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
topologyProfile: external-topic-validated.v1
lifecycleProfile: bounded-startup-shutdown.v1
operatorControlProfile: authenticated-internal-web-disposition.v1
schemaSetHash: ""
settingsDigest: ""
evidenceFingerprint: ""
evidenceTasks:
- verifyMessagingKafkaProducerR2
requiredScenarios:
- messaging.operator.authenticated-disposition.v1
- messaging.operator.stale-generation-rejected.v1
runbookIds:
- runbook.messaging-terminal-delivery-disposition.v1
owner: adapter-inbound-web
@@ -0,0 +1,55 @@
schemaVersion: 1
releaseProfiles:
- releaseProfileId: messaging-first-r2-polling-producer-release.v1
compatibilityProfileId: messaging-first-r2-polling-producer.v1
selectedCardIds:
- messaging-outbox-publish.v1
- kafka-spring-acknowledged-idempotent.v1
- postgresql-polling-outbox.v2
- postgresql-per-record-jit-claim.v1
- json-schema-envelope.v1
- external-topic-validated.v1
- kafka-sasl-ssl-scram-sha-512.v1
- kafka-compression-none.v1
- per-key-normal-path-sequence-detectable.v1
- same-postgresql-transaction-resource.v1
- authenticated-internal-web-disposition.v1
requiredCardMaturity: release-eligible
expectedProfileHash: ""
expectedCatalogHash: ""
expectedSchemaHash: ""
expectedSettingsHash: ""
requiredEvidenceTasks:
- verifyMessagingContracts
- verifyMessagingJsonSchemaV1
- verifyMessagingPollingOutboxR2
- verifyMessagingKafkaProducerR2
- verifyMessagingSecurityR2
- verifyMessagingReleaseProfile
- verifyMessagingTargetBindingPreflight
- verifyMessagingTargetBinding
- verifyMessagingDeploymentCutover
- verifyMessagingCleanupTargetBinding
- verifyMessagingFinalR2Profile
requiredScenarios:
- messaging.release.contracts-schema.v1
- messaging.release.polling-outbox.v2
- messaging.release.kafka-producer.v1
- messaging.release.security-topology.v1
- messaging.release.shutdown-rotation.v1
runbookIds:
- runbook.messaging-producer-unavailable-or-unauthorized.v1
- runbook.messaging-outbox-backlog-and-stale-lease.v1
- runbook.messaging-delivery-indeterminate-and-duplicate-burst.v1
- runbook.messaging-schema-poison-or-record-too-large.v1
- runbook.messaging-terminal-delivery-disposition.v1
- runbook.messaging-topic-policy-or-partition-change.v1
- runbook.messaging-shutdown-deploy-and-secret-rotation.v1
- runbook.messaging-legacy-to-v2-relay-authority-cutover.v1
evidencePolicy:
rejectMissing: true
rejectSkipped: true
rejectStale: true
rejectWrongSource: true
rejectMismatchedProfile: true
maximumAgeSeconds: 86400
+93 -93
View File
@@ -1,93 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -22,6 +22,9 @@ Package root: `dev.caskeleton.sample.portfolio`.
`RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`).
- Contract-test fixtures the app-bootstrap verification suite analyses
(`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests).
- Disposable messaging contract fixture: the exact `WorkLogReservedPayload` record, its static
application-core contract contribution, sample-owned schema/digest and golden vectors. It is not
runtime-discovered, and validator compatibility remains unproven until messaging Task 6.
- Sample application collaborators consume invocation context through application-core ports and
must not import SLF4J/MDC.
@@ -39,6 +42,8 @@ Package root: `dev.caskeleton.sample.portfolio`.
- Production-required behaviour living here: deleting this module must not break the
production build or runtime (the base `GlobalExceptionHandler`, envelope, and
error-code contract live in `adapter:inbound:web` / `shared-contract`).
- Treating a sample schema/contribution as an automatically discovered production registry entry,
or placing JSON mapper, physical topic, Kafka or bootstrap-server concerns in the contribution.
## Test
+17
View File
@@ -223,6 +223,23 @@ curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{
이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가
깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것).
### WorkLogReservedPayload / …ContractContribution — canonical contract fixture
- `WorkLogReservedPayload`는 새 closed contract SPI에 제공하는 exact final record다. v1은
`workLogId` 하나만 소유하며, schema와 같은 canonical bounded identifier 규칙을 생성자에서 지킨다.
- `contracts/messaging/portfolio.worklog.reserved/v1.schema.json`과 golden vector는
**sample fixture contract**다. 공통 envelope는 WorkLog 필드를 알지 못하며 이 모듈을 삭제해도
production module build/runtime은 깨지지 않아야 한다.
- contribution은 contract ID, exact payload type/component order, classpath resource ID, checked-in
digest와 provider-neutral descriptor만 제공한다. JSON mapper, physical topic, Kafka 설정을 소유하지
않으며 매 호출 filesystem I/O도 하지 않는다.
- 기존 3-field `WorkLogReservedIntegrationEvent`/hand-rolled mapper는 현재 legacy outbox
characterization 경로에 남아 있다. 새 1-field canonical payload는 그 타입을 조용히 대체하지 않으며,
closed catalog와 encoder가 조립되기 전까지 양쪽은 서로 호출하거나 직렬화하지 않는다.
- 현재는 classpath scanning이나 runtime discovery가 없다. Task 5의 closed catalog 조립 전에는
자동 등록되지 않고, Task 6 validator qualification 전에는 Draft 2020-12 validator compatibility를
주장하지 않는다.
### RepoStatsPort
- 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound).
@@ -0,0 +1,69 @@
package dev.caskeleton.sample.portfolio.application.event;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import dev.caskeleton.application.messaging.contract.Sha256;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
/** Static sample contribution for the {@code portfolio.worklog.reserved} v1 contract. */
public final class WorkLogReservedContractContribution
implements IntegrationEventContractContribution<WorkLogReservedPayload> {
private static final ContractId CONTRACT_ID = new ContractId("portfolio.worklog.reserved");
private static final SchemaResourceId SCHEMA_RESOURCE =
new SchemaResourceId("contracts/messaging/portfolio.worklog.reserved/v1.schema.json");
private static final Sha256 SCHEMA_HASH =
new Sha256(
HexFormat.of()
.parseHex("7264efd4e2531e6fd00010bb3deca96a" + "f2f1d0408d0021d014feaf90190f2eac"));
private static final ContractDescriptor DESCRIPTOR =
new ContractDescriptor(
"sample-portfolio",
new LogicalDestinationId("portfolio-domain-events"),
"json-schema-envelope-v1",
true,
64 * 1024,
128 * 1024,
ContractDescriptor.SensitivityClassification.INTERNAL,
Duration.ofDays(7));
@Override
public ContractId contractId() {
return CONTRACT_ID;
}
@Override
public int payloadVersion() {
return 1;
}
@Override
public Class<WorkLogReservedPayload> exactPayloadRecordType() {
return WorkLogReservedPayload.class;
}
@Override
public List<String> canonicalRecordComponentOrder() {
return List.of("workLogId");
}
@Override
public SchemaResourceId payloadSchemaResource() {
return SCHEMA_RESOURCE;
}
@Override
public Sha256 payloadSchemaHash() {
return SCHEMA_HASH;
}
@Override
public ContractDescriptor descriptor() {
return DESCRIPTOR;
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.sample.portfolio.application.event;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
/** Immutable sample payload for the {@code portfolio.worklog.reserved} v1 contract. */
public record WorkLogReservedPayload(String workLogId) implements IntegrationPayload {
private static final int MAXIMUM_WORK_LOG_ID_LENGTH = 160;
private static final String WORK_LOG_ID_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
public WorkLogReservedPayload {
if (workLogId == null
|| workLogId.length() > MAXIMUM_WORK_LOG_ID_LENGTH
|| !workLogId.matches(WORK_LOG_ID_GRAMMAR)) {
throw new IllegalArgumentException(
"workLogId must be a 1-160 character canonical US-ASCII identifier");
}
}
}
@@ -0,0 +1,18 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:dev-caskeleton:contracts:messaging:portfolio.worklog.reserved:v1",
"title": "WorkLogReserved payload v1",
"type": "object",
"required": [
"workLogId"
],
"properties": {
"workLogId": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
}
},
"unevaluatedProperties": false
}
@@ -0,0 +1 @@
7264efd4e2531e6fd00010bb3deca96af2f1d0408d0021d014feaf90190f2eac
@@ -0,0 +1,181 @@
package dev.caskeleton.sample.portfolio.application.event;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
import dev.caskeleton.application.messaging.contract.ContractId;
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Modifier;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Locale;
import org.junit.jupiter.api.Test;
class WorkLogReservedContractContributionTest {
private static final String SCHEMA_RESOURCE =
"contracts/messaging/portfolio.worklog.reserved/v1.schema.json";
private static final String DIGEST_RESOURCE =
"contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256";
private static final String VALID_RESOURCE =
"contracts/messaging/portfolio.worklog.reserved/v1.valid.json";
private static final String INVALID_UNKNOWN_FIELD_RESOURCE =
"contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Test
void payloadIsTheExactImmutableValidatedIntegrationPayloadRecord() {
WorkLogReservedPayload payload = new WorkLogReservedPayload("worklog-42");
assertThat(payload.workLogId()).isEqualTo("worklog-42");
assertThat(WorkLogReservedPayload.class.isRecord()).isTrue();
assertThat(Modifier.isFinal(WorkLogReservedPayload.class.getModifiers())).isTrue();
assertThat(IntegrationPayload.class).isAssignableFrom(WorkLogReservedPayload.class);
assertThatThrownBy(() -> new WorkLogReservedPayload(null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new WorkLogReservedPayload(""))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new WorkLogReservedPayload("worklog 42"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new WorkLogReservedPayload("x".repeat(161)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void contributionPublishesOnlyTheClosedProviderNeutralContractDescription() {
WorkLogReservedContractContribution contribution = new WorkLogReservedContractContribution();
assertThat(contribution.contractId()).isEqualTo(new ContractId("portfolio.worklog.reserved"));
assertThat(contribution.payloadVersion()).isEqualTo(1);
assertThat(contribution.exactPayloadRecordType()).isEqualTo(WorkLogReservedPayload.class);
assertThat(contribution.canonicalRecordComponentOrder()).containsExactly("workLogId");
assertThat(contribution.payloadSchemaResource())
.isEqualTo(
new SchemaResourceId("contracts/messaging/portfolio.worklog.reserved/v1.schema.json"));
assertThat(contribution.payloadSchemaHash()).isSameAs(contribution.payloadSchemaHash());
ContractDescriptor descriptor = contribution.descriptor();
assertThat(descriptor.ownerModule()).isEqualTo("sample-portfolio");
assertThat(descriptor.logicalDestination())
.isEqualTo(new LogicalDestinationId("portfolio-domain-events"));
assertThat(descriptor.serializerId()).isEqualTo("json-schema-envelope-v1");
assertThat(descriptor.orderingRequired()).isTrue();
assertThat(descriptor.maximumPayloadBytes()).isEqualTo(64 * 1024);
assertThat(descriptor.maximumEnvelopeBytes()).isEqualTo(128 * 1024);
assertThat(descriptor.sensitivityClassification())
.isEqualTo(ContractDescriptor.SensitivityClassification.INTERNAL);
assertThat(descriptor.sameEventRequeueHorizon()).isEqualTo(Duration.ofDays(7));
assertThat(WorkLogReservedContractContribution.class.getDeclaredMethods())
.allSatisfy(
method -> {
String name = method.getName().toLowerCase(Locale.ROOT);
assertThat(name)
.doesNotContain(
"json", "mapper", "tree", "parser", "topic", "kafka", "bootstrap");
});
}
@Test
void checkedInPayloadSchemaIsClosedBoundedAndMatchesItsExactByteDigest() throws Exception {
byte[] schemaBytes = readResource(SCHEMA_RESOURCE);
String schemaText = strictUtf8(schemaBytes);
JsonNode schema = OBJECT_MAPPER.readTree(schemaText);
assertThat(schema.path("$schema").textValue())
.isEqualTo("https://json-schema.org/draft/2020-12/schema");
String schemaId = schema.path("$id").textValue();
assertThat(schemaId)
.isEqualTo("urn:dev-caskeleton:contracts:messaging:portfolio.worklog.reserved:v1");
assertThat(URI.create(schemaId).isAbsolute()).isTrue();
assertThat(schema.path("type").textValue()).isEqualTo("object");
assertThat(schema.path("required")).containsExactly(OBJECT_MAPPER.valueToTree("workLogId"));
assertThat(schema.path("properties").size()).isEqualTo(1);
assertThat(schema.path("properties").has("workLogId")).isTrue();
JsonNode workLogId = schema.path("properties").path("workLogId");
assertThat(workLogId.path("type").textValue()).isEqualTo("string");
assertThat(workLogId.path("minLength").intValue()).isEqualTo(1);
assertThat(workLogId.path("maxLength").intValue()).isEqualTo(160);
assertThat(workLogId.path("pattern").textValue()).isEqualTo("^[A-Za-z0-9][A-Za-z0-9._:-]*$");
assertThat(schema.path("unevaluatedProperties").booleanValue()).isFalse();
assertThat(schemaText).doesNotContainPattern("\"type\"\\s*:\\s*\\[[^]]*\"null\"");
assertThat(schemaText).doesNotContain("\"$ref\"");
byte[] manifestBytes = readResource(DIGEST_RESOURCE);
String manifest = strictUtf8(manifestBytes);
assertThat(manifestBytes).endsWith((byte) '\n');
assertThat(manifest).matches("[0-9a-f]{64}\\n");
String exactDigest =
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(schemaBytes));
assertThat(manifest).isEqualTo(exactDigest + "\n");
WorkLogReservedContractContribution contribution = new WorkLogReservedContractContribution();
assertThat(contribution.payloadSchemaHash().toString()).isEqualTo(exactDigest);
}
@Test
void goldenVectorsFreezeRequiredUnknownAndNullPolicyWithoutClaimingValidatorCompatibility()
throws Exception {
JsonNode valid = OBJECT_MAPPER.readTree(strictUtf8(readResource(VALID_RESOURCE)));
JsonNode invalidUnknown =
OBJECT_MAPPER.readTree(strictUtf8(readResource(INVALID_UNKNOWN_FIELD_RESOURCE)));
JsonNode missing = OBJECT_MAPPER.readTree("{}");
JsonNode explicitNull = OBJECT_MAPPER.readTree("{\"workLogId\":null}");
assertThat(valid).isEqualTo(OBJECT_MAPPER.readTree("{\"workLogId\":\"worklog-42\"}"));
assertThat(invalidUnknown.path("workLogId").textValue()).isEqualTo("worklog-42");
assertThat(invalidUnknown.has("unexpected")).isTrue();
assertThat(matchesStructuralV1Policy(valid)).isTrue();
assertThat(matchesStructuralV1Policy(invalidUnknown)).isFalse();
assertThat(matchesStructuralV1Policy(missing)).isFalse();
assertThat(matchesStructuralV1Policy(explicitNull)).isFalse();
}
private static boolean matchesStructuralV1Policy(JsonNode candidate) {
if (!candidate.isObject() || candidate.size() != 1 || !candidate.has("workLogId")) {
return false;
}
JsonNode workLogId = candidate.path("workLogId");
if (!workLogId.isTextual()) {
return false;
}
String value = workLogId.textValue();
return value.length() <= 160 && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
private static byte[] readResource(String resource) throws IOException {
try (InputStream input =
WorkLogReservedContractContributionTest.class
.getClassLoader()
.getResourceAsStream(resource)) {
if (input == null) {
throw new IOException("Missing classpath resource: " + resource);
}
return input.readAllBytes();
}
}
private static String strictUtf8(byte[] bytes) throws CharacterCodingException {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
}
}
@@ -0,0 +1 @@
{"workLogId":"worklog-42","unexpected":"rejected"}
@@ -0,0 +1 @@
{"workLogId":"worklog-42"}
+5
View File
@@ -23,6 +23,9 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat
`tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist,
and the span-error-recording seam (feature-distributed-tracing-contract; the OTel
tracer runtime is a fork-activated seam, so these stay Java-stdlib-only).
- Generic messaging envelope schema resources under `contracts/messaging/envelope/`.
They own transport-neutral envelope metadata only; feature payload schemas remain in their
feature-owner modules. Resource presence is not runtime discovery or registration.
## Allowed
- Java standard library ONLY. No Spring, no Jackson, no JPA imports.
@@ -30,6 +33,8 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat
## Forbidden
- business/domain concept (domain error codes belong in the consuming module).
- framework imports (HTTP status is expressed as transport-neutral `int`; the web module maps it).
- claiming Draft 2020-12 validator compatibility before the messaging adapter's Task 6 validator
qualification exists.
## Test
```bash
+14
View File
@@ -14,6 +14,20 @@ persistence·outbound)이 프레임워크 충돌 없이 이 타입들을 공유
---
## messaging — generic envelope schema resource
- `contracts/messaging/envelope/v1.schema.json` 은 비즈니스 필드를 모르는 공통 envelope v1의
저장소 소유 Draft 2020-12 리소스다. root와 aggregate metadata는 닫혀 있고, `payload`는 object
크기만 제한한다. 실제 payload 필드와 닫힘 정책은 feature owner의 별도 schema가 소유한다.
- `.schema.sha256`은 schema 파일의 exact bytes SHA-256이다. 같은 version의 schema bytes를 바꾸면
digest도 의도적으로 갱신하고 compatibility 검토를 다시 해야 한다.
- 이 단계의 테스트는 UTF-8, 정본 구조, digest, remote `$ref` 금지만 JDK로 검사한다. Task 6의
실제 Draft 2020-12 validator가 붙기 전까지 validator compatibility가 증명된 것은 아니다.
- classpath resource가 존재한다는 사실은 runtime discovery나 자동 등록을 의미하지 않는다.
contract catalog와 encoder가 명시적으로 조립되기 전에는 어떤 publisher도 이 리소스를 읽지 않는다.
---
## error — 에러 코드 계약
### ApiErrorCode (인터페이스)
@@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:dev-caskeleton:contracts:messaging:envelope:v1",
"title": "Messaging envelope v1",
"type": "object",
"required": [
"envelopeVersion",
"eventId",
"contractId",
"payloadVersion",
"logicalDestination",
"aggregate",
"occurredAt",
"correlationId",
"contentType",
"payload"
],
"properties": {
"envelopeVersion": {
"const": 1
},
"eventId": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"contractId": {
"type": "string",
"minLength": 3,
"maxLength": 160,
"pattern": "^(?!.*(?:\\.|-)v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$"
},
"payloadVersion": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"logicalDestination": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^(?!.*-v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
},
"aggregate": {
"type": "object",
"required": [
"type",
"id",
"sequence",
"eventIndex"
],
"properties": {
"type": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$"
},
"id": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"sequence": {
"type": "integer",
"minimum": 1,
"maximum": 9223372036854775807
},
"eventIndex": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647
}
},
"unevaluatedProperties": false
},
"occurredAt": {
"type": "string",
"minLength": 20,
"maxLength": 64,
"format": "date-time"
},
"correlationId": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"contentType": {
"const": "application/json"
},
"payload": {
"type": "object",
"maxProperties": 64
}
},
"unevaluatedProperties": false
}
@@ -0,0 +1 @@
bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1
@@ -0,0 +1,274 @@
package dev.caskeleton.shared.contract.messaging;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import org.junit.jupiter.api.Test;
class MessagingEnvelopeSchemaResourceTest {
private static final String SCHEMA_RESOURCE = "contracts/messaging/envelope/v1.schema.json";
private static final String DIGEST_RESOURCE = "contracts/messaging/envelope/v1.schema.sha256";
private static final String CONTRACT_ID_PATTERN =
"^(?!.*(?:\\.|-)v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
+ "(?:\\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$";
private static final String LOGICAL_DESTINATION_PATTERN =
"^(?!.*-v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$";
private static final BigInteger INT_MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
private static final BigInteger LONG_MAXIMUM = BigInteger.valueOf(Long.MAX_VALUE);
@Test
void envelopeV1IsTheCanonicalBusinessFreeClosedResource() throws Exception {
String schema = strictUtf8(readResource(SCHEMA_RESOURCE));
assertThat(schema)
.isEqualTo(
"""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:dev-caskeleton:contracts:messaging:envelope:v1",
"title": "Messaging envelope v1",
"type": "object",
"required": [
"envelopeVersion",
"eventId",
"contractId",
"payloadVersion",
"logicalDestination",
"aggregate",
"occurredAt",
"correlationId",
"contentType",
"payload"
],
"properties": {
"envelopeVersion": {
"const": 1
},
"eventId": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"contractId": {
"type": "string",
"minLength": 3,
"maxLength": 160,
"pattern": "^(?!.*(?:\\\\.|-)v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\\\\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$"
},
"payloadVersion": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"logicalDestination": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^(?!.*-v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
},
"aggregate": {
"type": "object",
"required": [
"type",
"id",
"sequence",
"eventIndex"
],
"properties": {
"type": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$"
},
"id": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"sequence": {
"type": "integer",
"minimum": 1,
"maximum": 9223372036854775807
},
"eventIndex": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647
}
},
"unevaluatedProperties": false
},
"occurredAt": {
"type": "string",
"minLength": 20,
"maxLength": 64,
"format": "date-time"
},
"correlationId": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"contentType": {
"const": "application/json"
},
"payload": {
"type": "object",
"maxProperties": 64
}
},
"unevaluatedProperties": false
}
""");
assertThat(schema)
.doesNotContain(
"\"workLogId\"", "\"portfolio.worklog.reserved\"", "\"topic\"", "\"bootstrapServers\"");
assertThat(URI.create("urn:dev-caskeleton:contracts:messaging:envelope:v1").isAbsolute())
.isTrue();
assertThat(schema).doesNotContainPattern("\"type\"\\s*:\\s*\\[[^]]*\"null\"");
assertThat(schema).doesNotContain("\"$ref\"");
}
@Test
void identifierPatternsStayInParityWithTaskThreeValueObjects() throws Exception {
String schema = strictUtf8(readResource(SCHEMA_RESOURCE));
assertThat(schema)
.contains(
"\"pattern\": \"" + jsonEscape(CONTRACT_ID_PATTERN) + "\"",
"\"pattern\": \"" + jsonEscape(LOGICAL_DESTINATION_PATTERN) + "\"");
assertThat(List.of("portfolio.worklog.reserved", "portfolio-v1.worklog.reserved"))
.allMatch(value -> value.matches(CONTRACT_ID_PATTERN));
assertThat(List.of("portfolio.worklog.v1", "portfolio.worklog-v1"))
.noneMatch(value -> value.matches(CONTRACT_ID_PATTERN));
assertThat(List.of("portfolio-domain-events", "events"))
.allMatch(value -> value.matches(LOGICAL_DESTINATION_PATTERN));
assertThat(List.of("events-v2")).noneMatch(value -> value.matches(LOGICAL_DESTINATION_PATTERN));
}
@Test
void numericConstraintsFreezeJavaBoundaryAndOverBoundaryVectors() throws Exception {
String schema = strictUtf8(readResource(SCHEMA_RESOURCE));
assertThat(countOccurrences(schema, "\"maximum\": 2147483647")).isEqualTo(2);
assertThat(countOccurrences(schema, "\"maximum\": 9223372036854775807")).isEqualTo(1);
assertThat(withinRange("2147483647", BigInteger.ONE, INT_MAXIMUM)).isTrue();
assertThat(withinRange("2147483648", BigInteger.ONE, INT_MAXIMUM)).isFalse();
assertThat(withinRange("2147483647", BigInteger.ZERO, INT_MAXIMUM)).isTrue();
assertThat(withinRange("2147483648", BigInteger.ZERO, INT_MAXIMUM)).isFalse();
assertThat(withinRange("9223372036854775807", BigInteger.ONE, LONG_MAXIMUM)).isTrue();
assertThat(withinRange("9223372036854775808", BigInteger.ONE, LONG_MAXIMUM)).isFalse();
}
@Test
void checkedInDigestMatchesExactSchemaBytesAndCanonicalManifestFormat() throws Exception {
byte[] schemaBytes = readResource(SCHEMA_RESOURCE);
byte[] manifestBytes = readResource(DIGEST_RESOURCE);
String manifest = strictUtf8(manifestBytes);
assertThat(manifestBytes).endsWith((byte) '\n');
assertThat(manifest).matches("[0-9a-f]{64}\\n");
assertThat(manifest).isEqualTo(HexFormat.of().formatHex(sha256(schemaBytes)) + "\n");
}
@Test
void utf8DecoderRejectsMalformedResourceBytes() {
byte[] malformed = {(byte) 0xc3, (byte) 0x28};
assertThatThrownBy(() -> strictUtf8(malformed)).isInstanceOf(CharacterCodingException.class);
}
@Test
void frozenEnvelopeExampleRetainsTheDesignValuesWithoutOwningBusinessSchema() {
String frozenExample =
"""
{
"envelopeVersion": 1,
"eventId": "event-1",
"contractId": "portfolio.worklog.reserved",
"payloadVersion": 1,
"logicalDestination": "portfolio-domain-events",
"aggregate": {
"type": "worklog",
"id": "worklog-42",
"sequence": 17,
"eventIndex": 0
},
"occurredAt": "2026-07-28T05:10:30.123Z",
"correlationId": "corr-1",
"contentType": "application/json",
"payload": {
"workLogId": "worklog-42"
}
}
""";
assertThat(frozenExample)
.contains(
"\"envelopeVersion\": 1",
"\"eventId\": \"event-1\"",
"\"contractId\": \"portfolio.worklog.reserved\"",
"\"logicalDestination\": \"portfolio-domain-events\"",
"\"sequence\": 17",
"\"eventIndex\": 0",
"\"occurredAt\": \"2026-07-28T05:10:30.123Z\"",
"\"correlationId\": \"corr-1\"",
"\"contentType\": \"application/json\"",
"\"workLogId\": \"worklog-42\"");
}
private static byte[] readResource(String resource) throws IOException {
try (InputStream input =
MessagingEnvelopeSchemaResourceTest.class.getClassLoader().getResourceAsStream(resource)) {
if (input == null) {
throw new IOException("Missing classpath resource: " + resource);
}
return input.readAllBytes();
}
}
private static String strictUtf8(byte[] bytes) throws CharacterCodingException {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
}
private static byte[] sha256(byte[] bytes) throws NoSuchAlgorithmException {
return MessageDigest.getInstance("SHA-256").digest(bytes);
}
private static String jsonEscape(String value) {
return value.replace("\\", "\\\\");
}
private static int countOccurrences(String value, String fragment) {
return (value.length() - value.replace(fragment, "").length()) / fragment.length();
}
private static boolean withinRange(String candidate, BigInteger minimum, BigInteger maximum) {
BigInteger value = new BigInteger(candidate);
return value.compareTo(minimum) >= 0 && value.compareTo(maximum) <= 0;
}
}