merge: integrate messaging R2 polling producer

# Conflicts:
#	docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md
#	src/app-bootstrap/gradle.lockfile
#	src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java
#	src/build.gradle
This commit is contained in:
donghyeon-ka
2026-08-01 00:04:02 +09:00
92 changed files with 10591 additions and 54 deletions
@@ -1,7 +1,7 @@
# Messaging Production Capability Deep Design # Messaging Production Capability Deep Design
- 작성일: 2026-07-28 - 작성일: 2026-07-28
- 상태: 상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수 - 상태: 상세 설계 승인, P0 characterization 및 P1 implementation candidate 완료, P2 이후 미착수
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
- 현재 outbound leaf: `adapter-outbound-messaging` - 현재 outbound leaf: `adapter-outbound-messaging`
- 미래 inbound leaf: `adapter-inbound-messaging-kafka` - 미래 inbound leaf: `adapter-inbound-messaging-kafka`
@@ -124,8 +124,8 @@ cdc = disabled
| Phase | 산출물 | 2026-07-28 상태 | 허용 표현 | | Phase | 산출물 | 2026-07-28 상태 | 허용 표현 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | current truth, design, characterization | `IMPLEMENTATION_PLAN_READY` | 설계·실행 계획 완료, 구현 미착수 | | P0 | current truth, design, characterization | `CHARACTERIZED` | legacy R0 동작 고정, production 동작 변경 없음 |
| P1 | event contract/catalog/envelope/schema | `NOT_STARTED` | 미구현 | | P1 | event contract/catalog/envelope/schema | `IMPLEMENTED_CANDIDATE` | deterministic local wire contract 후보, production 미연결 |
| P2 | immutable event + polling delivery v2 | `NOT_STARTED` | legacy polling만 존재 | | P2 | immutable event + polling delivery v2 | `NOT_STARTED` | legacy polling만 존재 |
| P3 | Spring Kafka ACK-aware producer | `NOT_STARTED` | Kafka seam R0 | | P3 | Spring Kafka ACK-aware producer | `NOT_STARTED` | Kafka seam R0 |
| P4 | security/observability/fault/real-service R2 evidence | `NOT_STARTED` | R2 주장 금지 | | P4 | security/observability/fault/real-service R2 evidence | `NOT_STARTED` | R2 주장 금지 |
@@ -136,6 +136,20 @@ cdc = disabled
Phase 진행도와 capability readiness는 별도 축이다. 예를 들어 P3 코드가 존재해도 exact selected Phase 진행도와 capability readiness는 별도 축이다. 예를 들어 P3 코드가 존재해도 exact selected
profile이 real Kafka, 보안, fault, shutdown evidence를 통과하지 않으면 R2가 아니다. profile이 real Kafka, 보안, fault, shutdown evidence를 통과하지 않으면 R2가 아니다.
P1 후보는 closed application contract SPI, generic/sample schema, exact destination/card binding,
single-snapshot deterministic envelope, pinned local Draft 2020-12 validation과 payload-free build
evidence까지 구현했다. `verifyMessagingJsonSchemaV1` 28개와 `verifyMessagingContracts` 82개
scenario가 실패/skip 없이 통과했으며 생성 manifest도 공통 Draft 2020-12 schema로 실제
검증한다. `json-schema-envelope.v1``implemented-candidate`이고 evidence fingerprint는
release attestation이 아니므로 비워 둔다. Kafka ACK producer, durable outbox v2, production
runtime wiring, full validator compatibility와 regex execution timeout은 여전히 미구현/미증명이다.
P0 characterization은 blank broker의 disabled sentinel, 선택 broker의 sender 누락 및 broker ID
불일치 startup 실패, legacy void sender 정상 반환 뒤 `PUBLISHED`, sender 예외 뒤 `FAILED/DEAD`,
상태 mark 실패 뒤 `IN_FLIGHT`와 duplicate 가능 구간, 같은 transaction의 append rollback,
`occurred_at` timestamp만 사용하는 FIFO의 동률 한계를 현재 truth로 고정한다. 여기서 legacy
void 반환은 broker acknowledgement가 아니다.
## 1. 설계 판정 ## 1. 설계 판정
이번 설계는 provider-neutral semantic contract와 Kafka reference implementation을 분리한다. 이번 설계는 provider-neutral semantic contract와 Kafka reference implementation을 분리한다.
@@ -1056,10 +1070,9 @@ transaction/inbox provider를 조립한다.
- provider setting; - provider setting;
- feature-specific topic. - feature-specific topic.
현재 `shared-contract/CLAUDE.md`에는 messaging schema resource가 아직 책임으로 등록되어 있지 P1 구현에서 공통 envelope schema와 함께 `shared-contract/CLAUDE.md`의 Responsibility,
않다. P1에서 공통 envelope schema를 추가하는 변경은 같은 commit scope에서 해당 Java-stdlib-only 규칙, business-free 검증을 갱신했다. 이후 schema resource를 확장할 때도
`CLAUDE.md`의 Responsibility를 갱신하고 Java-stdlib-only 규칙과 business-free 검증을 이 세 계약을 같은 변경 범위에서 유지한다.
추가해야 한다. 이 정책 갱신 없이 resource만 넣지 않는다.
### 9.7 `app-bootstrap` ### 9.7 `app-bootstrap`
@@ -5429,7 +5442,7 @@ Kafka에 갈 수 있는 시점부터 old/new relay를 동시에 켜는 rollback
- LLM Wiki capture. - LLM Wiki capture.
현재 문서 상태는 사용자 승인에 따라 현재 문서 상태는 사용자 승인에 따라
`상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수`다. `상세 설계 승인, P0 characterization 및 P1 implementation candidate 완료, P2 이후 미착수`다.
### 31.2 First R2 implementation complete ### 31.2 First R2 implementation complete
+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. - Implement outbound message publication and broker integration behind application/domain ports.
- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox - Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox
publication adaptation. 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 - Own structured rendering of `OutboxRelayFailureReport` through the single unconditional
`Slf4jOutboxRelayFailureReportAdapter` bean. `Slf4jOutboxRelayFailureReportAdapter` bean.
- Reuse `adapter:outbound:support` for shared technical concerns. - 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 - Allowed dependency edges come only from the module's
`src/config/architecture/modules.json` entry. `src/config/architecture/modules.json` entry.
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency. - 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. - 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 - `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 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을 남기지 않는다. 그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다.
반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7). 반환 타입을 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 ## OutboxRelayFailureReport 구조화 ERROR
`MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의 `MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의
@@ -58,3 +74,86 @@ ERROR 하나로 렌더링한다.
payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로 payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로
붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted 붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted
FAILED/DEAD outcome을 바꾸지 않는다. 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 project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure' 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' implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
} }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } 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. # This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised. # Manual edits can break the build and are not advised.
# This file is expected to be part of source control. # This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=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.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=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.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.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=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.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=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.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.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor 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.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor 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.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath 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.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle 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-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation: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.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 jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs 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.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=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-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-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api: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 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.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=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-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree: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.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath 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-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j 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-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor 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-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter: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-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient: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-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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson: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-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-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat: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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc: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-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test: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 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.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
empty= 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 { class SuccessPath {
@Test @Test
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() { void legacyVoidSenderNormalReturnCompletesWithoutBrokerConfirmationCharacterization() {
FakeBroker broker = new FakeBroker(); FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
@@ -128,7 +128,7 @@ class OutboxMessagePublishAdapterTest {
class FailClosedPath { class FailClosedPath {
@Test @Test
void publishFailurePropagatesAsRuntimeException() { void senderExceptionPropagatesAsRuntimeExceptionCharacterization() {
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down")); FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); 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
@@ -834,6 +834,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결
BOM 이 관리. BOM 이 관리.
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated - **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
loggers / loggers 쓰기 거부)를 검증한다. 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 "violation-as-data" fixture 의존성 (test 컴파일러 전용)
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지 ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
+2
View File
@@ -106,6 +106,8 @@ dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
// test-only: jackson-databind for the deserialization-policy boundary test. See README. // test-only: jackson-databind for the deserialization-policy boundary test. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-json' 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. // sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
sampleFixture project(':sample-portfolio') sampleFixture project(':sample-portfolio')
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README. // 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 ch.qos.logback:logback-core:1.5.34=sampleFixture
com.approvaltests:approvaltests-util:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests-util:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -57,6 +58,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisCom
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.jayway.jsonpath:json-path:2.9.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle 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:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-json:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -8,6 +8,8 @@ import com.tngtech.archunit.lang.EvaluationResult;
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase; import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
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.BulkWriteWithoutWriteAccessUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository; import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort; import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
@@ -96,6 +98,10 @@ class ArchitectureViolationFixtureTest {
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY = private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class); 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 // 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 // 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. // silently broken branch cannot pass vacuously via the other one in the shared pool.
@@ -445,6 +451,20 @@ class ArchitectureViolationFixtureTest {
.isTrue(); .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 @Test
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() { void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
EvaluationResult result = EvaluationResult result =
@@ -1497,6 +1497,62 @@ class CleanArchitectureTest {
} }
} }
@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 @ArchTest
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE = static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
classes() classes()
@@ -121,6 +121,7 @@ class DisabledAdapterArchitectureTest {
"org.springframework.beans.factory.ObjectProvider", "org.springframework.beans.factory.ObjectProvider",
"org.springframework.beans.factory.ObjectProvider", "org.springframework.beans.factory.ObjectProvider",
"org.springframework.beans.factory.ObjectProvider", "org.springframework.beans.factory.ObjectProvider",
"org.springframework.beans.factory.ObjectProvider",
"dev.caskeleton.adapter.outbound.cache.redis.RedisCapabilityObservationPort")); "dev.caskeleton.adapter.outbound.cache.redis.RedisCapabilityObservationPort"));
} }
@@ -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 @Test
void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() { void sameTransactionAppendRollbackRemovesOutboxRowCharacterization() {
Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
try (AnnotationConfigApplicationContext ctx = 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) // 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. - Own application transaction boundaries through the `TransactionPort` abstraction.
- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own - Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own
MDC or other concrete storage. 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 ## Allowed
@@ -46,6 +48,8 @@ Package root: `dev.caskeleton.application`.
- Persistence-layer transaction annotations of any kind inside this module. - Persistence-layer transaction annotations of any kind inside this module.
- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express - Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express
diagnostic intent through a specific outbound `*Port`; adapters own rendering. 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 ## Contract types
@@ -63,6 +67,10 @@ Package root: `dev.caskeleton.application`.
| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. | | `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. |
| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. | | `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. |
| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. | | `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. |
## Notification R1 application boundary ## Notification R1 application boundary
@@ -218,3 +226,6 @@ cd src
./gradlew :application-core:test ./gradlew :application-core:test
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' ./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
@@ -22,6 +22,34 @@ Micrometer meter/tag 렌더링은 Redis adapter가 소유한다. 관측 실패
--- ---
## 메시징 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 / command / query / capability)
### UseCase / CommandUseCase / QueryUseCase ### 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.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -61,7 +62,7 @@ class PublishPendingOutboxEventsUseCaseTest {
// ---- success path ---- // ---- success path ----
@Test @Test
void successfulPublishTransitionsEventToPublished() { void legacyVoidPublisherNormalReturnTransitionsEventToPublishedCharacterization() {
OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(event); store.addClaimable(event);
@@ -93,7 +94,7 @@ class PublishPendingOutboxEventsUseCaseTest {
// ---- transient failure path ---- // ---- transient failure path ----
@Test @Test
void transientFailureTransitionsEventToFailedWithBackoff() { void senderExceptionTransitionsEventToFailedWithBackoffCharacterization() {
OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(event); store.addClaimable(event);
publishPort.failOn("evt-fail", new RuntimeException("broker down")); publishPort.failOn("evt-fail", new RuntimeException("broker down"));
@@ -122,7 +123,7 @@ class PublishPendingOutboxEventsUseCaseTest {
} }
@Test @Test
void failureOnThirdAttemptTransitionsEventToDead() { void senderExceptionAtRetryLimitTransitionsEventToDeadCharacterization() {
// attemptCount=3 means this is the 3rd attempt — next failure should DEAD // attemptCount=3 means this is the 3rd attempt — next failure should DEAD
OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3); OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
store.addClaimable(event); store.addClaimable(event);
@@ -202,8 +203,8 @@ class PublishPendingOutboxEventsUseCaseTest {
// ---- markPublished failure — spec §엣지·실패·의존 semantics ---- // ---- markPublished failure — spec §엣지·실패·의존 semantics ----
/** /**
* When {@code store.markPublished} throws after a SUCCESSFUL publish, the exception must * When {@code store.markPublished} throws after a normal void publisher return, the exception
* propagate out of {@code handle()} rather than being caught and misclassified as a publish * 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 * failure (which would trigger FAILED/DEAD state machine and potentially dead-letter a
* successfully-delivered event). * successfully-delivered event).
* *
@@ -213,19 +214,21 @@ class PublishPendingOutboxEventsUseCaseTest {
* <li>The exception propagates — it is NOT swallowed inside {@code publishOne}. * <li>The exception propagates — it is NOT swallowed inside {@code publishOne}.
* <li>{@code markFailed} is NOT called for the event (no misclassification). * <li>{@code markFailed} is NOT called for the event (no misclassification).
* <li>{@code markDead} 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>{@code publishPort.publish} is not called again before the in-flight timeout.
* <li>The row remains IN_FLIGHT and is recovered via the orphan visibility-timeout reclaim path * <li>The row remains IN_FLIGHT and can be recovered via the orphan visibility-timeout reclaim
* on the next tick — re-published → duplicate absorbed by consumer dedupe (at-least-once). * path on a later tick. Because the prior void publisher return is not broker
* acknowledgement evidence, a later publish can be a duplicate.
* </ul> * </ul>
*/ */
@Test @Test
void markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure() { void legacyMarkPublishedFailureLeavesInFlightAndDuplicatePossibleCharacterization() {
OutboxEvent event = OutboxEvent event =
makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
ThrowingOnMarkPublishedStorePort throwingStore = MutableClock mutableClock = new MutableClock(NOW, ZoneOffset.UTC);
new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished")); TimeoutAwareThrowingOnMarkPublishedStorePort throwingStore =
throwingStore.addClaimable(event); new TimeoutAwareThrowingOnMarkPublishedStorePort(
event, NOW, new RuntimeException("DB down on markPublished"));
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore = PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase( new PublishPendingOutboxEventsUseCase(
@@ -234,7 +237,7 @@ class PublishPendingOutboxEventsUseCaseTest {
reporter, reporter,
tx, tx,
backoffPolicy, backoffPolicy,
clock, mutableClock,
BATCH_SIZE, BATCH_SIZE,
IN_FLIGHT_TIMEOUT); IN_FLIGHT_TIMEOUT);
@@ -244,17 +247,36 @@ class PublishPendingOutboxEventsUseCaseTest {
.isInstanceOf(RuntimeException.class) .isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markPublished"); .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(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. // No misclassification: the event must NOT be marked FAILED or DEAD.
assertThat(throwingStore.failedEvents) assertThat(throwingStore.markFailedCalls()).isZero();
.as("markFailed must NOT be called when only markPublished fails") assertThat(throwingStore.markDeadCalls()).isZero();
.doesNotContainKey("evt-store-fail");
assertThat(throwingStore.deadEvents)
.as("markDead must NOT be called when only markPublished fails")
.doesNotContain("evt-store-fail");
assertThat(reporter.reports).isEmpty(); 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 { static final class ThrowingTransitionStorePort extends FakeOutboxStorePort {
private final RuntimeException markFailedException; private final RuntimeException markFailedException;
private final RuntimeException markDeadException; private final RuntimeException markDeadException;
@@ -566,6 +677,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. */ /** Deterministic RandomGenerator that always returns 0 — produces zero jitter. */
static final class ZeroRandom implements RandomGenerator { static final class ZeroRandom implements RandomGenerator {
@Override @Override
+500
View File
@@ -1,5 +1,8 @@
import groovy.json.JsonSlurper import groovy.json.JsonSlurper
import groovy.json.JsonOutput import groovy.json.JsonOutput
import groovy.xml.XmlSlurper
import java.time.Duration
import java.time.Instant
import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.artifacts.dsl.LockMode
import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.AbstractArchiveTask
@@ -47,6 +50,115 @@ ext.releaseVersion = releaseVersion
ext.sourceRevision = sourceRevision ext.sourceRevision = sourceRevision
ext.traceableVersion = traceableVersion 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 // 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 // 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 // grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
@@ -289,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. // One explicit command regenerates every module's Gradle-default lockfile.
tasks.register('resolveAndLockAll') { tasks.register('resolveAndLockAll') {
group = 'build setup' 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
+5
View File
@@ -22,6 +22,9 @@ Package root: `dev.caskeleton.sample.portfolio`.
`RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`). `RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`).
- Contract-test fixtures the app-bootstrap verification suite analyses - Contract-test fixtures the app-bootstrap verification suite analyses
(`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests). (`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 - Sample application collaborators consume invocation context through application-core ports and
must not import SLF4J/MDC. 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-required behaviour living here: deleting this module must not break the
production build or runtime (the base `GlobalExceptionHandler`, envelope, and production build or runtime (the base `GlobalExceptionHandler`, envelope, and
error-code contract live in `adapter:inbound:web` / `shared-contract`). 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 ## 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 ### RepoStatsPort
- 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound). - 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 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
@@ -25,6 +25,9 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat
`tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist, `tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist,
and the span-error-recording seam (feature-distributed-tracing-contract; the OTel 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). 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 ## Allowed
- Java standard library ONLY. No Spring, no Jackson, no JPA imports. - Java standard library ONLY. No Spring, no Jackson, no JPA imports.
@@ -32,6 +35,8 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat
## Forbidden ## Forbidden
- business/domain concept (domain error codes belong in the consuming module). - 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). - 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 ## Test
```bash ```bash
+14
View File
@@ -33,6 +33,20 @@ Spring/Lettuce 타입을 노출하지 않는다.
--- ---
## 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 — 에러 코드 계약 ## error — 에러 코드 계약
### ApiErrorCode (인터페이스) ### 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;
}
}