diff --git a/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md b/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md index 55bf8f9e..201a48e3 100644 --- a/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md +++ b/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md @@ -1,7 +1,7 @@ # Messaging Production Capability Deep Design - 작성일: 2026-07-28 -- 상태: 상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수 +- 상태: 상세 설계 승인, P0 characterization 및 P1 implementation candidate 완료, P2 이후 미착수 - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 현재 outbound leaf: `adapter-outbound-messaging` - 미래 inbound leaf: `adapter-inbound-messaging-kafka` @@ -124,8 +124,8 @@ cdc = disabled | Phase | 산출물 | 2026-07-28 상태 | 허용 표현 | | --- | --- | --- | --- | -| P0 | current truth, design, characterization | `IMPLEMENTATION_PLAN_READY` | 설계·실행 계획 완료, 구현 미착수 | -| P1 | event contract/catalog/envelope/schema | `NOT_STARTED` | 미구현 | +| P0 | current truth, design, characterization | `CHARACTERIZED` | legacy R0 동작 고정, production 동작 변경 없음 | +| P1 | event contract/catalog/envelope/schema | `IMPLEMENTED_CANDIDATE` | deterministic local wire contract 후보, production 미연결 | | P2 | immutable event + polling delivery v2 | `NOT_STARTED` | legacy polling만 존재 | | P3 | Spring Kafka ACK-aware producer | `NOT_STARTED` | Kafka seam R0 | | P4 | security/observability/fault/real-service R2 evidence | `NOT_STARTED` | R2 주장 금지 | @@ -136,6 +136,20 @@ cdc = disabled Phase 진행도와 capability readiness는 별도 축이다. 예를 들어 P3 코드가 존재해도 exact selected 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. 설계 판정 이번 설계는 provider-neutral semantic contract와 Kafka reference implementation을 분리한다. @@ -1056,10 +1070,9 @@ transaction/inbox provider를 조립한다. - provider setting; - feature-specific topic. -현재 `shared-contract/CLAUDE.md`에는 messaging schema resource가 아직 책임으로 등록되어 있지 -않다. P1에서 공통 envelope schema를 추가하는 변경은 같은 commit scope에서 해당 -`CLAUDE.md`의 Responsibility를 갱신하고 Java-stdlib-only 규칙과 business-free 검증을 -추가해야 한다. 이 정책 갱신 없이 resource만 넣지 않는다. +P1 구현에서 공통 envelope schema와 함께 `shared-contract/CLAUDE.md`의 Responsibility, +Java-stdlib-only 규칙, business-free 검증을 갱신했다. 이후 schema resource를 확장할 때도 +이 세 계약을 같은 변경 범위에서 유지한다. ### 9.7 `app-bootstrap` @@ -5429,7 +5442,7 @@ Kafka에 갈 수 있는 시점부터 old/new relay를 동시에 켜는 rollback - LLM Wiki capture. 현재 문서 상태는 사용자 승인에 따라 -`상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수`다. +`상세 설계 승인, P0 characterization 및 P1 implementation candidate 완료, P2 이후 미착수`다. ### 31.2 First R2 implementation complete diff --git a/src/adapter/outbound/messaging/CLAUDE.md b/src/adapter/outbound/messaging/CLAUDE.md index b91a7088..a9a02746 100644 --- a/src/adapter/outbound/messaging/CLAUDE.md +++ b/src/adapter/outbound/messaging/CLAUDE.md @@ -15,6 +15,11 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`. - Implement outbound message publication and broker integration behind application/domain ports. - Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox publication adaptation. +- Own the closed local contract/destination/capability binding compiler, stable catalog/settings/ + schema digests, and deterministic partition-key algorithm. Physical topics and bootstrap servers + stay in deployment settings and compiled outbound bindings. +- Own the deterministic UTF-8 envelope writer and exact-resource Draft 2020-12 registry. Schema + validation is local and precompiled; YAML and runtime network/filesystem resolution are forbidden. - Own structured rendering of `OutboxRelayFailureReport` through the single unconditional `Slf4jOutboxRelayFailureReportAdapter` bean. - Reuse `adapter:outbound:support` for shared technical concerns. @@ -24,6 +29,42 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`. - Allowed dependency edges come only from the module's `src/config/architecture/modules.json` entry. - No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency. +- Contract compilation accepts only explicitly supplied application SPI contributions; do not scan, + import sample payload classes, use `Class.forName`, or discover contracts from raw JSON/tree data. +- Snapshot each contribution accessor exactly once. Compiled contracts/publication bindings have + no public constructor and ACTIVE descriptors are created only through the canonical compile path. +- Keep adapter-local compiler/card/binding types off public instance return surfaces. Use + package-private accessors and narrowly scoped public static composition bridges; expose only + primitive/String/core values from public instance methods. +- `DISABLED` with an empty catalog/binding is a zero-resource state. The R1 compiler is intentionally + not wired to legacy `MessagingConfig`; it must not claim Kafka ACK, wire compatibility, + `ACTIVE_READY`, or R2 qualification. +- The local encoder accepts only exact registered final payload records and a closed value + vocabulary. No `Map`, raw JSON/tree, polymorphic type or custom serializer input is allowed. +- Compile and freeze the complete declared payload generic graph before encoding. Allow only the + closed scalar/enum vocabulary, `Optional`, `List`, and exact final nested records; reject + open/raw/wildcard/interface/tree types and runtime record discovery. +- Snapshot every payload record accessor exactly once, encode the payload once, and reuse those + exact trusted bytes for payload-schema validation and envelope embedding. Do not add a raw JSON + parser/generator injection path. +- Apply decimal/list/output admission before unbounded allocation: bound plain decimal digits + before `toPlainString`, check list size before bounded iteration without `toArray`, fail closed on + mutation/concurrency, and enforce output bytes while the generator writes. +- Keep the nine exact checked-in Draft 2020-12 schema/meta bytes and digests synchronized. Startup + compares those bytes, IDs, and NetworkNT runtime schema trees without assuming the validator's + `CodeSource` is a regular JAR. Strict dependency locks and `verifyJsonSchemaRuntimeGraph` own the + NetworkNT 3.0.2 artifact provenance. +- Permit only lowercase exact `urn` root `$id` and absolute `$ref` schemes. Reject every + root-external nested `$id` key regardless of value type, and reject `$dynamicRef`, + `$dynamicAnchor`, deprecated `$recursiveRef`/`$recursiveAnchor`, and `$anchor` anywhere in the + Task 6 closed subset. +- Keep common evidence validation in test/build qualification code: validate exact schema and + generated manifest bytes with the pinned NetworkNT/`LocalJsonSchemaRegistry`, retain schema + meta-validation, and order JSON-only evidence before combined contract evidence so the combined + manifest deterministically owns the shared output. +- The adopted JDK regex engine has no proven execution timeout. Keep regex input and document + budgets finite, retain the adversarial corpus, and preserve the explicit unsupported evidence + claim until a separately reviewed bounded engine or isolation policy exists. - Do not hide use-case sequencing or business routing policy in broker adapters. - `OutboxMessagePublishAdapter` is mapping/send-only and emits no dependency log. The confirmed FAILED/DEAD transition owns the one canonical ERROR; only the general fail-open publisher keeps diff --git a/src/adapter/outbound/messaging/README.md b/src/adapter/outbound/messaging/README.md index 82a45ea3..941306ce 100644 --- a/src/adapter/outbound/messaging/README.md +++ b/src/adapter/outbound/messaging/README.md @@ -45,6 +45,22 @@ application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7). +## Legacy R0 characterization + +현재 `KafkaSender.send(...)`와 `MessageBroker.send(...)`의 정상 void 반환은 호출이 예외 없이 +끝났다는 뜻일 뿐 broker acknowledgement 증거가 아니다. relay는 이 정상 반환 뒤 legacy +`PUBLISHED`를 기록한다. sender 예외는 fail-closed outbox 경로에서 전파되어 relay의 +`FAILED/DEAD` 전이를 유발한다. + +broker 설정이 blank면 두 포트는 각각 `DisabledMessagePublisher`와 +`DisabledOutboxMessagePublisher`에 바인딩된다. broker를 선택했지만 project-supplied sender가 +없거나 선택 ID와 활성 `MessageBroker.brokerId()`가 다르면 startup이 실패한다. + +publisher 정상 반환 뒤 DB mark가 실패하면 row는 `IN_FLIGHT`에 남아 timeout reclaim 후 같은 +event가 다시 publish될 수 있다. 현재 consumer/inbox가 없으므로 이 duplicate 가능 구간을 +중복 안전 전달로 표현하지 않는다. 또한 legacy FIFO gate는 `occurred_at`의 strict less-than 비교만 +사용하므로 동일 aggregate의 timestamp 동률 row는 서로를 gate하지 않는다. + ## OutboxRelayFailureReport 구조화 ERROR `MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의 @@ -58,3 +74,86 @@ ERROR 하나로 렌더링한다. payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로 붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted FAILED/DEAD outcome을 바꾸지 않는다. + +## Closed local binding compiler R1 + +`contract`, `destination`, `config` package는 명시적으로 주입된 +`IntegrationEventContractContribution` 목록만 사용해 contract catalog와 deployment binding을 +로컬에서 컴파일한다. payload record의 exact `Class` token, 선언 순서, schema resource/hash를 +검증하며 scan, `Class.forName`, assignable discovery, raw JSON/tree discovery를 하지 않는다. +catalog/settings/schema digest는 정렬된 semantic identity와 length-prefixed UTF-8/raw SHA-256 +bytes로 계산한다. + +physical topic과 bootstrap server는 `DestinationBindingSettings`와 +`CompiledPublicationBinding`에만 존재한다. application contract에는 broker, topic, credential +설정이 유출되지 않는다. logical destination과 physical topic은 각각 deployment 안에서 unique다. +compiled publication binding은 package-local compiler만 만들 수 있고 active descriptor는 public +static compile 경로로만 생성한다. compiled integration-event contract도 catalog compiler만 +package-local constructor로 만들 수 있다. partition key v1은 canonical non-null tenant scope, +logical destination, aggregate type/id를 strict UTF-8 REPORT + domain-separated +length-prefixed SHA-256으로 계산하고 lowercase hex US-ASCII bytes를 반환한다. malformed +surrogate는 replacement 문자로 바꾸지 않고 거부한다. + +adapter 내부 compiler/card/binding 타입은 direct 또는 generic argument 형태의 public instance +response surface로 노출하지 않는다. +cross-package adapter composition이 정확한 compiled identity를 확인해야 할 때만 public static +bridge를 사용하며, instance accessor는 package-private이고 외부에 필요한 상태는 boolean/String/ +core value로만 제공한다. 이는 broker SDK뿐 아니라 adapter 자체의 조립 타입도 B7 반환 경계를 +통해 application consumer로 새지 않게 한다. + +`MessagingCapabilityCardRegistry`는 first R2 tuple의 11개 ID와 각 role을 닫힌 Java vocabulary로 +제공할 뿐이다. readiness registry의 `not-implemented` 상태를 승격하거나 R2/`ACTIVE_READY`를 +주장하지 않는다. `DISABLED + empty catalog/bindings`는 client, thread, scheduler, network, +filesystem resource가 정확히 0인 descriptor다. `ACTIVE`는 empty catalog, missing/duplicate/extra +binding, unknown/mismatched card와 ordering/schema/security 완화를 resource 생성 전에 거부한다. + +## Deterministic local JSON Schema encoder candidate + +`envelope` package는 exact final `IntegrationPayload` record만 받아 frozen v1 field order로 UTF-8 +JSON을 쓴다. `Map`, raw JSON, `JsonNode`, polymorphic typing, custom serializer, assignable search는 +입력 경로에 없다. contract compiler가 선언된 generic type graph를 scalar/enum, +`Optional`/`List`/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와 +동작은 그대로다. diff --git a/src/adapter/outbound/messaging/build.gradle b/src/adapter/outbound/messaging/build.gradle index 56bf8821..2700ef93 100644 --- a/src/adapter/outbound/messaging/build.gradle +++ b/src/adapter/outbound/messaging/build.gradle @@ -4,7 +4,63 @@ dependencies { implementation project(':adapter:outbound:support') implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.boot:spring-boot-starter-json' + implementation('com.networknt:json-schema-validator:3.0.2') { + exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml' + } implementation 'org.slf4j:slf4j-api' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } +tasks.withType(Test).configureEach { + systemProperty 'messaging.commonEvidenceSchema', + rootProject.file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') + .absolutePath +} + +configurations.configureEach { + exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml' + exclude group: 'org.yaml', module: 'snakeyaml' + exclude group: 'org.snakeyaml', module: 'snakeyaml-engine' +} + +tasks.register('verifyJsonSchemaRuntimeGraph') { + group = 'verification' + description = 'Verifies the closed Jackson 3 / NetworkNT graph contains no YAML or Jackson 2 runtime.' + doLast { + Set modules = configurations.runtimeClasspath.incoming.resolutionResult + .allComponents + .findAll { it.moduleVersion != null } + .collect { + "${it.moduleVersion.group}:${it.moduleVersion.name}:${it.moduleVersion.version}" + .toString() + } as Set + List 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') +} diff --git a/src/adapter/outbound/messaging/gradle.lockfile b/src/adapter/outbound/messaging/gradle.lockfile index 5370d98c..3d0db3f2 100644 --- a/src/adapter/outbound/messaging/gradle.lockfile +++ b/src/adapter/outbound/messaging/gradle.lockfile @@ -1,23 +1,24 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor @@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle @@ -44,7 +46,7 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs @@ -60,9 +62,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle @@ -95,10 +97,10 @@ org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs @@ -108,26 +110,27 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath @@ -145,8 +148,7 @@ org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java new file mode 100644 index 00000000..b0ea8ae1 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java @@ -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 contracts; + private final List bindings; + private final Sha256 catalogDigest; + private final Sha256 settingsDigest; + private final Sha256 schemaSetDigest; + + private CompiledMessagingDescriptor( + ActivationMode activationMode, + List contracts, + List 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 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 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 contracts() { + return contracts; + } + + List bindings() { + return bindings; + } + + /** Narrow immutable composition bridge for the exact compiled catalog. */ + public static List contractsOf( + CompiledMessagingDescriptor descriptor) { + return requireDescriptor(descriptor).contracts; + } + + /** Narrow immutable composition bridge for the exact compiled bindings. */ + public static List 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 contracts, + List 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 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 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 + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistry.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistry.java new file mode 100644 index 00000000..cf646bfe --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistry.java @@ -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 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 SUPPORTED_ROLES = supportedRoles(); + + private final List cards; + private final Map cardsById; + private final ExactSelection selection; + + private MessagingCapabilityCardRegistry(List 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 cards) { + if (cards == null) { + throw new IllegalArgumentException("capability cards must not be null"); + } + return new MessagingCapabilityCardRegistry(new ArrayList<>(cards)); + } + + List 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 index(List cards) { + Set 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 result = new HashMap<>(); + Set 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 cards) { + EnumMap 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 supportedRoles() { + Map 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"); + } + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/CompiledIntegrationEventContract.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/CompiledIntegrationEventContract.java new file mode 100644 index 00000000..a4b9608d --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/CompiledIntegrationEventContract.java @@ -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 exactPayloadRecordType; + private final List canonicalRecordComponentOrder; + private final SchemaResourceId payloadSchemaResource; + private final Sha256 payloadSchemaHash; + private final ContractDescriptor descriptor; + private final PayloadShape payloadShape; + + CompiledIntegrationEventContract( + ContractId contractId, + int payloadVersion, + Class exactPayloadRecordType, + List canonicalRecordComponentOrder, + SchemaResourceId payloadSchemaResource, + Sha256 payloadSchemaHash, + ContractDescriptor descriptor) { + this( + contractId, + payloadVersion, + exactPayloadRecordType, + canonicalRecordComponentOrder, + payloadSchemaResource, + payloadSchemaHash, + descriptor, + ContractCatalogCompiler.compilePayloadShape(exactPayloadRecordType)); + } + + CompiledIntegrationEventContract( + ContractId contractId, + int payloadVersion, + Class exactPayloadRecordType, + List 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 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 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 exactPayloadRecordType() { + return exactPayloadRecordType; + } + + public List 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 components; + private final PayloadShape elementShape; + + PayloadShape( + PayloadKind kind, + Class exactJavaType, + List 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 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; + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java new file mode 100644 index 00000000..66d6ab3c --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java @@ -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 compile( + List> contributions) { + if (contributions == null) { + throw new IllegalArgumentException("contract contributions must not be null"); + } + + Set stableKeys = new HashSet<>(); + Set schemaResources = new HashSet<>(); + Set> exactPayloadTypes = new HashSet<>(); + Map destinationsByContract = new HashMap<>(); + List 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 compileExact( + List> contributions) { + return new ContractCatalogCompiler().compile(contributions); + } + + private static CompiledIntegrationEventContract compileOne( + IntegrationEventContractContribution contribution, + Set stableKeys, + Set schemaResources, + Set> exactPayloadTypes, + Map destinationsByContract) { + ContractId contractId = contribution.contractId(); + int payloadVersion = contribution.payloadVersion(); + Class payloadType = contribution.exactPayloadRecordType(); + List 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 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 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 validateComponentOrder( + Class payloadType, List declared) { + if (declared == null) { + throw new IllegalArgumentException("canonical record component order must not be null"); + } + List 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 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 payloadType) { + validateExactPayloadType(payloadType); + return compileDeclaredType(payloadType, new ArrayDeque<>()); + } + + private static CompiledIntegrationEventContract.PayloadShape compileDeclaredType( + Type declaredType, ArrayDeque> 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 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()); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigest.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigest.java new file mode 100644 index 00000000..57cf07bc --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigest.java @@ -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 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); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/CompiledPublicationBinding.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/CompiledPublicationBinding.java new file mode 100644 index 00000000..43cdeb88 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/CompiledPublicationBinding.java @@ -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 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 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 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(); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java new file mode 100644 index 00000000..9759d0fb --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java @@ -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 compile( + List 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 contractsByKey = indexContracts(contracts); + Map destinations = + indexDestinations(settings.destinations()); + Map 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 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 compileExact( + List 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 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 indexContracts( + List contracts) { + Map 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 + indexDestinations(List destinations) { + Map 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 indexContractBindings( + java.util.Collection destinations, + Map contractsByKey) { + Map result = new HashMap<>(); + Set 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); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingSettings.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingSettings.java new file mode 100644 index 00000000..8fb7ffb5 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingSettings.java @@ -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 destinations; + private final MessagingCapabilityCardRegistry.ExactSelection selectedCards; + private final boolean orderingRequired; + private final boolean schemaValidationRequired; + private final boolean secureTransportRequired; + private final Optional legacyAliases; + + public DestinationBindingSettings( + List destinations, + MessagingCapabilityCardRegistry.ExactSelection selectedCards, + boolean orderingRequired, + boolean schemaValidationRequired, + boolean secureTransportRequired, + Optional 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 copiedDestinations = List.copyOf(destinations); + Set logicalDestinations = new HashSet<>(); + Set 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 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() { + return legacyAliases; + } + + /** Narrow immutable composition bridge for sibling messaging adapter packages. */ + public static List destinationsOf(DestinationBindingSettings settings) { + return requireSettings(settings).destinations; + } + + /** Narrow immutable composition bridge for sibling messaging adapter packages. */ + public static Optional 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 contracts; + private final String physicalTopic; + private final List bootstrapServers; + private final int maximumRecordBytes; + + public DestinationBinding( + LogicalDestinationId logicalDestination, + List contracts, + String physicalTopic, + List 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 contracts() { + return contracts; + } + + public String physicalTopic() { + return physicalTopic; + } + + public List 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 brokerAlias, + Optional topicAlias, + List 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"); + } + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java new file mode 100644 index 00000000..a6c74dbd --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java new file mode 100644 index 00000000..a03502c8 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java @@ -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. + * + *

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 components = PayloadShape.componentsOf(shape); + if (components.size() > limits.maximumObjectProperties()) { + throw new IllegalArgumentException("record exceeds object properties admission limit"); + } + List names = new ArrayList<>(components.size()); + List 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 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 names; + private final List values; + + private SnapshotValue( + SnapshotKind kind, Object scalar, List names, List 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 names() { + return names; + } + + private List 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 values) { + return new SnapshotValue(SnapshotKind.ARRAY, null, List.of(), values); + } + + private static SnapshotValue object(List names, List 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); + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java new file mode 100644 index 00000000..e0fd8343 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeHashV1.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeHashV1.java new file mode 100644 index 00000000..63b3ba7a --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeHashV1.java @@ -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); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoder.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoder.java new file mode 100644 index 00000000..5fa19d05 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoder.java @@ -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 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 contracts, + List 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 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 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 payloadErrors = registry.validate(entry.payloadSchemaId(), payloadBytes); + if (!payloadErrors.isEmpty()) { + throw new IllegalArgumentException( + "payload schema validation failed: " + String.join(",", payloadErrors)); + } + List 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; + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java new file mode 100644 index 00000000..485aa37a --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java @@ -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. + * + *

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 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 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 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 schemasById; + private final Map schemasByResource; + private final Sha256 pinnedDraft202012AuthorityHash; + + public LocalJsonSchemaRegistry( + Map 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 parsedById = new LinkedHashMap<>(); + Map 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 byId = new LinkedHashMap<>(); + Map 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 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 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 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 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 parsedById) { + for (ParsedSource source : parsedById.values()) { + List roots = collectReferences(source.schemaId(), source.root()); + for (ReferenceTarget target : roots) { + validateReferenceChain(target, parsedById, new ArrayDeque<>(), 1); + } + } + } + + private void validateReferenceChain( + ReferenceTarget target, + Map sources, + ArrayDeque 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 collectReferences(String ownerId, JsonNode node) { + List result = new ArrayList<>(); + collectReferences(ownerId, node, result); + return result; + } + + private static void collectReferences( + String ownerId, JsonNode node, List 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 parsedById) { + Map 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 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 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 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 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 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 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 nodesById, Sha256 authorityHash) {} + + private record ReferenceTarget(String schemaId, String fragment) {} +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/authority.sha256 b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/authority.sha256 new file mode 100644 index 00000000..3466e466 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/authority.sha256 @@ -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 diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/applicator b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/applicator new file mode 100644 index 00000000..ca699230 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/applicator @@ -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" } + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/content b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/content new file mode 100644 index 00000000..2f6e056a --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/content @@ -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" } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/core b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/core new file mode 100644 index 00000000..dfc092d9 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/core @@ -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" + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-annotation b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-annotation new file mode 100644 index 00000000..51ef7ea1 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-annotation @@ -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" } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-assertion b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-assertion new file mode 100644 index 00000000..1a4f106c --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-assertion @@ -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" } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/meta-data b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/meta-data new file mode 100644 index 00000000..05cbc22a --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/meta-data @@ -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 + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/unevaluated b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/unevaluated new file mode 100644 index 00000000..5f62a3ff --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/unevaluated @@ -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" } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/validation b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/validation new file mode 100644 index 00000000..606b87ba --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/validation @@ -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": [] + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/schema b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/schema new file mode 100644 index 00000000..d5e2d31c --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/schema @@ -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 + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java new file mode 100644 index 00000000..98014fb8 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java @@ -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; + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistryTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistryTest.java new file mode 100644 index 00000000..8edcd1ef --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistryTest.java @@ -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 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 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 exact, CapabilityCard unsupported, String message) { + List cards = new ArrayList<>(exact); + cards.set(1, unsupported); + assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(cards)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(message); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompilerTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompilerTest.java new file mode 100644 index 00000000..75074c80 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompilerTest.java @@ -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 componentOrder = new ArrayList<>(List.of("eventId", "count")); + Contribution 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 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 first = validContribution("fixture.event.created", 1); + + assertThatThrownBy(() -> COMPILER.compile(null)).isInstanceOf(IllegalArgumentException.class); + List> 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 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 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 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 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 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 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 type, List 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 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

Contribution

contribution( + String contractId, + int version, + Class

type, + List componentOrder, + String schemaResource, + Sha256 schemaHash, + ContractDescriptor descriptor) { + return new Contribution<>( + new ContractId(contractId), + version, + type, + componentOrder, + new SchemaResourceId(schemaResource), + schemaHash, + descriptor); + } + + private record Contribution

( + ContractId contractId, + int payloadVersion, + Class

exactPayloadRecordType, + List canonicalRecordComponentOrder, + SchemaResourceId payloadSchemaResource, + Sha256 payloadSchemaHash, + ContractDescriptor descriptor) + implements IntegrationEventContractContribution

{} + + private static final class StatefulContribution

+ implements IntegrationEventContractContribution

{ + + private final ContractId contractId; + private final int payloadVersion; + private final Class

exactPayloadRecordType; + private final List canonicalRecordComponentOrder; + private final SchemaResourceId payloadSchemaResource; + private final List payloadSchemaHashes; + private final List 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

exactPayloadRecordType, + List canonicalRecordComponentOrder, + SchemaResourceId payloadSchemaResource, + List payloadSchemaHashes, + List 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

exactPayloadRecordType() { + exactPayloadRecordTypeCalls++; + return exactPayloadRecordType; + } + + @Override + public List 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 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> 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 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 value) implements IntegrationPayload {} + + private record GenericNested(T value) {} + + private static final class MutablePayload implements IntegrationPayload {} +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigestTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigestTest.java new file mode 100644 index 00000000..1f541d4d --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigestTest.java @@ -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

DigestContribution

contribution( + String contractId, + int version, + Class

payloadType, + List 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

( + ContractId contractId, + int payloadVersion, + Class

exactPayloadRecordType, + List canonicalRecordComponentOrder, + SchemaResourceId payloadSchemaResource, + Sha256 payloadSchemaHash, + ContractDescriptor descriptor) + implements IntegrationEventContractContribution

{} +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java new file mode 100644 index 00000000..500efc00 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java @@ -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 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 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 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 firstContribution = + contribution( + "fixture.event.created", + BindingPayload.class, + "contracts/messaging/fixture.event.created/v1.schema.json"); + BindingContribution duplicateSchemaContribution = + contribution( + "fixture.other.created", + AlternateBindingPayload.class, + "contracts/messaging/fixture.event.created/v1.schema.json"); + BindingContribution 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 bindings, + MessagingCapabilityCardRegistry.ExactSelection selection, + boolean ordering, + boolean schema, + boolean security, + Optional 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 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 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

BindingContribution

contribution( + String contractId, Class

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

( + ContractId contractId, + int payloadVersion, + Class

exactPayloadRecordType, + List canonicalRecordComponentOrder, + SchemaResourceId payloadSchemaResource, + Sha256 payloadSchemaHash, + ContractDescriptor descriptor) + implements IntegrationEventContractContribution

{} +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1Test.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1Test.java new file mode 100644 index 00000000..b9c9d3d7 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1Test.java @@ -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"); + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdversarialCorpusTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdversarialCorpusTest.java new file mode 100644 index 00000000..e0ac636a --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdversarialCorpusTest.java @@ -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

void assertRejected(P payload, String message) { + Class

exactType = (Class

) payload.getClass(); + assertThatThrownBy(() -> WRITER.write(draft(payload), contract(exactType))) + .isInstanceOf(IllegalArgumentException.class) + .satisfies(exception -> assertThat(exception.getMessage()).containsIgnoringCase(message)); + } + + private static

IntegrationEventDraft

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

CompiledIntegrationEventContract contract( + Class

type) { + List order = + java.util.Arrays.stream(type.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + IntegrationEventContractContribution

contribution = + new IntegrationEventContractContribution<>() { + @Override + public ContractId contractId() { + return new ContractId("test.adversarial"); + } + + @Override + public int payloadVersion() { + return 1; + } + + @Override + public Class

exactPayloadRecordType() { + return type; + } + + @Override + public List 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 value) implements IntegrationPayload {} + + private record DoublePayload(double value) implements IntegrationPayload {} + + private record StringPayload(String value) implements IntegrationPayload {} + + private record IntegerListPayload(List 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 optional, + List 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 { + + @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 { + + @Override + public Integer get(int index) { + return List.of(1, 2).get(index); + } + + @Override + public int size() { + return 1; + } + + @Override + public Iterator iterator() { + return List.of(1, 2).iterator(); + } + } + + private static final class ConcurrentFailureList extends AbstractList { + + @Override + public Integer get(int index) { + return 1; + } + + @Override + public int size() { + return 1; + } + + @Override + public Iterator iterator() { + return new Iterator<>() { + @Override + public boolean hasNext() { + throw new ConcurrentModificationException("fixture"); + } + + @Override + public Integer next() { + return 1; + } + }; + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java new file mode 100644 index 00000000..698406cf --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java @@ -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 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 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 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 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 { + + @Override + public ContractId contractId() { + return new ContractId("test.event"); + } + + @Override + public int payloadVersion() { + return 1; + } + + @Override + public Class exactPayloadRecordType() { + return TestPayload.class; + } + + @Override + public List 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 note, + List tags, + NestedPayload nested) + implements IntegrationPayload {} + + private record OtherPayload(String value) implements IntegrationPayload {} + + private record StatefulContribution(Sha256 payloadSchemaHash) + implements IntegrationEventContractContribution { + + @Override + public ContractId contractId() { + return new ContractId("test.event"); + } + + @Override + public int payloadVersion() { + return 1; + } + + @Override + public Class exactPayloadRecordType() { + return StatefulPayload.class; + } + + @Override + public List 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 tags) implements IntegrationPayload { + + private static final AtomicInteger CALLS = new AtomicInteger(); + + @Override + @SuppressWarnings("UnusedMethod") + public List 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 + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java new file mode 100644 index 00000000..b364e1d7 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java @@ -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 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 first = + registry.validate( + "urn:test:format", + "{\"when\":\"not-a-time\",\"secret\":\"do-not-log\"}".getBytes(StandardCharsets.UTF_8)); + List 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); + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java index 3281fedd..49fc4ae2 100644 --- a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java @@ -77,7 +77,7 @@ class OutboxMessagePublishAdapterTest { class SuccessPath { @Test - void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() { + void legacyVoidSenderNormalReturnCompletesWithoutBrokerConfirmationCharacterization() { FakeBroker broker = new FakeBroker(); OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); @@ -128,7 +128,7 @@ class OutboxMessagePublishAdapterTest { class FailClosedPath { @Test - void publishFailurePropagatesAsRuntimeException() { + void senderExceptionPropagatesAsRuntimeExceptionCharacterization() { FakeBroker broker = new FakeBroker(new IllegalStateException("broker down")); OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker); diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidator.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidator.java new file mode 100644 index 00000000..1a3fc898 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidator.java @@ -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. + * + *

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 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); + } + } +} diff --git a/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidatorTest.java b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidatorTest.java new file mode 100644 index 00000000..2c0967e3 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidatorTest.java @@ -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)); + } +} diff --git a/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.invalid.json b/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.invalid.json new file mode 100644 index 00000000..e127ca96 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.invalid.json @@ -0,0 +1,13 @@ +{ + "name": "", + "count": -1, + "enabled": true, + "amount": 12.50, + "status": "UNKNOWN", + "note": null, + "tags": [], + "nested": { + "code": "N1" + }, + "unknown": "closed-schema" +} diff --git a/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.schema.json b/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.schema.json new file mode 100644 index 00000000..74a3d91a --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.schema.json @@ -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 +} diff --git a/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.valid.json b/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.valid.json new file mode 100644 index 00000000..52c2bce0 --- /dev/null +++ b/src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.valid.json @@ -0,0 +1,15 @@ +{ + "name": "정확한-UTF8", + "count": 7, + "enabled": true, + "amount": 12.50, + "status": "READY", + "note": null, + "tags": [ + "alpha", + "β" + ], + "nested": { + "code": "N1" + } +} diff --git a/src/app-bootstrap/README.md b/src/app-bootstrap/README.md index 2bdc60ff..c69c9149 100644 --- a/src/app-bootstrap/README.md +++ b/src/app-bootstrap/README.md @@ -834,6 +834,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결 BOM 이 관리. - **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated loggers / loggers 쓰기 거부)를 검증한다. +- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity, + wildcard-free compatibility, evidence task/scenario/runbook 선언을 + `MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며 + 모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다. + +### Messaging qualification scaffold + +`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다. +현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer, +CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과 +`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest +validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와 +무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*` +root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed +guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는 +다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread, +network 또는 다른 런타임 resource를 생성하지 않는다. ### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용) ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지 diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 32a90676..36d7b987 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -106,6 +106,8 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' // test-only: jackson-databind for the deserialization-policy boundary test. See README. testImplementation 'org.springframework.boot:spring-boot-starter-json' + // test-only: parses checked-in Messaging capability registries for the fail-closed drift gate. + testImplementation 'org.yaml:snakeyaml' // sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it. sampleFixture project(':sample-portfolio') // test-only: ArchUnit violation fixtures intentionally import forbidden types. See README. diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index b38058c3..772b084f 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -9,6 +9,7 @@ ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,r ch.qos.logback:logback-core:1.5.34=sampleFixture com.approvaltests:approvaltests-util:31.0.0=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.21=sampleFixture 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.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins 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:9.37.4=sampleFixture com.puppycrawl.tools:checkstyle:13.5.0=checkstyle @@ -346,7 +348,7 @@ org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClas org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,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: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: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 diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java index e7f43fb8..43434fe3 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java @@ -8,6 +8,8 @@ import com.tngtech.archunit.lang.EvaluationResult; import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; import dev.caskeleton.bootstrap.architecture.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.FixtureRepository; import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort; @@ -96,6 +98,10 @@ class ArchitectureViolationFixtureTest { private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY = new ClassFileImporter().importClasses(CleanProjectionQueryPort.class); + private static final JavaClasses GENERIC_ADAPTER_LEAK_ONLY = + new ClassFileImporter() + .importClasses(GenericTypeLeakingAdapterFixture.class, RawExternalResponseFixture.class); + // feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation // branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a // silently broken branch cannot pass vacuously via the other one in the shared pool. @@ -445,6 +451,20 @@ class ArchitectureViolationFixtureTest { .isTrue(); } + @Test + void outboundAdapterMethodReturnsOnlyDomainCatchesGenericAdapterLeakInIsolation() { + EvaluationResult result = + CleanArchitectureTest + .MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS + .evaluate(GENERIC_ADAPTER_LEAK_ONLY); + + assertThat(result.hasViolation()) + .as( + "MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS " + + "must recursively catch List (B7)") + .isTrue(); + } + @Test void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() { EvaluationResult result = diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index 6211660a..78dcc3dc 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -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 notReturnAdapterTypesIncludingGenericArguments( + String... forbiddenPackages) { + DescribedPredicate forbidden = + JavaClass.Predicates.resideInAnyPackage(forbiddenPackages); + return new ArchCondition<>( + "not return adapter-local types directly or through generic arguments") { + @Override + public void check(JavaMethod method, ConditionEvents events) { + for (JavaClass involved : method.getReturnType().getAllInvolvedRawTypes()) { + if (forbidden.test(involved)) { + events.add( + SimpleConditionEvent.violated( + method, + "Method " + + method.getFullName() + + " leaks " + + involved.getName() + + " through its return type (directly or as a generic argument)" + + " — B7 permits only narrow static composition bridges for " + + "adapter-local types")); + } + } + } + }; + } + @ArchTest static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE = classes() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java index 1012cedc..7bd26257 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java @@ -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", "dev.caskeleton.adapter.outbound.cache.redis.RedisCapabilityObservationPort")); } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/messaging/GenericTypeLeakingAdapterFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/messaging/GenericTypeLeakingAdapterFixture.java new file mode 100644 index 00000000..a2f406e0 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/messaging/GenericTypeLeakingAdapterFixture.java @@ -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 leakGeneric() { + return List.of(new RawExternalResponseFixture()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java new file mode 100644 index 00000000..90b41f68 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java @@ -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 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 LEGAL_MATURITY = + Set.of("not-implemented", "implemented-candidate", "release-eligible"); + + private static final Set REQUIRED_VERIFICATION_TASKS = + Set.of( + "verifyMessagingContracts", + "verifyMessagingJsonSchemaV1", + "verifyMessagingPollingOutboxR2", + "verifyMessagingKafkaProducerR2", + "verifyMessagingSecurityR2", + "verifyMessagingReleaseProfile", + "verifyMessagingTargetBindingPreflight", + "verifyMessagingTargetBinding", + "verifyMessagingDeploymentCutover", + "verifyMessagingCleanupTargetBinding", + "verifyMessagingFinalR2Profile"); + + private static final Set FORBIDDEN_EXTENSION_TOKENS = + Set.of( + "consumer", + "inbox", + "cdc", + "debezium", + "connect", + "eos", + "exactly-once", + "schema-registry", + "schema_registry", + "avro", + "protobuf"); + + private static final List 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 registry = readYaml(requiredConfig("readiness-cards.yaml")); + List> cards = rows(registry, "cards"); + Set 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> cards = + rows(readYaml(requiredConfig("readiness-cards.yaml")), "cards"); + + for (Map 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 registry = readYaml(requiredConfig("profile-compatibility.yaml")); + List> profiles = rows(registry, "profiles"); + + assertThat(profiles).isNotEmpty(); + assertUnique(profiles, "profileId"); + assertThat(flattenStrings(registry)) + .allSatisfy( + value -> { + assertThat(value).doesNotContain("*"); + assertThat(value).doesNotContain("?"); + }); + + for (Map profile : profiles) { + List 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 registry = readYaml(requiredConfig("release-profile-assertions.yaml")); + List> profiles = rows(registry, "releaseProfiles"); + + assertThat(profiles).isNotEmpty(); + assertUnique(profiles, "releaseProfileId"); + Set compatibilityProfileIds = + stringValues( + rows(readYaml(requiredConfig("profile-compatibility.yaml")), "profiles"), "profileId"); + for (Map profile : profiles) { + List 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 requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks"); + assertListIntegrity( + requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId")); + assertThat(requiredEvidenceTasks) + .containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS); + assertNonEmptyStringList(profile, "requiredScenarios"); + assertNonEmptyStringList(profile, "runbookIds"); + + Map 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 readiness = readYaml(requiredConfig("readiness-cards.yaml")); + Map compatibility = readYaml(requiredConfig("profile-compatibility.yaml")); + Map releases = readYaml(requiredConfig("release-profile-assertions.yaml")); + + List 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 profile : rows(compatibility, "profiles")) { + rowIdentities.addAll(stringList(profile, "selectedCardIds")); + } + for (Map 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 root = + new ObjectMapper().readValue(schema.toFile(), new TypeReference>() {}); + + 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 root = readJsonSchema(); + Map properties = map(root, "properties"); + Map 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 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 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 hashes = map(properties, "hashes"); + assertClosedObject(hashes, Set.of("profile", "catalog", "schema", "settings")); + Map 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 result = map(definitions, "result"); + assertClosedObject(result, Set.of("scenarioId", "reason")); + Map 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 readYaml(Path path) throws Exception { + try (InputStream input = Files.newInputStream(path)) { + return readYaml(input); + } + } + + private static Map readYaml(String yaml) { + return readYaml((Object) yaml); + } + + private static Map readYaml(InputStream yaml) { + return readYaml((Object) yaml); + } + + @SuppressWarnings("unchecked") + private static Map 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) value; + } + + private static Map readJsonSchema() throws Exception { + Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json"); + return new ObjectMapper() + .readValue(schema.toFile(), new TypeReference>() {}); + } + + @SuppressWarnings("unchecked") + private static List> rows(Map root, String key) { + assertThat(root.get(key)).as("top-level '%s' rows", key).isInstanceOf(List.class); + return (List>) root.get(key); + } + + private static Set stringValues(List> rows, String key) { + Set values = new LinkedHashSet<>(); + for (Map row : rows) { + assertThat(row.get(key)).isInstanceOf(String.class); + values.add((String) row.get(key)); + } + return values; + } + + private static void assertUnique(List> rows, String key) { + Set values = stringValues(rows, key); + assertThat(values).hasSameSizeAs(rows); + } + + private static void assertNonEmptyStringList(Map row, String key) { + assertListIntegrity(stringList(row, key), key, row); + } + + private static void assertListIntegrityIfPresent(Map row, String key) { + if (row.containsKey(key)) { + assertListIntegrity(stringList(row, key), key, row); + } + } + + private static void assertListIntegrity(List 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 stringList(Map row, String key) { + assertThat(row.get(key)).as("required list '%s' on %s", key, row).isInstanceOf(List.class); + List values = (List) row.get(key); + assertThat(values).allMatch(String.class::isInstance); + return values.stream().map(String.class::cast).toList(); + } + + @SuppressWarnings("unchecked") + private static Map map(Map row, String key) { + assertThat(row.get(key)).as("required map '%s' on %s", key, row).isInstanceOf(Map.class); + return (Map) row.get(key); + } + + private static void assertClosedObject(Map schema, Set required) { + assertThat(schema).containsEntry("type", "object").containsEntry("additionalProperties", false); + assertThat(stringList(schema, "required")).containsExactlyInAnyOrderElementsOf(required); + } + + private static void assertArrayOfReference( + Map 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 properties, String field, int minimum) { + assertThat(map(properties, field)) + .containsEntry("type", "integer") + .containsEntry("minimum", minimum); + } + + private static List flattenStrings(Object value) { + List strings = new ArrayList<>(); + flattenStrings(value, strings, new HashSet<>()); + return strings; + } + + private static void flattenStrings( + Object value, List destination, Set 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)); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java index f375bc79..934e6d5d 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java @@ -53,7 +53,7 @@ class OutboxAppendTransactionalContractTest { } @Test - void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() { + void sameTransactionAppendRollbackRemovesOutboxRowCharacterization() { Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC); try (AnnotationConfigApplicationContext ctx = diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java index 830918a2..16142e51 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java @@ -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) // diff --git a/src/application-core/CLAUDE.md b/src/application-core/CLAUDE.md index e0b1aca7..402fb1ef 100644 --- a/src/application-core/CLAUDE.md +++ b/src/application-core/CLAUDE.md @@ -21,6 +21,8 @@ Package root: `dev.caskeleton.application`. - Own application transaction boundaries through the `TransactionPort` abstraction. - Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own MDC or other concrete storage. +- Own the framework-free semantic integration-event draft, validated-event value contract and + exact typed payload contribution SPI. This is the messaging semantic contract R1 boundary only. ## Allowed @@ -46,6 +48,8 @@ Package root: `dev.caskeleton.application`. - Persistence-layer transaction annotations of any kind inside this module. - Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express diagnostic intent through a specific outbound `*Port`; adapters own rendering. +- Messaging provider/runtime types: physical topic, Kafka record or metadata, JSON tree/raw JSON + payload, serializer/schema-validator implementation, security topology and publication epoch. ## Contract types @@ -63,6 +67,10 @@ Package root: `dev.caskeleton.application`. | `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. | | `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. | | `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. | +| `messaging.contract.IntegrationEventContractContribution

` | Closed exact-record payload type, canonical component order, local schema identity/hash and provider-neutral descriptor contribution. | +| `messaging.event.IntegrationEventDraft

` | 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 @@ -218,3 +226,6 @@ cd src ./gradlew :application-core:test ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' ``` + +The messaging types above establish semantic contract R1 only. They do not claim JSON Schema +qualification, Kafka publication, durable outbox persistence or any messaging R2 capability. diff --git a/src/application-core/README.md b/src/application-core/README.md index b9570615..eca1bcb8 100644 --- a/src/application-core/README.md +++ b/src/application-core/README.md @@ -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 / CommandUseCase / QueryUseCase diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractDescriptor.java new file mode 100644 index 00000000..cac6e945 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractDescriptor.java @@ -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 + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractId.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractId.java new file mode 100644 index 00000000..3f512128 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractId.java @@ -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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContribution.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContribution.java new file mode 100644 index 00000000..8ab18703 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContribution.java @@ -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

exact feature-owned payload record type + */ +public interface IntegrationEventContractContribution

{ + + ContractId contractId(); + + int payloadVersion(); + + Class

exactPayloadRecordType(); + + List canonicalRecordComponentOrder(); + + SchemaResourceId payloadSchemaResource(); + + Sha256 payloadSchemaHash(); + + ContractDescriptor descriptor(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationPayload.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationPayload.java new file mode 100644 index 00000000..280078dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationPayload.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.messaging.contract; + +/** Marker for a feature-owned, immutable integration-event payload record. */ +public interface IntegrationPayload {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/LogicalDestinationId.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/LogicalDestinationId.java new file mode 100644 index 00000000..0eb50fba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/LogicalDestinationId.java @@ -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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/SchemaResourceId.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/SchemaResourceId.java new file mode 100644 index 00000000..945259ce --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/SchemaResourceId.java @@ -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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/Sha256.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/Sha256.java new file mode 100644 index 00000000..4a614789 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/Sha256.java @@ -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); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateIdentity.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateIdentity.java new file mode 100644 index 00000000..aafb695e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateIdentity.java @@ -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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateOrder.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateOrder.java new file mode 100644 index 00000000..eec44187 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateOrder.java @@ -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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/EventId.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/EventId.java new file mode 100644 index 00000000..d2f38d7c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/EventId.java @@ -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._:-]*"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventDraft.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventDraft.java new file mode 100644 index 00000000..99b6c011 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventDraft.java @@ -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

( + EventId eventId, + ContractId contractId, + int payloadVersion, + LogicalDestinationId destinationId, + AggregateIdentity aggregate, + AggregateOrder order, + Instant occurredAt, + String correlationId, + Optional 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"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventEncoderPort.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventEncoderPort.java new file mode 100644 index 00000000..5dee3255 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventEncoderPort.java @@ -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); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEvent.java new file mode 100644 index 00000000..ea6016e7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEvent.java @@ -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 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"); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContributionTest.java b/src/application-core/src/test/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContributionTest.java new file mode 100644 index 00000000..56775d76 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContributionTest.java @@ -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 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 { + + @Override + public ContractId contractId() { + return new ContractId("test.event.created"); + } + + @Override + public int payloadVersion() { + return 1; + } + + @Override + public Class exactPayloadRecordType() { + return TestPayload.class; + } + + @Override + public List 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(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/messaging/event/IntegrationEventDraftTest.java b/src/application-core/src/test/java/dev/caskeleton/application/messaging/event/IntegrationEventDraftTest.java new file mode 100644 index 00000000..96155e73 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/messaging/event/IntegrationEventDraftTest.java @@ -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 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 draft( + TestPayload payload, Optional 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 {} +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEventTest.java b/src/application-core/src/test/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEventTest.java new file mode 100644 index 00000000..4fca830a --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEventTest.java @@ -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"); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java index abdb1b02..c56dc63e 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java @@ -7,6 +7,7 @@ import dev.caskeleton.application.transaction.TransactionPort; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -61,7 +62,7 @@ class PublishPendingOutboxEventsUseCaseTest { // ---- success path ---- @Test - void successfulPublishTransitionsEventToPublished() { + void legacyVoidPublisherNormalReturnTransitionsEventToPublishedCharacterization() { OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); store.addClaimable(event); @@ -93,7 +94,7 @@ class PublishPendingOutboxEventsUseCaseTest { // ---- transient failure path ---- @Test - void transientFailureTransitionsEventToFailedWithBackoff() { + void senderExceptionTransitionsEventToFailedWithBackoffCharacterization() { OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); store.addClaimable(event); publishPort.failOn("evt-fail", new RuntimeException("broker down")); @@ -122,7 +123,7 @@ class PublishPendingOutboxEventsUseCaseTest { } @Test - void failureOnThirdAttemptTransitionsEventToDead() { + void senderExceptionAtRetryLimitTransitionsEventToDeadCharacterization() { // attemptCount=3 means this is the 3rd attempt — next failure should DEAD OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3); store.addClaimable(event); @@ -202,8 +203,8 @@ class PublishPendingOutboxEventsUseCaseTest { // ---- markPublished failure — spec §엣지·실패·의존 semantics ---- /** - * When {@code store.markPublished} throws after a SUCCESSFUL publish, the exception must - * propagate out of {@code handle()} rather than being caught and misclassified as a publish + * When {@code store.markPublished} throws after a normal void publisher return, the exception + * must propagate out of {@code handle()} rather than being caught and misclassified as a publish * failure (which would trigger FAILED/DEAD state machine and potentially dead-letter a * successfully-delivered event). * @@ -213,19 +214,21 @@ class PublishPendingOutboxEventsUseCaseTest { *

  • The exception propagates — it is NOT swallowed inside {@code publishOne}. *
  • {@code markFailed} is NOT called for the event (no misclassification). *
  • {@code markDead} is NOT called for the event (no misclassification). - *
  • {@code publishPort.publish} was called exactly once. - *
  • The row remains IN_FLIGHT and is recovered via the orphan visibility-timeout reclaim path - * on the next tick — re-published → duplicate absorbed by consumer dedupe (at-least-once). + *
  • {@code publishPort.publish} is not called again before the in-flight timeout. + *
  • The row remains IN_FLIGHT and can be recovered via the orphan visibility-timeout reclaim + * path on a later tick. Because the prior void publisher return is not broker + * acknowledgement evidence, a later publish can be a duplicate. * */ @Test - void markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure() { + void legacyMarkPublishedFailureLeavesInFlightAndDuplicatePossibleCharacterization() { OutboxEvent event = makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1); - ThrowingOnMarkPublishedStorePort throwingStore = - new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished")); - throwingStore.addClaimable(event); + MutableClock mutableClock = new MutableClock(NOW, ZoneOffset.UTC); + TimeoutAwareThrowingOnMarkPublishedStorePort throwingStore = + new TimeoutAwareThrowingOnMarkPublishedStorePort( + event, NOW, new RuntimeException("DB down on markPublished")); PublishPendingOutboxEventsUseCase useCaseWithThrowingStore = new PublishPendingOutboxEventsUseCase( @@ -234,7 +237,7 @@ class PublishPendingOutboxEventsUseCaseTest { reporter, tx, backoffPolicy, - clock, + mutableClock, BATCH_SIZE, IN_FLIGHT_TIMEOUT); @@ -244,17 +247,36 @@ class PublishPendingOutboxEventsUseCaseTest { .isInstanceOf(RuntimeException.class) .hasMessage("DB down on markPublished"); - // publish was called exactly once — the broker call succeeded. + // A normal void return records only that the legacy publisher call completed. assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail"); + assertThat(throwingStore.currentStatus()).isEqualTo(OutboxEventStatus.IN_FLIGHT); + assertThat(throwingStore.attemptCount()).isEqualTo(1); + assertThat(throwingStore.nextAttemptAt()).isEqualTo(NOW.plus(IN_FLIGHT_TIMEOUT)); // No misclassification: the event must NOT be marked FAILED or DEAD. - assertThat(throwingStore.failedEvents) - .as("markFailed must NOT be called when only markPublished fails") - .doesNotContainKey("evt-store-fail"); - assertThat(throwingStore.deadEvents) - .as("markDead must NOT be called when only markPublished fails") - .doesNotContain("evt-store-fail"); + assertThat(throwingStore.markFailedCalls()).isZero(); + assertThat(throwingStore.markDeadCalls()).isZero(); assertThat(reporter.reports).isEmpty(); + + mutableClock.advance(IN_FLIGHT_TIMEOUT.minusNanos(1)); + OutboxRelayResult beforeTimeout = + useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE); + assertThat(beforeTimeout.claimedCount()).isZero(); + assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail"); + assertThat(throwingStore.attemptCount()).isEqualTo(1); + + // Move strictly past the timeout. The orphan becomes eligible and the same event is sent again + // because no PUBLISHED transition was persisted after the first normal void return. + mutableClock.advance(Duration.ofNanos(2)); + assertThatThrownBy( + () -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("DB down on markPublished"); + assertThat(throwingStore.currentStatus()).isEqualTo(OutboxEventStatus.IN_FLIGHT); + assertThat(throwingStore.attemptCount()).isEqualTo(2); + assertThat(publishPort.publishedEvents) + .as("mark failure leaves a duplicate-possible retry window") + .containsExactly("evt-store-fail", "evt-store-fail"); } /** @@ -487,6 +509,95 @@ class PublishPendingOutboxEventsUseCaseTest { } } + static final class TimeoutAwareThrowingOnMarkPublishedStorePort implements OutboxStorePort { + private final OutboxEvent event; + private final RuntimeException markPublishedException; + private OutboxEventStatus currentStatus = OutboxEventStatus.PENDING; + private int attemptCount; + private Instant nextAttemptAt; + private int markFailedCalls; + private int markDeadCalls; + + TimeoutAwareThrowingOnMarkPublishedStorePort( + OutboxEvent event, Instant firstEligibleAt, RuntimeException markPublishedException) { + this.event = event; + this.nextAttemptAt = firstEligibleAt; + this.markPublishedException = markPublishedException; + } + + @Override + public List 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 countByStatus() { + return Map.of(currentStatus, 1L); + } + + @Override + public Map oldestUnpublishedAgeSecondsByEventType(Instant now) { + return Map.of(); + } + + OutboxEventStatus currentStatus() { + return currentStatus; + } + + int attemptCount() { + return attemptCount; + } + + Instant nextAttemptAt() { + return nextAttemptAt; + } + + int markFailedCalls() { + return markFailedCalls; + } + + int markDeadCalls() { + return markDeadCalls; + } + } + static final class ThrowingTransitionStorePort extends FakeOutboxStorePort { private final RuntimeException markFailedException; private final RuntimeException markDeadException; @@ -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. */ static final class ZeroRandom implements RandomGenerator { @Override diff --git a/src/build.gradle b/src/build.gradle index b8f1383e..1f3d883a 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,5 +1,8 @@ import groovy.json.JsonSlurper 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.component.ModuleComponentIdentifier import org.gradle.api.tasks.bundling.AbstractArchiveTask @@ -47,6 +50,115 @@ ext.releaseVersion = releaseVersion ext.sourceRevision = sourceRevision ext.traceableVersion = traceableVersion +// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed. +// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid, +// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass. +Map> 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 messagingFailClosedEvidenceGuard = { String taskName, List relativePaths -> + List evidenceFiles = relativePaths.collect { rootProject.file(it) } + List 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:' + } + if (expectedProfileHash.isBlank()) { + violations << 'missing -PmessagingProfileHash=sha256:' + } + + 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 evidencePaths -> + tasks.register(taskName) { + group = 'verification' + description = "Fail-closed Messaging qualification skeleton for ${taskName}." + inputs.files(evidencePaths.collect { rootProject.file(it) }).optional() + outputs.upToDateWhen { false } + doLast { + messagingFailClosedEvidenceGuard(taskName, evidencePaths) + } + } +} + // Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or // protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the // grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as @@ -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 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 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 resultDirectories -> + List> 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 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 exactRootKeys = [ + 'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds', + 'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips', + 'unsupportedClaims' + ] as Set + Set exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set + Set exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set + List 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 resultDirectories, List 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= -PmessagingArtifactDigest= ' + + '-PmessagingProfileHash= --console=plain', + generatedAt: Instant.now().toString(), + hashes: [ + profile: messagingSha256Bytes(messagingProfileFile.bytes), + catalog: messagingSha256FileSet( + 'ca-skeleton.messaging.evidence.catalog.v1', + [file('config/messaging/readiness-cards.yaml')]), + schema: messagingSha256FileSet( + 'ca-skeleton.messaging.evidence.schema-set.v1', + [ + file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'), + file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json') + ] + fileTree( + 'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12' + ).files.toList()), + settings: messagingSha256FileSet( + 'ca-skeleton.messaging.evidence.settings.v1', + [ + file('adapter/outbound/messaging/build.gradle'), + file('adapter/outbound/messaging/gradle.lockfile') + ]) + ], + failures: [], + skips: [], + unsupportedClaims: [ + 'consumer-compatibility-full-suite', + 'durable-outbox-r2', + 'kafka-acknowledged-r2', + 'regex-engine-timeout', + 'remote-schema-resolution' + ] + ] + validateMessagingEvidenceStructure(manifest, producerTask) + File commonSchema = + file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') + if (!commonSchema.isFile()) { + throw new GradleException('Common Messaging evidence schema is missing.') + } + File output = messagingEvidenceFile.get().asFile + output.parentFile.mkdirs() + output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator() + Map reloaded = new JsonSlurper().parse(output) as Map + validateMessagingEvidenceStructure(reloaded, producerTask) + logger.lifecycle( + "${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.") +} + +def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') { + group = 'verification' + description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.' + dependsOn messagingJsonSchemaQualification + dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph') + outputs.file(messagingEvidenceFile) + outputs.upToDateWhen { false } + doLast { + writeMessagingEvidence( + 'verifyMessagingJsonSchemaV1', + ['json-schema'], + [':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest', + 'verifyMessagingJsonSchemaV1']) + } +} + +def validateMessagingJsonSchemaV1EvidenceManifestSchema = + tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) { + group = 'verification' + description = + 'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.' + dependsOn verifyMessagingJsonSchemaV1 + classpath = + project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath + mainClass = + 'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator' + args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') + .absolutePath, + messagingEvidenceFile.get().asFile.absolutePath + inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')) + inputs.file(messagingEvidenceFile) + outputs.upToDateWhen { false } + } +verifyMessagingJsonSchemaV1.configure { + finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema +} + +def verifyMessagingContracts = tasks.register('verifyMessagingContracts') { + group = 'verification' + description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.' + dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema + dependsOn messagingApplicationQualification + dependsOn messagingSharedQualification + dependsOn messagingSampleQualification + dependsOn messagingCompiledQualification + dependsOn messagingJsonSchemaQualification + dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph') + outputs.file(messagingEvidenceFile) + outputs.upToDateWhen { false } + doLast { + writeMessagingEvidence( + 'verifyMessagingContracts', + ['application', 'shared', 'sample', 'compiled', 'json-schema'], + [ + ':application-core:messagingApplicationContractQualificationTest', + ':shared-contract:messagingSharedSchemaQualificationTest', + ':sample-portfolio:messagingSampleContractQualificationTest', + ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest', + ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest', + 'verifyMessagingContracts' + ]) + } +} + +def validateMessagingContractsEvidenceManifestSchema = + tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) { + group = 'verification' + description = + 'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.' + dependsOn verifyMessagingContracts + classpath = + project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath + mainClass = + 'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator' + args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json') + .absolutePath, + messagingEvidenceFile.get().asFile.absolutePath + inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')) + inputs.file(messagingEvidenceFile) + outputs.upToDateWhen { false } + } +verifyMessagingContracts.configure { + finalizedBy validateMessagingContractsEvidenceManifestSchema +} + // One explicit command regenerates every module's Gradle-default lockfile. tasks.register('resolveAndLockAll') { group = 'build setup' diff --git a/src/config/messaging/evidence/build-evidence-manifest-v1.schema.json b/src/config/messaging/evidence/build-evidence-manifest-v1.schema.json new file mode 100644 index 00000000..cacd5cfe --- /dev/null +++ b/src/config/messaging/evidence/build-evidence-manifest-v1.schema.json @@ -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 + } + } + } + } +} diff --git a/src/config/messaging/profile-compatibility.yaml b/src/config/messaging/profile-compatibility.yaml new file mode 100644 index 00000000..97f5576d --- /dev/null +++ b/src/config/messaging/profile-compatibility.yaml @@ -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 diff --git a/src/config/messaging/readiness-cards.yaml b/src/config/messaging/readiness-cards.yaml new file mode 100644 index 00000000..a4403978 --- /dev/null +++ b/src/config/messaging/readiness-cards.yaml @@ -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 diff --git a/src/config/messaging/release-profile-assertions.yaml b/src/config/messaging/release-profile-assertions.yaml new file mode 100644 index 00000000..830f6182 --- /dev/null +++ b/src/config/messaging/release-profile-assertions.yaml @@ -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 diff --git a/src/sample-portfolio/CLAUDE.md b/src/sample-portfolio/CLAUDE.md index 1a88a57d..c3698c34 100644 --- a/src/sample-portfolio/CLAUDE.md +++ b/src/sample-portfolio/CLAUDE.md @@ -22,6 +22,9 @@ Package root: `dev.caskeleton.sample.portfolio`. `RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`). - Contract-test fixtures the app-bootstrap verification suite analyses (`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests). +- Disposable messaging contract fixture: the exact `WorkLogReservedPayload` record, its static + application-core contract contribution, sample-owned schema/digest and golden vectors. It is not + runtime-discovered, and validator compatibility remains unproven until messaging Task 6. - Sample application collaborators consume invocation context through application-core ports and must not import SLF4J/MDC. @@ -39,6 +42,8 @@ Package root: `dev.caskeleton.sample.portfolio`. - Production-required behaviour living here: deleting this module must not break the production build or runtime (the base `GlobalExceptionHandler`, envelope, and error-code contract live in `adapter:inbound:web` / `shared-contract`). +- Treating a sample schema/contribution as an automatically discovered production registry entry, + or placing JSON mapper, physical topic, Kafka or bootstrap-server concerns in the contribution. ## Test diff --git a/src/sample-portfolio/README.md b/src/sample-portfolio/README.md index 2351fac9..1b8a328f 100644 --- a/src/sample-portfolio/README.md +++ b/src/sample-portfolio/README.md @@ -223,6 +223,23 @@ curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{ 이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가 깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것). +### WorkLogReservedPayload / …ContractContribution — canonical contract fixture + +- `WorkLogReservedPayload`는 새 closed contract SPI에 제공하는 exact final record다. v1은 + `workLogId` 하나만 소유하며, schema와 같은 canonical bounded identifier 규칙을 생성자에서 지킨다. +- `contracts/messaging/portfolio.worklog.reserved/v1.schema.json`과 golden vector는 + **sample fixture contract**다. 공통 envelope는 WorkLog 필드를 알지 못하며 이 모듈을 삭제해도 + production module build/runtime은 깨지지 않아야 한다. +- contribution은 contract ID, exact payload type/component order, classpath resource ID, checked-in + digest와 provider-neutral descriptor만 제공한다. JSON mapper, physical topic, Kafka 설정을 소유하지 + 않으며 매 호출 filesystem I/O도 하지 않는다. +- 기존 3-field `WorkLogReservedIntegrationEvent`/hand-rolled mapper는 현재 legacy outbox + characterization 경로에 남아 있다. 새 1-field canonical payload는 그 타입을 조용히 대체하지 않으며, + closed catalog와 encoder가 조립되기 전까지 양쪽은 서로 호출하거나 직렬화하지 않는다. +- 현재는 classpath scanning이나 runtime discovery가 없다. Task 5의 closed catalog 조립 전에는 + 자동 등록되지 않고, Task 6 validator qualification 전에는 Draft 2020-12 validator compatibility를 + 주장하지 않는다. + ### RepoStatsPort - 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound). diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java new file mode 100644 index 00000000..26704cb4 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java @@ -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 { + + 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 exactPayloadRecordType() { + return WorkLogReservedPayload.class; + } + + @Override + public List 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; + } +} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java new file mode 100644 index 00000000..3f78dec1 --- /dev/null +++ b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java @@ -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"); + } + } +} diff --git a/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json b/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json new file mode 100644 index 00000000..d73485e9 --- /dev/null +++ b/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json @@ -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 +} diff --git a/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 b/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 new file mode 100644 index 00000000..d1e0dcef --- /dev/null +++ b/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 @@ -0,0 +1 @@ +7264efd4e2531e6fd00010bb3deca96af2f1d0408d0021d014feaf90190f2eac diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java new file mode 100644 index 00000000..f6ce3fb2 --- /dev/null +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java @@ -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(); + } +} diff --git a/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json b/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json new file mode 100644 index 00000000..832008d7 --- /dev/null +++ b/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json @@ -0,0 +1 @@ +{"workLogId":"worklog-42","unexpected":"rejected"} diff --git a/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json b/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json new file mode 100644 index 00000000..4de16dd2 --- /dev/null +++ b/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json @@ -0,0 +1 @@ +{"workLogId":"worklog-42"} diff --git a/src/shared-contract/CLAUDE.md b/src/shared-contract/CLAUDE.md index 1f1ddcf2..be1de9a6 100644 --- a/src/shared-contract/CLAUDE.md +++ b/src/shared-contract/CLAUDE.md @@ -25,6 +25,9 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat `tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist, and the span-error-recording seam (feature-distributed-tracing-contract; the OTel tracer runtime is a fork-activated seam, so these stay Java-stdlib-only). +- Generic messaging envelope schema resources under `contracts/messaging/envelope/`. + They own transport-neutral envelope metadata only; feature payload schemas remain in their + feature-owner modules. Resource presence is not runtime discovery or registration. ## Allowed - Java standard library ONLY. No Spring, no Jackson, no JPA imports. @@ -32,6 +35,8 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat ## Forbidden - business/domain concept (domain error codes belong in the consuming module). - framework imports (HTTP status is expressed as transport-neutral `int`; the web module maps it). +- claiming Draft 2020-12 validator compatibility before the messaging adapter's Task 6 validator + qualification exists. ## Test ```bash diff --git a/src/shared-contract/README.md b/src/shared-contract/README.md index 59eb6aae..e4f44b04 100644 --- a/src/shared-contract/README.md +++ b/src/shared-contract/README.md @@ -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 — 에러 코드 계약 ### ApiErrorCode (인터페이스) diff --git a/src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json b/src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json new file mode 100644 index 00000000..04f9007d --- /dev/null +++ b/src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json @@ -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 +} diff --git a/src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.sha256 b/src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.sha256 new file mode 100644 index 00000000..f8946b03 --- /dev/null +++ b/src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.sha256 @@ -0,0 +1 @@ +bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1 diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java new file mode 100644 index 00000000..8379b5b7 --- /dev/null +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java @@ -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; + } +}