From e5af2912696d07433f4d14da86d2d0f316019fa6 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Fri, 31 Jul 2026 23:48:43 +0900 Subject: [PATCH] feat: add messaging R2 polling producer --- ...-28-messaging-first-r2-polling-producer.md | 3203 +++++++++ ...-messaging-production-capability-design.md | 5750 +++++++++++++++++ src/adapter/outbound/messaging/CLAUDE.md | 41 + src/adapter/outbound/messaging/README.md | 99 + src/adapter/outbound/messaging/build.gradle | 56 + .../outbound/messaging/gradle.lockfile | 46 +- .../config/CompiledMessagingDescriptor.java | 204 + .../MessagingCapabilityCardRegistry.java | 235 + .../CompiledIntegrationEventContract.java | 263 + .../contract/ContractCatalogCompiler.java | 274 + .../contract/ContractCatalogDigest.java | 91 + .../CompiledPublicationBinding.java | 105 + .../DestinationBindingCompiler.java | 248 + .../DestinationBindingSettings.java | 238 + .../messaging/destination/PartitionKeyV1.java | 132 + .../envelope/DeterministicEnvelopeWriter.java | 545 ++ .../envelope/EnvelopeAdmissionLimits.java | 56 + .../messaging/envelope/EnvelopeHashV1.java | 36 + .../JsonSchemaIntegrationEventEncoder.java | 232 + .../envelope/LocalJsonSchemaRegistry.java | 701 ++ .../meta/draft-2020-12/authority.sha256 | 9 + .../draft/2020-12/meta/applicator | 48 + .../draft-2020-12/draft/2020-12/meta/content | 17 + .../draft-2020-12/draft/2020-12/meta/core | 51 + .../draft/2020-12/meta/format-annotation | 14 + .../draft/2020-12/meta/format-assertion | 11 + .../draft/2020-12/meta/meta-data | 37 + .../draft/2020-12/meta/unevaluated | 15 + .../draft/2020-12/meta/validation | 98 + .../meta/draft-2020-12/draft/2020-12/schema | 58 + .../messaging/MessagingConfigTest.java | 88 + .../MessagingCapabilityCardRegistryTest.java | 97 + .../contract/ContractCatalogCompilerTest.java | 564 ++ .../contract/ContractCatalogDigestTest.java | 112 + .../DestinationBindingCompilerTest.java | 648 ++ .../destination/PartitionKeyV1Test.java | 104 + .../EnvelopeAdversarialCorpusTest.java | 334 + ...JsonSchemaIntegrationEventEncoderTest.java | 413 ++ .../envelope/LocalJsonSchemaRegistryTest.java | 378 ++ .../OutboxMessagePublishAdapterTest.java | 4 +- ...sagingEvidenceManifestSchemaValidator.java | 68 + ...ngEvidenceManifestSchemaValidatorTest.java | 81 + .../messaging/test.event/v1.invalid.json | 13 + .../messaging/test.event/v1.schema.json | 70 + .../messaging/test.event/v1.valid.json | 15 + src/app-bootstrap/README.md | 17 + src/app-bootstrap/build.gradle | 2 + src/app-bootstrap/gradle.lockfile | 4 +- .../ArchitectureViolationFixtureTest.java | 20 + .../architecture/CleanArchitectureTest.java | 56 + .../GenericTypeLeakingAdapterFixture.java | 14 + ...ssagingCapabilityRegistryContractTest.java | 510 ++ ...OutboxAppendTransactionalContractTest.java | 2 +- .../OutboxRowLifecycleContractTest.java | 37 + src/application-core/CLAUDE.md | 11 + src/application-core/README.md | 28 + .../contract/ContractDescriptor.java | 57 + .../messaging/contract/ContractId.java | 19 + .../IntegrationEventContractContribution.java | 25 + .../contract/IntegrationPayload.java | 4 + .../contract/LogicalDestinationId.java | 17 + .../messaging/contract/SchemaResourceId.java | 15 + .../messaging/contract/Sha256.java | 38 + .../messaging/event/AggregateIdentity.java | 24 + .../messaging/event/AggregateOrder.java | 14 + .../application/messaging/event/EventId.java | 14 + .../event/IntegrationEventDraft.java | 53 + .../event/IntegrationEventEncoderPort.java | 7 + .../event/ValidatedIntegrationEvent.java | 162 + ...egrationEventContractContributionTest.java | 217 + .../event/IntegrationEventDraftTest.java | 119 + .../event/ValidatedIntegrationEventTest.java | 158 + ...PublishPendingOutboxEventsUseCaseTest.java | 180 +- src/build.gradle | 502 ++ .../build-evidence-manifest-v1.schema.json | 170 + .../messaging/profile-compatibility.yaml | 30 + src/config/messaging/readiness-cards.yaml | 381 ++ .../messaging/release-profile-assertions.yaml | 55 + src/gradlew.bat | 186 +- src/sample-portfolio/CLAUDE.md | 5 + src/sample-portfolio/README.md | 17 + .../WorkLogReservedContractContribution.java | 69 + .../event/WorkLogReservedPayload.java | 19 + .../portfolio.worklog.reserved/v1.schema.json | 18 + .../v1.schema.sha256 | 1 + ...rkLogReservedContractContributionTest.java | 181 + .../v1.invalid-unknown-field.json | 1 + .../portfolio.worklog.reserved/v1.valid.json | 1 + src/shared-contract/CLAUDE.md | 5 + src/shared-contract/README.md | 14 + .../messaging/envelope/v1.schema.json | 100 + .../messaging/envelope/v1.schema.sha256 | 1 + .../MessagingEnvelopeSchemaResourceTest.java | 274 + 93 files changed, 19617 insertions(+), 139 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md create mode 100644 docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistry.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/CompiledIntegrationEventContract.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigest.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/CompiledPublicationBinding.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingSettings.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeHashV1.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoder.java create mode 100644 src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/authority.sha256 create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/applicator create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/content create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/core create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-annotation create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/format-assertion create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/meta-data create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/unevaluated create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/meta/validation create mode 100644 src/adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12/draft/2020-12/schema create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingCapabilityCardRegistryTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompilerTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogDigestTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1Test.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdversarialCorpusTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidator.java create mode 100644 src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/qualification/MessagingEvidenceManifestSchemaValidatorTest.java create mode 100644 src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.invalid.json create mode 100644 src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.schema.json create mode 100644 src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.valid.json create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/messaging/GenericTypeLeakingAdapterFixture.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractDescriptor.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/ContractId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContribution.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/IntegrationPayload.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/LogicalDestinationId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/SchemaResourceId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/contract/Sha256.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateIdentity.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/event/AggregateOrder.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/event/EventId.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventDraft.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/event/IntegrationEventEncoderPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEvent.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/messaging/contract/IntegrationEventContractContributionTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/messaging/event/IntegrationEventDraftTest.java create mode 100644 src/application-core/src/test/java/dev/caskeleton/application/messaging/event/ValidatedIntegrationEventTest.java create mode 100644 src/config/messaging/evidence/build-evidence-manifest-v1.schema.json create mode 100644 src/config/messaging/profile-compatibility.yaml create mode 100644 src/config/messaging/readiness-cards.yaml create mode 100644 src/config/messaging/release-profile-assertions.yaml create mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java create mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java create mode 100644 src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json create mode 100644 src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 create mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java create mode 100644 src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json create mode 100644 src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json create mode 100644 src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json create mode 100644 src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.sha256 create mode 100644 src/shared-contract/src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java diff --git a/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md b/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md new file mode 100644 index 00000000..556554d3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md @@ -0,0 +1,3203 @@ +# Messaging First R2 Polling Producer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) +> syntax for tracking. Behavior changes also require `superpowers:test-driven-development`; +> completion claims require `superpowers:verification-before-completion` and an independent +> `superpowers:requesting-code-review`. + +**Goal:** Build one production-reference Messaging path from a typed integration event through a +same-transaction PostgreSQL polling outbox to an acknowledgement-aware Spring Kafka producer, with +an authenticated disposition control and exact R2 evidence. + +**Architecture:** `application-core` owns provider-neutral event/publication/disposition semantics; +`adapter:outbound:messaging` owns deterministic JSON/schema compilation and Kafka; PostgreSQL +persistence owns event/delivery/audit rows and token/lease CAS; inbound web owns only operator HTTP +mapping; bootstrap composes the exact tuple, readiness and schedulers. The first path is polling-only +and keeps consumer, inbox, DLT, replay and CDC disabled. + +**Tech Stack:** Java 21, Spring Boot 4.0.0, Spring Kafka 4.0 through the Boot BOM, Jackson 3, +`com.networknt:json-schema-validator:3.0.2`, PostgreSQL, Flyway, JPA, Gradle, JUnit 5, AssertJ, +Testcontainers Kafka/PostgreSQL, Micrometer. + +--- + +- 작성일: 2026-07-28 +- 상태: 실행 계획 작성·독립 검토 완료, 모든 task 미착수 +- 설계 정본: + [Messaging Production Capability Deep Design](../specs/2026-07-28-messaging-production-capability-design.md) +- 구현 범위: P0–P4의 first R2 polling producer tuple +- 명시적 비범위: inbound Kafka consumer, inbox, retry topic, DLT/replay, Kafka EOS, + Debezium/Kafka Connect CDC, Avro/Protobuf/schema registry, alternate broker, multi-cluster +- 비교한 계획: + [Redis Foundation](2026-07-28-redis-production-capability-foundation.md), + [Redis Runtime](2026-07-28-redis-runtime-cache.md), + [HTTP Client Foundation](2026-07-28-httpclient-production-capability-foundation.md), + [HTTP Client Total Deadline](2026-07-28-httpclient-total-deadline.md), + [Fileserver Foundation](2026-07-28-fileserver-production-capability-foundation.md), + [Fileserver Durable Recovery](2026-07-28-fileserver-durable-recovery.md), + [Notification](2026-07-28-notification-production-capability.md) + +Repository commit policy는 모든 플랫폼에서 `human-only`다. 이 계획에는 `git add`, `git commit`, +`git amend`, `git push` 단계가 없다. 구현자는 작업 결과와 검증 증거만 전달하고 candidate +commit은 사람이 만든다. + +## 1. Exact selected tuple and non-guarantees + +첫 구현과 qualification 대상은 다음 tuple 하나다. + +```text +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 +``` + +이 계획이 완료돼도 다음은 주장하지 않는다. + +- broker와 PostgreSQL 사이 exactly-once; +- consumer effect의 deduplication 또는 inbox 보장; +- global FIFO, failure/rotation/requeue 뒤 strict FIFO; +- single-node Kafka test만으로 multi-broker RF/min ISR 내구성; +- CDC-ready, DLT-ready, replay-ready; +- local plaintext profile을 production security profile로 승격; +- `ACKNOWLEDGED`가 consumer 처리 또는 business effect 완료를 뜻함. + +## 2. Target flow and fixed decisions + +```text +feature mapper + -> IntegrationEventDraft + -> IntegrationEventEncoderPort + -> ValidatedIntegrationEvent(exact UTF-8 bytes + hashes) + -> TransactionPort.inWrite( + business state + + immutable outbox_event + + CURRENT/READY outbox_delivery + ) + +Outbox relay invocation + -> acquire one bounded local admission permit + -> Tx B: one-row JIT claim + token/DB-time lease + ATTEMPT_ADMITTED + -> no DB transaction: Kafka send + future ACK wait + -> Tx C: outcome observation + valid-lease/token CAS state transition + -> release permit + +late Kafka callback + -> bounded payload-free observation source + -> application drain + -> DB commit + -> source ACK + +authenticated internal endpoint + -> inbound DTO/principal mapping + -> ApplyOutboxDispositionUseCase + -> permission/policy + -> PostgreSQL CAS + immutable audit +``` + +고정 결정: + +1. `src/config/architecture/modules.json`이 leaf와 production project edge의 유일한 SSOT다. + first R2 production 구현에는 새 leaf나 project edge가 필요 없다. +2. `sample-portfolio -> adapter-outbound-messaging` edge는 standalone sample을 실제 ACTIVE + producer로 바꾸는 별도 승인 작업 전에는 추가하지 않는다. +3. application/domain/shared Java API에는 Kafka, Jackson, JSON validator, Spring, JPA 타입을 + 노출하지 않는다. +4. physical topic은 application contract가 아니라 outbound destination binding이다. +5. exact UTF-8 `BYTEA`가 wire authority다. retry에서 payload를 다시 직렬화하지 않는다. +6. `outbox_event`는 immutable event, `outbox_delivery`는 mutable delivery control이다. +7. claim/outcome/renew는 opaque token, owner, CURRENT generation, expected version, + `claim_until > database_now`를 모두 확인한다. +8. local admission을 확보한 뒤 한 record만 JIT claim한다. initial profile의 admitted record + upper bound는 1이다. +9. broker call은 DB transaction 밖에서 수행한다. +10. `ACKNOWLEDGED`, `ACKNOWLEDGED_MISMATCH`, `REJECTED`, `INDETERMINATE`는 exhaustive outcome이다. +11. acceptance certainty와 retry disposition은 독립 축이다. +12. deadline 뒤 late ACK는 기존 outcome/state를 뒤집지 않고 append-only observation만 제안한다. +13. operator requeue는 기존 row를 READY로 덮지 않고 이전 authority를 supersede한 뒤 새 + delivery generation을 만든다. +14. requeue generation deadline은 + `min(generation.created_at + maximumAutomaticPublicationAge, + event.created_at + sameEventRequeueHorizon)`이다. +15. P2는 additive schema/control-plane candidate일 뿐이다. `LEGACY_POLLING` authority는 P3의 + fenced cutover까지 유지한다. +16. live non-empty V3 database는 base template migration이 자동 backfill하지 않는다. 별도 + deployment migration design과 승인이 없으면 중단한다. +17. production ACTIVE는 SASL_SSL + SCRAM-SHA-512, external topic attestation, least-privilege + evidence가 없으면 실패한다. +18. disabled state는 contract/destination/client/AdminClient/thread/scheduler/network/secret + refresh가 모두 0이다. + +## 3. Scope boundary and owner leaves + +| 책임 | owner leaf | Gradle path | production edge 변경 | +| --- | --- | --- | --- | +| typed event, outcome, relay, late drain, disposition policy | `application-core` | `:application-core` | 없음 | +| generic envelope schema resource | `shared-contract` | `:shared-contract` | 없음 | +| JSON/schema/catalog/Kafka/provider lifecycle | `adapter-outbound-messaging` | `:adapter:outbound:messaging` | 외부 dependency만 추가 | +| event/delivery/journal/epoch/CAS | `adapter-outbound-persistence-jpa` | `:adapter:outbound:persistence-jpa` | 없음 | +| authenticated operator HTTP mapping | `adapter-inbound-web` | `:adapter:inbound:web` | 없음 | +| tuple composition/readiness/schedulers/real-service lane | `app-bootstrap` | `:app-bootstrap` | test dependency만 추가 | +| sample payload/schema/contribution fixture | `sample-portfolio` | `:sample-portfolio` | messaging edge 없음 | + +금지: + +- controller가 repository, JPA entity 또는 outbound adapter를 직접 사용; +- persistence mapper/query에 retry, disposition 또는 topic 정책을 넣음; +- messaging adapter가 sample, persistence 또는 inbound-web를 의존; +- bootstrap settings/configuration에 business event mapping이나 retry policy를 구현; +- `shared-contract`에 WorkLog schema 또는 provider setting을 넣음; +- 현재 dirty worktree의 Fileserver/Object Storage/Notification 변경을 되돌리거나 덮어씀. + +## 4. Evidence ladder and promotion rule + +| evidence | 허용되는 주장 | +| --- | --- | +| pure/application unit | provider-neutral contract와 state policy가 정의됨 | +| schema/catalog/codec unit/property | local deterministic document와 closed catalog가 정의됨 | +| adapter fake gateway | outcome mapping과 lifecycle protocol이 정의됨 | +| real PostgreSQL | same-store append, constraint, claim/CAS/audit protocol의 local evidence | +| single-node real Kafka | actual ACK metadata와 client/provider behavior evidence | +| TLS/SASL/ACL lane | exact security principal/profile evidence | +| multi-broker RF/min ISR lane | selected topology failure/recovery evidence | +| fault/capacity/rotation/cutover drill | exact tuple의 operational R2 evidence | + +낮은 row를 높은 row, 다른 broker version, cluster, topic, principal 또는 security profile로 +일반화하지 않는다. 모든 selected scenario가 fresh evidence artifact에 PASS일 때만 machine card를 +`release-eligible`로 바꾼다. 그 전에는 최대 `implemented-candidate`다. + +## 5. Execution rules + +1. 모든 checkbox는 구현 시작 시 `[ ]`다. +2. task 시작 전 `git status --short`, 현재 migration 목록, owner leaf의 가장 가까운 + `CLAUDE.md`, `modules.json` edge를 다시 확인한다. +3. behavior task는 RED test 작성 → 같은 focused command에서 예상 원인으로 실패 확인 → 최소 + 구현 → 같은 command GREEN 순서를 지킨다. +4. RED가 처음부터 통과하면 기존 coverage인지 잘못된 test인지 조사하고 assertion을 강화한다. +5. compilation drift, 외부 환경 또는 unrelated dirty change가 RED 원인이면 구현하지 말고 + 원인을 분리한다. +6. 한 shared worktree에서 여러 Gradle process를 동시에 실행하지 않는다. 이전에 같은 output + directory를 병렬 갱신해 compile collision이 발생했으므로 Gradle command는 한 invocation으로 + 묶거나 순차 실행한다. +7. 실제 service가 필요한 release task는 service/credential/image/no-test 문제를 SKIP/PASS로 + 바꾸지 않는다. local ordinary `test`와 release qualification task를 분리한다. +8. migration은 expand-first, forward-only다. 기존 `V3__outbox_event.sql`은 수정하지 않는다. +9. 새 provider와 v2 scheduler는 authority cutover 전까지 dark/disabled다. +10. P2에서 v2 claim/send/authority switch를 활성화하지 않는다. +11. event/payload/schema/hash/credential/raw header는 log, metric tag, evidence artifact에 넣지 + 않는다. +12. 각 Wave exit에서 설계 §0 ledger, card maturity, plan checkbox, LLM Wiki branch-note를 실제 + 증거에 맞춰 갱신한다. +13. plan surface 밖의 파일이나 타입이 필요하면 조용히 확장하지 않고 이 문서를 먼저 갱신한다. +14. 설계와 plan이 충돌하면 구현으로 타협하지 않고 상세 설계를 먼저 수정·재승인한다. + +## 6. Stop conditions + +다음 중 하나라도 확인되면 해당 task 또는 Wave를 중단한다. + +- application/domain에 framework, Kafka, JSON, persistence 타입을 넣어야만 진행 가능; +- module edge가 `modules.json`에 허용되지 않음; +- `V7`이 실행 시점에 이미 다른 migration으로 사용됐거나 다른 승인 계획이 먼저 구현됨. + 모든 Flyway location을 다시 스캔해 다음 global version으로 이 계획과 tests를 먼저 갱신한다; +- V3 legacy row가 live non-empty인데 empty/drained evidence나 별도 live migration 승인이 없음; +- business repository와 outbox append가 같은 transaction resource임을 증명할 수 없음; +- Kafka producer retry/timeout/effective setting을 finite하게 고정할 수 없음; +- adopted JSON Schema validator가 Draft 2020-12, offline registry, format assertion 또는 required + adversarial bound를 만족하지 못함; +- topic/RF/min ISR/ACL을 runtime와 provisioning evidence의 명시된 source로 attest할 수 없음; +- legacy relay와 v2 relay를 동시에 active하게 해야만 rollout 가능; +- active writer/relay/producer를 fence하지 않은 채 authority switch가 필요; +- DB에 INDETERMINATE/HOLD를 기록하지 못한 상태로 producer generation을 강제 전환해야 함; +- operator endpoint가 active unexpired claim을 무시하거나 raw status update를 해야 함; +- real Kafka/security/multi-broker evidence 없이 R2/production-ready 표현이 필요. + +## 7. Batch graph and checkpoints + +```text +Wave A / P0 + truth + machine registry skeleton + -> Wave B / P1 + application contract + schema + catalog + codec + -> Wave C / P2 + additive DB v2 + append + claim/CAS + policy + -> Wave D / P3 + Spring Kafka + endpoint + composition + cutover + -> Wave E / P4 + real-service/security/fault/release evidence +``` + +| Wave | exit claim | rollback posture | +| --- | --- | --- | +| A | current R0 truth와 planned cards가 정확함 | behavior 변화 없음 | +| B | local event contract/codec candidate | Kafka/outbox R2 아님 | +| C | polling v2 schema/control-plane candidate | LEGACY_POLLING 유지, v2 scheduler off | +| D | ACK-aware polling path/cutover candidate | pause admission, preserve DB schema/backlog/epoch | +| E | exact evidence가 통과한 tuple만 release-eligible | destructive schema downgrade 금지 | + +--- + +## Wave A — P0 truth freeze and execution scaffolding + +### Task 1: Freeze current R0 behavior and approved design truth + +**Owner:** documentation + existing application/messaging/persistence/bootstrap tests +**Depends on:** approved detailed design +**Behavior change:** none + +**Files — modify:** + +- `docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java` +- `src/adapter/outbound/messaging/README.md` + +**Files — create:** + +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java` + +- [ ] Capture current branch, `git status --short`, design digest, registry edges, dependency graph, + migrations and current test counts in the LLM Wiki branch-note. +- [ ] Add characterization assertions for: + broker blank → disabled sentinels; broker selected + missing sender → startup failure; broker ID + mismatch → failure; sender normal return → legacy `PUBLISHED`; sender exception → + `FAILED/DEAD`; ACK-to-mark failure → `IN_FLIGHT` and possible duplicate; same-transaction + append rollback; timestamp FIFO limitation. +- [ ] Keep tests explicitly named `legacy` or `characterization`; do not rename current void-return + success to broker ACK. +- [ ] Run the baseline sequentially: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*PublishPendingOutboxEventsUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*MessagingConfigTest' \ + --tests '*OutboxMessagePublishAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxAppendTransactionalContractTest' \ + --tests '*OutboxRowLifecycleContractTest' --console=plain + ``` + +- [ ] Expected GREEN: current behavior is reproducible without source behavior changes. +- [ ] Update §0 to `P0=CHARACTERIZED`, leaving P1–P4 `NOT_STARTED`. +- [ ] Acceptance: no “Kafka ACK”, “dedupe safe” or “R2” claim is introduced. + +**Rollback checkpoint:** characterization tests and truth documentation are independently reversible; +legacy code remains the executable baseline through the P3 cutover window. + +### Task 2: Add fail-closed Messaging card registries and verification task skeleton + +**Owner:** repository configuration + `app-bootstrap` contract tests +**Depends on:** Task 1 + +**Files — create:** + +- `src/config/messaging/readiness-cards.yaml` +- `src/config/messaging/profile-compatibility.yaml` +- `src/config/messaging/release-profile-assertions.yaml` +- `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java` + +**Files — modify:** + +- `src/build.gradle` +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/README.md` + +- [ ] Write a RED contract test that requires exactly the P0–P4 first tuple rows, closed maturity + values `not-implemented|implemented-candidate|release-eligible`, wildcard-free compatibility, + unique IDs, declared evidence tasks/scenarios/runbooks and no consumer/CDC/EOS/schema-registry + rows. +- [ ] Seed all first tuple rows with `maturity: not-implemented` and empty evidence fingerprint; do + not predeclare future extension-ledger names. +- [ ] Define one checked-in, payload-free build-evidence schema with required source/artifact digest, + producer task, scenario IDs/counts, command/timestamp, profile/catalog/schema/settings hashes, + failures, skips and unsupported claims. Every later local manifest validates against this + schema before release aggregation; a producer may add a stricter offline schema but may not + weaken these common fields. +- [ ] Define these task names in `src/build.gradle` without making them pass yet: + + ```text + verifyMessagingContracts + verifyMessagingJsonSchemaV1 + verifyMessagingPollingOutboxR2 + verifyMessagingKafkaProducerR2 + verifyMessagingSecurityR2 + verifyMessagingReleaseProfile + verifyMessagingTargetBindingPreflight + verifyMessagingTargetBinding + verifyMessagingDeploymentCutover + verifyMessagingCleanupTargetBinding + verifyMessagingFinalR2Profile + ``` + + Each task must fail on no matching tests. Release aggregation must reject missing, skipped, + stale, wrong-source or mismatched-profile evidence. +- [ ] Verify RED then GREEN for registry structure only: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingCapabilityRegistryContractTest' --console=plain + ``` + +- [ ] Verify the existing dependency boundary remains unchanged: + + ```bash + cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Acceptance: registry truth exists, every card is `not-implemented`, and no verification task + can falsely claim R2. + +**Rollback checkpoint:** registry/task scaffolding creates no runtime resources and may be removed +without data migration. + +--- + +## Wave B — P1 typed contract, schema, catalog and deterministic bytes + +### Task 3: Add framework-free integration-event contract and contribution SPI + +**Owner:** `application-core` (`:application-core`) +**Depends on:** Task 2 + +**Files — create under +`src/application-core/src/main/java/dev/caskeleton/application/messaging/`:** + +- `contract/IntegrationPayload.java` +- `contract/IntegrationEventContractContribution.java` +- `contract/ContractId.java` +- `contract/LogicalDestinationId.java` +- `contract/SchemaResourceId.java` +- `contract/Sha256.java` +- `contract/ContractDescriptor.java` +- `event/EventId.java` +- `event/AggregateIdentity.java` +- `event/AggregateOrder.java` +- `event/IntegrationEventDraft.java` +- `event/ValidatedIntegrationEvent.java` +- `event/IntegrationEventEncoderPort.java` + +**Files — create under +`src/application-core/src/test/java/dev/caskeleton/application/messaging/`:** + +- `contract/IntegrationEventContractContributionTest.java` +- `event/IntegrationEventDraftTest.java` +- `event/ValidatedIntegrationEventTest.java` + +**Files — modify:** + +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` + +- [ ] Write RED value tests for canonical ASCII event ID grammar, closed contract/destination IDs, + positive versions, nonblank canonical tenant scope, aggregate sequence/index bounds, + immutable/defensively-copied bytes and fixed SHA-256 length. +- [ ] Write RED SPI tests requiring exact final Java record payload type, canonical component order, + schema resource/hash and provider-neutral descriptor. Reject `Map`, raw JSON string/tree, + assignable-type discovery and Java class-name routing. +- [ ] Implement one-public-type-per-file framework-free records/interfaces. The boundary shape is: + + ```java + public interface IntegrationPayload {} + + public interface IntegrationEventContractContribution

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

exactPayloadRecordType(); + List canonicalRecordComponentOrder(); + SchemaResourceId payloadSchemaResource(); + Sha256 payloadSchemaHash(); + ContractDescriptor descriptor(); + } + + public interface IntegrationEventEncoderPort { + ValidatedIntegrationEvent encode(IntegrationEventDraft draft); + } + ``` + +- [ ] Keep physical topic, Kafka record metadata, JSON node, serializer, schema validator and + publication epoch out of these types. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests 'dev.caskeleton.application.messaging.*' --console=plain + ``` + +- [ ] Run application purity: + + ```bash + cd src && ./gradlew verifyApplicationCoreDependencyPurity \ + verifyOneTypePerFile --console=plain + ``` + +- [ ] Acceptance claim: framework-free semantic contract R1 only; no schema/Kafka/persistence R2. + +**Rollback checkpoint:** these are additive contracts; legacy `NewOutboxEvent` remains until the +validated append path is green. + +### Task 4: Check in the generic envelope schema and sample payload contract + +**Owner leaves:** `shared-contract` (`:shared-contract`), `sample-portfolio` +(`:sample-portfolio`) +**Depends on:** Task 3 + +**Files — create:** + +- `src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json` +- `src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.sha256` +- `src/shared-contract/src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java` +- `src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json` +- `src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java` +- `src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json` +- `src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java` + +**Files — modify:** + +- `src/shared-contract/README.md` +- `src/shared-contract/CLAUDE.md` +- `src/sample-portfolio/README.md` +- `src/sample-portfolio/CLAUDE.md` + +- [ ] Write RED resource tests requiring UTF-8, explicit Draft 2020-12 `$schema`, immutable absolute + `$id`, checked-in lowercase SHA-256, `unevaluatedProperties: false`, bounded strings/arrays, + required/null/missing policy and no HTTP/file remote `$ref`. +- [ ] Define envelope v1 with the exact fields frozen by design: + + ```json + { + "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" + } + } + ``` + +- [ ] Keep the envelope business-free and keep the WorkLog payload schema only in sample. +- [ ] Make `WorkLogReservedPayload` a typed immutable record implementing `IntegrationPayload`; + contribution provides type/order/resource/hash only and no JSON mapper. +- [ ] Do not add `sample-portfolio -> adapter-outbound-messaging` to `modules.json` or Gradle. +- [ ] Verify RED then GREEN sequentially: + + ```bash + cd src && ./gradlew :shared-contract:test \ + --tests '*MessagingEnvelopeSchemaResourceTest' --console=plain + cd src && ./gradlew :sample-portfolio:test \ + --tests '*WorkLogReservedContractContributionTest' --console=plain + ``` + +- [ ] Acceptance claim: checked-in generic/sample contract artifacts exist; validator compatibility + is still unproven until Task 6. + +**Rollback checkpoint:** resources and sample contribution are additive; no production runtime +discovers or publishes them yet. + +### Task 5: Compile the closed contract, destination and exact capability binding + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Tasks 3–4 + +**Files — create under +`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/`:** + +- `contract/ContractCatalogCompiler.java` +- `contract/CompiledIntegrationEventContract.java` +- `contract/ContractCatalogDigest.java` +- `destination/DestinationBindingSettings.java` +- `destination/DestinationBindingCompiler.java` +- `destination/CompiledPublicationBinding.java` +- `destination/PartitionKeyV1.java` +- `config/MessagingCapabilityCardRegistry.java` +- `config/CompiledMessagingDescriptor.java` + +**Files — create under +`src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/`:** + +- `contract/ContractCatalogCompilerTest.java` +- `contract/ContractCatalogDigestTest.java` +- `destination/DestinationBindingCompilerTest.java` +- `destination/PartitionKeyV1Test.java` +- `config/MessagingCapabilityCardRegistryTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/README.md` +- `src/adapter/outbound/messaging/CLAUDE.md` + +- [ ] Write RED tests for duplicate contract/destination/schema IDs; missing binding; unknown card; + final-record exact type; component-order mismatch; code/deployment byte-bound intersection; + config attempting to relax ordering/schema/security; legacy + canonical conflict; unsupported + future card rejection. +- [ ] Add golden partition-key vectors using the design's domain-separated, length-prefixed SHA-256 + input. Assert exactly 64 lowercase hex ASCII characters and tenant-enabled/disabled canonical + non-null scope. +- [ ] Compile: + + ```text + contract descriptor + + destination descriptor + + producer/serialization/security/card descriptor + = immutable CompiledPublicationBinding + ``` + + Physical topic and bootstrap servers stay only in the compiled deployment binding. +- [ ] Compute stable catalog/settings/schema digests using sorted IDs and length-prefixed bytes; + never depend on `Map` iteration order or `toString()`. +- [ ] Empty catalog + DISABLED must compile to a zero-resource descriptor. ACTIVE + empty catalog + must fail before any client/thread is created. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*ContractCatalog*Test' \ + --tests '*DestinationBindingCompilerTest' \ + --tests '*PartitionKeyV1Test' \ + --tests '*MessagingCapabilityCardRegistryTest' --console=plain + ``` + +- [ ] Acceptance claim: closed local binding compiler R1; no wire bytes or Kafka client yet. + +**Rollback checkpoint:** compiler is not wired into `MessagingConfig`; legacy selection remains +authoritative. + +### Task 6: Implement the deterministic JSON Schema envelope encoder + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 5 + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoder.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeHashV1.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistryTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/JsonSchemaIntegrationEventEncoderTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdversarialCorpusTest.java` +- `src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.schema.json` +- `src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.valid.json` +- `src/adapter/outbound/messaging/src/test/resources/contracts/messaging/test.event/v1.invalid.json` + +**Files — modify:** + +- `src/adapter/outbound/messaging/build.gradle` +- `src/adapter/outbound/messaging/gradle.lockfile` +- `src/build.gradle` + +- [ ] Write RED tests for Draft 2020-12 meta-schema, checksum mismatch, duplicate `$id`, unknown + dialect/vocabulary, remote/unmapped `$ref`, cycles beyond the supported depth, pathological + regex corpus, format assertion, valid/invalid envelope and payload, required/null/missing, + unknown property and unsupported payload version. +- [ ] Write RED parser/admission tests for duplicate JSON key, malformed UTF-8, unpaired surrogate, + trailing garbage, depth/string/array/object/number bounds, non-finite number, exact UTF-8 + value/key/header bytes and deterministic field/scalar order. +- [ ] Add: + + ```groovy + 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' + } + ``` + + Keep Jackson/schema runtime in the messaging leaf. Regenerate only affected dependency locks + and review the resolved Jackson 3 graph, license and vulnerability report. +- [ ] Configure NetworkNT Draft 2020-12 with format assertions enabled and an exact classpath + resource map. After startup compilation, network/file schema resolution is impossible. +- [ ] Make the writer consume only exact registered final record types. Disable polymorphic typing, + feature-provided serializers, unknown properties and reflective assignable-type search. +- [ ] Compute: + + ```text + SHA-256( + UTF8("ca-skeleton.messaging.envelope.v1") || 0x00 + || u32be(len(exactEnvelopeBytes)) + || exactEnvelopeBytes + ) + ``` + + and return defensive copies in `ValidatedIntegrationEvent`. +- [ ] Add validator compatibility evidence using the adopted JSON Schema Test Suite/Bowtie corpus; + custom contract compatibility still requires repository golden vectors. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*LocalJsonSchemaRegistryTest' \ + --tests '*JsonSchemaIntegrationEventEncoderTest' \ + --tests '*EnvelopeAdversarialCorpusTest' --console=plain + cd src && ./gradlew verifyMessagingJsonSchemaV1 \ + verifyDependencyLocks --console=plain + ``` + +- [ ] On GREEN, `verifyMessagingJsonSchemaV1` validates and writes this exact payload-free candidate + manifest: + + ```text + src/build/messaging-evidence/contracts-schema/manifest.json + ``` + + It conforms to `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` and binds + the human/CI-supplied source/artifact digest, schema/catalog hashes, dependency-lock digest, + exact scenario IDs/counts, command/timestamp, failed=0, skipped=0 and unsupported claims. + Missing digest input fails the manifest-producing lane; an ordinary focused unit test may + still run without claiming release evidence. +- [ ] Update JSON/schema cards to `implemented-candidate` only after the exact tests and locks pass. +- [ ] Acceptance claim: deterministic local wire contract candidate; Kafka and durable outbox R2 are + still unimplemented. + +**Rollback checkpoint:** encoder/catalog stays unwired from production append; removing it does not +change legacy rows. + +### Wave B exit checkpoint + +- [ ] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :shared-contract:check \ + :adapter:outbound:messaging:check \ + :sample-portfolio:check \ + verifyMessagingContracts \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Confirm no production leaf imports `dev.caskeleton.sample`. +- [ ] Update the design ledger to `P1=IMPLEMENTED_CANDIDATE` only if all Wave B evidence is GREEN. +- [ ] Update the LLM Wiki branch-note; record whether a new derived raw document exists or explicitly + record “없음”. + +--- + +## Wave C — P2 immutable event, polling delivery and operator policy + +### Task 7: Add the forward-only PostgreSQL outbox v2 schema + +**Owner:** `adapter:outbound:persistence-jpa` (`:adapter:outbound:persistence-jpa`) +**Depends on:** Wave B +**Activation:** schema/control-plane only; `LEGACY_POLLING` remains ACTIVE + +**Candidate migration file:** + +- `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__messaging_outbox_v2.sql` + +`V7` is the current candidate because the sample Flyway location already contains +`V6__poster.sql`. The Notification plan also uses `V7`/`V8` as candidates; plan text is not a +simultaneous Flyway reservation. Before implementation, scan every runtime Flyway location and all +implemented or actively executing plans. The first implementation claims the next global version; +the later plan must reserve the following version and update every path/test before writing SQL. +Messaging and Notification persistence migrations must not execute concurrently with unresolved +version ownership. + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDeliveryId.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDeliveryEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDeliveryAttemptObservationEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxDispositionAuditEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxPublicationEpochEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxAuthorityCutoverEvidenceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAttemptObservationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDispositionAuditJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPublicationEpochJpaRepository.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2MigrationContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxEventEntity.java` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` + +- [ ] Before editing, classify V3 row count/state/data and assert the base card accepts only a fresh + or verified empty/drained legacy table. Any live non-empty database fails this task pending a + separate deployment-specific migration plan. +- [ ] Write a RED real-PostgreSQL migration test. Expected failure: v2 columns/tables/constraints do + not exist. +- [ ] Keep `V3__outbox_event.sql` byte-for-byte unchanged. Add immutable metadata columns + additively while retaining legacy columns for compatibility. +- [ ] Widen `event_id VARCHAR(64)` to `VARCHAR(96)` in the forward migration; this is compatible + with old writers' shorter grammar. Keep the other V3 NOT NULL columns through the rollback + window and choose one explicit compatibility projection for every canonical insert: + + ```text + event_type = contract_id legacy alias + payload = exact UTF-8 envelope bytes decoded as text + status = PENDING compatibility sentinel + attempt_count = 0 + next_attempt_at = occurred_at + idempotency_key = event_id + ``` + + These columns are not authority after `POLLING_V2`. Epoch predicates prevent every legacy + claim/mutation/reaper from observing canonical rows, and a post-cutover immutable guard + prevents them from drifting. Do not relax NOT NULL/defaults or leave canonical inserts + unspecified. +- [ ] Create: + + ```text + outbox_delivery + outbox_delivery_attempt_observation + outbox_disposition_audit + outbox_publication_epoch + outbox_authority_cutover_evidence + outbox_write_admission + outbox_runtime_node_lease + ``` + + with event/generation primary keys, one-CURRENT partial unique constraint, delivery FK, + authority/state CHECKs, DB timestamps, row version, immutable automatic deadline and an + expiring one-shot cutover evidence identity/digest. A cutover attempt has the closed durable + state machine `CUTOVER_PENDING -> FINALIZING_V2 -> CONSUMED_V2` or + `CUTOVER_PENDING -> RECOVERING_LEGACY -> RECOVERED_LEGACY`; the two branches are mutually + exclusive CAS transitions. `RECOVERING_LEGACY` also stores an opaque recovery operation ID, + owner/lease deadline and recovery evidence digest. Lease expiry permits recovery-only + takeover and never resets the attempt to `CUTOVER_PENDING`. Give every attempt the constant + database authority scope `OUTBOX_PUBLICATION`, ACTIVE legacy epoch ID, frozen fence generation + and target-binding digest. A partial unique constraint permits exactly one nonterminal + (`CUTOVER_PENDING`, `FINALIZING_V2`, `RECOVERING_LEGACY`) attempt in that authority scope. + Attempt creation, finalization and recovery lock the write-admission singleton first and the + ACTIVE epoch second, then validate the exact frozen generation/target binding before touching + the attempt. The write admission singleton starts OPEN at generation 1; runtime node leases + are bounded and bind + node/source/artifact/fence-protocol identity without payload or credentials. +- [ ] Add event ID, partition-key, SHA-256, tenant-scope, order uniqueness and exact `BYTEA` + constraints. Protect immutable event columns with a post-cutover guard that is dormant during + compatibility migration and enabled only by the fenced P3 cutover. +- [ ] Seed exactly one ACTIVE `LEGACY_POLLING` epoch/generation for fresh/empty base template. Do not + activate `POLLING_V2`, create v2 delivery for live legacy rows or claim/send from v2. +- [ ] RED/GREEN cases: + fresh V1–V7; V3-empty upgrade; non-empty preflight rejection; duplicate current generation; + nullable tenant attack; invalid hash/key/event ID; mutable event update after guard; FK/audit + retention; publication epoch uniqueness; duplicate nonterminal authority attempt under + concurrent insert; illegal finalization/recovery state transition; 65–96 character event ID; + old-writer short ID; canonical compatibility projection satisfying every retained V3 NOT NULL + constraint. +- [ ] Verify: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxV2MigrationContractTest' --console=plain + ``` + +- [ ] Acceptance claim: additive polling v2 schema candidate only; legacy relay authority unchanged. + +**Rollback checkpoint:** rollback disables new code and keeps additive schema/backlog. Never +destructively downgrade the database. + +### Task 8: Append validated event and initial delivery in the business transaction + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa`, `app-bootstrap` test +fixture +**Depends on:** Task 7 + +**Files — create:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAppendAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAppendAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2AppendTransactionalContractTest.java` + +**Files — modify:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAppendPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java` +- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java` +- `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java` + +- [ ] Write RED application tests making `OutboxAppendPort.append(ValidatedIntegrationEvent)` the + only canonical method. Move raw `NewOutboxEvent` append to a separately named/deprecated + `LegacyOutboxAppendPort`; never overload or silently reinterpret raw payload as v1. +- [ ] Split the current combined store/append implementation: `OutboxStoreAdapter` remains only the + legacy relay store during the observation window, while a separately named + `LegacyOutboxAppendAdapter` implements only `LegacyOutboxAppendPort`. It has no component + annotation and bootstrap may compose it only for the explicit sample/R0 compatibility graph. +- [ ] Write RED real-PostgreSQL tests proving business state + event + current delivery commit or + rollback together; encoder/schema failure rolls back business state; exact `BYTEA` and hash + round-trip. Cross-resource ACTIVE startup rejection belongs to Task 19 after both leaf + descriptors exist. +- [ ] In the persistence adapter, lock/read the ACTIVE publication epoch inside the caller-owned + transaction, attach DB-authoritative `created_at`, `publication_epoch`, + `dispatch_authority`, `transaction_resource_id`, and insert initial delivery only when + authority is `POLLING_V2`. +- [ ] For an initial current delivery, compute and persist in that same DB transaction: + + ```text + automaticAttemptDeadline = + min(eventDbCreatedAt + maximumAutomaticPublicationAge, + eventDbCreatedAt + contract.sameEventRequeueHorizon) + ``` + + The value is immutable and profile reload never moves an existing generation's deadline. +- [ ] During compatibility `LEGACY_POLLING`, write both legacy required columns and validated v2 + metadata in the same transaction but do not create/send a v2 current delivery. +- [ ] Make the legacy claim read model distinguish true v0 rows from rows carrying canonical v1 + metadata without exposing a physical topic in application. For a canonical row, + `OutboxMessagePublishAdapter` resolves the stored logical destination through the closed + compiled binding and sends the stored partition-key bytes plus immutable `envelope_bytes` + byte-for-byte. It must not invoke `OutboxEnvelopeJson` or reinterpret the retained V3 + `payload` projection. Only a true v0 row may use the old wrapper/event-type route. +- [ ] Add golden cases for canonical append under `LEGACY_POLLING` → legacy claim → exact compiled + destination/key/envelope bytes → legacy `PUBLISHED`. This remains + `LEGACY_RECORDED_UNVERIFIED` at cutover and is never automatically resent by v2. Test v0 and + canonical branches independently; mixed/missing metadata fails closed. +- [ ] Add a post-cutover canonical append fixture proving the retained V3 NOT NULL compatibility + projection, immutable event + CURRENT/READY delivery and deadline all commit together while + the epoch-fenced legacy claim/reaper sees the row count as 0. +- [ ] Use an app-bootstrap test-source typed contribution/draft to prove the canonical append path. + Do not inject the messaging encoder into `sample-portfolio` or add a project edge in this + task. Change the sample use case dependency explicitly to `LegacyOutboxAppendPort`; the + existing sample mapper remains a visibly R0, non-active compatibility fixture until the + separate standalone-sample activation plan. +- [ ] Remove component auto-discovery from the legacy append/store adapter. Bootstrap may compose it + only for an exact `LEGACY_POLLING` compatibility graph; canonical `POLLING_V2` must have + `LegacyOutboxAppendPort` bean count 0. Update every exact existing legacy/sample fixture listed + above in the same task so changing `OutboxAppendPort` cannot leave compile-only hidden users. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*Outbox*' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxAppendAdapterTest' \ + --tests '*LegacyOutboxAppendAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxV2AppendTransactionalContractTest' --console=plain + cd src && ./gradlew :sample-portfolio:test \ + --tests '*CreateWorkLogOutboxTest' --console=plain + ``` + +- [ ] Acceptance claim: validated same-transaction append candidate; v2 relay remains disabled. + +**Rollback checkpoint:** keep compatibility writes while rolling back the new relay. Do not generate +a second event ID or dual-write outside the transaction. + +### Task 9: Define exhaustive publication outcomes and one-record relay policy + +**Owner:** `application-core` (`:application-core`) +**Depends on:** Task 8 + +**Files — create under +`src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/`:** + +- `PublicationOutcome.java` +- `PublicationReceipt.java` +- `PublicationFailure.java` +- `AcceptanceCertainty.java` +- `RetryDisposition.java` +- `PublicationFailureStage.java` +- `PublicationFailureClass.java` +- `PublicationAttemptId.java` +- `PublicationAdmission.java` +- `PublicationAdmissionPort.java` +- `AcknowledgedPublicationPort.java` + +**Files — create under +`src/application-core/src/main/java/dev/caskeleton/application/outbox/`:** + +- `OutboxDelivery.java` +- `OutboxDeliveryState.java` +- `DeliveryAuthorityStatus.java` +- `ClaimToken.java` +- `ClaimedOutboxDelivery.java` +- `OutboxDeliveryStorePort.java` +- `PublishNextOutboxDeliveryCommand.java` +- `PublishNextOutboxDeliveryResult.java` +- `PublishNextOutboxDeliveryUseCase.java` + +**Files — create under +`src/application-core/src/test/java/dev/caskeleton/application/`:** + +- `messaging/publication/PublicationOutcomeTest.java` +- `outbox/PublishNextOutboxDeliveryUseCaseTest.java` +- `outbox/OutboxDeliveryStateTest.java` + +**Files — modify or retain as legacy until Task 26:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxBackoffPolicy.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java` + +- [ ] Write RED tests for the sealed outcome shape: + + ```java + public sealed interface PublicationOutcome { + record Acknowledged(PublicationReceipt receipt) implements PublicationOutcome {} + record AcknowledgedMismatch(PublicationReceipt receipt) implements PublicationOutcome {} + record Rejected(PublicationFailure failure) implements PublicationOutcome {} + record Indeterminate(PublicationFailure failure) implements PublicationOutcome {} + } + ``` + + Receipt/failure contains bounded provider-neutral values only; no Kafka SDK type or raw + exception/message. +- [ ] Test certainty and retry as independent axes. Ambiguous/post-admission/timeout/unknown maps to + `INDETERMINATE`; `REJECTED` requires definite non-acceptance. +- [ ] Write relay RED tests for this exact sequence: + + ```text + acquire bounded admission + -> Tx B claim exactly one row + ATTEMPT_ADMITTED + -> publish outside DB transaction + -> Tx C outcome observation + token/valid-lease CAS transition + -> release admission + ``` + +- [ ] Cover: + no admission → claim 0; no eligible row → release permit; ACK → `DELIVERY_RECORDED`; mismatch + → `HOLD`; definite transient rejection → `RETRY_WAIT`; permanent/budget exhaustion → + `EXHAUSTED`; indeterminate → duplicate-aware retry or HOLD according to remaining finite + budget; transition failure propagates; diagnostic reporter failure cannot change persisted + state. +- [ ] Enforce one command invocation/one record. A scheduler may invoke it again; the use case must + not loop and open per-row `REQUIRES_NEW` transactions. +- [ ] Replace attempt-only backoff with a descriptor that includes maximum attempts, immutable + automatic deadline, bounded delay/jitter and same-event horizon. Do not start age at + `first_attempt_at`. +- [ ] Keep legacy void port/use case explicitly deprecated and separately wired until Task 26; + canonical code must not adapt exception-only success into `Acknowledged`. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*PublicationOutcomeTest' \ + --tests '*PublishNextOutboxDeliveryUseCaseTest' \ + --tests '*OutboxDeliveryStateTest' --console=plain + ``` + +- [ ] Acceptance claim: application polling/outcome policy candidate; provider and DB CAS remain + adapter work. + +**Rollback checkpoint:** canonical use case remains unwired. Legacy relay continues to serve +`LEGACY_POLLING`. + +### Task 10: Implement per-record JIT claim, valid-lease CAS and attempt journal + +**Owner:** `adapter:outbound:persistence-jpa` (`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 9 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxDeliveryClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDeliveryStoreAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxDeliveryClaimRepositoryTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2ClaimCasContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2MultiWorkerContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java` + +- [ ] Write unit RED tests that the adapter maps application values without adding retry/topic + policy and requires affected-row count exactly 1 for every CAS. +- [ ] Write real-PostgreSQL RED tests for: + two workers claim disjoint rows; same aggregate total order uses sequence/index rather than + timestamp; different aggregates progress; hot aggregate does not starve all others; expired + claim reclaim; same-token renew; stale token/owner/generation/version rejection; expired but + not yet reclaimed owner cannot record ACK/failure. +- [ ] The worker-owned mutation predicate must include: + + ```sql + WHERE event_id = :event_id + AND delivery_generation = :generation + AND authority_status = 'CURRENT' + AND state = 'CLAIMED' + AND claim_token = :token + AND claim_owner = :owner + AND claim_until > CURRENT_TIMESTAMP + AND row_version = :expected_row_version + ``` + +- [ ] Claim eligibility is CURRENT `READY`, due `RETRY_WAIT` or expired `CLAIMED`, subject to + ordering-head eligibility. `EXHAUSTED`, `HOLD`, `LEGACY_RECORDED_UNVERIFIED` never release the + next ordered event. +- [ ] Tx B atomically updates claim count/token/owner/DB-time lease/publication attempt count and + inserts `ATTEMPT_ADMITTED`. Raw claim token is never copied; journal stores a domain-separated + digest. +- [ ] Before `ATTEMPT_ADMITTED`, use DB time to verify both the automatic deadline and a full + application-attempt/Tx-C safety window remain. If `database_now >= deadline` or the full + window does not fit, perform a fenced `EXHAUSTED` transition without admission/send. +- [ ] When reclaiming an expired `CLAIMED` row whose previous `publicationAttemptId` has + `ATTEMPT_ADMITTED` but no outcome, append exactly one idempotent + `OUTCOME_OBSERVED(INDETERMINATE)` for that old attempt before replacing the token and admitting + the new attempt. Never fabricate a definite rejection or erase the prior attempt. +- [ ] Tx C atomically inserts `OUTCOME_OBSERVED` and performs ACK/retry/exhaust/hold CAS. Provider + metadata is a bounded opaque reference. +- [ ] Use DB time for eligibility, lease, created-at and retry due. Before send admission, ensure + remaining lease exceeds the full attempt + DB transition + safety budget. +- [ ] Verify RED then GREEN sequentially: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxDeliveryStoreAdapterTest' \ + --tests '*PostgreSqlOutboxDeliveryClaimRepositoryTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxV2ClaimCasContractTest' \ + --tests '*OutboxV2MultiWorkerContractTest' --console=plain + ``` + +- [ ] Acceptance claim: real-PostgreSQL JIT claim/CAS protocol candidate; no Kafka send or authority + cutover. + +**Rollback checkpoint:** leave v2 scheduler off and `LEGACY_POLLING` active. Claimed test rows are +disposable; production rollback preserves all event/delivery rows. + +### Task 11: Persist late publication observations with DB-commit-before-source-ACK + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa` +**Depends on:** Task 10 + +**Files — create in `application-core`:** + +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/ObservationId.java` +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/LatePublicationObservation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/LatePublicationObservationSourcePort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAttemptObservationPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RecordLatePublicationObservationsUseCaseTest.java` + +**Files — create in `adapter:outbound:persistence-jpa`:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAttemptObservationAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAttemptObservationAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLateObservationContractTest.java` + +- [ ] Write application RED tests for: + + ```text + poll/lease bounded batch + -> tx.inNew(idempotent DB append) returns after commit + -> acknowledgePersisted + ``` + + DB append/commit failure calls `releaseForRetry`; source ACK never runs in a transaction + callback. +- [ ] Use observation identity + `(eventId, deliveryGeneration, publicationAttemptId, LATE_ACK_OBSERVED)` and a DB unique + constraint/`ON CONFLICT DO NOTHING`. +- [ ] Test DB commit → process crash before source ACK by redelivering the same observation; exactly + one journal fact remains. +- [ ] Test late observation never changes `DELIVERY_RECORDED`, `RETRY_WAIT`, `EXHAUSTED`, `HOLD` or + current generation. It is diagnostic, not correctness authority. +- [ ] Test empty poll, bounded maximum, poison item release, source ACK failure and commit failure. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*RecordLatePublicationObservationsUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxAttemptObservationAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxLateObservationContractTest' --console=plain + ``` + +- [ ] Acceptance claim: durable idempotent late-observation drain boundary; callback capture is + still bounded-loss and no messaging queue exists until Task 16. + +**Rollback checkpoint:** disabling the drain loses only bounded diagnostics, never changes delivery +authority. Alert/readiness must expose the degradation. + +### Task 12: Implement audited application disposition policy and atomic persistence transitions + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa` +**Depends on:** Tasks 10–11 + +**Files — create in `application-core`:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxDisposition.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxDispositionResultCodec.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxDispositionPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/ApplyOutboxDispositionUseCaseTest.java` + +**Files — create in `adapter:outbound:persistence-jpa`:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDispositionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxDispositionAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxDispositionTransactionalContractTest.java` + +- [ ] Write application RED tests with `@RequiresPermission("outbox:disposition")` and + `@UseCaseCapability(idempotency = Idempotency.KEYED, ...)`. + `SKIP_WITH_GAP` and `COMPENSATE` additionally call `AuthorizationPort` for + `outbox:disposition:destructive`. +- [ ] Command requires: + + ```text + eventId + expectedDeliveryGeneration + expectedRowVersion + disposition + bounded reason + incident/change reference + IdempotencyContext(scope + request fingerprint + bounded TTL) + operator principal + destructive approval reference when required + compensation event reference for COMPENSATE + ``` + +- [ ] Inject the existing application-owned `IdempotencyExecutor` into + `ApplyOutboxDispositionUseCase`. Execute authorization/policy/CAS exactly once under the + command's `IdempotencyContext`, using a framework-free deterministic + `OutboxDispositionResultCodec`. Replay returns the stored application result; same key with a + different request fingerprint raises the existing mismatch exception. Controller and + persistence adapter must not implement their own idempotency state machine. +- [ ] Application tests cover first execution, completed replay, in-flight conflict, request + mismatch, action failure/discard and bounded TTL. Persistence integration reuses the existing + `IdempotencyStorePort` adapter to prove atomic claim/complete; `outbox_disposition_audit` + remains the immutable business/operation audit rather than a second idempotency registry. +- [ ] Validate allowed source state, ordering impact, active-unexpired-claim absence and finite + same-event requeue horizon in application policy for early feedback. This precheck is not the + concurrency fence. +- [ ] In the persistence transaction, lock the CURRENT delivery row and atomically re-evaluate + expected generation/state/row version plus `NOT (state='CLAIMED' AND + claim_until > database_now)` before audit/mutation. Add a race test that inserts a worker + claim after application precheck but before the locked mutation; operator CAS must fail + without partial audit/handoff. +- [ ] Write real-PostgreSQL RED/GREEN for: + stale generation/version; live claim; idempotency replay/mismatch; concurrent requeue; one + CURRENT constraint; partial handoff rollback; old generation never claimable again; immutable + audit. +- [ ] REQUEUE transaction locks current row, inserts audit, marks old authority `SUPERSEDED`, sets + `superseded_by_generation`, and inserts generation + 1 `CURRENT/READY` with: + + ```text + automaticAttemptDeadline = + min(newDeliveryDbCreatedAt + maximumAutomaticPublicationAge, + eventDbCreatedAt + sameEventRequeueHorizon) + ``` + +- [ ] HOLD/SKIP/COMPENSATE/legacy accept use expected generation/state/row version and do not mimic + the worker token predicate. `COMPENSATED` requires an already-created immutable compensating + event reference in the same transaction. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*ApplyOutboxDispositionUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxDispositionAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxDispositionTransactionalContractTest' --console=plain + ``` + +- [ ] Acceptance claim: provider-neutral authenticated disposition policy and DB protocol candidate; + no HTTP surface yet. + +**Rollback checkpoint:** operator surface is not exposed. Data/audit rows are forward-only and must +not be rewritten by raw SQL. + +### Task 13: Prove P2 compatibility fence and no-dual-authority state + +**Owner:** persistence/bootstrap integration +**Depends on:** Tasks 7–12 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublicationEpochContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyV2CompatibilityContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyCanonicalWireCompatibilityContractTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxV2RetentionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxV2RetentionAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2RetentionContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java` + +- [ ] Write the epoch, canonical-wire compatibility and retention tests first, then run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxPublicationEpochContractTest' \ + --tests '*OutboxLegacyV2CompatibilityContractTest' \ + --tests '*OutboxLegacyCanonicalWireCompatibilityContractTest' \ + --tests '*OutboxV2RetentionContractTest' --console=plain + ``` + + Expected non-zero: the legacy claim/reaper lacks an epoch fence and v2 retention protocol is + absent. A compile failure unrelated to those missing contracts is not an accepted RED. +- [ ] RED test that the compatibility legacy writer fills v2 immutable metadata in the same + transaction while the legacy relay can claim only the exact ACTIVE `LEGACY_POLLING` + epoch/generation. +- [ ] Add a legacy mutation/claim fence predicate tied to the ACTIVE publication epoch. Old + pre-fence binaries are explicitly incompatible and must be drained to zero before P3. +- [ ] Prove `POLLING_V2` claim is rejected while `LEGACY_POLLING` is active and legacy claim is + rejected after the epoch changes. +- [ ] Prove no row can be claimed/sent by both paths; publication epoch lock and expected generation + are mandatory. +- [ ] Prove the compatibility publisher sends a canonical metadata row exactly once through the + legacy authority using the compiled destination, stored key and byte-identical v1 envelope; + v0 rows still use the old wrapper. Nested envelope, event-type-as-topic for canonical rows, + mixed metadata, and automatic resend of legacy `PUBLISHED` after cutover all fail. +- [ ] Bind the legacy reaper to the exact ACTIVE `LEGACY_POLLING` epoch and stop/drain it before + cutover. Implement v2 retention separately: delete only when the unique CURRENT generation is + resolved as `DELIVERY_RECORDED` or audited `SKIPPED/COMPENSATED/ + LEGACY_ACCEPTED_UNVERIFIED`, with no claim/requeue, unresolved observation, legal/operator + hold, audit-retention or replay-horizon obligation. +- [ ] Real PostgreSQL retention cases cover reaper-vs-claim/requeue, delivery/audit FK and + no-silent-cascade, superseded generations, `EXHAUSTED`, `HOLD`, + `LEGACY_RECORDED_UNVERIFIED`, unresolved late observation and epoch mismatch. +- [ ] Do not perform legacy row reconciliation, v2 delivery creation or authority switch in this + task. +- [ ] Re-run the same focused command GREEN; all three exact tests must pass with no skip. Then run + the candidate gate: + + ```bash + cd src && ./gradlew verifyMessagingPollingOutboxR2 --console=plain + ``` + + At P2, `verifyMessagingPollingOutboxR2` may report `implemented-candidate`; it must not emit a + release-eligible claim. On GREEN it validates and writes the exact candidate manifest: + + ```text + src/app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json + ``` + + The manifest conforms to + `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` and binds the supplied + source/artifact digest, migration/schema/card/profile hashes, exact PostgreSQL scenario + IDs/counts, commands/timestamps, failed=0, skipped=0 and unsupported Kafka/security claims. +- [ ] Update P2 card rows to `implemented-candidate` only if real PostgreSQL cases pass. + +**Rollback checkpoint:** keep `LEGACY_POLLING` ACTIVE and canonical v2 scheduler off. If the fence +cannot be deployed to all nodes, do not proceed to Wave D. + +### Wave C exit checkpoint + +- [ ] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + :app-bootstrap:test \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Confirm broker/client/network resources are still 0 and v2 authority has not switched. +- [ ] Update the design ledger to `P2=IMPLEMENTED_CANDIDATE` only from actual tests. +- [ ] Update the LLM Wiki branch-note and derived-document decision. + +--- + +## Wave D — P3 ACK-aware Spring Kafka, operator endpoint and reference-path cutover + +### Task 14: Bind and compile finite canonical Messaging settings + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Wave C + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingExpectedState.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingR2Settings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingSettingsCompiler.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/CompiledKafkaProducerSettings.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingSettingsCompilerTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/config/MessagingDisabledResourceContractTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerSettingsTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java` + +- [ ] Write RED binding/compiler tests for exact `DISABLED|ACTIVE` expected state and the canonical + fields in design §21.1. Raw `Map` Kafka overrides are forbidden. +- [ ] Freeze the first effective profile: + + ```text + acks=all + enable.idempotence=true + retries=MAX/effectively-unbounded under delivery.timeout.ms + max.in.flight.requests.per.connection<=5 + compression.type=none + partitioner.ignore.keys=false + finite request/delivery/max.block/linger/batch/buffer/request bounds + maximumAdmittedRecords=1 + ``` + +- [ ] Validate: + + ```text + deliveryTimeout >= requestTimeout + linger + applicationAttemptBudget >= + admissionWait + maxBlock + deliveryTimeout + callback/transitionReserve + claimLease > + applicationAttemptBudget + dbTransitionReserve + schedulingSafetyMargin + ``` + +- [ ] Reject ACTIVE + unknown/missing card/provider/bootstrap/destination/security; plaintext in + production; literal credentials; contract bytes over any bound; ordering + null key; + transaction resource mismatch; legacy + canonical keys; active durable contract + disabled + dispatch. +- [ ] DISABLED must instantiate no schema compiler with active contracts, producer factory, + template, AdminClient, semaphore, observation queue, scheduler, secret refresh or network + connection. +- [ ] Legacy keys are parsed only into an R0 descriptor and conflict with canonical keys. Do not map + `broker=kafka` to `kafka-spring` or `relay-enabled=true` to polling v2. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*MessagingSettingsCompilerTest' \ + --tests '*MessagingDisabledResourceContractTest' \ + --tests '*KafkaProducerSettingsTest' --console=plain + ``` + +- [ ] Acceptance claim: finite static descriptor candidate; no Kafka client created yet. + +**Rollback checkpoint:** canonical activation remains disabled; legacy settings continue only in R0 +mode. + +### Task 15: Add explicit Spring Kafka producer factory and ACK-aware gateway + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 14 + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerFactoryConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaPublishGateway.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGateway.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaPublicationFailureClassifier.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/publication/AckAwareOutboxPublicationAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerFactoryConfigTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGatewayTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaPublicationFailureClassifierTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/publication/AckAwareOutboxPublicationAdapterTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/build.gradle` +- `src/adapter/outbound/messaging/gradle.lockfile` + +- [ ] Add `implementation 'org.springframework.kafka:spring-kafka'`; accept the Spring Boot 4.0.0 BOM + version unless a separately reviewed compatibility override is necessary. Regenerate and + review the messaging lockfile. +- [ ] RED test exact producer properties and `DefaultKafkaProducerFactory` / + `KafkaTemplate`. Use byte serializers; no JSON serialization in Kafka + callbacks. +- [ ] RED gateway tests for: + + ```text + future success + metadata + expected topic -> ACKNOWLEDGED + future success + metadata + wrong topic -> ACKNOWLEDGED_MISMATCH + definite pre-admission/local rejection -> REJECTED + ambiguous/post-admission/deadline/unknown -> INDETERMINATE + ``` + +- [ ] Build `ProducerRecord` only from compiled topic, stored partition-key bytes, + exact envelope bytes and bounded allowlisted headers. +- [ ] Await the future to a monotonic application deadline and verify non-null metadata. Do not call + per-message `flush()`. `cancel()` is not delivery cancellation evidence. +- [ ] Map Kafka exception categories to stable stage/class/certainty/disposition without exposing + class names or messages. Ambiguity defaults to INDETERMINATE. +- [ ] The adapter returns bounded provider generation, local ACK observation time and safe opaque + record reference. Application never routes from it. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*KafkaProducerFactoryConfigTest' \ + --tests '*SpringKafkaPublishGatewayTest' \ + --tests '*KafkaPublicationFailureClassifierTest' \ + --tests '*AckAwareOutboxPublicationAdapterTest' --console=plain + cd src && ./gradlew verifyDependencyLocks --console=plain + ``` + +- [ ] Acceptance claim: fake-gateway ACK-aware provider candidate; real Kafka ACK remains Task 21. + +**Rollback checkpoint:** producer beans remain gated/dark and v2 scheduler off. + +### Task 16: Add bounded admission, late-completion source and producer generation lifecycle + +**Owner leaves:** `application-core` (`:application-core`), +`adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 15 + +**Files — create:** + +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/PublicationGenerationLifecyclePort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/messaging/publication/PublicationGenerationDrainResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RotatePublicationGenerationCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RotatePublicationGenerationResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RotatePublicationGenerationUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RotatePublicationGenerationUseCaseTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/publication/BoundedPublicationAdmission.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/observation/BoundedLatePublicationObservationSource.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerGeneration.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerGenerationManager.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerLifecycle.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/publication/BoundedPublicationAdmissionTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/observation/BoundedLatePublicationObservationSourceTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/lifecycle/KafkaProducerGenerationManagerTest.java` + +**Files — modify:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGateway.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/publication/AckAwareOutboxPublicationAdapter.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProducerFactoryConfig.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/SpringKafkaPublishGatewayTest.java` + +- [ ] RED test finite permit count/queue wait, deadline inclusion, saturation, interrupt, release-on-all + outcomes and zero queued outbox rows after admission exhaustion. +- [ ] Implement one atomic terminal marker per send. If deadline wins, synchronous outcome stays + INDETERMINATE; one later successful callback may enqueue one payload-free late observation. +- [ ] Wire the actual Kafka future callback in `SpringKafkaPublishGateway` to the bounded source, + capturing only stable event ID, delivery generation, publication attempt ID, producer + generation and binding revision before send. Test before-deadline completion, callback-wins, + deadline-wins, duplicate callback, late success, late failure, overflow and generation-close + race against the same atomic terminal marker. +- [ ] RED test observation source lease/poll/ACK/release, bounded capacity, duplicate callback, + timeout-callback race, queue overflow/drop metric and payload/header absence. +- [ ] Overflow never mutates delivery state. It degrades readiness and alerts; capacity + qualification requires zero drop. +- [ ] RED producer generation tests for: + stop admission/claim; bounded drain; unresolved attempts durably reported + INDETERMINATE/HOLD before swap; bounded old close; secret generation resolve; new + create/attest; global generation barrier; no old/new overlap. +- [ ] Keep the messaging implementation provider-only: + `PublicationGenerationLifecyclePort` returns bounded admitted/in-flight resolution facts and + performs pause/drain/create/attest/close, but it imports no outbox store, transaction or + persistence type and never chooses HOLD/retry policy. +- [ ] `RotatePublicationGenerationUseCase` owns orchestration. It pauses new admission/claim through + provider-neutral ports, asks the provider to drain, persists every unresolved durable attempt + as INDETERMINATE and every affected ordered scope as HOLD through + `OutboxDeliveryStorePort`/`TransactionPort`, then permits close/create/attest/barrier switch. + Bootstrap invokes this use case; messaging configuration never calls persistence directly. +- [ ] A DB outage preventing durable INDETERMINATE/HOLD blocks generation switch and keeps + admission closed. Best-effort unresolved sends may return INDETERMINATE but are never + auto-replayed. +- [ ] Fatal producer state blocks new admission, lowers readiness and recreates a new immutable + generation; it does not change acceptance certainty or remove duplicate risk. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*BoundedPublicationAdmissionTest' \ + --tests '*BoundedLatePublicationObservationSourceTest' \ + --tests '*KafkaProducerGenerationManagerTest' --console=plain + cd src && ./gradlew :application-core:test \ + --tests '*RotatePublicationGenerationUseCaseTest' --console=plain + ``` + +- [ ] Acceptance claim: bounded local resource/lifecycle protocol candidate; security/topology and + real broker evidence remain. + +**Rollback checkpoint:** close the dark producer generation and keep canonical scheduler off. + +### Task 17: Attest external topic topology and production Kafka security + +**Owner:** `adapter:outbound:messaging` (`:adapter:outbound:messaging`) +**Depends on:** Task 16 + +**Files — create:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecurityProfile.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecuritySettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretReference.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretMaterial.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretMaterialResolver.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaTopicTopologyAttestor.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaProvisioningEvidence.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaTopicAttestation.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecuritySettingsTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaTopicTopologyAttestorTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSecretRedactionTest.java` + +- [ ] RED test typed allowlist: + local-only plaintext, TLS server auth, and production + `SASL_SSL + SCRAM-SHA-512`. First production tuple rejects PLAIN/OAuth/mTLS profiles, + plaintext downgrade, trust-all, hostname verification disable and literal JAAS credentials. +- [ ] `KafkaSecretMaterial` exposes no secret in `toString`, exception, descriptor or log; it carries + bounded generation/expiry and clear/close lifecycle. Configuration carries only + `secret://messaging/kafka/producer`. +- [ ] Freeze the first supported resolver boundary as `mounted-secret-files-v1`. + `KafkaSecretMaterialResolver` accepts only the exact typed reference and returns SCRAM + username/password plus trust material, generation and expiry; no application/shared type + contains these provider details. Unknown scheme/path traversal, missing field, wrong + permission/format, expired generation and literal credential fail closed. +- [ ] RED topology tests for topic existence, partitions, RF, min ISR, cleanup policy, retention, + max bytes, leader/ISR and wrong cluster/binding. +- [ ] Runtime AdminClient uses only bounded `Describe` and exact-topic `DescribeConfigs`. It never + creates/alters/deletes topics, enumerates all ACLs or requires broker-wide configuration. +- [ ] Assert the same resolved generation is applied to both producer factory and AdminClient: + `security.protocol=SASL_SSL`, `sasl.mechanism=SCRAM-SHA-512`, hostname verification enabled + and no literal JAAS value in settings/descriptor/log. A partial producer-only or + AdminClient-only resolution fails startup. +- [ ] Provisioning evidence supplies runtime-inaccessible assertions: + broker policy, auto-create/unclean election, exact positive/negative ACL probes, cluster/topic + resource identity, config/ACL digest, issuer/provenance, generated/expiry time and release + assertion digest. +- [ ] Missing, stale, wrong-cluster, invalid provenance or runtime/provisioning mismatch prevents + `ACTIVE_READY`. Transient broker unavailability yields bounded `ACTIVE_NOT_READY`; static + credential/security/binding errors fail closed. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*KafkaSecuritySettingsTest' \ + --tests '*KafkaTopicTopologyAttestorTest' \ + --tests '*KafkaSecretRedactionTest' --console=plain + ``` + +- [ ] Acceptance claim: local topology/security validation candidate; actual TLS/SASL/ACL and + multi-broker evidence remain Wave E. + +**Rollback checkpoint:** attestation failure keeps relay admission off; it never falls back to topic +auto-create, wildcard ACL or plaintext. + +### Task 18: Expose the authenticated, idempotent disposition endpoint + +**Owner:** `adapter:inbound:web` (`:adapter:inbound:web`) +**Depends on:** Task 12 + +**Files — create:** + +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/controller/MessagingOutboxDispositionController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/dto/request/OutboxDispositionRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/dto/response/OutboxDispositionResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mapper/OutboxDispositionWebMapper.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/controller/MessagingOutboxDispositionControllerWireTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mapper/OutboxDispositionWebMapperTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/controller/MessagingOutboxDispositionOpenApiContractTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/MessagingOutboxDispositionRateLimitTest.java` +- `src/adapter/inbound/web/src/test/resources/openapi/messaging-outbox-disposition-openapi-snapshot.json` + +**Files — modify:** + +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/authz/RolePermissionPolicyTest.java` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` + +- [ ] RED wire tests for: + + ```text + POST /internal/operations/messaging/outbox/{eventId}/dispositions + required header: Idempotency-Key + base permission: outbox:disposition + destructive permission: outbox:disposition:destructive + ``` + +- [ ] Cover unauthenticated, insufficient permission, missing/malformed key, invalid DTO, stale + generation/version, idempotency replay/mismatch, live claim conflict, horizon exceeded, + missing destructive approval/compensation reference and success response. +- [ ] Request contains expected delivery generation, expected row version, closed disposition, + bounded reason and incident/change reference. Mapper converts + `AuthenticatedPrincipal`/request/path/header to framework-free command; no web/security type + crosses into application. +- [ ] Use `IdempotencyKeySupport` to build the existing principal/use-case-scoped + `IdempotencyScope` and compute `RequestFingerprint` from the canonical disposition request + fields, including event ID, expected generation/version, disposition, reason, incident, + approval and compensation reference. Pass the resulting `IdempotencyContext` to the use case; + never pass only a raw header string. +- [ ] Reuse `IdempotencyKeySupport` and existing authorization enforcement. Controller calls only + `ApplyOutboxDispositionUseCase`; it imports no repository, entity, outbound adapter or + transaction manager. +- [ ] Keep the endpoint authenticated and absent from the public-path allowlist. Add explicit + internal network/rate-bound contract and a committed endpoint OpenAPI snapshot. The public + path snapshot must remain unchanged; `verifyPublicPathSnapshot` proves the endpoint was not + accidentally allowlisted. +- [ ] Map stale CAS to conflict, invalid policy to safe 4xx, authorization to existing envelope and + unknown failures to safe 5xx without event payload/hash leakage. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*MessagingOutboxDispositionControllerWireTest' \ + --tests '*OutboxDispositionWebMapperTest' \ + --tests '*MessagingOutboxDispositionOpenApiContractTest' \ + --tests '*MessagingOutboxDispositionRateLimitTest' \ + --tests '*RolePermissionPolicyTest' --console=plain + cd src && ./gradlew verifyPublicPathSnapshot --console=plain + ``` + +- [ ] Acceptance claim: authenticated transport mapping candidate; persistence/application tests + remain authority for policy/CAS. + +**Rollback checkpoint:** disable route exposure through composition/network policy, not by allowing +raw SQL mutation. + +### Task 19: Compose exact tuple, schedulers, readiness and observability + +**Owner:** `app-bootstrap` (`:app-bootstrap`) +**Depends on:** Tasks 14–18 + +**Files — create:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityReadiness.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingRuntimeDescriptor.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MountedKafkaSecretResolverSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MountedKafkaSecretMaterialResolver.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSecretRefreshScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/LatePublicationObservationScheduler.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/PersistenceTransactionResourceDescriptor.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityReadinessTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingRuntimeDescriptorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MountedKafkaSecretMaterialResolverTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/KafkaSecretRefreshSchedulerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/LatePublicationObservationSchedulerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingDisabledZeroResourceContractTest.java` + +**Files — modify:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/test/resources/application-test.yml` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` + +- [ ] RED composition tests prove settings bind/compile before secret/client, then producer, topic + attestation, readiness, v2 relay and late-drain scheduler in that order. +- [ ] Bootstrap aggregates leaf descriptors and same transaction resource identity only. It must not + reimplement catalog/schema/retry/disposition/provider rules. +- [ ] Persistence exposes a sanitized `transactionResourceId` plus resolved + DataSource/EntityManagerFactory/PlatformTransactionManager identity descriptor. Bootstrap + compares it with `TransactionPort`, canonical append adapter and the business repository + resource before creating ACTIVE clients or schedulers. Add a composition RED/GREEN case where + a second DataSource causes startup rejection and Kafka/network resource count remains 0. +- [ ] Implement `mounted-secret-files-v1` in bootstrap with an explicit bounded root, exact + reference-to-directory mapping, no symlink/path escape, owner/permission checks where the + platform exposes them, atomic generation manifest read, expiry validation, redacted failure + and prompt clearing of old char/byte material. The refresh scheduler invokes + `RotatePublicationGenerationUseCase`; it never mutates a live producer object. +- [ ] Missing/wrong/expired secret blocks ACTIVE before producer/AdminClient creation. Refresh + failure may retain the old generation only until its configured expiry/safety margin, then + closes admission/readiness. DISABLED creates resolver/refresh/file-watch resource count 0. +- [ ] Gate the v2 relay and late-drain scheduler on canonical ACTIVE + `POLLING_V2` epoch + fresh + producer/topic/security readiness. Remove the legacy `relay-enabled` boolean from canonical + mode. +- [ ] Exact `LEGACY_POLLING` compatibility composition may expose `LegacyOutboxAppendPort`; + canonical `POLLING_V2` composition must assert legacy append/store/publish/relay bean count 0. +- [ ] Readiness roles stay separate: + + ```text + relay = producer + topic/security + DB claim + catalog + durable write = DB append + backlog capacity + direct required producer = producer/topic/security + liveness = process-internal only + ``` + +- [ ] Add bounded hysteresis/freshness and explicit `STARTING|ACTIVE_NOT_READY|ACTIVE_READY`. + Static configuration/security mismatch fails startup; transient broker outage never starts + relay admission. +- [ ] Runtime descriptor exposes only card IDs, versions, catalog/schema/settings digests, + destination aliases/revisions, resource ID, epoch/authority, generation, readiness, + evidence status, non-guarantees and runbook IDs. Redact servers/topics where policy requires; + never expose credentials/payload/hash/raw headers. +- [ ] Replace legacy metrics with bounded dimensions for logical attempt, certainty, failure stage, + claim conflict/lease/backlog/order block/generation/late-drop. Reject event/aggregate/tenant/ + key/correlation/hash/exception-message tags. One confirmed persisted transition owns the + canonical error. +- [ ] DISABLED integration test asserts client/factory/template/AdminClient/semaphore/queue/thread/ + scheduler/secret resolver/network count 0. +- [ ] Verify RED then GREEN: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingCapability*Test' \ + --tests '*MessagingRuntimeDescriptorTest' \ + --tests '*MountedKafkaSecretMaterialResolverTest' \ + --tests '*KafkaSecretRefreshSchedulerTest' \ + --tests '*LatePublicationObservationSchedulerTest' \ + --tests '*MessagingDisabledZeroResourceContractTest' \ + --tests '*OutboxConfigTest' \ + --tests '*OutboxSettingsTest' --console=plain + ``` + +- [ ] Acceptance claim: complete dark reference graph candidate; target deployment authority remains + legacy until Task 25. + +**Rollback checkpoint:** keep canonical expected-state DISABLED and `LEGACY_POLLING` ACTIVE. No DB +schema downgrade. + +### Task 20: Implement and rehearse the fenced authority cutover without production switch + +**Owner leaves:** `application-core`, `adapter-outbound-persistence-jpa`, `app-bootstrap`, +`adapter-outbound-messaging` +**Depends on:** Tasks 13–19 +**Base template gate:** fresh or verified empty/drained V3 only + +Every cutover in this task runs against a disposable rehearsal database and test broker. It proves +the code/protocol but does not change a target deployment, start its v2 relay, resume its business +writes or delete legacy runtime. Production remains `LEGACY_POLLING`. + +**Files — create:** + +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxWriteAdmissionControlPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelayControlPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxWriteAdmissionSnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelaySnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionEvidence.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAuthorityCutoverPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxPreCommitRecoveryPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/ResumePollingV2WriteAdmissionUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxCutoverPreconditionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxWriteAdmissionEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/entity/OutboxRuntimeNodeLeaseEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxWriteAdmissionJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxRuntimeNodeLeaseJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/PostgreSqlOutboxWriteAdmissionGuard.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxWriteAdmissionControlAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxRuntimeNodeLeaseAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxWriteAdmissionControlAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxRuntimeNodeLeaseAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapterTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFence.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFenceTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapter.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRuntimeNodeLeaseScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverJobSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunner.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxRuntimeNodeLeaseSchedulerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunnerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyToV2CutoverContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxWriteAdmissionMultiNodeContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2SentinelContractTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` +- `src/app-bootstrap/build.gradle` +- `src/build.gradle` + +- [ ] RED application tests scope `FinalizeOutboxAuthorityCutoverUseCase` to the atomic database + finalization contract. It accepts an opaque, human-approved cutover evidence ID and never + trusts command booleans for writer/relay/producer drain. Before reconciliation it must CAS the + exact fresh attempt `CUTOVER_PENDING -> FINALIZING_V2` in the same database transaction that + commits the epoch, after taking the global write-admission/ACTIVE-epoch locks in the fixed + order and proving it is the sole nonterminal `OUTBOX_PUBLICATION` attempt; + `RECOVERING_LEGACY`, an expired attempt or a different operation owner is a hard rejection. + Rollback restores `CUTOVER_PENDING`, while a successful epoch commit records `CONSUMED_V2`. + This is a one-shot maintenance use case, not a second web endpoint. +- [ ] `OutboxLegacyToV2CutoverCoordinator` is the deployment/composition owner. Through + `OutboxWriteAdmissionControlPort`, `LegacyOutboxRelayControlPort` and + `PublicationGenerationLifecyclePort`, it freezes writes, drains writers/relay/futures, + closes/fences legacy Write, compiles/attests the canonical tuple and asks + `OutboxCutoverPreconditionPort` to persist a short-lived one-shot evidence record containing + exact node/writer/relay/producer generations, zero-active facts, epoch, manifest digest, + approver and expiry in `CUTOVER_PENDING`. Evidence creation uses the global lock order + write-admission singleton `FOR UPDATE` then ACTIVE epoch `FOR UPDATE`, requires the exact + FROZEN generation/target binding and rejects any nonterminal attempt in + `OUTBOX_PUBLICATION`; the partial unique constraint is the final concurrent-insert guard. + Bootstrap imports only application ports; it never queries repositories or Kafka adapter + internals. +- [ ] Implement the production write fence with the PostgreSQL singleton created in Task 7. + `SpringTransactionPort.inWrite` begins its transaction, acquires `FOR KEY SHARE` through + `PostgreSqlOutboxWriteAdmissionGuard`, and verifies OPEN + expected fence generation before + invoking any business action. The control adapter takes `FOR UPDATE`, waits for all older + share-holding writers to commit/rollback, writes FROZEN generation and then returns a durable + zero-active snapshot. New `inWrite` calls fail and roll back; `inNew` remains available only + for maintenance/outbox/audit and never bypasses a business write. +- [ ] Implement both explicit generation-CAS exits from FROZEN without a raw status update. + `ResumePollingV2WriteAdmissionUseCase` requires expected frozen generation, exact ACTIVE + `POLLING_V2` epoch, canonical cutover sentinel created through `OutboxAppendAdapter` and + persisted as `DELIVERY_RECORDED`, fresh target binding/readiness and no legacy runtime. It + writes `OPEN(generation+1)` once; mismatch/replay/failure leaves FROZEN. + `RecoverLegacyOutboxAuthorityUseCase` is legal only before epoch commit. Before any external + ACL mutation, its prepare operation takes the same global write-admission/ACTIVE-epoch lock + order, proves the exact attempt is the sole nonterminal authority attempt plus + FROZEN/`LEGACY_POLLING`/zero-v2-authority, then CAS-claims + `CUTOVER_PENDING -> RECOVERING_LEGACY` and atomically invalidates that attempt for v2 + finalization. Choosing this branch is irreversible for that attempt; only recovery completion + or recovery-only lease takeover remains legal. The complete operation follows the separately + fenced recovery protocol below. +- [ ] Register bounded `outbox_runtime_node_lease` heartbeats for every runtime node with node ID, + source/artifact digest, write-fence protocol version, epoch and scheduler roles. Precondition + evidence requires all deployment-inventory instances to have a matching fresh lease and + rejects stale, unknown, pre-fence or missing nodes. A lease table alone does not prove the + absence of an unregistered process; target deployment inventory/provenance is also mandatory. +- [ ] `LegacyOutboxRelayControlAdapter` owns composition of existing runtime controls: + pause new `OutboxRelayScheduler` cycles, wait active cycles/claims to the finite deadline, + pause/drain the epoch-fenced `OutboxReaper`, and close + `LegacyPublicationWriteFence` so `OutboxMessagePublishAdapter` rejects every later send. + Snapshot counts/generations are bounded facts only. Reopen methods require expected component + generations plus fresh pre-commit recovery evidence and reject once ACTIVE epoch is not + `LEGACY_POLLING`; no generic boolean setter exists. Application cutover policy sees the port, + not concrete scheduler/reaper/messaging types. +- [ ] The disposable security rehearsal and actual target preflight use distinct legacy/canonical + principals. Revoke legacy exact-topic Write and require a negative Write probe while canonical + Describe/DescribeConfigs/Write stays positive. The in-process fence plus external ACL evidence + are both required; neither substitutes for the other. +- [ ] Add the exact non-web one-shot operational entrypoint + `MessagingAuthorityCutoverApplicationRunner`. It activates only for + the closed operations `legacy-to-polling-v2`, `recover-legacy-precommit`, or + `resume-polling-v2-writes`. It requires opaque operation/approval-evidence IDs plus expected + target/source/artifact/epoch/fence generation, invokes only the corresponding application use + case/coordinator, emits no payload/secret, and exits non-zero on mismatch/replay/failure. The + main cutover exits 0 only after sentinel/readiness proof and write admission + `OPEN(generation+1)`; a post-commit resume failure stays FROZEN and requires the separately + one-shot `resume-polling-v2-writes` operation. Consumed DB evidence/operation IDs make retries + non-reentrant; no controller endpoint is added. + + ```text + ca-skeleton.messaging.maintenance.operation + ca-skeleton.messaging.maintenance.operation-id + ca-skeleton.messaging.maintenance.approval-evidence-id + ca-skeleton.messaging.maintenance.expected-target-alias + ca-skeleton.messaging.maintenance.expected-source-digest + ca-skeleton.messaging.maintenance.expected-artifact-digest + ca-skeleton.messaging.maintenance.expected-epoch + ca-skeleton.messaging.maintenance.expected-fence-generation + ``` + + Task 23 registers these exact maintenance-only keys and the runbook's non-web launcher + contract; none has a default that enables the runner. +- [ ] In the rehearsal harness: + deploy ACK-aware producer/v2 relay scheduler-disabled; compile the candidate tuple; prove + disposition auth/CAS negatives; execute the real PostgreSQL business-write admission freeze; + drain active writers/epoch share holders; stop legacy new claims and legacy reaper; drain + `IN_FLIGHT` to the maximum budget; audit remaining indeterminate; close/fence legacy producer + Write and DB legacy mutation. +- [ ] Real multi-node PostgreSQL tests hold old `inWrite` transactions across freeze, start new + writers during/after freeze, inject a stale/pre-fence node lease and omit a deployment + inventory member. Freeze must wait for old holders, reject new writes without partial business/ + outbox state, and refuse evidence until every live instance/fence/relay/reaper/producer fact is + exact and zero-active. +- [ ] In one PostgreSQL transaction: + + ```text + lock OUTBOX_PUBLICATION write-admission singleton FOR UPDATE + -> lock ACTIVE LEGACY_POLLING epoch FOR UPDATE + -> assert exact FROZEN generation/target binding and sole nonterminal attempt + -> lock exact fresh cutover attempt + -> CAS CUTOVER_PENDING -> FINALIZING_V2 + -> assert writer/legacy mutation fences + -> capture fixed legacy handoff watermark + -> final reconcile every row through watermark including final delta + -> assert exactly one CURRENT delivery per event, active claims 0 + -> assert row count + event ID/hash manifest, unmapped/duplicate count 0 + -> switch ACTIVE epoch LEGACY_POLLING -> POLLING_V2 + -> append v2 cutover sentinel through the canonical append adapter + with retained V3 projection + CURRENT/READY delivery + -> mark the same attempt CONSUMED_V2 + -> commit + ``` + +- [ ] Missing, expired, reused, wrong-epoch/generation, wrong-manifest or non-zero cutover evidence + rolls back before reconciliation. `RECOVERING_LEGACY`, `RECOVERED_LEGACY`, a foreign recovery + owner or any non-`CUTOVER_PENDING` state also rejects finalization. The final transaction + marks the evidence consumed; the coordinator cannot replay it. +- [ ] Migration-only state mapping is exact: + + ```text + PENDING -> READY + FAILED -> RETRY_WAIT with reviewed DB-time due/budget + DEAD -> EXHAUSTED + PUBLISHED -> LEGACY_RECORDED_UNVERIFIED + IN_FLIGHT -> HOLD + remaining-indeterminate audit + ``` + + Preserve reviewed attempt count/due/deadline and every historical observation; never + fabricate broker metadata, definite rejection, ACK observed time or `DELIVERY_RECORDED`. +- [ ] Any unknown contract/status/hash mismatch, duplicate current row, count/manifest mismatch, + active claim, fence failure or sentinel failure rolls the whole transaction back and leaves + `LEGACY_POLLING` authoritative. +- [ ] In the disposable rehearsal only, after commit start v2 relay, require sentinel + ACK/`DELIVERY_RECORDED`, prove the sentinel used the canonical append path and retained V3 NOT + NULL projection, verify legacy writer/claim/reaper/send 0, then execute the exact + FROZEN→OPEN generation CAS. Only after OPEN, exercise an ordinary `TransactionPort.inWrite` + canonical append; failure immediately freezes a new generation and fails the rehearsal. +- [ ] RED/GREEN fault cases at every numbered point, including crash before transaction, after + watermark, during final delta, before epoch switch, before/after sentinel insert and after + commit. No case permits dual authority or missing manifest row. +- [ ] Rehearse the two authority zones and both pre-commit choices: + + ```text + pre-commit: + keep all fences closed + -> bounded forward retry while evidence/approval remains fresh + OR + prove epoch still LEGACY_POLLING + v2 business send/sentinel authority 0 + exact inventory + -> DB-CAS exact attempt CUTOVER_PENDING -> RECOVERING_LEGACY + and atomically invalidate it for every forward finalizer + -> externally regrant legacy exact-topic Write and verify a fresh positive probe + -> re-lock attempt + epoch and revalidate RECOVERING_LEGACY owner/lease + LEGACY_POLLING + (on mismatch immediately revoke legacy Write and prove a fresh negative probe) + -> append immutable recovery/ACL audit + -> reopen in-process legacy Write fence, reaper and relay with expected generations + -> CAS write admission FROZEN -> OPEN(generation+1) and mark RECOVERED_LEGACY last + post-commit, regardless of whether a business v2 send occurred: + legacy reactivation/reverse epoch is unsupported + -> keep write admission FROZEN + -> after sentinel/readiness/canonical-projection proof, CAS OPEN(generation+1) + -> otherwise preserve backlog/schema/epoch/audit and forward-fix + ``` + + Once `RECOVERING_LEGACY` is claimed, no forward retry or separately created attempt can commit + in `OUTBOX_PUBLICATION`, including after recovery lease expiry. Inject barrier races in both + lock orders for same-attempt finalization versus recovery prepare, different-attempt creation/ + finalization versus recovery prepare/completion, plus crash/abort before and after the durable + recovery claim, external ACL regrant, post-ACL epoch revalidation, each component reopen and + final admission CAS. + Wrong epoch/inventory/ACL evidence, stale generation, partial legacy reopen, duplicate + operation, resume-before-sentinel and DB failure must never open business writes. If + post-ACL revalidation fails, immediately revoke legacy Write and require a new negative probe; + if a legacy component was reopened but final admission CAS failed, business writes stay + FROZEN and the coordinator re-fences or records the exact safe degraded state for idempotent + recovery-only retry. +- [ ] Verify: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*FinalizeOutboxAuthorityCutoverUseCaseTest' \ + --tests '*ResumePollingV2WriteAdmissionUseCaseTest' \ + --tests '*RecoverLegacyOutboxAuthorityUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*OutboxAuthorityCutoverAdapterTest' \ + --tests '*OutboxWriteAdmissionControlAdapterTest' \ + --tests '*OutboxRuntimeNodeLeaseAdapterTest' \ + --tests '*OutboxPreCommitRecoveryAdapterTest' \ + --tests '*SpringTransactionPortTest' --console=plain + cd src && ./gradlew :adapter:outbound:messaging:test \ + --tests '*LegacyPublicationWriteFenceTest' \ + --tests '*OutboxMessagePublishAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxLegacyToV2CutoverCoordinatorTest' \ + --tests '*OutboxLegacyPreCommitRecoveryCoordinatorTest' \ + --tests '*LegacyOutboxRelayControlAdapterTest' \ + --tests '*OutboxRuntimeNodeLeaseSchedulerTest' \ + --tests '*MessagingAuthorityCutoverApplicationRunnerTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OutboxLegacyToV2CutoverContractTest' \ + --tests '*OutboxWriteAdmissionMultiNodeContractTest' \ + --tests '*OutboxV2SentinelContractTest' --console=plain + ``` + +- [ ] After the disposable RED/GREEN matrix passes, schema-validate and write: + + ```text + src/app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json + ``` + + It binds the supplied source/artifact digest, migration/card/profile/catalog/settings hashes, + disposable database/broker identity, every fault point and rollback-zone scenario, exact row/ + manifest/sentinel assertions, pre-commit recovery and post-commit resume generation-CAS + scenarios, commands/timestamps, failed=0, skipped=0 and the explicit non-claim + `targetDeploymentCutOver=false`. It conforms to the common build-evidence schema. +- [ ] Acceptance claim: cutover implementation and disposable fault rehearsal candidate only. + Target deployments remain `LEGACY_POLLING`; no legacy code/config is deleted and the tuple + remains non-R2 until Wave E evidence. + +**Rollback checkpoint:** discard the rehearsal database/broker. Never apply rehearsal evidence as a +target deployment switch or destructively downgrade schema. + +### Wave D exit checkpoint + +- [ ] Run focused owner checks and: + + ```bash + cd src && ./gradlew verifyMessagingContracts \ + verifyMessagingJsonSchemaV1 \ + verifyMessagingPollingOutboxR2 \ + verifyCleanArchitectureDependencies \ + verifyEnvKeys \ + verifyPublicPathSnapshot \ + --console=plain + ``` + +- [ ] Keep Kafka/security cards at most `implemented-candidate`. +- [ ] Update the design ledger to `P3=IMPLEMENTED_CANDIDATE` only after the dark graph and disposable + cutover rehearsal pass; target authority is still legacy. +- [ ] Update the LLM Wiki branch-note and derived-document decision. + +--- + +## Wave E — P4 real-service, security, fault and release qualification + +### Task 21: Prove real PostgreSQL + real Kafka reference behavior + +**Owner:** `app-bootstrap` qualification tests +**Depends on:** Wave D + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingKafkaR2ContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingPollingKafkaEndToEndContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingKafkaFaultContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/messaging/MessagingKafkaContainerSupport.java` +- `src/app-bootstrap/src/test/resources/messaging/evidence/messaging-evidence-schema-v1.json` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `src/build.gradle` + +- [ ] Add test-only: + + ```groovy + testImplementation 'org.testcontainers:testcontainers-kafka' + testImplementation 'org.testcontainers:testcontainers-toxiproxy' + testImplementation 'org.springframework.kafka:spring-kafka-test' + ``` + + Pin container image digest in qualification settings and record broker/client/Spring + versions. +- [ ] Register `:app-bootstrap:messagingKafkaProducerR2` with these exact filters and + `failOnNoMatchingTests=true`: + + ```text + dev.caskeleton.bootstrap.integration.messaging.MessagingKafkaR2ContractTest + dev.caskeleton.bootstrap.integration.messaging.MessagingPollingKafkaEndToEndContractTest + dev.caskeleton.bootstrap.integration.messaging.MessagingKafkaFaultContractTest + ``` + + Root `verifyMessagingKafkaProducerR2` depends on that Test task and validates its evidence. + Docker/image pull/test skip is failure, not PASS. +- [ ] After registering the task but before implementing the three tests, run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingKafkaProducerR2 --console=plain + ``` + + Expected non-zero: no matching required tests or absent real-service evidence. Any unrelated + compile failure must be fixed before proceeding. +- [ ] Real Kafka RED/GREEN cases: + actual topic/partition/offset metadata; expected-topic mismatch; `acks=all`/idempotence + effective config; stable key/partition; header/record oversize; missing topic with auto-create + disabled; broker unavailable before send; leader/retriable failure; response loss/deadline/ + late ACK; local buffer saturation/max-block; throttle; no per-message flush; graceful/forced + close; fatal generation recreation. +- [ ] Combined real PostgreSQL + Kafka cases: + event/delivery commit; JIT claim/admission; ACK → delivery CAS; ACK-to-DB crash/reclaim + duplicate; stale token after late ACK; outcome commit failure; late DB-commit-before-source-ACK + duplicate absorption; backlog outage/recovery; multi-worker disjoint claim/order/fairness. +- [ ] Add the rolling-compatibility golden path against the real broker: canonical append while + `LEGACY_POLLING` is active → legacy claim → broker-observed exact compiled topic, stored key and + byte-identical v1 envelope → legacy terminal `PUBLISHED` → disposable cutover maps + `LEGACY_RECORDED_UNVERIFIED` with no automatic v2 resend. Nested envelope, legacy event-type + routing for a canonical row and mixed metadata are negative cases. +- [ ] Fault injection must observe both broker event IDs and DB state at: + + ```text + after event commit + after claim commit + before send + after request write + after broker append before ACK receipt + after ACK before DB transition + during DB transition commit + after DB success before scheduler result + during shutdown + ``` + +- [ ] Single-node evidence is labelled provider baseline only. It cannot satisfy RF/min ISR, + leader-loss or production security rows. +- [ ] Generate a sanitized manifest at the non-versioned exact path: + + ```text + src/app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json + ``` + + Validate it against + `src/app-bootstrap/src/test/resources/messaging/evidence/messaging-evidence-schema-v1.json`. + Also validate the same bytes against + `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json`; the lane schema may add + fields but cannot weaken the common source/artifact/scenario/failure/skip contract. + CI retains the same bytes under + `ci-artifact://messaging/{sourceDigest}/real-kafka-postgresql-r2/manifest.json`. The manifest + contains source/artifact digest supplied by human/CI, commands/timestamps, + test counts, versions/image digests, non-secret effective settings, hashes, scenarios/results, + skips/failures, unsupported claims and runbook IDs. +- [ ] Re-run GREEN: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingKafkaProducerR2 \ + verifyMessagingKafkaProducerR2 \ + verifyMessagingPollingOutboxR2 --console=plain + ``` + + Expected: all listed scenario IDs occur exactly once, failed=0, skipped=0, schema validation + PASS and source/artifact digests match. +- [ ] Acceptance claim: real single-node Kafka + PostgreSQL R2-candidate evidence; production tuple + remains NOT_QUALIFIED. + +**Rollback checkpoint:** qualification uses disposable services. Production activation is still +blocked by Task 22. + +### Task 22: Qualify SASL_SSL/SCRAM, least privilege and multi-broker topology + +**Owner:** deployment/security qualification lane + `app-bootstrap` aggregator +**Depends on:** Task 21 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/qualification/messaging/MessagingSecurityR2QualificationTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/qualification/messaging/MessagingMultiBrokerR2QualificationTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/qualification/messaging/MessagingRotationShutdownQualificationTest.java` +- `src/app-bootstrap/src/test/resources/messaging/qualification/docker-compose.kafka-r2.yml` +- `src/app-bootstrap/src/test/resources/messaging/qualification/README.md` +- `src/config/messaging/evidence/messaging-release-evidence-schema-v1.json` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/build.gradle` +- `src/config/messaging/release-profile-assertions.yaml` + +- [ ] Register three non-ordinary Test tasks with exact filters and + `failOnNoMatchingTests=true`: + + ```text + :app-bootstrap:messagingSecurityR2 + -> dev.caskeleton.bootstrap.qualification.messaging.MessagingSecurityR2QualificationTest + :app-bootstrap:messagingMultiBrokerR2 + -> dev.caskeleton.bootstrap.qualification.messaging.MessagingMultiBrokerR2QualificationTest + :app-bootstrap:messagingRotationShutdownR2 + -> dev.caskeleton.bootstrap.qualification.messaging.MessagingRotationShutdownQualificationTest + ``` + + Root `verifyMessagingSecurityR2` depends on all three evidence validators. Missing topology, + credential fixture, certificate, Docker/image or tests fails the release task. +- [ ] After task registration but before the qualification environment/tests are complete, run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingSecurityR2 \ + :app-bootstrap:messagingMultiBrokerR2 \ + :app-bootstrap:messagingRotationShutdownR2 \ + --console=plain + ``` + + Expected non-zero for an exact missing test/topology/security prerequisite. SKIPPED is not an + accepted RED or GREEN result. +- [ ] Use a pinned three-broker topology with RF=3/min ISR=2 and production-like + SASL_SSL/SCRAM-SHA-512. Ephemeral test credentials/certificates never enter source/evidence. +- [ ] Security positive/negative cases: + trusted TLS; untrusted CA; hostname mismatch; expired/not-yet-valid cert; valid/invalid SCRAM; + missing/expired secret; production plaintext rejection; redaction; exact-topic Describe/ + DescribeConfigs/Write; denied Create/Delete/Alter/other-topic Write/consumer Read. Use distinct + canonical and legacy fixture principals and prove the legacy principal's exact-topic Write can + be revoked without removing canonical Describe/DescribeConfigs/Write. +- [ ] Topology cases: + expected partitions/RF/min ISR; cleanup/retention/max bytes drift; wrong cluster/topic; + auto-create disabled; leader loss with ISR sufficient; below-min-ISR rejection/indeterminate + mapping; recovery; provisioning evidence freshness/provenance. +- [ ] Provisioning evidence includes the selected broker's + `replica.lag.time.max.ms`, the producer's effective `request.timeout.ms` and the approved + compatibility relation from design §16.6. Add a mismatch negative case; do not infer the + broker value from a client default. +- [ ] Rotation/lifecycle cases: + stop admission; bounded old drain; forced unresolved → durable INDETERMINATE/HOLD; old close; + new secret/producer/attestation; generation barrier; no old/new overlap; DB-unavailable switch + rejection; shutdown under load. +- [ ] Capacity/soak cases: + sustained drain, hot aggregate, broker throttle/outage/recovery storm, retry amplification, + producer memory/buffer/GC, DB pool/claim query, metric cardinality and late-observation drop 0. + Record numbers as selected-environment evidence, not universal repository performance claims. +- [ ] Emit and schema-validate these non-versioned exact files: + + ```text + src/app-bootstrap/build/messaging-evidence/security-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json + ``` + + Validate each byte-identical file against both + `src/config/messaging/evidence/build-evidence-manifest-v1.schema.json` and the stricter + `src/config/messaging/evidence/messaging-release-evidence-schema-v1.json`. Add a contract test + proving the lane schema retains every common required field and rejection rule. + CI retains byte-identical artifacts under the source-digest-qualified + `ci-artifact://messaging/` namespace. Each manifest must match exact source/artifact digest, + `qualificationEnvironmentIdentity` (fixture broker/image/principal provenance), topic/security + profile, card/settings/catalog/schema hashes, scenario set and freshness window. Any mismatch + or skip keeps all affected cards `implemented-candidate`. This identity is never reused as a + target `deploymentBindingIdentity`; only capability/profile, supported broker/client version + constraints, settings/catalog/schema and scenario-contract revisions are portable. +- [ ] Re-run GREEN: + + ```bash + cd src && ./gradlew :app-bootstrap:messagingSecurityR2 \ + :app-bootstrap:messagingMultiBrokerR2 \ + :app-bootstrap:messagingRotationShutdownR2 \ + verifyMessagingSecurityR2 --console=plain + ``` + + Expected: every required scenario ID exactly once, failed=0, skipped=0, all three manifests + pass `messaging-release-evidence-schema-v1.json`, and all source/artifact/profile digests + match. +- [ ] Acceptance claim: exact production security/topology candidate only after all required + scenarios PASS. No consumer/CDC claim. + +**Rollback checkpoint:** failed qualification prevents release promotion; do not weaken RF/min ISR, +ACL, TLS or card requirements to make the lane green. + +### Task 23: Synchronize configuration, registries, runbooks and operational truth + +**Owner:** repository documentation/configuration +**Depends on:** Tasks 19–22 + +**Files — modify:** + +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/test/resources/application-test.yml` +- `src/.env` +- `docs/registries/env-keys.yaml` +- `docs/registries/capabilities.yaml` +- `docs/registries/error-codes.yaml` +- `docs/registries/metrics.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` +- `src/shared-contract/README.md` +- `src/shared-contract/CLAUDE.md` +- `src/adapter/outbound/messaging/README.md` +- `src/adapter/outbound/messaging/CLAUDE.md` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` +- `docs/runbooks/outbox-publish-failed.md` +- `docs/runbooks/outbox-dead-letter.md` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java` + +**Files — create:** + +- `docs/runbooks/messaging-producer-unavailable-or-unauthorized.md` +- `docs/runbooks/messaging-outbox-backlog-and-stale-lease.md` +- `docs/runbooks/messaging-delivery-indeterminate-and-duplicate-burst.md` +- `docs/runbooks/messaging-schema-poison-or-record-too-large.md` +- `docs/runbooks/messaging-terminal-delivery-disposition.md` +- `docs/runbooks/messaging-topic-policy-or-partition-change.md` +- `docs/runbooks/messaging-shutdown-deploy-and-secret-rotation.md` +- `docs/runbooks/messaging-legacy-to-v2-relay-authority-cutover.md` + +- [ ] Modify the three listed contract tests first, then run RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*RunbookCoverageContractTest' \ + --tests '*OutboxStatusRegistryContractTest' \ + --tests '*EventPayloadPiiContractTest' --console=plain + ``` + + Expected non-zero because new status/key/metric/error/runbook/redaction entries are absent. + Register owner, type/default/allowlist, secret classification, validation, compatibility + impact and required test for each implemented key. +- [ ] Remove canonical reliance on: + + ```text + APP_MESSAGING_BROKER + APP_MESSAGING_KAFKA_BROKERS + ca-skeleton.outbox.relay-enabled + ``` + + in the canonical graph. Retain them only as explicit R0 compatibility inputs through the + deployment observation window; legacy + canonical keys fail with no silent precedence. Final + removal belongs to Task 26. Do not add `consumer.enabled`, `cdc.enabled` or + `schemaRegistry.url`. +- [ ] Keep base skeleton default DISABLED with active contracts/destinations/resource count 0. + Deployment-specific destination/topic/security values are explicit placeholders or secret + references, never usable credentials. +- [ ] Replace producer `DEAD/dead-letter` vocabulary with `EXHAUSTED`; distinguish it from future + consumer DLT. Remove raw SQL status rewrite and fabricated consumer-dedupe claims from old + runbooks. +- [ ] Each first-R2 runbook contains detection, blast radius, guarantee degradation, safe first + response, evidence, non-destructive mitigation, destructive approval boundary, + reconciliation, recovery proof, rollback, audit and related cards/metrics/errors. +- [ ] The authority-cutover runbook contains the exact pre-commit bounded-forward-retry and + abort-to-legacy recovery state machine, the irreversible + `CUTOVER_PENDING -> RECOVERING_LEGACY` claim before external mutation, recovery-only lease + takeover, the `OUTBOX_PUBLICATION` sole-nonterminal-attempt constraint and global lock order, + same-/cross-attempt finalizer rejection, external legacy ACL regrant/positive probe, post-ACL + epoch revalidation and revoke/negative-probe compensation, expected generation ordering, + partial-reopen re-fence behavior, and the post-commit `resume-polling-v2-writes` path. It + explicitly forbids any post-commit legacy reactivation or raw admission/epoch SQL. +- [ ] Document exact state/table/class names, token/generation/audit operator API, role readiness, + no-dual-authority cutover and non-guarantees. No stub alert/dashboard references count as + evidence. +- [ ] Re-run the same three tests GREEN after registries/runbooks are complete, then verify global + drift gates: + + ```bash + cd src && ./gradlew verifyEnvKeys \ + verifyPublicPathSnapshot \ + :app-bootstrap:test \ + --tests '*RunbookCoverageContractTest' \ + --tests '*OutboxStatusRegistryContractTest' \ + --tests '*EventPayloadPiiContractTest' \ + --console=plain + ``` + + Expected: all three contract tests PASS, no skip, and env/public-path verification PASS. + +- [ ] Acceptance claim: documentation/configuration reflects actual implementation and evidence; + unexecuted lanes remain NOT_QUALIFIED. + +**Rollback checkpoint:** docs describe deployed/evidenced truth, not preferred state. Never rewrite +failed evidence or instruct operators to dual-send/raw-update. + +### Task 24: Freeze and aggregate the pre-cutover release candidate + +**Owner:** repository-wide verification and documentation +**Depends on:** every selected Task 1–23 requirement + +**Files — modify:** + +- `docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md` +- `docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md` +- `src/config/messaging/readiness-cards.yaml` +- `src/config/messaging/release-profile-assertions.yaml` +- `src/build.gradle` + +**Files — create:** + +- `src/config/messaging/evidence/deployment-rollout-manifest-v1.schema.json` +- `src/config/messaging/evidence/deployment-binding-attestation-v1.schema.json` +- `src/config/messaging/evidence/final-r2-profile-v1.schema.json` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingDeploymentRolloutEvidenceContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingFinalR2ProfileContractTest.java` + +- [ ] Freeze the candidate source tree and dependency locks. A human supplies the candidate + commit/source digest; CI builds the exact artifact. Agent never stages, commits or pushes. +- [ ] Make `verifyMessagingReleaseProfile` consume these exact build outputs: + + ```text + src/build/messaging-evidence/contracts-schema/manifest.json + src/app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json + src/app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/security-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json + src/app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json + ``` + + Each producer validates its schema before writing. The aggregator verifies all required + scenario IDs, source/artifact digest, card/profile/catalog/schema/settings hashes, cluster/ + qualification-environment identity, freshness, failed=0 and skipped=0, then writes: + + ```text + src/build/reports/messaging/release-profile/manifest.json + ``` + + CI retains the exact bytes under a source-digest-qualified + `ci-artifact://messaging/` release-profile path. +- [ ] Revalidate every one of the seven inputs against the common build-evidence schema and its + lane-specific schema. Add contract fixtures proving a lane schema cannot omit or relax common + source/artifact/scenario/failure/skip fields. +- [ ] Implement fail-closed deployment/final gate contracts before any target cutover: + `verifyMessagingTargetBindingPreflight` validates a non-mutating target preflight; + `verifyMessagingTargetBinding` consumes the fresh target-specific topology/security/ACL + attestation created inside maintenance after the legacy Write fence; + `verifyMessagingDeploymentCutover` consumes that immutable original attestation plus the exact + local target rollout manifest; `verifyMessagingCleanupTargetBinding` consumes a distinct + cleanup-artifact attestation; `verifyMessagingFinalR2Profile` requires both attestations, the + qualified cleanup release manifest, original deployment-cutover manifest and cleanup-rollout + manifest, validates the final-profile schema and writes the final aggregate. Missing/stale/ + wrong-target/wrong-digest/failed/skipped evidence is non-zero. +- [ ] Run the gate contract tests RED then GREEN with checked-in invalid/valid payload-free fixtures: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingDeploymentRolloutEvidenceContractTest' \ + --tests '*MessagingFinalR2ProfileContractTest' --console=plain + ``` + + RED is an intentionally invalid fixture accepted or a missing required validator; GREEN means + every invalid fixture is rejected and every exact valid fixture is accepted. This does not + create target rollout evidence. +- [ ] Run focused owner gates sequentially: + + ```bash + cd src && ./gradlew :application-core:check \ + :shared-contract:check \ + :adapter:outbound:messaging:check \ + :adapter:outbound:persistence-jpa:check \ + :adapter:inbound:web:check \ + :app-bootstrap:check \ + :sample-portfolio:check \ + --console=plain + ``` + +- [ ] Run Messaging gates: + + ```bash + cd src && ./gradlew verifyMessagingContracts \ + verifyMessagingJsonSchemaV1 \ + verifyMessagingPollingOutboxR2 \ + verifyMessagingKafkaProducerR2 \ + verifyMessagingSecurityR2 \ + verifyMessagingReleaseProfile \ + --console=plain + ``` + + Expected: every exact input manifest exists and validates; no mismatch/stale/skip; aggregate + manifest PASS. Missing service/credential/image/test is non-zero, never PASS. +- [ ] Run repository gates: + + ```bash + cd src && ./gradlew test --console=plain + cd src && ./gradlew check --console=plain + cd src && ./gradlew verifyDependencyLocks --console=plain + cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain + cd src && ./gradlew verifyPublicPathSnapshot --console=plain + cd src && ./gradlew verifyEnvKeys --console=plain + cd src && ./gradlew verifyOneTypePerFile \ + verifyApplicationCoreDependencyPurity --console=plain + git diff --check + ``` + +- [ ] Perform independent reviews for: + Clean Architecture/module ownership; schema/contract evolution; transaction/concurrency/CAS; + Kafka outcome/lifecycle; security/topology; operator endpoint; migration/cutover/rollback; + evidence/no-skip/operations. Candidate qualification requires blocker 0 and high 0. +- [ ] From the aggregate only, promote the exact artifact/card rows to `release-eligible`. Record + §0 as `P4=RELEASE_CANDIDATE_QUALIFIED_DEPLOYMENT_NOT_CUT_OVER`; target publication authority + and production runtime remain legacy. P5/P6 stay `DESIGNED_NOT_IMPLEMENTED`, P7 stays + `OPTIONAL_BACKLOG`. +- [ ] Update the branch-note with release-candidate evidence and explicitly state target + cutover/cleanup are pending. Do not make the final implementation-complete claim. + +**Rollback checkpoint:** if aggregation fails, keep cards `implemented-candidate`, target +`LEGACY_POLLING`, and preserve schema/backlog/evidence. Never weaken a gate or copy evidence from +another artifact. + +### Task 25: Execute the approved target deployment cutover + +**Owner:** deployment coordinator + application/persistence cutover protocol +**Depends on:** Tasks 23–24, human deployment approval, exact fresh/empty-drained migration card +**Source change:** none + +- [ ] Fail before maintenance unless the target matches the exact Task 24 source/artifact digest, + release profile, schema/catalog/settings hashes, supported broker/client constraints and the + checked-in cutover runbook. Do not require the target cluster/principal to equal Task 22's + qualification fixture. A live non-empty V3 target stops for a separate approved deployment + migration plan. +- [ ] Against the actual target, resolve the exact canonical secret generation and run a + non-mutating fresh topology/security preflight. Do not revoke the still-authoritative legacy + principal before maintenance. Bind the prepared exact ACL change/provenance and emit identical + sanitized bytes: + + ```text + src/app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/target-binding-preflight/manifest.json + ``` + + `deploymentBindingIdentity` binds target cluster/topic/canonical and legacy principal + identities, canonical secret generation, provisioning provenance and prepared mutation. It + proves canonical Describe/DescribeConfigs/Write and validates the target constraints, but + explicitly records `legacyWriteRevoked=false`; it is not cutover evidence. +- [ ] Run before maintenance: + + ```bash + cd src && ./gradlew verifyMessagingTargetBindingPreflight --console=plain + ``` + + Expected GREEN only for a fresh exact target/source/artifact/release/profile binding with + failed=0 and skipped=0. Fixture identity cannot satisfy this gate. +- [ ] Before target execution, run the fail-closed rollout gate once with no current rollout + artifact: + + ```bash + cd src && ./gradlew verifyMessagingDeploymentCutover --console=plain + ``` + + Expected non-zero: the final in-maintenance target attestation and target rollout artifact are + absent. A stale prior-target artifact must fail for target/source/release-digest mismatch, not + satisfy this RED. +- [ ] Execute the runbook preflight: + deploy candidate with v2 relay scheduler-disabled; attest exact tuple; prove disposition + auth/CAS; freeze durable business-write admission; drain writers/epoch holders; stop/drain + legacy claim and reaper; record remaining IN_FLIGHT as indeterminate/HOLD; fence legacy + producer Write and DB mutation. Only after zero-active drain, apply the prepared ACL mutation, + require legacy exact-topic Write negative and canonical Describe/DescribeConfigs/Write + positive, then write and internally schema-validate: + + ```text + src/app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/target-binding-attestation/manifest.json + ``` + + The one-shot precondition evidence binds this attestation digest. The maintenance runner uses + the same fail-closed validator as `verifyMessagingTargetBinding` before it may call finalization; + raw server, credential and certificate bytes are excluded. +- [ ] Invoke the exact non-web `MessagingAuthorityCutoverApplicationRunner` operation + `legacy-to-polling-v2` with opaque operation/approval-evidence IDs and expected + target/source/artifact/epoch. It calls the coordinator and + `FinalizeOutboxAuthorityCutoverUseCase`; the single transaction repeats the Task 20 + `CUTOVER_PENDING -> FINALIZING_V2` CAS, + watermark/final-delta/manifest/current-delivery assertions, epoch switch, sentinel insert and + `CONSUMED_V2` transition. `RECOVERING_LEGACY` is rejected before reconciliation. Any mismatch + exits non-zero and rolls back to legacy authority. Reusing the operation or consumed evidence + ID exits non-zero without mutation. + + ```text + --spring.main.web-application-type=none + --ca-skeleton.messaging.maintenance.operation=legacy-to-polling-v2 + --ca-skeleton.messaging.maintenance.operation-id={opaqueOperationId} + --ca-skeleton.messaging.maintenance.approval-evidence-id={opaqueApprovalEvidenceId} + --ca-skeleton.messaging.maintenance.expected-target-alias={targetAlias} + --ca-skeleton.messaging.maintenance.expected-source-digest={sourceDigest} + --ca-skeleton.messaging.maintenance.expected-artifact-digest={artifactDigest} + --ca-skeleton.messaging.maintenance.expected-epoch={legacyEpoch} + --ca-skeleton.messaging.maintenance.expected-fence-generation={openFenceGeneration} + ``` +- [ ] If finalization fails before epoch commit, keep every fence closed. Either retry forward within + the still-fresh bounded evidence/approval window, or execute the approved abort-to-legacy + protocol. The latter first proves epoch still `LEGACY_POLLING`, v2 business send/sentinel + authority 0 and exact inventory, then runs the prepare half of + `recover-legacy-precommit`: DB-CAS the exact attempt + `CUTOVER_PENDING -> RECOVERING_LEGACY`, bind the recovery operation/lease/evidence digest and + atomically make every forward finalizer reject it. This choice is irreversible for that + attempt, including after lease expiry. The prepare transaction holds the global + write-admission/ACTIVE-epoch locks in order and proves the partial-unique-protected attempt is + the sole nonterminal `OUTBOX_PUBLICATION` attempt. Only then may external provisioning + regrant legacy exact-topic Write and emit a fresh positive probe. The completion half + re-locks the global authority, exact attempt and epoch after that external mutation; + owner/lease, uniqueness or `LEGACY_POLLING` mismatch immediately re-revokes legacy Write, + proves a fresh negative probe and leaves writes FROZEN. + On success it appends recovery/ACL audit, reopens the in-process legacy Write fence/reaper/ + relay, then CAS-opens durable writes and marks `RECOVERED_LEGACY` last. Emit: + + ```text + src/app-bootstrap/build/messaging-evidence/precommit-legacy-recovery/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/precommit-legacy-recovery/manifest.json + ``` + + Validate identical bytes against the common and deployment-rollout schemas with + `outcome=ABORTED_PRECOMMIT`, exact restored legacy/write-admission generations, recovery + scenario IDs exactly once, failed=0 and skipped=0. + Any partial failure leaves writes FROZEN and is retried/re-fenced; recovery lease takeover is + recovery-only and never restores forward-finalization eligibility. Race tests must run + recovery-prepare versus same- and different-attempt creation/finalization in both lock orders, + and crash tests must cover every boundary before/after claim, ACL regrant, epoch revalidation, + component reopen and final admission CAS. The protocol never uses raw SQL. An aborted attempt + ends Task 25 without cutover; another attempt is rejected until recovery atomically records + `RECOVERED_LEGACY` and opens a new fence generation, then requires fresh preflight, approval + and target attestation. +- [ ] After epoch commit, start only v2 relay while writes remain FROZEN. Require the canonical + sentinel `DELIVERY_RECORDED`, retained V3 projection, fresh target binding/readiness and legacy + writer/claim/reaper/send 0. Then `ResumePollingV2WriteAdmissionUseCase` CAS-opens + `OPEN(generation+1)`. If the original runner dies or resume fails after commit, + `resume-polling-v2-writes` is the only recovery operation; it rechecks the same facts and + cannot reactivate legacy. After OPEN, exercise an ordinary canonical append; failure freezes a + new generation and keeps the rollout non-healthy. +- [ ] Apply the rehearsed rollback zones exactly: + pre-commit failure retains legacy DB authority but stays in maintenance until bounded forward + retry or the full audited recovery above succeeds; after epoch commit, reverse epoch and legacy + reactivation are unsupported even before the first business v2 send. Keep admission FROZEN, + preserve backlog/schema/epoch/audit and forward-fix or run the guarded v2 resume. +- [ ] Abort and fence admission on any duplicate anomaly, unexpected indeterminate, backlog/SLO + breach, late-observation drop, stale legacy mutation, topic/security drift or readiness loss. + Never automatically resend while diagnosing. +- [ ] Emit a sanitized target rollout artifact: + + ```text + src/app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json + ci-artifact://messaging/{targetAlias}/{sourceDigest}/deployment-cutover/manifest.json + ``` + + It binds target environment/cluster alias, source/artifact/release manifest digest, + target-binding-attestation digest, precondition evidence digest, watermark/manifest counts, + epoch/sentinel facts, final OPEN fence generation, ordinary post-resume append probe, + commands/timestamps, rollback zone, failures/skips and operator approvals. Payload, event hash, + credential and raw IDs remain excluded. The target runner writes identical bytes to the local + handoff and retained CI URI; both validate against the common build-evidence schema and + `deployment-rollout-manifest-v1.schema.json`. +- [ ] After commit, sentinel proof and artifact handoff, run + `verifyMessagingTargetBinding verifyMessagingDeploymentCutover` GREEN. Expected: binding and + deployment schemas PASS, exact target/source/artifact/release/attestation digest match, + approval/epoch/sentinel scenario IDs present exactly once, failed=0 and skipped=0. +- [ ] Acceptance claim: the exact target uses `POLLING_V2` and the sentinel/reference path is + healthy, and durable write admission is OPEN at the recorded post-cutover generation. Legacy + source/runtime cleanup is still pending the observation/rollback window. + +**Rollback checkpoint:** use only the two rehearsed zones. Schema is forward-only; dual relay +authority and destructive downgrade are forbidden. + +### Task 26: Complete observation, remove legacy runtime and requalify the cleanup artifact + +**Owner:** all affected leaves + repository-wide verification/Wiki +**Depends on:** Task 25, reviewed observation window, human cleanup approval + +**Files — delete:** + +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessageBroker.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaSender.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaMessageBroker.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/KafkaAdapterSettings.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFence.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfigTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/LegacyPublicationWriteFenceTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java` +- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxMessagePublishPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelayControlPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxRelaySnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionEvidence.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxCutoverPreconditionPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxAuthorityCutoverPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxPreCommitRecoveryPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/FinalizeOutboxAuthorityCutoverUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/RecoverLegacyOutboxAuthorityUseCaseTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxStorePort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEvent.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxEventStatus.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxCutoverPreconditionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxAuthorityCutoverAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxPreCommitRecoveryAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlOutboxClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaper.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxStoreAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxReaperWiringTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLeaderElectionToken.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapter.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverJobSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunner.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyToV2CutoverCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxLegacyPreCommitRecoveryCoordinatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/LegacyOutboxRelayControlAdapterTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/MessagingAuthorityCutoverApplicationRunnerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxLegacyToV2CutoverContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxAppendTransactionalContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxPublisherLeaderElectionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfMessagingBrokerConfigured.java` + +**Files — create:** + +- `src/config/messaging/legacy-runtime-denylist.txt` +- `src/config/messaging/legacy-runtime-allowlist.txt` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingLegacyRuntimeDenylistTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingLegacyActivationRunbookContractTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingWriteAdmissionRecoverySettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/MessagingWriteAdmissionRecoveryApplicationRunner.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/MessagingWriteAdmissionRecoveryApplicationRunnerTest.java` + +**Files — modify:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/OutboxEventJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java` +- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxMetrics.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxRelayScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfig.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxSettingsTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/messaging/MessagingCapabilityConfigTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxV2SentinelContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/OutboundWithoutPermissionUseCase.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/OutboxStatusRegistryContractTest.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/test/resources/application-test.yml` +- `src/sample-portfolio/src/main/resources/application.yml` +- `src/sample-portfolio/src/test/resources/application-test.yml` +- `src/.env` +- `src/config/messaging/readiness-cards.yaml` +- `src/config/messaging/profile-compatibility.yaml` +- `src/config/messaging/release-profile-assertions.yaml` +- `docs/registries/env-keys.yaml` +- `docs/registries/capabilities.yaml` +- `docs/registries/error-codes.yaml` +- `docs/registries/metrics.yaml` +- `docs/registries/secrets-classification.yaml` +- `docs/runbooks/outbox-publish-failed.md` +- `docs/runbooks/outbox-dead-letter.md` +- `docs/runbooks/messaging-producer-unavailable-or-unauthorized.md` +- `docs/runbooks/messaging-outbox-backlog-and-stale-lease.md` +- `docs/runbooks/messaging-delivery-indeterminate-and-duplicate-burst.md` +- `docs/runbooks/messaging-schema-poison-or-record-too-large.md` +- `docs/runbooks/messaging-terminal-delivery-disposition.md` +- `docs/runbooks/messaging-topic-policy-or-partition-change.md` +- `docs/runbooks/messaging-shutdown-deploy-and-secret-rotation.md` +- `docs/runbooks/messaging-legacy-to-v2-relay-authority-cutover.md` +- `src/README.md` +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` +- `src/shared-contract/README.md` +- `src/shared-contract/CLAUDE.md` +- `src/adapter/outbound/messaging/README.md` +- `src/adapter/outbound/messaging/CLAUDE.md` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` +- `src/sample-portfolio/README.md` +- `src/sample-portfolio/CLAUDE.md` +- `src/app-bootstrap/build.gradle` +- `src/build.gradle` +- `docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md` +- `docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md` +- `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md` and only genuinely + derived raw documents + +- [ ] Observation exit requires the reviewed duration with duplicate anomaly 0, unexpected + indeterminate 0, late-drop 0, stable backlog/readiness, no legacy mutation and successful + disposition/rotation/shutdown drills. Durable write admission must remain OPEN at the recorded + post-cutover generation except for audited drills with successful guarded resume. Any breach + postpones cleanup. +- [ ] Write `MessagingLegacyRuntimeDenylistTest` first and run RED; it must find every exact + production symbol/config key above plus legacy reaper repository methods/beans. The denylist + contains exact FQCNs and keys, including: + + ```text + dev.caskeleton.adapter.outbound.messaging.core.MessageBroker + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaMessageBroker + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig + dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterSettings + dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher + dev.caskeleton.adapter.outbound.messaging.outbox.LegacyPublicationWriteFence + dev.caskeleton.adapter.outbound.messaging.outbox.OutboxEnvelopeJson + dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter + dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter + dev.caskeleton.application.outbox.OutboxMessagePublishPort + dev.caskeleton.application.outbox.LegacyOutboxRelayControlPort + dev.caskeleton.application.outbox.LegacyOutboxRelaySnapshot + dev.caskeleton.application.outbox.OutboxCutoverPreconditionEvidence + dev.caskeleton.application.outbox.OutboxCutoverPreconditionPort + dev.caskeleton.application.outbox.FinalizeOutboxAuthorityCutoverCommand + dev.caskeleton.application.outbox.FinalizeOutboxAuthorityCutoverResult + dev.caskeleton.application.outbox.OutboxAuthorityCutoverPort + dev.caskeleton.application.outbox.FinalizeOutboxAuthorityCutoverUseCase + dev.caskeleton.application.outbox.OutboxPreCommitRecoveryPort + dev.caskeleton.application.outbox.RecoverLegacyOutboxAuthorityCommand + dev.caskeleton.application.outbox.RecoverLegacyOutboxAuthorityResult + dev.caskeleton.application.outbox.RecoverLegacyOutboxAuthorityUseCase + dev.caskeleton.application.outbox.OutboxStorePort + dev.caskeleton.application.outbox.OutboxEvent + dev.caskeleton.application.outbox.OutboxEventStatus + dev.caskeleton.application.outbox.OutboxRelayFailureReport + dev.caskeleton.application.outbox.OutboxRelayFailureReportPort + dev.caskeleton.application.outbox.OutboxRelayResult + dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand + dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxCutoverPreconditionAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxAuthorityCutoverAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxPreCommitRecoveryAdapter + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository + dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository + dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper + dev.caskeleton.bootstrap.outbox.OutboxLeaderElectionToken + dev.caskeleton.bootstrap.outbox.OutboxLegacyToV2CutoverCoordinator + dev.caskeleton.bootstrap.outbox.OutboxLegacyPreCommitRecoveryCoordinator + dev.caskeleton.bootstrap.outbox.LegacyOutboxRelayControlAdapter + dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverJobSettings + dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverApplicationRunner + OutboxEventJpaRepository.deletePublishedBefore + OutboxEventJpaRepository.countGroupedByStatus + OutboxEventJpaRepository.findOldestUnpublishedOccurredAtByEventType + OutboxClaimRepository.claimEligible + outboxLeaderElection + outboxReaper + app.messaging.broker + app.messaging.kafka.brokers + APP_MESSAGING_BROKER + APP_MESSAGING_KAFKA_BROKERS + ca-skeleton.outbox.relay-enabled + ca-skeleton.messaging.maintenance.operation + ca-skeleton.messaging.maintenance.operation-id + ca-skeleton.messaging.maintenance.approval-evidence-id + ca-skeleton.messaging.maintenance.expected-target-alias + ca-skeleton.messaging.maintenance.expected-source-digest + ca-skeleton.messaging.maintenance.expected-artifact-digest + ca-skeleton.messaging.maintenance.expected-epoch + ca-skeleton.messaging.maintenance.expected-fence-generation + ``` + + This is the complete mandatory production token/key set derived one-to-one from `Files — + delete` plus the legacy repository methods, bean names and configuration keys. The contract + test snapshots the exact eight-key Task 20 maintenance settings/registry set and asserts set + equality with these eight denylist keys before scanning source/config/runbook references. It + rejects a missing or extra maintenance key, a missing denylist entry, an unclassified deleted + production class, and any allowlist entry outside the exact sample/R0 list below. + + The allowlist contains only these repository-relative paths: + + ```text + src/application-core/src/main/java/dev/caskeleton/application/outbox/NewOutboxEvent.java + src/application-core/src/main/java/dev/caskeleton/application/outbox/LegacyOutboxAppendPort.java + src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapter.java + src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/outbox/LegacyOutboxAppendAdapterTest.java + src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java + src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java + src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java + ``` + + No directory wildcard or silently ignored unknown path is allowed. +- [ ] Run the denylist RED before deletion: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingLegacyRuntimeDenylistTest' --console=plain + ``` + + Expected non-zero with every still-present forbidden FQCN/key/bean reported. Missing scan + roots or a test skip is not an accepted RED. +- [ ] Delete the listed runtime/relay/reaper sources and obsolete config keys. Retain + `NewOutboxEvent`, `LegacyOutboxAppendPort` and a separately named sample-only + `LegacyOutboxAppendAdapter` only as the documented R0 fixture until standalone sample + activation. Canonical ACTIVE context must prove legacy append/store/publish/relay/reaper bean + count 0. Replace the deleted cutover runner with the disabled-by-default + `MessagingWriteAdmissionRecoveryApplicationRunner`, which accepts only + `resume-polling-v2-writes`, imports no legacy/cutover type, and uses + `ResumePollingV2WriteAdmissionUseCase` with expected FROZEN generation, ACTIVE + `POLLING_V2` epoch and fresh sentinel/readiness evidence. + + ```text + ca-skeleton.messaging.write-admission-recovery.operation + ca-skeleton.messaging.write-admission-recovery.operation-id + ca-skeleton.messaging.write-admission-recovery.approval-evidence-id + ca-skeleton.messaging.write-admission-recovery.expected-target-alias + ca-skeleton.messaging.write-admission-recovery.expected-epoch + ca-skeleton.messaging.write-admission-recovery.expected-fence-generation + ``` + + No operation/default means no runner resource. Unknown or legacy operation names fail before + mutation; replay/mismatch and resume-before-sentinel remain non-zero. +- [ ] Keep retained V3 DB columns and canonical compatibility projection until a later forward + schema cleanup. Do not drop columns or rewrite history here. +- [ ] Re-run the runtime denylist GREEN across production Java, build files, YAML/env and registries; + only the exact sample/R0 source allowlist may remain. Explicitly exclude design/spec/plan/Wiki + history from raw-symbol matching: historical documentation is evidence, not an activation + surface. Compile success alone is not bean/resource absence evidence. Run the same focused + command GREEN; expect forbidden runtime match 0, canonical legacy bean/resource count 0 and + every allowlisted sample/R0 reference classified exactly. +- [ ] Run `MessagingLegacyActivationRunbookContractTest` RED before cleanup and GREEN after cleanup. + It scans operational runbooks semantically for executable legacy activation keys, commands, + dual-relay instructions or rollback-to-legacy actions, while allowing clearly labelled + historical facts and “must remain disabled/forbidden” statements. It does not raw-match this + plan/spec/branch-note. + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingLegacyActivationRunbookContractTest' --console=plain + ``` +- [ ] Because cleanup changes source/artifact digest, freeze a new human candidate and rerun the + Task 21 and 22 qualification lanes plus the Task 24 focused/Messaging/repository commands and + release-profile aggregator against the cleanup artifact. Do not replay Task 24's pre-cutover + status wording: production is already `POLLING_V2`. Pre-cleanup evidence is stale and cannot + qualify the cleanup artifact. +- [ ] Before deploying the cleanup artifact, run: + + ```bash + cd src && ./gradlew verifyMessagingFinalR2Profile --console=plain + ``` + + Expected non-zero because the qualified cleanup release manifest and cleanup rollout artifact + do not yet form a matching final chain. +- [ ] Deploy the requalified cleanup artifact without changing the already-active `POLLING_V2` + epoch. Before deployment, rerun target topology/security/ACL probes for the cleanup source/ + artifact and emit: + + ```text + src/app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json + ci-artifact://messaging/{targetAlias}/{cleanupSourceDigest}/cleanup-target-binding-attestation/manifest.json + ``` + + Run `verifyMessagingCleanupTargetBinding` GREEN; it must bind the same target identity and + current secret generation to the cleanup release digest without overwriting the immutable + original Task 25 attestation. Then deploy and emit byte-identical sanitized evidence: + + ```text + src/app-bootstrap/build/messaging-evidence/cleanup-rollout/manifest.json + ci-artifact://messaging/{targetAlias}/{cleanupSourceDigest}/cleanup-rollout/manifest.json + ``` + + It binds the target alias, cleanup source/artifact digest, qualified cleanup release-manifest + digest, cleanup-target-attestation digest, original deployment-cutover manifest digest, + unchanged epoch, recorded OPEN write-admission generation, sentinel/backlog/readiness/ + legacy-bean-0 facts, approval, + commands/timestamps, failed=0 and skipped=0. Validate it against the common and + deployment-rollout schemas. +- [ ] Re-run `verifyMessagingDeploymentCutover`, `verifyMessagingCleanupTargetBinding` and + `verifyMessagingFinalR2Profile` GREEN: + + ```bash + cd src && ./gradlew verifyMessagingTargetBinding \ + verifyMessagingDeploymentCutover \ + verifyMessagingCleanupTargetBinding \ + verifyMessagingFinalR2Profile --console=plain + ``` + + The final task consumes these exact local inputs: + + ```text + src/build/reports/messaging/release-profile/manifest.json + src/app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json + src/app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json + src/app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json + src/app-bootstrap/build/messaging-evidence/cleanup-rollout/manifest.json + ``` + + If qualification/build cleanup removed a Task 25 local handoff, restore only the byte-identical + retained CI artifact to its exact path after verifying its recorded digest/signature and target + provenance. Never synthesize, edit or substitute a current artifact for the immutable original. + The final task writes: + + ```text + src/build/reports/messaging/final-r2-profile/manifest.json + ``` + + Expected: exact target/cleanup source/artifact/release digest chain, approval/epoch/sentinel/ + write-admission-OPEN/legacy-zero scenario IDs exactly once, failed=0, skipped=0 and schema + PASS. CI retains the + final bytes under the target/cleanup-source-qualified namespace. +- [ ] Run final focused, Messaging and repository gates exactly as Task 24 plus: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*MessagingLegacyRuntimeDenylistTest' \ + --tests '*MessagingWriteAdmissionRecoveryApplicationRunnerTest' \ + --tests '*MessagingDisabledZeroResourceContractTest' --console=plain + ``` + +- [ ] Update §0 and card status from the final R2 aggregate only. First R2 may be marked complete + only after `verifyMessagingFinalR2Profile` passes; P5/P6/P7 remain unchanged. +- [ ] Before final Wiki capture, read the configured vault's `AGENTS.md`, `CLAUDE.md` and relevant + `rules/`, `.agents/`, `.claude/`, `.codex/` instructions. Resolve the branch with + `git branch --show-current`; for this plan's current `main` branch the canonical target is: + + ```text + /home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md + ``` + + Record implementation, files, decisions, exact commands/results/failures, release and rollout + evidence, unsupported claims and remaining risks. Add/link derived raw documents only when + honestly produced; otherwise record “없음”. Canonical vault unavailability is an explicit + completion blocker. +- [ ] Final handoff lists changed files, behavior, exact verification counts/results, failed/not-run + lanes, release/rollout fingerprints, Wiki capture and follow-up risks. + +**Rollback checkpoint:** after cleanup, do not restore legacy code for events already sent by v2. +Pause admission, preserve schema/backlog/epoch/audit and forward-fix. + +--- + +## 8. Task dependency graph + +```text +1 -> 2 + | + 3 -> 4 -> 5 -> 6 + | + 7 -> 8 -> 9 -> 10 -> 11 -> 12 -> 13 + | + 14 -> 15 -> 16 -> 17 + | | + +-------> 18 + | + 19 -> 20 + | + 21 -> 22 -> 23 -> 24 + | + 25 -> 26 +``` + +Parallel work is allowed only at non-overlapping stable boundaries: + +- Task 4 shared/sample resource work may run in parallel after Task 3, but Task 5 starts only after + both resource owners are stable. +- Persistence Tasks 7–13 are sequential because they share migration/entity/repository/CAS surfaces. +- Task 18 may start after Task 12 while Tasks 15–17 proceed, but Task 19 waits for both. +- Disposable cutover rehearsal Task 20 is never parallelized with producer, persistence or + configuration changes. +- Real Kafka Task 21 and security topology setup for Task 22 may prepare in parallel only after the + final canonical artifact is frozen; their evidence aggregation remains ordered. +- Target cutover Task 25 is a serialized deployment operation after Task 24 qualification. Task 26 + cleanup starts only after the reviewed observation window and requires a newly qualified artifact. +- Shared-worktree Gradle invocations remain sequential even when source subtasks are delegated. + +## 9. Minimum completion matrix + +| Requirement | Proving task | +| --- | --- | +| approved truth/no ACK overclaim | 1 | +| closed first-tuple registry/no future switches | 2, 24 | +| framework-free typed event/SPI | 3 | +| generic envelope + sample-owned payload schema | 4 | +| closed catalog/destination/digest/key | 5 | +| Draft 2020-12 deterministic bytes/admission | 6 | +| forward-only immutable event/delivery/journal/epoch | 7 | +| same-transaction validated append | 8 | +| exhaustive outcome + one-record relay | 9 | +| JIT claim/token/unexpired-lease CAS | 10 | +| late observation DB commit before source ACK | 11 | +| audited requeue/hold/skip/compensate | 12 | +| legacy/v2 mutual exclusion | 13 | +| finite typed config/disabled 0 | 14 | +| actual ACK-aware Spring Kafka gateway | 15 | +| admission/late queue/generation lifecycle | 16 | +| topic/security attestation | 17 | +| authenticated internal disposition endpoint | 18 | +| composition/readiness/observability | 19 | +| atomic watermark/reconcile/epoch/sentinel cutover implementation + disposable rehearsal | 20 | +| real PostgreSQL + real Kafka fault evidence | 21 | +| TLS/SASL/ACL + multi-broker RF/min ISR | 22 | +| env/registries/runbooks | 23 | +| no-skip pre-cutover release artifact + independent review | 24 | +| exact-target approved deployment cutover evidence | 25 | +| observation exit + legacy cleanup + cleanup-artifact requalification + final Wiki | 26 | + +## 10. Follow-up plans after first R2 + +다음은 이 계획을 확장하는 checkbox가 아니라 별도 설계 승인과 실행 계획이다. + +1. **Inbound Kafka + inbox + DLT/replay (P5)** + - 20번째 `adapter:inbound:messaging-kafka` leaf registry migration; + - manual ACK after application commit; + - inbox/effect identity, bounded retry, DLT ACK, replay/audit. +2. **PostgreSQL Debezium CDC (P6)** + - external Connect/Debezium asset, publication/slot/offset/WAL; + - insert-only mapping, shadow, authority-exclusive cutover/rollback and retention proof. +3. **Optional cards (P7)** + - Avro/Protobuf registry, Kafka EOS, retry topic, compaction, object-storage claim check, + alternate broker, multi-cluster, module split; + - each requirement gets an independent card/compatibility/evidence plan. +4. **Live non-empty legacy database migration** + - collect actual row volume/state distribution, lock/replication budget, data classification, + maintenance window and rollback evidence; + - separately approve either `LIVE_ADDITIVE_BACKFILL_IN_PLACE.v1` or + `COPY_AND_CUTOVER_WITH_RECONCILIATION.v1`. + +## 11. Final non-negotiable assertions + +- `KafkaSender.send()` return is not broker ACK. +- Kafka future success plus metadata is ACK observation; it is not consumer processing. +- `acks=all` without RF/min ISR/unclean-election evidence is not durable topology evidence. +- Kafka producer idempotence does not remove ACK-to-DB, restart, late-ACK or operator-requeue + duplicates. +- `EXHAUSTED` does not mean definitely not delivered. +- late ACK observation never rewrites authoritative delivery state. +- timestamp/random event ID is not aggregate total order. +- `outbox_event` wire bytes are immutable authority; JSONB/re-serialization is not. +- polling and CDC may never be simultaneous production dispatch authorities. +- operator mutation requires auth, expected generation/version, no active claim and immutable audit. +- disabled and configured-but-not-ready are different states. +- fake/single-node/local evidence cannot be relabelled as production R2. +- implementation completion requires exact commands/results and LLM Wiki capture. +- agent never stages, commits, amends or pushes. 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 new file mode 100644 index 00000000..2c811e54 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md @@ -0,0 +1,5750 @@ +# Messaging Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: 상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 현재 outbound leaf: `adapter-outbound-messaging` +- 미래 inbound leaf: `adapter-inbound-messaging-kafka` +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) + +## 0. 문서 상태와 구현 상태 + +이 문서는 messaging 전체 수명주기를 한 번에 설계하되 구현은 단계적으로 진행하기 위한 정본이다. +여기서 messaging 전체 수명주기는 다음을 뜻한다. + +```text +domain event + -> integration event + -> transactional outbox + -> polling 또는 CDC dispatch + -> acknowledgement-aware Kafka producer + -> Kafka consumer + -> inbox + application side effect + -> DLT / replay / reconciliation +``` + +이 문서가 검토되었다는 사실은 위 기능이 구현되었거나 production-ready라는 뜻이 아니다. +구현 상태와 향후 추가 가능 범위를 혼동하지 않도록 세 종류의 표현만 사용한다. + +| 표현 | 의미 | +| --- | --- | +| `현재 구현` | 2026-07-28 repository에서 코드와 테스트로 직접 확인한 범위 | +| `최초 R2 구축 대상` | 첫 실행 계획에서 실제로 구현하고 real-service 증거를 만들 범위 | +| `후속 설계 완료 / 미구현` | 경계와 보장은 이 문서에서 결정했지만 코드·설정·증거는 아직 없는 범위 | + +### 0.1 현재 구현 + +현재 repository에는 다음 기반이 있다. + +- `application-core`의 framework-free transactional outbox append/store/publish port; +- 비즈니스 쓰기와 같은 `TransactionPort.inWrite(...)` 안에서 outbox event를 append하는 계약; +- PostgreSQL `SKIP LOCKED` 기반 claim, `PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD` 상태, + retry/backoff, timestamp 기반 aggregate FIFO gate; +- broker publish를 DB transaction 밖에서 수행하고 결과 상태만 짧은 transaction으로 갱신하는 + relay use case; +- `app.messaging.broker`로 단일 `MessageBroker`를 선택하는 outbound composition; +- `KafkaSender`라는 project-supplied seam과 fake 기반 unit test; +- 비활성 시 fail-fast하는 `DisabledMessagePublisher`와 + `DisabledOutboxMessagePublisher`; +- 일반 publisher의 fail-open과 durable outbox publisher의 fail-closed 구분; +- 확인된 FAILED/DEAD 전이 뒤에만 기록되는 typed `OutboxRelayFailureReport`; +- outbox backlog/lag/outcome metric과 stub runbook; +- `sample-portfolio`의 WorkLog/Poster integration-event 예시. + +이 기반이 증명하지 않는 것은 다음과 같다. + +- 실제 Kafka client가 존재한다는 것; +- `KafkaSender.send()` 반환이 broker acknowledgement를 뜻한다는 것; +- 현재 `PUBLISHED` 상태가 실제 broker ACK 뒤에만 기록된다는 것; +- 현재 hand-written JSON envelope가 schema-valid 또는 rolling-compatible하다는 것; +- consumer가 존재하거나 중복을 inbox로 흡수한다는 것; +- TLS/SASL, ACL, topic topology, resource bound, graceful drain이 준비되었다는 것; +- CDC mode나 polling/CDC 전환이 가능하다는 것; +- real Kafka/PostgreSQL/Kafka Connect 장애 시험을 통과했다는 것. + +### 0.2 최초 R2 구축 대상 + +첫 R2 reference tuple은 다음 하나다. + +```text +producer-provider = kafka-spring +producer-semantics = acknowledged-idempotent-v1 +outbox-dispatch = postgresql-polling-v2 +claim-strategy = postgresql-per-record-jit-claim-v1 +wire-format = json-schema-envelope-v1 +topic-management = externally-provisioned-and-validated-v1 +security = sasl-ssl-scram-sha-512-v1 +compression = none-v1 +ordering = per-key-normal-path-sequence-detectable-v1 +transaction-resource = same-postgresql-transaction-resource-v1 +operator-control = authenticated-internal-web-disposition-v1 +consumer = disabled +cdc = disabled +``` + +첫 구현은 다음 순서로 하나의 실제 경로를 만든다. + +1. logical destination과 versioned event contract catalog; +2. UTF-8 JSON envelope v1과 checked-in JSON Schema; +3. immutable `outbox_event`, polling-only `outbox_delivery`, append-only attempt journal; +4. publication epoch, per-record JIT claim, claim token/valid lease, aggregate sequence, + bounded retry/attempt budget; +5. `Spring Kafka`의 `KafkaTemplate`/`ProducerFactory`를 직접 소유하는 outbound provider; +6. broker ACK를 기다리는 typed outcome; +7. TLS/SASL_SSL production profile, finite queue/timeout, readiness와 graceful shutdown; +8. application disposition use case + authenticated internal web operator control; +9. PostgreSQL + real Kafka 통합·장애·보안 evidence. + +첫 R2 구축에는 inbound Kafka consumer, inbox, retry topic, DLT replay, Debezium/Kafka Connect가 +들어가지 않는다. 다만 최초 wire/outbox 구조가 그 후속 기능을 갈아 끼우거나 추가할 수 있도록 +설계한다. + +### 0.3 후속 설계 완료 / 미구현 + +| Capability | 문서상 결정 | 현재 구현 | +| --- | --- | --- | +| inbound Kafka | 별도 `adapter:inbound:messaging-kafka` leaf | 없음 | +| consumer acknowledgement | application commit 뒤 `MANUAL_IMMEDIATE` | 없음 | +| inbox | `(consumerId, eventId)` unique + business write와 같은 DB transaction | 없음 | +| consumer retry | 짧고 bounded한 blocking retry가 기본 | 없음 | +| retry topic | ordering을 잃는 opt-in card | 없음 | +| DLT | DLT publish ACK 뒤 원본 offset 진행 | 없음 | +| replay | 별도 group/job, 범위·승인·audit 필수 | 없음 | +| CDC | 외부 Kafka Connect/Debezium, insert-only event source | 없음 | +| polling/CDC 전환 | 같은 production destination에서 상호 배타, 별도 cutover runbook | 없음 | +| Avro/Protobuf | schema-registry와 함께 optional serialization card | 없음 | +| Kafka transaction/EOS | DB-free Kafka consume-process-produce에만 optional | 없음 | +| 대체 broker | 동일 semantic guarantee/evidence를 만족하는 provider card로만 추가 | 없음 | + +### 0.4 상태 ledger + +이 표는 구현 진척의 human-readable SSOT다. 이후 구현 작업은 이 표만 갱신하고 완료 표현을 +본문 여러 곳에 복제하지 않는다. + +| Phase | 산출물 | 2026-07-28 상태 | 허용 표현 | +| --- | --- | --- | --- | +| 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 주장 금지 | +| P5 | inbound Kafka leaf + inbox + DLT/replay | `DESIGNED_NOT_IMPLEMENTED` | 후속 설계 | +| P6 | PostgreSQL Debezium CDC + cutover | `DESIGNED_NOT_IMPLEMENTED` | 후속 설계 | +| P7 | Avro/Protobuf/EOS/대체 provider cards | `OPTIONAL_BACKLOG` | 후보 | + +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을 분리한다. +provider-neutral이라는 말은 모든 broker의 최저 공통분모를 가진 범용 +`send(topic, key, payload)` API를 만들겠다는 뜻이 아니다. + +선택한 구조는 다음과 같다. + +1. Application은 integration event의 의미, logical destination, identity, ordering intent, + transactional append와 consumption policy를 소유한다. +2. `adapter:outbound:messaging`은 wire contract compilation, JSON envelope, Kafka producer, + ACK outcome, producer lifecycle과 provider telemetry를 소유한다. +3. `adapter:outbound:persistence-jpa`는 same-store outbox/inbox persistence와 claim CAS를 + 소유한다. +4. 첫 reference provider는 outbound leaf 내부의 Spring Kafka + `DefaultKafkaProducerFactory` + `KafkaTemplate`이다. +5. 현재 `KafkaSender`는 legacy/test migration seam으로만 남길 수 있고 R2 provider로 + 표시하지 않는다. +6. durable publication의 성공은 broker ACK metadata가 확인된 경우에만 선언한다. +7. ACK를 기다리다 deadline/cancellation/connection loss가 발생하면 성공이나 확정 실패가 아니라 + `INDETERMINATE`다. +8. 첫 serialization은 versioned UTF-8 JSON envelope와 checked-in JSON Schema다. +9. event type을 physical Kafka topic으로 직접 사용하지 않는다. logical destination을 + deployment binding이 physical topic으로 컴파일한다. +10. polling outbox는 immutable event와 mutable delivery control을 분리한다. +11. CDC는 첫 R2에 포함하지 않고 같은 immutable event source를 사용하는 후속 dispatch card다. +12. consumer를 구현하기 전 inbound Kafka 전용 leaf를 registry에 추가한다. +13. 자동 publication은 bounded하므로 무조건적인 eventual delivery를 주장하지 않는다. + consumer/inbox까지 구현된 범위만 duplicate-possible delivery와 idempotent effect로 표현한다. +14. Kafka producer idempotence나 transaction을 DB와 Kafka 사이의 generic exactly-once로 + 표현하지 않는다. +15. 사용하지 않는 producer/consumer/CDC profile은 connection, thread, scheduler, AdminClient, + connector, schema가 0개여야 한다. + +이 결정의 핵심은 “Kafka로 먼저 하나를 만든다”와 “나중에 교체 가능하게 한다”를 동시에 만족하는 +것이다. 교체 가능성은 빈 SPI 하나가 아니라 stable semantic contract, explicit capability card, +exact profile validation, provider별 evidence로 확보한다. + +## 2. 상위 설계와 기존 심화 설계의 관계 + +상위 Production Capability Platform Design은 다음을 이미 결정했다. + +- transactional outbox append는 source-of-truth transaction과 함께한다; +- dispatch는 `disabled | polling | cdc`로 선택한다; +- immutable `outbox_event`와 polling-only `outbox_delivery`를 분리한다; +- physical topic은 adapter 설정이고 application event type이 아니다; +- real Kafka producer는 ACK, bounded delivery timeout, idempotence, security를 가져야 한다; +- consumer는 별도 inbound leaf, manual acknowledgement, inbox, DLT/replay를 사용한다; +- DB와 broker를 아우르는 generic exactly-once를 주장하지 않는다. + +이번 문서는 그 방향을 구현자가 다시 추론하지 않도록 다음을 추가로 고정한다. + +- 첫 R2 exact tuple; +- stable contract와 replaceable capability card의 경계; +- integration event identity와 versioned wire envelope; +- logical destination catalog와 physical topic binding; +- producer ACK/REJECTED/INDETERMINATE state machine; +- Kafka client retry와 relay retry의 combined amplification budget; +- immutable event/polling delivery schema와 claim token; +- polling ACK-to-DB gap, dead resolution, replay와 retention; +- future consumer/inbox/DLT/replay의 정확한 transaction과 acknowledgement 순서; +- future CDC connector, slot/offset/WAL, shadow/cutover/rollback 조건; +- configuration expected-state, readiness card와 evidence fingerprint; +- 보안, privacy, observability, test/CI/no-skip와 runbook 요구. + +Redis/FileServer/HTTPClient deep design에서 재사용하는 공통 패턴은 다음이다. + +- 현재 truth와 목표를 문서 앞에서 분리한다; +- semantic contract와 provider runtime을 분리한다; +- logical ID와 physical endpoint/topic을 분리한다; +- exact-one provider selection과 disabled resource-0를 사용한다; +- typed outcome으로 definite/indeterminate를 구분한다; +- finite deadline, queue, body/message size와 graceful shutdown을 계약으로 둔다; +- readiness를 capability card와 real-service evidence로 제한한다; +- optional provider/config를 구현되기 전에 존재하는 것처럼 노출하지 않는다. + +그 문서에서 그대로 복사하지 않는 부분은 다음이다. + +- HTTP mutation의 unknown outcome과 Kafka duplicate 가능성은 비슷하지만 동일하지 않다; +- Redis fail-open cache 정책은 durable messaging에 적용하지 않는다; +- Fileserver의 atomic rename/manifest가 Kafka acknowledgement를 대체하지 않는다; +- Kafka partition ordering을 global FIFO나 distributed lock으로 표현하지 않는다; +- Kafka transaction은 DB outbox transaction을 대체하지 않는다. + +세부 내용이 상위 문서의 messaging 요약과 다르면 이 문서가 messaging 범위의 정본이다. +다른 capability 결정은 변경하지 않는다. + +### 2.1 Normative decision ledger + +| 결정 | 정본 절 | +| --- | --- | +| 현재 구현/후속 상태 | §0 | +| 첫 R2 exact tuple | §0.2, §10 | +| guarantee와 readiness 용어 | §7 | +| architecture와 module ownership | §8–§9 | +| capability card와 교체 조건 | §10 | +| identity와 ordering vocabulary | §11 | +| event transformation | §12 | +| contract/destination/topic catalog | §13 | +| JSON envelope와 schema evolution | §14 | +| application outcome contract | §15 | +| ACK-aware Kafka producer | §16–§17 | +| polling outbox state/data | §18–§19 | +| best-effort 경계 | §20 | +| activation/configuration | §21 | +| security/topic governance | §22 | +| observability/readiness | §23 | +| consumer/inbox/DLT/replay | §24–§25 | +| CDC와 mode cutover | §26–§27 | +| test/CI/evidence | §28–§29 | +| migration/status update | §30 | +| 완료/후속 card | §31 | +| runbook | §32 | + +예시 YAML, Java pseudocode, migration alias, README, runbook은 이 ledger의 정본 절보다 우선하지 +않는다. 두 activation source가 충돌하면 임의 precedence나 fallback을 적용하지 않고 startup을 +실패시킨다. + +## 3. 증거 기반 현재 상태 + +### 3.1 실제 Kafka production dependency가 없다 + +`adapter:outbound:messaging/build.gradle`의 production dependency는 현재 다음뿐이다. + +```text +application-core +shared-contract +adapter:outbound:support +spring-boot-autoconfigure +slf4j-api +``` + +`spring-kafka`와 `kafka-clients`가 없으므로 production `KafkaProducer`, +`ProducerFactory`, `KafkaTemplate`, `AdminClient`도 없다. `app-bootstrap`의 +`testCompileOnly kafka-clients`는 architecture test classpath를 위한 것이며 실제 provider가 +아니다. + +따라서 현재 `app.messaging.broker=kafka`는 Kafka capability 활성화가 아니라 fork project가 +`KafkaSender` bean을 별도로 제공했을 때 seam을 선택한다는 의미다. + +### 3.2 `void KafkaSender.send()`는 broker ACK를 표현하지 못한다 + +현재 contract는 다음과 같다. + +```java +void send(OutboundMessage message) throws Exception; +``` + +Kafka `send()`는 일반적으로 local buffer에 record를 넣고 future를 즉시 반환한다. 외부 seam이 +future를 기다리는지, 어떤 `acks`를 쓰는지, delivery timeout이 유한한지 repository는 알 수 없다. +그런데 application의 `OutboxEventStatus.PUBLISHED` 문서는 broker acknowledgement를 뜻한다고 +설명한다. + +현재 seam 구현자가 callback/future 완료 전에 정상 반환하면 relay는 ACK가 없는 record를 +`PUBLISHED`로 기록한다. 이는 단순한 구현 누락이 아니라 현재 상태 이름과 실제 evidence의 +불일치다. + +### 3.3 event type이 physical topic으로 사용된다 + +현재 `OutboxMessagePublishAdapter`는 다음 매핑을 한다. + +```text +topic = event.eventType() +key = event.aggregateId() +payload = hand-written envelope +``` + +이 구조는 application event naming이 Kafka topic naming, ACL, retention, partition count, +replication, environment naming과 결합되게 한다. event type을 동적으로 만들 수 있으면 arbitrary +topic publication과 metric cardinality도 열린다. + +목표에서는 `contractId -> logicalDestination -> physicalTopicBinding`의 닫힌 두 단계 mapping을 +사용한다. + +### 3.4 envelope가 schema-bound document가 아니다 + +`OutboxEnvelopeJson`은 문자열을 직접 이어 붙인다. payload는 “이미 올바른 JSON”이라는 주석 +계약만 있고 parser/schema validation 없이 verbatim 삽입된다. + +현재 envelope에는 다음도 없다. + +- envelope spec version; +- payload schema version; +- contract ID; +- aggregate type와 aggregate sequence; +- causation ID; +- content type; +- logical destination; +- schema hash/compatibility evidence; +- maximum depth/string/array/record bytes; +- rolling producer/consumer compatibility fixture. + +따라서 현재 JSON은 example wire shape이지 versioned integration contract가 아니다. + +### 3.5 immutable event와 mutable delivery state가 한 행에 섞여 있다 + +현재 `outbox_event`에는 event metadata와 다음 polling state가 함께 있다. + +```text +status +attempt_count +next_attempt_at +``` + +relay는 같은 row를 `PENDING -> IN_FLIGHT -> PUBLISHED/FAILED/DEAD`로 UPDATE한다. +Debezium Outbox Event Router는 outbox table을 INSERT-only queue로 기대하고 UPDATE를 비정상 +operation으로 분류한다. 현재 table에 connector flag만 켜는 방식으로 CDC를 추가할 수 없다. + +### 3.6 현재 FIFO는 동일 timestamp와 긴 batch에서 불완전하다 + +현재 claim과 defensive sort는 `occurred_at` 중심이다. 같은 aggregate에서 같은 timestamp를 가진 +두 event의 완전한 tie-breaker나 domain aggregate sequence가 없다. 따라서 strict ordering을 +증명할 수 없다. + +또한 batch row는 같은 시점에 `now + inFlightTimeout`으로 claim되고 순차 publish된다. batch의 +최악 처리 시간이 in-flight timeout을 넘으면 뒤쪽 row를 첫 worker가 처리 중일 때 다른 worker가 +재claim할 수 있다. + +목표에서는 aggregate sequence, claim token, per-row remaining-lease validation, attempt budget과 +claim lease의 관계를 고정한다. + +### 3.7 polling ACK-to-DB gap은 이미 duplicate를 허용한다 + +현재 순서는 다음과 같다. + +```text +publishPort.publish(event) + -> 별도 DB transaction에서 markPublished(eventId) +``` + +broker가 record를 수락한 뒤 process가 종료되거나 `markPublished`가 실패하면 row는 +`IN_FLIGHT`에 남고 timeout 뒤 재claim된다. 이는 올바른 transactional outbox에서 피할 수 없는 +ACK-to-state duplicate gap이며 consumer dedupe가 필요하다. + +현재 repository에는 inbound consumer와 inbox가 없으므로 “consumer dedupe가 안전하게 흡수한다”는 +runbook 표현은 목표 계약이지 현재 보장이 아니다. + +### 3.8 retry 분류가 모든 exception을 같은 경로로 보낸다 + +현재 publish exception은 attempt count가 남았으면 FAILED, 소진됐으면 DEAD가 된다. 다음을 +구분하지 않는다. + +- local schema/size violation처럼 절대 broker에 도달하지 않은 permanent rejection; +- authorization/topic-not-found처럼 configuration/operator 조치가 필요한 failure; +- retriable broker/network failure; +- broker가 수락했을 수도 있는 timeout/cancel/connection loss; +- programming defect; +- stale claim owner가 수행한 결과. + +poison event도 max attempt까지 재시도하므로 불필요한 amplification과 aggregate head blocking을 +만든다. + +### 3.9 configuration은 topology와 guarantee를 표현하지 못한다 + +현재 typed settings는 사실상 다음뿐이다. + +```text +app.messaging.broker +app.messaging.kafka.brokers +``` + +다음이 없다. + +- expected state; +- producer semantic profile; +- logical destination binding; +- contract/schema catalog; +- dispatch mode; +- acknowledgement deadline; +- delivery/request/max-block timeout; +- buffer/in-flight/batch/record size; +- security protocol, TLS, SASL, secret reference; +- topic partitions/replication/min ISR/retention expectation; +- readiness requirement; +- shutdown drain; +- selected capability/evidence digest. + +host:port regex도 IPv6, duplicate endpoint, port range, blank-normalization과 secret/source policy를 +충분히 검증하지 않는다. + +### 3.10 best-effort와 durable success vocabulary가 섞일 수 있다 + +일반 `OutboundMessagePublisher`는 exception을 삼키는 fail-open이고 outbox publisher는 +exception을 전파하는 fail-closed다. 이 구분 자체는 유용하다. + +그러나 둘 다 같은 `MessageBroker.send(void)`를 호출하므로 다음을 구분하지 못한다. + +- local admission; +- producer buffer enqueue; +- broker acknowledgement; +- definite rejection; +- indeterminate result. + +일반 publisher의 `logSuccess`도 broker ACK가 아니라 seam의 정상 반환만 의미할 수 있다. + +### 3.11 consumer/inbox/CDC runtime이 없다 + +현재 registry에는 19개 leaf만 있고 inbound Kafka leaf가 없다. production source 검색 기준으로 +다음도 없다. + +- `@KafkaListener` 또는 listener container; +- consumer group/offset/ack policy; +- deserializer allowlist; +- inbox table/port/executor; +- retry/DLT/replay; +- rebalance/pause/resume/drain; +- Debezium connector/Kafka Connect deployment; +- replication slot/offset/WAL monitoring. + +이 기능은 package를 outbound leaf에 추가하지 않고 각각 §24–§27의 단계에서 도입한다. + +### 3.12 현재 test와 runbook이 증명하는 범위 + +현재 focused messaging test는 fake sender/broker와 settings/composition/report rendering을 +검증한다. PostgreSQL outbox integration test는 same-transaction append, row lifecycle, +normal-path two-worker `SKIP LOCKED` row partitioning과 일부 claim 동작을 검증한다. + +현재 test가 증명하지 않는 것은 다음이다. + +- real broker ACK metadata; +- leader loss/min ISR/timeout/duplicate; +- real buffer saturation; +- TLS/SASL/ACL; +- topic drift; +- producer close/drain; +- schema compatibility; +- consumer rebalance/inbox; +- CDC restart/slot/offset. + +`outbox-publish-failed` runbook은 이미 제거된 `APP_MESSAGING_KAFKA_ENABLED`와 존재하지 않는 +`KafkaOutboxMessagePublishAdapter`를 참조한다. `outbox-dead-letter` runbook은 raw SQL로 상태를 +직접 수정하며 operator identity, reason, CAS, audit generation이 없다. 두 문서는 R2 구현과 함께 +도구 기반 절차로 교체해야 한다. + +## 4. 범위와 명시적 비범위 + +### 4.1 전체 설계 범위 + +이 문서는 다음을 설계한다. + +- domain event와 integration event의 분리; +- framework-free event metadata와 application outbox contract; +- logical destination, contract, payload schema, topic binding catalog; +- JSON envelope v1, schema evolution와 compatibility evidence; +- ACK-aware Kafka producer와 typed certainty; +- producer retry, ordering, batching, compression, resource bound와 lifecycle; +- immutable event + polling delivery state; +- claim token, retry/dead/replay/retention; +- best-effort와 durable publication 구분; +- typed activation, expected state와 capability cards; +- TLS/SASL, ACL, topic governance, secret rotation; +- metrics/tracing/log/readiness; +- future inbound Kafka leaf; +- consumer manual ack, inbox, retry, DLT, replay; +- future PostgreSQL Debezium CDC; +- polling/CDC shadow, cutover, rollback; +- real-service/fault/security/compatibility CI. + +### 4.2 최초 R2 baseline + +최초 R2는 다음 common subset만 구현한다. + +- Kafka 한 provider; +- polling outbox 한 dispatch mode; +- JSON Schema 한 serialization profile; +- external topic provisioning + startup validation; +- acknowledged idempotent producer; +- production TLS/SASL_SSL와 explicit local plaintext profile; +- single-cluster, single-region producer; +- bounded record, buffer, batch, retry, deadline와 shutdown; +- real PostgreSQL + Kafka qualification. + +consumer와 CDC는 설계에 포함되지만 최초 R2 implementation acceptance에는 포함되지 않는다. + +### 4.3 후속 optional capability + +다음은 stable boundary 뒤에 추가할 수 있다. + +- Kafka manual-ack consumer + PostgreSQL inbox; +- retry-topic/delayed retry; +- Debezium PostgreSQL CDC; +- Avro + schema registry; +- Protobuf + schema registry; +- JSON Schema registry; +- Kafka transactional consume-process-produce; +- Kafka Streams; +- alternative partitioner proven by ordering vectors; +- multi-cluster replication/failover; +- broker/provider 대체; +- contract-specific compaction; +- large-message claim-check pattern with object storage; +- module split 또는 external capability artifact. + +optional card는 이름만 등록하지 않는다. code, typed settings, tests, runbook, evidence가 같은 +변경에서 존재할 때 registry에 추가한다. + +### 4.4 비범위 + +다음은 이번 설계의 목표가 아니다. + +- arbitrary topic/key/header를 application에 노출하는 Kafka facade; +- 모든 broker를 lowest-common-denominator API로 감싸기; +- DB와 Kafka를 XA/distributed transaction으로 묶기; +- generic exactly-once delivery 주장; +- Kafka partition ownership을 distributed lock으로 사용하기; +- Kafka를 source-of-truth database로 일반화하기; +- unbounded event sourcing platform; +- dynamic user input으로 topic 생성하기; +- payload에 임의 Java class/type header를 넣기; +- active-active multi-region ordering을 보장하기; +- 대용량 binary를 Kafka record에 직접 싣기; +- sample-portfolio business contract를 production leaf에 넣기. + +## 5. HARD invariants + +다음 중 하나라도 위반하면 동작하는 코드라도 messaging 설계 구현 완료가 아니다. + +1. `domain-core`는 Kafka, Spring, JSON library, database, transport 타입을 알지 않는다. +2. `application-core`는 `KafkaTemplate`, `ProducerRecord`, `ConsumerRecord`, offset, + partition SDK 타입을 알지 않는다. +3. business/best-effort/outbox publication producer와 inbound consumer는 같은 leaf에 두지 + 않는다. inbound processing lifecycle에만 쓰이는 closed retry/DLT publisher는 + `adapter:inbound:messaging-kafka`가 소유할 수 있는 유일한 명시적 예외이며 application publish, + outbox relay 또는 arbitrary destination 전송에 재사용하지 않는다. +4. inbound Kafka leaf는 persistence 또는 outbound messaging leaf에 직접 의존하지 않는다. +5. business write와 durable event append는 같은 source-of-truth transaction에 참여한다. +6. event type을 physical topic으로 암묵 변환하지 않는다. +7. application은 arbitrary topic/header/security property를 전달하지 않는다. +8. 모든 publish는 closed contract catalog와 destination binding을 통과한다. +9. wire envelope와 payload는 append 전에 versioned schema와 size limit을 통과한다. +10. broker ACK metadata 전에는 durable publish 성공을 선언하지 않는다. +11. future 반환/local enqueue를 broker ACK라고 부르지 않는다. +12. timeout/cancel/late callback race를 definite failure로 축소하지 않는다. +13. `INDETERMINATE`는 broker acceptance 가능성을 보존한다. +14. producer idempotence를 process restart나 relay retry 전체의 dedupe로 표현하지 않는다. +15. Kafka transaction을 DB + Kafka atomic commit으로 표현하지 않는다. +16. end-to-end는 bounded source/retention 조건 안의 duplicate-possible delivery와 + inbox-covered idempotent effect로만 표현한다. +17. `outbox_event`는 CDC qualification 전에 insert-only immutable source가 된다. +18. polling mutable state는 `outbox_delivery`에만 둔다. +19. polling과 CDC는 같은 production destination에서 동시에 발행하지 않는다. +20. shadow CDC는 격리된 topic과 격리된 consumer group만 사용한다. +21. strict aggregate ordering은 timestamp가 아니라 explicit aggregate sequence를 요구한다. +22. active worker가 소유한 renew/outcome transition은 current claim token/owner와 unexpired + DB-time lease를 CAS 조건으로 검증한다. initial claim, expired reclaim, operator disposition과 + generation authority handoff는 §18.6의 각기 다른 fenced predicate를 사용한다. +23. Kafka client retry와 relay retry는 하나의 finite amplification budget으로 검증한다. +24. timeout, queue, buffer, in-flight, batch, payload, header, retry는 모두 finite다. +25. automatic provider fallback과 automatic topic creation은 production에서 금지한다. +26. disabled profile은 client, AdminClient, listener, scheduler, connector, refresh thread가 0개다. +27. production plaintext와 literal secret은 startup fail-closed다. +28. event payload, partition key, tenant, credential, raw headers는 log/metric tag에 넣지 않는다. +29. consumer offset은 application transaction commit 뒤에만 진행한다. +30. `APPLIED`와 verified `DUPLICATE`만 바로 ACK할 수 있다. +31. poison record를 DLT로 보낼 때 DLT publish ACK 전에는 원본 offset을 진행하지 않는다. +32. retry topic이 ordering을 잃는다는 사실을 숨기지 않는다. +33. replay는 별도 audited operation이며 운영 group offset을 임의 rewind하지 않는다. +34. R2는 exact selected profile의 real-service/security/fault/shutdown evidence가 있어야 한다. +35. required qualification lane은 Docker나 broker 부재를 이유로 silent skip하지 않는다. +36. 현재 구현되지 않은 consumer/CDC/schema-registry setting을 live config처럼 추가하지 않는다. +37. production leaf는 `sample-portfolio` contract나 fixture에 의존하지 않는다. +38. 모든 dependency edge는 `src/config/architecture/modules.json` 변경과 검증을 통과한다. +39. nullable tenant scope를 unique/dedupe/order key에 사용하지 않는다. +40. legacy/v2/polling/CDC relay authority는 같은 destination에서 항상 정확히 하나다. +41. CDC commit order를 aggregate sequence order라고 표현하지 않는다. +42. plain unique-violation catch 뒤 rollback-only PostgreSQL/JPA transaction을 계속 사용하지 않는다. +43. consumer의 bounded retry가 소진되면 durable HOLD와 partition/container stop 중 하나로 + automation을 끝낸다. recoverer 실패로 같은 retry cycle을 무기한 다시 시작하지 않는다. + +## 6. 대안 검토 + +### 6.1 현재 `KafkaSender` seam만 확장 + +장점: + +- broker SDK가 leaf에 없으므로 가볍다; +- fake unit test가 쉽다; +- fork project가 client를 자유롭게 선택할 수 있다. + +문제: + +- actual producer config와 lifecycle evidence를 template이 소유하지 못한다; +- ACK, timeout, buffer saturation, TLS/SASL, metrics를 검증할 수 없다; +- fork마다 guarantee가 달라져 같은 capability label을 사용할 수 없다. + +판정: legacy/test seam으로만 유지한다. `void/throws` shape는 R2 선택 불가다. + +### 6.2 Apache Kafka client 직접 사용 + +장점: + +- `KafkaProducer` lifecycle, callback, metrics, transaction을 가장 직접 통제한다; +- Spring abstraction 없이 Kafka API 의미를 그대로 사용할 수 있다. + +문제: + +- producer factory, generation rotation, close/drain, observation, transaction cache, + Spring lifecycle integration을 모두 직접 소유해야 한다; +- 현재 Spring Boot composition과 중복이 커진다. + +판정: future provider candidate다. Spring Kafka가 보장을 막는 구체적 evidence가 있을 때만 +추가한다. + +### 6.3 Spring Kafka `KafkaTemplate` + explicit producer factory + +장점: + +- 실제 Kafka producer guarantee를 사용하면서 Spring lifecycle/composition과 정렬된다; +- future/ACK metadata, observation, test support를 사용할 수 있다; +- provider config를 outbound leaf가 직접 검증할 수 있다. + +주의: + +- Boot의 broad auto-configuration/default에 activation을 맡기지 않는다; +- `KafkaTemplate.send()` 반환 자체가 ACK가 아니므로 future 완료를 기다려야 한다; +- shared producer에서 per-message `flush()`를 사용하지 않는다; +- provider settings를 Kafka raw map으로 무제한 노출하지 않는다. + +판정: 첫 reference provider로 선택한다. + +### 6.4 Spring Cloud Stream/binder를 baseline으로 사용 + +장점: + +- binder 교체와 functional pipeline이 편리하다; +- broker-neutral developer experience를 제공할 수 있다. + +문제: + +- 현재 요구는 exact producer acknowledgement, outbox state, topic/security drift, + client retry와 lifecycle을 직접 증명하는 것이다; +- binder abstraction이 provider-specific guarantee와 evidence ownership을 흐릴 수 있다; +- dependency와 activation 범위가 첫 reference path보다 크다. + +판정: 첫 baseline으로 선택하지 않는다. 동일 semantic card와 exact evidence를 만족하는 +future provider로 검토할 수 있다. + +### 6.5 consumer를 outbound messaging leaf에 추가 + +장점: + +- Kafka dependency와 설정을 한 곳에서 공유한다. + +문제: + +- producer는 driven adapter이고 consumer는 driving adapter다; +- outbound leaf가 use case invocation, offset lifecycle, rebalance를 소유하게 된다; +- adapter-to-adapter/persistence 직접 의존 유혹이 생긴다. + +판정: 금지한다. consumer 구현 전 별도 inbound leaf를 추가한다. + +### 6.6 CDC를 첫 dispatch로 구현 + +장점: + +- Java polling scheduler와 ACK-to-mark gap을 제거한다; +- database log 기반 확장성이 좋을 수 있다. + +문제: + +- 현재 mutable table과 호환되지 않는다; +- Kafka Connect, Debezium, replication slot, WAL, offset topic, snapshot과 운영 범위가 함께 + 필요하다; +- 첫 실제 producer/contract도 없는 상태에서 장애 면적이 너무 크다. + +판정: 전체 설계에는 포함하되 첫 구현은 polling이다. + +### 6.7 polling만 설계하고 CDC는 나중에 처음부터 다시 설계 + +문제: + +- mutable row/envelope가 굳으면 CDC 전환 비용이 커진다; +- topic/identity/wire parity가 dispatch 구현마다 갈라진다. + +판정: 거부한다. 처음부터 immutable event와 replaceable dispatch card를 둔다. + +### 6.8 JSON Schema, Avro, Protobuf + +JSON Schema 장점: + +- 현재 JSON 예제와 이행 거리가 짧다; +- schema 파일과 golden vectors를 repository에서 바로 review할 수 있다; +- registry 없이 첫 contract를 세울 수 있다. + +JSON Schema 한계: + +- compatibility를 schema diff heuristic만으로 완전히 증명할 수 없다; +- binary 효율과 generated type safety는 Avro/Protobuf보다 약할 수 있다. + +Avro/Protobuf 장점: + +- generated type과 schema registry ecosystem이 강하다; +- compact binary wire를 제공한다. + +Avro/Protobuf 비용: + +- registry availability/security/compatibility mode와 build generation을 함께 설계해야 한다; +- 현재 skeleton의 첫 R2 경로를 넓힌다. + +판정: JSON Schema v1을 먼저 구축하고 Avro/Protobuf는 별도 evidence card로 추가한다. + +### 6.9 Kafka transaction을 모든 durable publish에 사용 + +Kafka transaction은 Kafka 안의 여러 record와 offset을 원자화할 수 있다. 그러나 PostgreSQL +business commit과 Kafka transaction을 하나의 atomic commit으로 만들지 않는다. Spring의 DB/Kafka +transaction synchronization도 순차 commit이며 두 번째 commit 실패 가능성이 남는다. + +판정: polling outbox producer baseline에서는 사용하지 않는다. DB-free +Kafka consume-process-produce에만 optional card로 둔다. + +## 7. Capability readiness와 guarantee vocabulary + +### 7.1 Readiness level + +| Level | 의미 | +| --- | --- | +| R0 | interface/seam/example만 존재; 실제 service guarantee 없음 | +| R1 | deterministic unit/contract/local composition은 검증; production topology/fault 증거 없음 | +| R2 | exact selected profile이 real service, security, fault, lifecycle, compatibility gate 통과 | +| R3 | HA/failover/upgrade/DR/capacity와 운영 rehearsal까지 통과 | + +`KafkaSender`는 R0다. 현재 PostgreSQL polling control plane은 일부 real-DB test가 있으므로 R1 +skeleton으로 설명할 수 있지만 end-to-end Kafka publication은 R0다. + +### 7.2 Publication vocabulary + +| 용어 | 정확한 의미 | +| --- | --- | +| `COMPILED` | contract + destination + provider profile이 startup에 검증됨 | +| `ADMITTED` | local bounded admission을 통과함 | +| `ENQUEUED` | producer local buffer가 record를 받음 | +| `ACKNOWLEDGED` | configured ACK 조건을 만족한 broker metadata를 local process가 관찰함 | +| `ACKNOWLEDGED_MISMATCH` | ACK metadata가 compiled destination과 달라 misroute incident가 됨 | +| `REJECTED` | provider가 record 비수락을 확정할 수 있음 | +| `INDETERMINATE` | broker가 수락했을 수도, 아닐 수도 있음 | +| `DELIVERY_RECORDED` | ACK 뒤 polling delivery row가 terminal success로 commit됨 | +| `CONSUMED` | consumer가 record를 읽음; business side effect 완료와 다름 | +| `APPLIED` | inbox + business effect transaction이 commit됨 | +| `OFFSET_COMMITTED` | APPLIED/DUPLICATE 뒤 Kafka offset이 진행됨 | + +`ACKNOWLEDGED`와 `DELIVERY_RECORDED` 사이 crash는 duplicate를 만든다. +`APPLIED`와 `OFFSET_COMMITTED` 사이 crash도 duplicate delivery를 만든다. 둘 다 event ID와 inbox가 +흡수해야 하는 duplicate-possible 경계다. `ACKNOWLEDGED_MISMATCH`는 retry 가능한 일반 실패가 +아니며 producer admission과 relay scope를 멈추는 fatal misroute incident다. + +### 7.3 허용 guarantee + +최초 R2가 주장할 수 있는 표현: + +```text +same-store transactional outbox append ++ bounded automatic publish attempts ++ broker ACK 또는 명시적 unresolved/operator disposition ++ 같은 producer generation의 정상 경로에서 stable key별 Kafka order ++ aggregate sequence를 통한 gap/regression 탐지 가능성 ++ idempotent Kafka producer within its supported producer session ++ explicit duplicate handling requirement +``` + +이는 무조건적인 eventual delivery 또는 failure-path strict FIFO가 아니다. finite budget이 끝난 +`EXHAUSTED`, 승인된 `SKIPPED/COMPENSATED`, 영구 hold가 존재할 수 있다. 따라서 첫 R2 card의 +정확한 표현은 `durable acknowledged-or-explicit-disposition publication`이다. + +future consumer/inbox까지 구현한 뒤 주장할 수 있는 표현: + +```text +declared source/retention/disposition horizon 안의 duplicate-possible delivery ++ idempotent application effect for inbox-covered handlers +``` + +허용하지 않는 표현: + +- exactly-once DB-to-Kafka; +- exactly-once end-to-end; +- global ordering; +- no duplicates; +- no loss across an unqualified CDC slot failover; +- DLT가 곧 성공 처리 또는 데이터 복구라는 표현. + +### 7.4 Evidence identity + +R2 evidence는 단순 test 이름이 아니라 다음 fingerprint에 묶인다. + +```text +semantic-card-version +provider-card-version +Kafka client version +Spring Kafka version +broker image/version +JDK version +security profile +topic profile +contract catalog hash +schema set hash +settings digest +test scenario version +``` + +다른 version/profile에 이전 evidence를 자동 승계하지 않는다. + +## 8. 목표 아키텍처 + +```mermaid +flowchart LR + DOMAIN[Domain event] --> MAP[Application integration-event mapping] + MAP --> APPEND[OutboxAppendPort] + APPEND --> DBTX[(Business DB transaction)] + DBTX --> EVENT[(immutable outbox_event)] + DBTX --> DELIVERY[(polling outbox_delivery)] + + DELIVERY --> RELAY[Application polling relay] + RELAY --> PUBPORT[OutboxMessagePublishPort] + PUBPORT --> CATALOG[Contract + destination compiler] + CATALOG --> KAFKA[Spring Kafka ACK-aware provider] + KAFKA --> TOPIC[(Kafka topic)] + + EVENT -. future CDC .-> DBZ[Debezium / Kafka Connect] + DBZ -. same wire contract .-> TOPIC + + TOPIC -. future consume .-> INBOUND[adapter:inbound:messaging-kafka] + INBOUND --> CUSE[Application consume use case] + CUSE --> INBOXTX[(Inbox + business + optional outbox transaction)] + INBOXTX --> ACK[Kafka offset ACK] + + BOOT[app-bootstrap] -. compile exact profiles .-> CATALOG + BOOT -. compose .-> RELAY + BOOT -. future compose .-> INBOUND + OBS[Metrics / trace / readiness] -. bounded observation .-> KAFKA + OBS -. bounded observation .-> INBOUND +``` + +### 8.1 Stable plane + +provider/dispatch가 바뀌어도 다음은 유지한다. + +- event ID와 contract ID; +- schema version과 envelope version; +- logical destination; +- aggregate identity/sequence와 partition-key intent; +- publication certainty taxonomy; +- inbox dedupe identity; +- application transaction meaning; +- capability/evidence descriptor shape. + +### 8.2 Replaceable plane + +다음은 capability card로 교체할 수 있다. + +- Kafka Spring provider / direct Kafka provider / future broker provider; +- polling / CDC; +- JSON Schema / Avro / Protobuf; +- blocking retry / retry topic; +- PostgreSQL inbox / other same-store inbox; +- local plaintext dev / TLS / SASL_SSL security; +- externally provisioned topic validation / future managed provisioning; +- non-transactional idempotent producer / Kafka transactional workflow. + +### 8.3 교체가 아닌 것 + +다음은 silent fallback이며 금지한다. + +- Kafka outage 때 다른 broker로 자동 전송; +- schema validation 실패 때 raw JSON으로 전송; +- CDC 장애 때 polling을 자동으로 동시에 켬; +- TLS secret 실패 때 plaintext로 연결; +- DLT publish 실패 때 원본 offset을 ACK; +- required consumer failure 때 record를 best-effort로 폐기. + +## 9. 모듈과 계층 소유권 + +### 9.1 `domain-core` + +소유: + +- 순수 domain event와 aggregate invariant; +- aggregate version/sequence가 domain 의미일 때 그 증가 규칙. + +금지: + +- integration topic/destination; +- JSON/schema; +- outbox/inbox; +- Kafka header/partition; +- retry/DLT. + +Domain event는 같은 bounded context 내부의 사실이다. Integration event는 외부 consumer와의 +versioned contract이므로 자동으로 동일한 타입을 직렬화하지 않는다. + +### 9.2 `application-core` + +소유: + +- integration-event draft의 framework-free metadata; +- `OutboxAppendPort`; +- polling relay orchestration; +- provider-neutral publication outcome/certainty; +- provider-neutral late-publication observation drain과 attempt observation port; +- producer generation 교체 시 durable `INDETERMINATE/HOLD`, admission 재개와 generation barrier를 + 조정하는 provider-neutral rotation use case; +- outbox requeue/hold/skip/compensate command use case와 authorization/audit policy; +- future `InboxStorePort`와 `MessageConsumptionExecutor`; +- feature consume use case와 transaction policy; +- retry/dead decision의 application/operational policy. + +금지: + +- physical topic; +- `KafkaTemplate`, record metadata SDK 타입; +- connector/replication slot; +- JPA entity; +- Micrometer/SLF4J. + +### 9.3 `adapter:outbound:persistence-jpa` + +소유: + +- `outbox_event`, `outbox_delivery`, future `inbox_consumption` migration/entity/repository; +- PostgreSQL claim query와 claim-token + unexpired DB-time lease CAS; +- same-store transaction participation; +- attempt observation append와 audited disposition/authority-handoff persistence; +- polling retention query와 operator transition persistence; +- future inbox unique constraint. + +금지: + +- Kafka producer; +- topic routing; +- event business mapping; +- use-case retry policy. + +### 9.4 `adapter:outbound:messaging` + +소유: + +- destination binding compiler; +- envelope/payload schema validation runtime; +- provider-private publish gateway; +- Spring Kafka producer factory/template/AdminClient; +- ACK mapping, finite deadline, buffer/admission; +- late completion을 payload-free bounded queue로 노출하는 application port 구현; +- producer security와 provider-private generation 생성/drain/close/attestation primitive 및 + payload-free lifecycle fact; +- adapter-local best-effort publisher; +- structured outbox diagnostics. + +금지: + +- consumer listener; +- inbox repository; +- JPA entity; +- business route/event policy; +- sample contract. + +초기 package shape: + +```text +dev.caskeleton.adapter.outbound.messaging + config/ + contract/ + destination/ + envelope/ + publication/ + kafka/ + lifecycle/ + observation/ + outbox/ +``` + +package 이름은 예시이고 책임 분리가 정본이다. + +### 9.5 미래 `adapter:inbound:messaging-kafka` + +consumer 구현 전 registry migration으로 새 leaf를 추가한다. + +```text +module id: adapter-inbound-messaging-kafka +gradle path: :adapter:inbound:messaging-kafka +source path: src/adapter/inbound/messaging-kafka +allowed production dependencies: + - application-core + - domain-core + - shared-contract +``` + +정확한 allowed edge는 그 시점의 `modules.json` review로 확정한다. persistence/outbound messaging +adapter edge는 추가하지 않는다. `app-bootstrap`만 inbound listener, application use case, +transaction/inbox provider를 조립한다. + +소유: + +- Kafka listener container; +- record/header/envelope decode와 application command mapping; +- ack/seek/pause/resume/rebalance; +- consumer-local retry/DLT publisher. §5의 HARD invariant 3에 둔 유일한 예외이며 closed + retry/DLT binding 외 + destination과 application/outbox publication에는 사용할 수 없음; +- consumer lifecycle/metrics/security. + +금지: + +- repository 직접 호출; +- JPA entity; +- producer outbox implementation; +- business effect. + +### 9.6 `shared-contract` + +소유 가능: + +- skeleton-wide generic envelope JSON Schema; +- framework-free bounded operational descriptor vocabulary; +- error/metric contract에서 truly shared인 값. + +금지: + +- WorkLog/Poster 등 business event schema; +- Kafka SDK; +- 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만 넣지 않는다. + +### 9.7 `app-bootstrap` + +소유: + +- leaf가 bind/validate한 typed settings의 cross-leaf aggregation; +- exact capability tuple selection; +- cross-field/expected-state validation; +- provider, relay, future listener와 health composition; +- required capability readiness aggregation; +- secret reference resolution. + +금지: + +- event mapping; +- retry/DLT business policy; +- repository/Kafka implementation; +- schema compatibility rule 자체. + +broker namespace의 typed settings/value validation은 현재 local SSOT와 같이 +`adapter:outbound:messaging`이 소유한다. `app-bootstrap`은 raw Kafka map을 다시 bind하지 않고 +leaf의 compiled descriptor를 transaction resource, persistence dispatch와 합성한다. + +### 9.8 `sample-portfolio` + +소유: + +- sample domain event -> sample integration event mapping; +- sample payload schema/golden vectors; +- sample contract catalog contribution; +- sample consumer fixture가 생길 경우 application consume use case. + +Production leaf는 sample module에 의존하지 않는다. 새 프로젝트는 sample contract를 제거하고 자기 +feature contract를 같은 확장점에 등록한다. + +합법적인 조립 경로는 다음으로 고정한다. + +1. framework-free `IntegrationEventContractContribution` SPI는 `application-core`에 둔다; +2. feature/sample module은 typed payload record, schema resource와 contribution bean을 제공한다; +3. outbound messaging compiler는 application SPI의 bean 목록만 주입받으며 sample class를 import, + scan 또는 `Class.forName`하지 않는다; +4. production `app-bootstrap`은 sample에 의존하지 않는다. base skeleton은 messaging + `DISABLED`이고 empty catalog가 정상이다; +5. provider qualification test는 test-source fixture contribution을 사용한다; +6. standalone sample이 ACTIVE example을 실행하는 phase에서만 + `sample-portfolio -> adapter-outbound-messaging` runtime edge를 `modules.json`과 + `sample-portfolio/build.gradle`에 함께 추가한다. 이 edge는 fixture consumer 방향이며 반대 + edge는 금지한다. + +Spring bean discovery는 조립 수단일 뿐 contract SSOT가 아니다. 동일 contribution 목록으로 +build-time checksum manifest와 runtime compiler를 검증한다. + +application SPI의 최소 shape는 다음처럼 closed type token을 포함한다. + +```java +interface IntegrationPayload {} + +interface IntegrationEventContractContribution

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

exactPayloadRecordType(); + List canonicalRecordComponentOrder(); + SchemaResourceId payloadSchemaResource(); + Sha256 payloadSchemaHash(); + ContractDescriptor descriptor(); +} +``` + +이는 API 이름을 고정하는 코드가 아니라 경계를 고정하는 pseudocode다. outbound compiler는 +startup에 `exactPayloadRecordType()`이 final Java record이고 descriptor가 허용한 scalar, +collection, nested-record component만 갖는지 검증한다. runtime payload는 exact class equality로 +closed catalog를 찾으며 assignable-type scan, `Class.forName`, default typing, feature-provided +Jackson serializer를 허용하지 않는다. contribution은 type token/order/schema/hash만 제공하고 +JSON mapper, deterministic writer, parser와 schema validator는 계속 outbound messaging leaf가 +소유한다. 따라서 feature mapper가 JSON string/tree를 만들거나 messaging leaf가 sample class를 +compile-time import할 필요가 없다. + +### 9.9 외부 deployment asset + +다음은 application Java leaf가 아니라 deployment/integration-test asset이다. + +- Kafka cluster/topic/ACL provisioning; +- Kafka Connect worker; +- Debezium connector JSON; +- PostgreSQL publication/replication-slot procedure; +- connector image/plugin digest; +- dashboard/alert/runbook. + +## 10. Capability card와 exact selection + +### 10.1 두 층의 card + +Semantic card는 application이 요구하는 의미를 나타낸다. + +| Card | 의미 | +| --- | --- | +| `messaging-best-effort-publish.v1` | persistence/replay 보장 없는 bounded attempt | +| `messaging-outbox-publish.v1` | transactional append 뒤 ACK 또는 explicit disposition까지 추적 | +| `messaging-inbox-consume.v1` | future inbox-covered idempotent application effect | +| `messaging-cdc-dispatch.v1` | future insert-only source log dispatch | + +Provider/profile card는 그 의미를 실제로 제공하는 조합이다. + +| Card | 초기 상태 | +| --- | --- | +| `external-kafka-sender-legacy.v1` | R0, R2 selection 금지 | +| `kafka-spring-acknowledged-idempotent.v1` | 최초 R2 목표 | +| `postgresql-polling-outbox.v2` | 최초 R2 목표 | +| `json-schema-envelope.v1` | 최초 R2 목표 | +| `external-topic-validated.v1` | 최초 R2 목표 | +| `postgresql-per-record-jit-claim.v1` | 최초 R2 목표 | +| `kafka-sasl-ssl-scram-sha-512.v1` | 최초 production R2 목표 | +| `kafka-compression-none.v1` | 최초 R2 목표 | +| `per-key-normal-path-sequence-detectable.v1` | 최초 R2 목표 | +| `same-postgresql-transaction-resource.v1` | 최초 R2 목표 | +| `authenticated-internal-web-disposition.v1` | 최초 R2 목표 | + +미래 consumer/CDC/EOS/Avro/Protobuf semantic/provider 이름은 §31.5의 **설계 extension +ledger**일 뿐 machine +registry row가 아니다. code, exact settings, test와 evidence가 생기는 변경에서만 machine +registry에 추가한다. + +### 10.2 Card 필드 + +각 executable card는 최소 다음을 가진다. + +```text +cardId +cardVersion +semanticContractIds +providerId +providerVersion +maturity +guarantees +explicitNonGuarantees +outcomeTaxonomyVersion +orderingProfile +resourceBounds +automaticPublicationAge +sameEventRequeueHorizon +securityProfile +topologyProfile +lifecycleProfile +operatorControlProfile +schemaSetHash +settingsDigest +evidenceFingerprint +evidenceTasks +requiredScenarios +runbookIds +owner +``` + +`maturity`는 정확히 다음 하나다. + +```text +not-implemented +implemented-candidate +release-eligible +``` + +별도 boolean `releaseEligible`이나 중복 maturity/readiness 필드를 두지 않는다. R0–R3는 +evidence 설명용 level이고 machine selection state를 대신하지 않는다. + +### 10.3 Selection 규칙 + +```text +required semantic contract/version 일치 +AND required guarantees ⊆ provider achieved guarantees +AND outcome/failure policy compatible +AND exact dispatch/serialization/security/topic/operator-control profiles compatible +AND automatic publication/requeue/dedupe horizons compatible +AND profile.maturity = release-eligible +AND current evidence fingerprint = PASS +``` + +하나라도 불충족하면 production ACTIVE startup 또는 release gate를 실패시킨다. + +### 10.4 First R2 selected tuple + +```text +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 +``` + +local/development는 별도 `local-plaintext.v1` evidence를 가질 수 있지만 production tuple에 +승격되지 않는다. + +### 10.5 Configuration과 registry의 역할 + +- `src/config/messaging/readiness-cards.yaml`: 구현된 card의 maturity와 base scenario; +- `src/config/messaging/profile-compatibility.yaml`: wildcard 없는 exact tuple과 interaction + scenario; +- `src/config/messaging/release-profile-assertions.yaml`: 실제 release configuration digest와 + expected selected profile assertion; +- deployment binding: 이 deployment가 어떤 exact card와 logical destination을 요구하는지 선언; +- compiled descriptor: 두 입력을 합성한 실제 runtime truth; +- §0 status ledger: 구현 phase 진행의 human truth. + +설정에 `provider=avro` 같은 값을 추가하는 것만으로 optional card가 생기지 않는다. registry에 +없는 값은 unknown configuration으로 startup 실패다. + +candidate 승격 deadlock을 피하기 위해 isolated test harness에만 `QUALIFICATION_ONLY`를 둔다. +production과 같은 binder/resolver/resource composition을 사용하되 +`implemented-candidate`를 허용하고, `ACTIVE_READY`, release assertion 또는 production +descriptor는 절대 만들지 않는다. machine registry 파일은 P1–P4 구현과 함께 생성하며, 현재 +설계 extension 이름만 미리 row로 만들지 않는다. + +## 11. Identity, ordering과 vocabulary + +### 11.1 Event ID + +`eventId`는 integration event의 canonical identity다. + +- globally unique하고 immutable하다; +- first card의 wire/storage grammar는 1–96자의 canonical US-ASCII + `[A-Za-z0-9][A-Za-z0-9._:-]*`이며 DB에는 `VARCHAR(96)` + CHECK로 저장한다; +- outbox retry, producer restart, polling/CDC mode가 바뀌어도 동일하다; +- Kafka producer attempt ID나 database row ID와 다르다; +- consumer inbox dedupe의 기본 identity다; +- payload와 함께 생성된 뒤 다시 계산하지 않는다. + +현재 `idempotencyKey`는 권장값이 `eventId`이고 별도 의미가 불명확하다. 목표 contract에서는 +consumer dedupe는 `eventId` 하나를 사용한다. 원본 command의 idempotency identity가 필요하면 +`sourceOperationId`처럼 의미가 다른 이름으로 보존하며 consumer dedupe key로 자동 대체하지 않는다. + +같은 `eventId`와 다른 exact envelope document hash가 관찰되면 정상 duplicate가 아니라 identity collision 또는 +contract violation이다. consumer는 이를 `DUPLICATE`로 ACK하지 않고 quarantine/operator path로 +보낸다. + +### 11.2 Contract ID와 event name + +`contractId`는 version과 분리된 안정적인 semantic name이다. + +예: + +```text +portfolio.worklog.reserved +portfolio.poster.published +``` + +규칙: + +- closed code/manifest catalog에 등록한다; +- user/tenant/request 입력으로 동적 생성하지 않는다; +- Java class name과 자동 결합하지 않는다; +- physical topic을 포함하지 않는다; +- metric tag로 사용할 때 catalog cardinality budget을 통과해야 한다. + +`eventType` legacy field는 migration 동안 contract ID alias로 읽을 수 있지만 새 event에는 +`contractId`를 사용한다. + +### 11.3 Envelope version과 payload version + +두 version을 분리한다. + +```text +envelopeVersion = messaging 공통 metadata shape version +payloadVersion = contractId별 business payload schema version +``` + +단일 `schemaVersion`으로 두 의미를 합치지 않는다. + +- envelope version 변경은 모든 producer/consumer/CDC mapping에 영향을 준다; +- payload version 변경은 특정 contract에만 영향을 준다; +- schema file은 version별 immutable하다; +- 같은 version file의 checksum 변경은 CI 실패다. + +### 11.4 Logical destination과 physical topic + +`logicalDestinationId`는 application/contract가 요구하는 delivery class를 나타낸다. +`physicalTopic`은 deployment binding이다. + +```text +contractId + -> logicalDestinationId + -> environment-specific physicalTopic +``` + +logical destination은 retention class, ordering class, maximum record size, sensitivity, +replay horizon과 같은 semantic/operational intent를 묶는다. topic 이름, cluster bootstrap server, +ACL principal은 포함하지 않는다. + +### 11.5 Aggregate identity와 total order + +ordering을 요구하는 contract는 다음을 가진다. + +```text +aggregateType +aggregateId +aggregateOrder = (aggregateSequence, eventIndex) +``` + +- `aggregateSequence`는 domain aggregate version 또는 같은 transaction에서 allocation한 + monotonically increasing sequence다; +- 하나의 aggregate version에서 여러 integration event가 나오면 `eventIndex`로 total order를 + 완성한다; +- 더 단순한 구현이 event마다 고유 단조 sequence를 할당하면 `eventIndex=0`으로 고정할 수 있다; +- `(tenant?, logicalDestinationId, aggregateType, aggregateId, + aggregateSequence, eventIndex)`는 unique constraint로 보호한다; +- sequence를 제공할 수 없는 event는 strict aggregate ordering card를 선택할 수 없다. + +timestamp와 random event ID는 strict total order의 대체물이 아니다. + +tenant scope는 nullable uniqueness에 맡기지 않는다. + +- tenant mode ACTIVE: canonical `tenant_scope`는 `NOT NULL`이고 unique key에 포함한다; +- tenant mode DISABLED: canonical non-null system scope를 저장하거나 tenant column을 제외한 별도 + constraint를 사용한다; +- 일반 PostgreSQL `UNIQUE`의 NULL-distinct 동작에 dedupe/order correctness를 의존하지 않는다; +- `NULLS NOT DISTINCT`를 선택하면 adopted PostgreSQL version과 migration test에 명시한다. + +### 11.6 Partition key + +partition key는 catalog가 정한 deterministic mapping이다. + +기본 ordered event: + +```text +partitionKeyText = + lowerHex( + SHA-256( + UTF8("ca-skeleton.messaging.partition-key.v1") || 0x00 + || u32be(len(UTF8(tenantScope))) || UTF8(tenantScope) + || u32be(len(UTF8(logicalDestinationId))) || UTF8(logicalDestinationId) + || u32be(len(UTF8(aggregateType))) || UTF8(aggregateType) + || u32be(len(UTF8(aggregateId))) || UTF8(aggregateId) + ) + ) +partitionKeyBytes = US_ASCII(partitionKeyText) +``` + +규칙: + +- `tenantScope`는 §11.5의 canonical non-null scope다; +- `u32be`는 뒤따르는 UTF-8 byte length의 unsigned 32-bit big-endian 표현이다; +- 결과는 정확히 64자의 lowercase hexadecimal text이고 DB에는 + `VARCHAR(64) NOT NULL` + lowercase-hex CHECK로 저장한다; +- 같은 ordering scope는 동일 text/bytes를 만든다; +- raw PII/tenant/user ID를 metric/log에 노출하지 않는다; +- null/blank key는 ordering-required contract에서 startup/runtime rejection이다; +- polling producer는 저장된 text의 US-ASCII bytes를 `ByteArraySerializer`로 보내고, CDC는 같은 + PostgreSQL `VARCHAR`를 Kafka Connect `StringConverter`로 보내 같은 bytes를 만든다; +- producer와 CDC가 domain-separated length-prefix golden vector를 공유한다; +- custom partitioner가 key를 무시하면 해당 ordering card는 invalid다. + +### 11.7 Attempt, claim과 generation + +다음 identity는 event ID와 다르다. + +| Identity | 용도 | +| --- | --- | +| `claimToken` | polling row의 현재 owner를 fence하는 opaque token | +| `deliveryGeneration` | operator replay/requeue가 만든 새 delivery lifecycle | +| `publicationAttemptId` | 한 application-level send attempt 진단 | +| `producerGeneration` | credential/settings rotation으로 생성된 producer runtime | +| `consumerId` | inbox effect identity | +| `replayOperationId` | audited replay 요청 | + +attempt/generation을 consumer dedupe event ID로 사용하지 않는다. + +### 11.8 Consumer identity + +future inbox의 `consumerId`는 최소 다음을 compile한다. + +```text +logical subscription ++ handler name ++ effect contract version ++ tenant dimension when storage is tenant-isolated +``` + +Kafka group ID가 배포 편의 때문에 바뀌어도 의도하지 않은 business effect 재적용이 일어나지 +않도록 logical identity를 명시한다. group ID를 consumer identity에 포함해야 하는 deployment는 +그 관계를 descriptor에 고정한다. intentional reprocessing은 새 `replayGeneration`과 승인을 +요구한다. + +### 11.9 Clock authority + +- `occurredAt`: event가 일어난 application/domain wall-clock fact; +- `createdAt`: database insert time; +- claim lease, retry due, retention cutoff: database time authority; +- producer deadline: monotonic process clock; +- broker record timestamp: event timestamp policy 또는 broker append time descriptor. + +여러 pod의 wall-clock으로 claim lease를 판정하지 않는다. database time을 사용하지 못하면 허용 +clock-skew bound와 failure policy를 card에 포함한다. + +## 12. Integration event pipeline + +### 12.1 Domain event와 integration event + +Domain event를 그대로 JSON으로 직렬화하지 않는다. + +```text +DomainEvent + -> feature application mapper + -> IntegrationEventDraft + -> bounded local contract compiler/encoder + -> ValidatedIntegrationEvent + -> immutable outbox_event + -> WireEnvelope v1 +``` + +feature application mapper가 소유하는 것은 외부에 공개할 semantic field 선택이다. encoder가 +소유하는 것은 UTF-8 JSON encoding, schema validation, byte bound와 checksum이다. mapper에 +retry/topic/security 정책을 넣지 않고 encoder에 business rule을 넣지 않는다. + +### 12.2 Draft + +개념적인 draft shape는 다음과 같다. + +```java +record IntegrationEventDraft

( + EventId eventId, + ContractId contractId, + int payloadVersion, + LogicalDestinationId destinationId, + AggregateIdentity aggregate, + AggregateOrder order, + Instant occurredAt, + CorrelationId correlationId, + Optional causationId, + Optional tenantId, + P featurePayload) {} +``` + +이는 구현 이름을 강제하는 Java API가 아니라 ownership을 보여주는 pseudocode다. +`featurePayload`는 §9.8 contribution의 exact type token으로 등록된 typed immutable Java +record다. JSON tree, Jackson node, raw map/string, Kafka record가 application contract가 되지 +않는다. + +### 12.3 Local contract compiler/encoder + +Application은 framework-free port를 통해 deterministic local encoder를 사용할 수 있다. 실제 +JSON/schema library는 outbound messaging adapter가 소유한다. + +encoder는: + +- startup에 schema/catalog를 precompile한다; +- runtime remote schema fetch를 하지 않는다; +- bounded CPU/memory 안에서 typed payload를 JSON으로 encode한다; +- envelope/payload schema, duplicate key, depth와 exact UTF-8 bytes를 검증한다; +- immutable serialized document와 schema/catalog digest를 반환한다. + +first encoder는 같은 logical event가 같은 exact UTF-8 document를 만들도록 deterministic field +order와 scalar rendering을 고정한다. 저장·재발행·CDC의 authority는 이 exact byte document이며 +JSONB 재직렬화 결과가 아니다. + +business transaction 안에서 호출될 경우 local computation만 수행하고 network, broker, +filesystem, secret refresh를 하지 않는다. encoding 비용이 transaction budget을 넘는 event는 +transaction 전에 immutable input을 준비하거나 별도 staged workflow를 사용한다. + +### 12.4 Transaction sequence + +durable application command의 기본 순서는 다음이다. + +```text +1. command/idempotency/authorization validation +2. tx.inWrite begin +3. domain aggregate load + invariant check + mutation +4. domain event -> integration-event draft mapping +5. precompiled local encoder validation +6. business state save +7. outbox_event INSERT +8. polling mode이면 outbox_delivery INSERT +9. commit +``` + +4–8 중 하나라도 실패하면 business write도 rollback한다. broker send는 이 transaction 안에서 +수행하지 않는다. + +same-store는 이름뿐인 가정이 아니다. compiled card는 `transactionResourceId`를 갖고 business +repository, `TransactionPort`, `OutboxAppendPort`, outbox migration이 같은 resolved +`DataSource`/`EntityManagerFactory`/`PlatformTransactionManager` resource에 bind됐는지 startup에 +검증한다. multi-datasource deployment는 contract별 resource binding을 명시한다. 다른 resource면 +ACTIVE를 거부한다. real rollback test가 이 identity assertion을 보완한다. + +dispatch mode 판단은 feature mapper가 하지 않는다. persistence append adapter가 같은 +transaction에서 §27의 active publication epoch를 읽고 event에 epoch/authority를 기록한 뒤, +`POLLING_V2`일 때만 delivery row를 함께 만든다. + +### 12.5 Validated event와 stored event + +`ValidatedIntegrationEvent`는 최소 다음을 가진다. + +```text +all stable identities +envelopeVersion +payloadVersion +logicalDestinationId +partitionKeyText and its exact US-ASCII bytes +validated envelope JSON bytes/document +contentType +schemaSetHash +envelopeSha256 +envelopeSchemaHash +payloadSchemaHash +contractCatalogRevision +destinationBindingRevision +validated traceparent/tracestate allowlist +``` + +`publicationEpoch`, `dispatchAuthority`, `transactionResourceId`, DB-authoritative `createdAt`은 +encoder 결과가 아니다. `OutboxAppendPort`의 persistence 구현이 caller의 write transaction +안에서 ACTIVE epoch를 읽고 same-store resource identity를 확인한 뒤 이 네 값을 더해 +`StoredOutboxEvent`를 구성한다. 따라서 transaction 전에 만들어 둔 validated bytes가 stale +application setting의 authority를 내장하거나 application clock을 DB creation time으로 가장하지 +않는다. + +retry 때 payload를 다시 business object에서 직렬화하지 않는다. polling attempt는 저장된 같은 +identity와 validated document를 사용한다. + +`envelopeSha256`은 다음 exact input으로 계산한다. + +```text +SHA-256( + UTF8("ca-skeleton.messaging.envelope.v1") || 0x00 + || u32be(len(exactEnvelopeBytes)) + || exactEnvelopeBytes +) +``` + +여기서 `u32be`는 §11.6과 같은 unsigned 32-bit big-endian byte length다. +이는 integrity/collision diagnosis용이지 confidentiality control이 아니다. DB/API/log/metric에 +노출하지 않고 payload와 같은 access control/retention을 적용한다. 같은 event ID에서 다른 +envelope hash는 duplicate가 아니라 collision/quarantine이다. semantic JSON을 JSONB로 round-trip한 +뒤 다시 hash하지 않는다. + +### 12.6 Polling/CDC wire parity + +polling과 CDC는 같은 logical `WireEnvelope v1`을 emit한다. + +- field와 semantic value가 같아야 한다; +- event ID, contract ID, versions, key가 같아야 한다; +- JSON object member byte ordering 차이를 허용할지 card가 명시한다; +- first baseline은 polling retry에서 exact stored UTF-8 bytes 재사용을 요구한다; +- CDC는 golden semantic equality와 consumer decode equality를 통과한다; +- “같은 contract”를 단순히 비슷한 JSON이라고 표현하지 않는다. + +## 13. Contract catalog, destination binding과 topic + +### 13.1 두 catalog + +Contract catalog는 code/repository artifact다. + +```text +contractId +payload versions +owner module +logical destination +payload schema resource/hash +serializer id +ordering requirement +partition-key policy +maximum payload/envelope bytes +sensitivity classification +supported producer/consumer version matrix +same-event requeue horizon +``` + +Destination binding은 deployment configuration다. + +```text +logical destination +Kafka cluster binding +physical topic +expected partitions +minimum replication factor +minimum in-sync replicas +cleanup policy +retention expectation +maximum record bytes +security profile +required readiness +``` + +contract가 infrastructure topology를 소유하지 않고 configuration이 business schema를 +재정의하지 않는다. + +### 13.2 Compile + +startup compiler는 다음을 합성한다. + +```text +contract descriptor ++ destination descriptor ++ producer provider descriptor ++ serialization descriptor ++ security descriptor ++ evidence card += CompiledPublicationBinding +``` + +검증: + +- contract/destination ID unique; +- 모든 active contract에 정확히 한 destination binding; +- unknown destination/topic 금지; +- ordering-required contract에 nonblank stable key; +- contract maximum bytes <= destination/provider/topic bounds; +- schema/catalog hash가 evidence와 일치; +- production profile과 security profile 호환; +- dispatch mode와 provider 요구 일치; +- required binding은 release-eligible evidence 보유. + +### 13.3 Configuration override 제한 + +설정은 code contract를 약화하지 못한다. + +- code maximum record bytes보다 크게 override할 수 없다; +- ordering-required를 `NONE`으로 낮출 수 없다; +- schema validation을 끌 수 없다; +- production TLS 요구를 plaintext로 바꿀 수 없다; +- required destination을 optional로 바꿀 수 없다; +- unknown compatibility mode를 선택할 수 없다. + +더 엄격한 deployment bound는 허용한다. + +### 13.4 Topic naming + +physical topic은 operator-owned static value다. + +- request/event/tenant 값을 문자열 보간하지 않는다; +- environment prefix/suffix는 binding compiler가 allowlist pattern으로 검증한다; +- producer principal은 production에서 Create/Delete/Alter 권한을 갖지 않는다; +- auto-create를 끈다; +- topic rename은 새 binding/revision과 migration runbook을 요구한다. + +### 13.5 Topic topology attestation + +ACTIVE startup 또는 pre-deploy gate는 최소 다음을 확인한다. + +- topic 존재; +- expected partition count; +- replication factor가 minimum 이상; +- `min.insync.replicas`가 policy minimum 이상; +- cleanup policy; +- retention/replay horizon; +- topic maximum message bytes; +- unclean leader election 관련 cluster/topic policy가 deployment 요구와 호환; +- producer principal의 최소 Describe/Write 동작; +- consumer/DLT profile이 있을 때 대응 Read/Write ACL. + +`acks=all`만 확인하고 replication/min ISR를 보지 않은 상태를 durable topology로 표시하지 않는다. + +first tuple은 verification source를 항목별로 고정한다. + +| 항목 | Runtime source | Release/provisioning source | +| --- | --- | --- | +| cluster identity, topic existence, partition/leader/ISR/RF | producer principal의 bounded AdminClient `Describe` | IaC expected resource identity | +| topic cleanup/retention/max bytes/min ISR/topic override | exact topic 범위 read-only `DescribeConfigs` | IaC rendered config/digest | +| broker-level unclean election/default/max bounds/auto-create | runtime에서 과도한 cluster config 권한을 요구하지 않음 | signed/provenance-attested broker policy | +| exact-topic Write와 denied Create/Alter/Delete/other-topic Write | startup에 임의 canary를 만들지 않음 | security release lane의 positive/negative probe | +| ACL/quota owner와 rollback | runtime ACL enumeration 금지 | IaC/security evidence | + +release/provisioning evidence는 environment/cluster alias, topic resource identity, rendered +config/ACL policy digest, issuer/provenance, generated-at, expires-at와 release assertion digest를 +가진다. missing, stale, wrong-cluster, signature/provenance failure 또는 runtime-observed 값과의 +mismatch는 production ACTIVE를 fail-closed한다. runtime에서 확인할 수 없는 값을 “검증됨”으로 +표시하지 않고 descriptor에 source와 freshness를 함께 노출한다. + +### 13.6 Partition expansion + +Kafka default key partitioning에서 partition 수가 바뀌면 같은 key가 다른 partition으로 이동할 수 +있다. rolling producer/consumer 기간에는 old/new partition의 event order가 섞일 수 있다. + +ordering-required topic은 in-place partition expansion을 일반적인 무중단 변경으로 취급하지 +않는다. 기본 절차는 새 topic/binding generation, write cutover watermark, consumer dual-read +또는 drain, order reconciliation과 rollback이다. + +event append 시 `destinationBindingRevision`을 immutable capture한다. retry는 같은 revision을 +resolve하며 current config의 새 topic으로 조용히 reroute하지 않는다. backlog를 새 binding으로 +옮기려면 audited delivery generation/explicit migration을 사용한다. + +### 13.7 Compaction + +first baseline topic은 delete-retention event log다. compaction은 다음이 모두 정의된 contract만 +별도 card로 선택한다. + +- key가 entity state identity인지; +- tombstone 의미; +- intermediate event 손실 허용 여부; +- consumer bootstrap 의미; +- minimum compaction lag; +- delete retention; +- replay/ordering 영향. + +integration event에 compaction을 기본 적용하지 않는다. + +## 14. JSON envelope v1과 schema evolution + +### 14.1 Dialect와 resource ownership + +first baseline은 JSON Schema Draft 2020-12를 사용한다. + +공통 envelope schema 예시 위치: + +```text +src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json +``` + +sample payload schema 예시 위치: + +```text +src/sample-portfolio/src/main/resources/contracts/messaging/ + portfolio.worklog.reserved/v1.schema.json +``` + +실제 production project는 feature owner module에 payload schema를 둔다. 각 schema는: + +- explicit `$schema`; +- immutable absolute `$id`; +- contract/payload version; +- checked-in checksum manifest; +- local prebundled `$ref` allowlist; +- owner와 compatibility vectors를 가진다. + +runtime HTTP/file remote `$ref` resolution은 금지한다. + +### 14.2 Envelope shape + +개념적인 envelope v1: + +```json +{ + "envelopeVersion": 1, + "eventId": "019...", + "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-...", + "causationId": "cause-...", + "contentType": "application/json", + "payload": { + "workLogId": "worklog-42" + } +} +``` + +tenant가 실제로 활성인 deployment만 bounded `tenantId`를 포함한다. causation ID가 없을 때 +null로 넣을지 field를 생략할지는 envelope schema가 하나로 고정한다. + +envelope에는 다음을 넣지 않는다. + +- physical topic/cluster/bootstrap server; +- delivery status/attempt/backoff/claim token; +- Kafka offset/partition; +- credential/security profile; +- Java class name; +- raw exception; +- arbitrary baggage; +- mutable consumer state. + +### 14.3 Envelope/header ownership + +authoritative semantic metadata는 envelope다. Kafka header는 transport 기능에 필요한 bounded +allowlist만 사용한다. + +초기 header allowlist 후보: + +```text +id +contract-id +payload-version +traceparent +tracestate +``` + +first header의 `id`는 Debezium Outbox Event Router와 polling producer가 공유하는 event ID다. +envelope와 header에 중복된 identity가 다르면 producer와 consumer 모두 reject한다. header 이름, +개수, key bytes, total bytes를 제한한다. arbitrary inbound header forwarding은 금지한다. + +### 14.4 Strict versioning + +first baseline은 `STRICT_VERSIONED` 정책을 사용한다. + +- 같은 schema version file을 수정하지 않는다; +- optional field 추가도 새 payload version을 만든다; +- consumer가 새 version을 지원한 뒤 producer를 배포한다; +- rolling overlap 동안 consumer는 최소 명시된 N/N-1 version allowlist를 가진다; +- producer가 지원 종료된 version을 emit하지 않는다는 release assertion을 둔다; +- unsupported future version은 일반 retry 대상이 아니다. + +JSON Schema diff heuristic만으로 backward/full compatibility를 주장하지 않는다. 실제 old/new +reader/writer golden vectors가 compatibility evidence다. Kafka/DLT/archive/inbox replay horizon +안에 남아 있는 **모든** payload version은 reader support를 유지한다. version retirement는 +해당 version의 source/DLT/archive가 더 이상 replay 가능하지 않거나 versioned upcaster가 +qualification됐다는 purge proof가 있어야 한다. N/N-1은 replay horizon을 대신하지 않는다. + +### 14.5 Object와 unknown-field policy + +- envelope v1은 `unevaluatedProperties: false`로 닫는다; +- payload schema도 first baseline에서 explicit property set을 사용한다; +- additive evolution은 in-place field 추가가 아니라 payload version 증가로 처리한다; +- tolerant-reader card를 나중에 추가할 수 있지만 그때 unknown-field behavior와 rolling vectors를 + 별도 증명한다. + +### 14.6 Scalar/collection policy + +각 schema가 표현할 수 있는 범위는 최소 다음을 명시한다. + +- `required`; +- null과 missing의 차이; +- string `minLength/maxLength`와 Unicode normalization policy; +- array maximum items; +- integer/number semantic range; +- enum evolution; +- timestamp string format; +- object property count; + +JSON Schema `format`은 implementation에 따라 annotation일 수 있다. runtime validator에서 format +assertion을 켰다는 evidence를 만들거나 timestamp/UUID 등을 explicit parser로 검증한다. + +Draft 2020-12가 직접 표현하지 않는 exact UTF-8 byte 수, nesting depth, numeric precision, +exponential notation/canonical lexical form, parser time/memory는 +`json-codec-admission-v1`의 별도 규칙이다. `maxLength`를 byte limit으로 오해하거나 custom +keyword 없이 schema가 이 제한을 증명한다고 쓰지 않는다. + +### 14.7 Parser hardening + +codec은 다음을 거부한다. + +- malformed UTF-8; +- duplicate object member names; +- unpaired surrogate; +- excessive nesting; +- maximum을 넘는 string/array/object; +- resource budget을 넘는 arbitrary-precision number; +- trailing garbage; +- non-finite number; +- remote reference; +- polymorphic Java type metadata. + +validation CPU/memory/time budget을 test한다. record size는 Java character 수가 아니라 최종 UTF-8 +key + value + header bytes로 계산한다. + +schema compiler는 exact offline resource registry만 사용한다. + +- duplicate `$id`/unknown vocabulary/unknown dialect 거부; +- remote URI가 local allowlist resource로 정확히 resolve되지 않으면 거부; +- cyclic/recursive `$ref`는 명시적 depth/resource budget 안에서 지원하거나 compile-time 거부; +- pathological regular expression/validator recursion adversarial corpus; +- meta-schema와 vocabulary도 pinned local checksum 대상; +- startup precompile 뒤 runtime schema fetch 0. + +### 14.8 Envelope document hash + +§12.5의 exact envelope byte hash는: + +- same event ID document collision 탐지; +- polling retry exact-document 확인; +- CDC shadow byte parity; +- audit/diagnosis + +에 사용한다. algorithm/input은 §12.5 하나만 정본으로 사용한다. hash를 metric tag로 쓰지 않고 +restricted storage 밖에 노출하지 않는다. + +### 14.9 Schema registry future card + +Avro/Protobuf/JSON Schema registry card가 추가되면 다음을 별도로 설계·검증한다. + +- subject naming; +- compatibility mode; +- registry auth/TLS/readiness; +- schema ID cache와 outage behavior; +- generated code ownership; +- rolling compatibility; +- registry unavailable 시 write policy; +- schema deletion/retention; +- cross-cluster replication. + +first JSON Schema card에는 registry 설정 key나 placeholder를 추가하지 않는다. + +## 15. Application contract와 publication outcome + +### 15.1 Durable append port + +`OutboxAppendPort`는 provider-neutral same-store append 의미를 유지한다. target input은 +legacy raw `String payload`가 아니라 validated integration event다. + +```java +interface OutboxAppendPort { + void append(ValidatedIntegrationEvent event); +} +``` + +실제 이름은 implementation plan에서 정하지만 다음은 변하지 않는다. + +- application/core 타입; +- same transaction requirement; +- provider/physical topic 없음; +- immutable event identity; +- validation/catalog evidence 포함. + +### 15.2 Publish port + +target publish port는 expected technical outcome을 exception 하나로 뭉치지 않는다. + +```java +sealed interface PublicationOutcome { + record Acknowledged(PublicationReceipt receipt) implements PublicationOutcome {} + record AcknowledgedMismatch(PublicationReceipt receipt) implements PublicationOutcome {} + record Rejected(PublicationFailure failure) implements PublicationOutcome {} + record Indeterminate(PublicationFailure failure) implements PublicationOutcome {} +} +``` + +`PublicationReceipt`는 Kafka SDK 타입 대신 다음 같은 bounded provider-neutral reference를 +가진다. + +```text +providerId +logicalDestinationId +providerGeneration +ackObservedAt +opaque bounded providerRecordReference +``` + +physical topic/partition/offset가 persistence audit에 필요하면 adapter가 safe bounded string으로 +만들며 application이 이를 routing에 사용하지 않는다. `ackObservedAt`은 broker clock이 아니라 +local future-completion 관찰 시각이다. + +#### Late completion observation boundary + +동기 publish 결과가 `INDETERMINATE`로 반환된 뒤 Kafka future가 완료될 수 있으므로 다음 +provider-neutral application ports를 둔다. + +```java +interface LatePublicationObservationSourcePort { + List pollBounded(int maximum); + void acknowledgePersisted(ObservationId id); + void releaseForRetry(ObservationId id); +} + +interface OutboxAttemptObservationPort { + void appendLateObservations(List observations); +} +``` + +- outbound messaging adapter가 bounded payload-free queue로 source port를 구현한다; +- persistence adapter가 append port를 구현한다; +- application의 `RecordLatePublicationObservationsUseCase`는 다음 순서를 고정한다: + `poll/lease bounded batch -> tx.inNew(idempotent append) 정상 반환 -> acknowledgePersisted`. + `tx.inNew(...)`의 정상 반환은 commit 완료를 뜻하며 source ACK를 transaction callback 안에서 + 호출하지 않는다; +- `app-bootstrap`은 provider ACTIVE일 때만 bounded drain scheduler를 조립한다; +- Kafka callback thread는 JPA/repository/transaction을 직접 호출하지 않는다; +- observation identity는 + `(eventId, deliveryGeneration, publicationAttemptId, LATE_ACK_OBSERVED)`이고 DB unique/ON + CONFLICT로 duplicate drain을 흡수한다; +- callback/timeout은 adapter-local atomic terminal marker로 단 한 synchronous outcome을 + 결정한다. deadline marker가 먼저 이기고 ACK가 나중에 오면 queue에 late observation 하나만 + 제안한다; +- DB append 또는 commit 실패는 `releaseForRetry`로 item을 bounded retry에 되돌리고 delivery + state를 바꾸지 않는다; +- DB commit 뒤 source ACK 전 process crash는 같은 observation의 duplicate drain을 만들 수 + 있으며 DB unique/`ON CONFLICT`가 이를 흡수한다. + +attempt admission과 deadline 시점의 `INDETERMINATE` outcome journal은 authoritative하고 반드시 +Tx B/Tx C에서 영속화한다. 반면 process crash나 queue overflow로 late callback diagnostic 자체를 +잃을 수 있으므로 `LATE_ACK_OBSERVED` capture를 delivery correctness 근거로 사용하지 않는다. +queue overflow/drop은 bounded metric, readiness degradation과 alert 대상이며 capacity +qualification에서는 0이어야 한다. 이 경계 덕분에 messaging→persistence project dependency를 +추가하지 않는다. + +### 15.3 Failure stage + +최소 stage: + +```text +CONTRACT_COMPILE +SERIALIZATION +LOCAL_ADMISSION +METADATA +SEND +BROKER_ACK +DEADLINE +SHUTDOWN +PROVIDER +``` + +최소 failure class: + +```text +INVALID_CONTRACT +INVALID_PAYLOAD +RECORD_TOO_LARGE +DESTINATION_MISSING +UNAUTHORIZED +AUTHENTICATION_FAILED +TLS_FAILED +TOPIC_POLICY_MISMATCH +BUFFER_EXHAUSTED +BROKER_UNAVAILABLE +THROTTLED +DEADLINE_EXCEEDED +CLIENT_CLOSED +UNKNOWN_PROVIDER_FAILURE +``` + +exception class name이나 message를 stable application contract로 사용하지 않는다. + +### 15.4 Certainty와 retry disposition + +acceptance certainty와 retryability는 독립 축이다. + +```text +acceptanceCertainty = NOT_ACCEPTED | ACCEPTED | INDETERMINATE | ACCEPTED_MISMATCH +retryDisposition = NO_RETRY | RETRY_WITHIN_BUDGET | STOP_PROVIDER | OPERATOR_HOLD +``` + +`REJECTED`는 provider가 broker acceptance가 없음을 확정할 수 있을 때만 사용한다. + +예: + +- schema/size/local catalog rejection; +- local admission 전 rejection; +- definitive broker authorization rejection; +- startup AdminClient attestation이 send admission 전에 확정한 missing destination. + +`INDETERMINATE` 예: + +- send 뒤 deadline; +- ACK response loss; +- connection break after request write; +- callback/cancel race; +- shutdown 중 unresolved in-flight; +- `NotEnoughReplicasAfterAppendException`처럼 append 뒤 실패할 수 있는 broker 응답; +- post-admission `UnknownTopicOrPartitionException`, retriable/unknown producer exception; +- provider가 acceptance를 증명할 수 없는 unknown exception. + +Kafka `RetriableException`이라는 사실은 미수락 증거가 아니다. 분류가 애매하면 +`INDETERMINATE`가 안전한 기본이다. retry 여부는 certainty를 바꾸지 않고 remaining +attempt/elapsed budget과 producer health로 결정한다. + +### 15.5 Relay decision + +| Publication outcome | Polling action | +| --- | --- | +| ACKNOWLEDGED | claim-token/valid-lease CAS로 `DELIVERY_RECORDED` 기록 | +| ACKNOWLEDGED_MISMATCH | relay scope HOLD, producer readiness DOWN, misroute incident | +| definite transient REJECTED | retry budget이 남으면 RETRY_WAIT | +| definite permanent REJECTED | 즉시 EXHAUSTED/operator disposition | +| INDETERMINATE | duplicate 가능성을 기록하고 bounded retry/reconciliation path | +| programming invariant failure | cycle 실패 + readiness/alert; 일반 transient로 숨기지 않음 | + +report는 persisted transition이 성공한 뒤에만 emit한다. report adapter failure는 authoritative +state를 바꾸지 않는다. + +### 15.6 Best-effort와 durable port + +두 contract는 계속 분리한다. + +- best-effort: persistence/replay 없음, bounded attempt 뒤 failure를 삼킬 수 있음; +- durable: transactionally stored event, polling/CDC, explicit terminal disposition. + +best-effort가 내부적으로 같은 ACK-aware producer를 사용해도 durable로 승격되지 않는다. +durable append를 자동 수행하지도 않는다. + +## 16. Spring Kafka producer protocol + +### 16.1 Runtime ownership + +`adapter:outbound:messaging`이 다음을 직접 만든다. + +- `DefaultKafkaProducerFactory`; +- `KafkaTemplate`; +- bounded AdminClient/topology attestor; +- provider generation의 생성/drain/close/attestation primitive owner; +- observation convention; +- resolved credential/certificate material을 한 provider generation에 적용하는 owner. + +`adapter:outbound:messaging`은 durable delivery state나 HOLD 정책을 결정하지 않는다. +`application-core`의 rotation use case가 provider-neutral lifecycle fact를 받아 +`INDETERMINATE/HOLD` persistence와 generation barrier/admission 재개를 조정하고, +`app-bootstrap`이 secret refresh와 그 use case invocation을 compose한다. + +classpath presence나 generic `spring.kafka.bootstrap-servers=localhost:9092` default로 활성화하지 +않는다. canonical messaging binding이 ACTIVE일 때만 만든다. + +### 16.2 Adapter-private gateway + +provider-private SPI는 ACK를 표현해야 한다. + +```java +interface KafkaPublishGateway { + KafkaAttemptOutcome publish( + CompiledKafkaRecord record, + MonotonicDeadline deadline); +} +``` + +이 SPI는 outbound adapter 내부 또는 package-private다. `KafkaTemplate`, `SendResult`, +`RecordMetadata`를 application/shared에 노출하지 않는다. + +### 16.3 Send sequence + +```text +1. compiled binding lookup +2. immutable envelope/key/header byte verification +3. local admission acquire +4. ProducerRecord construction +5. KafkaTemplate.send +6. send future를 monotonic deadline까지 await +7. RecordMetadata와 expected destination 검증 +8. ACKNOWLEDGED / ACKNOWLEDGED_MISMATCH / REJECTED / INDETERMINATE map +9. admission/resource release +``` + +serialization은 prevalidated bytes를 사용하는 Kafka `ByteArraySerializer` 계열로 단순화한다. +Kafka serializer callback 안에서 business JSON serialization이나 remote schema lookup을 하지 +않는다. + +### 16.4 ACK condition + +broker ACK 관찰은 다음으로 정의한다. + +```text +future completed successfully +AND metadata is present +``` + +metadata topic이 compiled topic과 같으면 `ACKNOWLEDGED`, 다르면 +`ACKNOWLEDGED_MISMATCH`다. deadline 뒤 future가 성공해도 ACK 관찰 사실은 append-only attempt +journal drain이 성공한 경우에만 `LATE_ACK_OBSERVED`로 남으며, 이미 정한 application +outcome/delivery state를 뒤집지 않는다. drain 전 crash/overflow로 진단 관찰을 잃을 수 있다는 +§15.2의 한계가 적용된다. provider generation의 현재 선택 여부도 broker fact 자체를 바꾸지 +않는다. + +`acks=0`은 모든 selected profile에서 금지한다. first R2는 `acks=all`이다. `acks=all`은 모든 +configured replica가 아니라 당시 ISR의 ACK를 뜻하므로 §13.5의 replication/min ISR/unclean +leader policy attestation과 함께 해석한다. + +### 16.5 Deadline/cancellation/late completion + +future await deadline이 끝나면: + +- application outcome은 `INDETERMINATE`; +- `cancel()`이 broker delivery 취소를 보장한다고 가정하지 않는다; +- late callback은 §15.2의 bounded source port에 payload-free observation을 제안하고 application + drain이 성공한 경우에만 §18.3 append-only journal에 기록한다. persisted + retry/exhausted/HOLD transition을 뒤집지 않는다; +- attempt terminal state는 atomic one-way transition이다; +- late ACK와 다음 retry가 duplicate를 만들 수 있음을 관측한다. + +deadline wrapper가 worker thread만 interrupt하고 producer request를 완전히 취소하지 못한다는 +한계를 descriptor에 기록한다. + +### 16.6 Effective producer configuration + +first R2는 다음을 explicit setting과 startup assertion으로 고정한다. + +```text +acks = all +enable.idempotence = true +retries = provider recommended effectively-unbounded/MAX +max.in.flight.requests.per.connection <= 5 +delivery.timeout.ms = finite +request.timeout.ms = finite +max.block.ms = finite +buffer.memory = finite +batch.size = finite +linger.ms = finite +max.request.size = finite +``` + +그리고 다음 관계를 검증한다. + +```text +delivery.timeout.ms >= request.timeout.ms + linger.ms +application attempt budget >= + admission wait budget + max.block.ms + delivery.timeout.ms + callback/transition reserve +claim remaining lease > + application attempt budget + DB transition reserve + clock/scheduling safety margin +``` + +Kafka library default가 현재 원하는 값과 같더라도 explicit effective config assertion을 둔다. +conflicting property가 idempotence를 끄면 startup을 실패시킨다. + +`retries`를 작은 숫자로 잘라 broker retry를 임의 약화하지 않고 `delivery.timeout.ms`가 한 +physical send의 시간 budget을 지배하게 한다. `request.timeout.ms`는 selected broker의 +`replica.lag.time.max.ms`와 Kafka 권고 관계를 provisioning evidence로 검증한다. + +size는 한 줄 부등식으로 합치지 않는다. + +1. exact envelope + key + headers + record overhead가 contract record bound 안; +2. uncompressed record batch가 producer batch/request 제약 안; +3. compressed record batch가 topic `max.message.bytes`와 broker bound 안; +4. 여러 partition batch를 담을 수 있는 request가 `max.request.size` 안. + +모든 limit에 protocol/header/batch headroom을 두며 payload와 request/topic candidate를 똑같이 +1 MiB로 두지 않는다. adopted serializer/compression의 실제 encoded batch를 real broker에서 +검증한다. + +exact numeric default와 허용 범위는 implementation plan의 benchmark/fault test로 고정한다. 무한 +또는 사실상 운영 shutdown/SLO를 넘는 값은 허용하지 않는다. + +### 16.7 Retry ownership + +Kafka client는 `delivery.timeout.ms` 안에서 같은 producer send를 retry할 수 있다. relay는 하나의 +application attempt가 definite/indeterminate failure로 끝난 뒤 새 attempt를 만든다. + +```text +physical Kafka retries + inside one publicationAttemptId + +relay retries + new publicationAttemptId, same eventId and wire document +``` + +Kafka producer idempotence는 supported producer session의 client retries를 보호하지만 다음을 +제거하지 않는다. + +- producer restart 뒤 relay resend; +- ACK-to-DB gap; +- application deadline 뒤 late ACK + resend; +- polling과 CDC 이중 활성; +- operator replay. + +fatal producer exception은 acceptance certainty와 별도로 generation lifecycle을 종료한다. +authorization/unsupported-version/out-of-order-sequence 또는 adopted client가 fatal로 정의한 +상태는 즉시 new admission 차단, readiness DOWN, bounded close/recreate를 수행한다. 같은 defunct +producer를 계속 사용하지 않으며 새 generation이 application resend duplicate를 제거한다고 +주장하지 않는다. + +### 16.8 Flush + +per-message `KafkaTemplate.flush()`를 금지한다. shared producer의 다른 batch를 강제로 flush하고 +throughput/latency를 결합하기 때문이다. future completion으로 해당 record ACK를 기다린다. + +flush는 bounded shutdown/explicit maintenance에서만 사용하고 그 보장과 timeout을 test한다. + +### 16.9 Producer transaction + +first polling provider는 Kafka transaction을 사용하지 않는다. Kafka transaction card가 later +추가되면 transactional ID uniqueness, producer fencing, cache size, timeout, abort, rolling deploy, +`read_committed` consumer까지 별도 evidence를 요구한다. + +### 16.10 Producer generation과 rotation + +credential/certificate/settings rotation은 immutable producer generation 교체로 처리한다. +소유권은 둘로 나뉜다. messaging adapter는 old/new provider generation의 +pause/drain/create/attest/close primitive와 bounded fact만 제공한다. application rotation use +case는 그 fact를 바탕으로 unresolved attempt의 durable `INDETERMINATE/HOLD`, DB failure 시 전환 +차단, generation barrier와 admission 재개 정책을 소유한다. bootstrap은 secret resolver와 +application use case를 연결할 뿐 state policy를 구현하지 않는다. + +```text +1. 신규 admission과 claim을 일시 중단 +2. old generation의 admitted/in-flight future를 bounded drain +3. drain deadline의 unresolved attempt를 Tx C에서 INDETERMINATE로 기록하고 영향받은 ordering + scope를 HOLD +4. old generation을 bounded close하고 더 이상 callback을 authoritative outcome으로 사용하지 않음 +5. 새 secret generation resolve +6. 새 producer compile/start/attest +7. 모든 old attempt가 ACK/REJECTED 또는 durable INDETERMINATE라는 application terminal + observation을 가진 뒤 generation barrier 전환 +8. HOLD 없는 scope의 admission 재개; HOLD scope는 audited duplicate-risk disposition 뒤에만 재개 +``` + +한 producer object의 mutable config를 바꾸지 않는다. old/new generation metric tag는 bounded +revision이어야 하며 secret value를 포함하지 않는다. first profile은 old/new generation send를 +겹치지 않는 global barrier를 사용한다. 여기서 “resolved”는 broker acceptance가 definitively +밝혀졌다는 뜻이 아니라 state machine이 ACK/REJECTED/**INDETERMINATE** 중 하나를 durable하게 +기록했다는 뜻이다. response loss의 영원한 확정을 기다리지 않는다. + +barrier 전환 뒤 reorder-tolerant scope는 card가 허용한 bounded duplicate-aware retry를 자동 +재개할 수 있다. ordering-required scope의 indeterminate head는 HOLD를 유지하고 +§19.4 operator가 `REMEDIATE_AND_REQUEUE`, `SKIP_WITH_GAP`, `COMPENSATE` 중 하나를 선택한다. DB가 +unavailable해 INDETERMINATE/HOLD를 durable하게 기록할 수 없으면 generation 전환과 admission을 +계속 막는다. forced crash/indeterminate write 뒤 failure-path strict order는 주장하지 않고 +aggregate sequence로 gap/regression을 탐지한다. + +persistence가 없는 best-effort/direct caller는 durable HOLD 대상이 아니다. bounded drain 뒤 +unresolved outcome을 caller/telemetry에 `INDETERMINATE`로 확정해 반환하고 새 generation을 +전환하되, 자동 replay나 ordering 안전을 주장하지 않는다. + +## 17. Ordering, retry budget, resource와 lifecycle + +### 17.1 Ordering guarantee + +Kafka가 제공하는 기본 ordering 범위는 한 partition 안이다. first R2의 정상 경로는 다음을 +요구한다. + +```text +stable physical topic generation ++ stable non-null partition key ++ idempotent producer-compatible config ++ aggregate total sequence ++ single authoritative aggregate-head claim/admission ++ same ordering scope의 concurrent out-of-order send 금지 ++ partitioner.ignore.keys = false ++ unqualified custom partitioner 없음 ++ one producer generation barrier += same generation normal-path key order + failure-path sequence detectability +``` + +global order, 여러 topic 사이 order, partition expansion 중 order, operator replay와 live stream +사이 order는 보장하지 않는다. process crash, indeterminate send, forced producer rotation, +operator replay 뒤의 strict order도 첫 card 보장이 아니다. sequence metadata만으로 Kafka +append order를 강제했다고 주장하지 않는다. strict effect order가 필요하면 future +consumer-side sequence gate/reorder card를 추가한다. + +### 17.2 Polling ordering gate + +ordering-required contract의 다음 event는 같은 ordering scope의 앞선 delivery가 +`DELIVERY_RECORDED` 또는 audited `SKIPPED/COMPENSATED`일 때만 claim한다. + +`EXHAUSTED` head는 후행을 block한다. 자동 skip하지 않는다. hot aggregate가 전체 batch를 +starve하지 않도록 batch selection은 scope별 head만 후보로 삼고 destination 전체 fairness를 +관측한다. + +### 17.3 Combined amplification budget + +최악의 wire work는 대략 다음이다. + +```text +relayAttempts +× Kafka client physical retries within delivery timeout +× number of destinations +× replay generations +``` + +first baseline은 event당 destination 하나다. 설정 compiler는: + +- maximum relay attempts; +- delivery-generation DB-created-at 기준 maximum automatic publication age; +- per-attempt deadline; +- backoff/jitter; +- producer internal delivery timeout; +- shutdown budget; +- dead/exhausted transition + +을 하나의 descriptor로 계산한다. max attempt만 있고 maximum automatic publication age가 없는 정책은 +허용하지 않는다. + +### 17.4 Failure class와 retry + +| Failure | 기본 | +| --- | --- | +| invalid contract/schema/size | retry 없음, writer rejection 또는 operator path | +| auth/ACL/topic policy mismatch | readiness down, 빠른 반복 retry 금지 | +| pre-admission transient metadata/network | definite rejection일 때만 bounded retry | +| post-admission leader/network/retriable | 기본 indeterminate + bounded duplicate-aware retry | +| not-enough-replicas-after-append | indeterminate | +| throttle | broker signal과 remaining budget 안에서 retry | +| local buffer exhausted | bounded admission/backpressure, retry budget 공유 | +| deadline/response loss | indeterminate, duplicate-aware retry | +| application programming defect | fail fast/alert, transient로 숨기지 않음 | + +### 17.5 Record and memory bounds + +다음을 별도로 제한한다. + +- key bytes; +- value UTF-8 bytes; +- header count/key/value/total bytes; +- uncompressed record bytes; +- compressed batch bytes; +- batch size; +- request size; +- producer buffer memory; +- application admitted in-flight records; +- pending callback/attempt contexts. + +Kafka client `buffer.memory`는 전체 producer memory의 완전한 hard bound가 아니다. compression, +in-flight request, object overhead와 callback context를 포함한 process memory budget을 +capacity test로 계산한다. + +### 17.6 Large message + +contract maximum을 넘는 payload는 outbox에 append하지 않는다. large payload가 실제 요구되면 +object storage에 immutable object를 먼저 publish하고 checksum/size/authorization이 있는 +claim-check event를 보내는 별도 design을 사용한다. + +object upload와 DB business transaction 사이 atomicity가 없으므로 staged object, outbox, +orphan cleanup과 authorization을 함께 설계한다. 단순 URL을 Kafka에 넣는 것은 대안이 아니다. + +### 17.7 Compression + +compression은 provider profile이다. + +- first profile은 `compression.type=none`으로 고정한다; +- 선택 시 broker/client version 지원과 CPU/memory를 test한다; +- decompression bomb 방어를 위해 consumer는 decoded envelope/payload bound를 별도로 검증한다; +- record limit은 wire/uncompressed 의미를 혼동하지 않는다. + +### 17.8 Admission과 backpressure + +producer 내부 buffer만을 application bulkhead로 사용하지 않는다. + +```text +application admission semaphore +-> per-record JIT claim +-> Kafka producer buffer +-> broker +``` + +- admission wait는 attempt deadline에 포함한다; +- queue는 finite이며 queue timeout을 가진다; +- queue saturation 때 더 많은 outbox row를 claim하지 않는다; +- virtual thread를 사용해도 in-flight/message/memory bound는 유지한다; +- initial polling R2는 per-record JIT claim과 bounded sequential send를 사용한다. 성능 evidence가 + 필요할 때만 partition-key-aware concurrency card를 추가한다. + +### 17.9 Graceful shutdown + +순서: + +```text +1. readiness에서 신규 relay admission 제거 +2. scheduler/new claim 중단 +3. active attempt를 bounded drain +4. 완료 ACK의 delivery transition을 bounded flush +5. unresolved attempt를 indeterminate로 남기거나 lease reclaim 가능하게 종료 +6. KafkaTemplate/ProducerFactory/AdminClient close +7. metrics/secret refresh resource close +``` + +shutdown timeout이 끝났다고 delivery row를 성공 처리하지 않는다. unresolved row는 lease expiry 뒤 +재claim되며 duplicate 가능성이 있다. + +### 17.10 Startup + +순서: + +```text +1. typed settings bind +2. card/catalog/schema hash compile +3. secret resolve +4. producer runtime create +5. topic/security attestation +6. readiness ACTIVE +7. polling scheduler admission +``` + +relay scheduler를 producer/topic readiness보다 먼저 시작하지 않는다. + +## 18. Polling outbox v2 + +### 18.1 Immutable event + +목표 `outbox_event` conceptual columns: + +```text +event_id VARCHAR(96) PK, canonical US-ASCII +envelope_version +contract_id +payload_version +logical_destination_id +destination_binding_revision +aggregate_type +aggregate_id +aggregate_sequence +aggregate_event_index +partition_key_text VARCHAR(64), canonical lowercase SHA-256 hex +occurred_at +created_at +tenant_scope NOT NULL canonical scope +correlation_id +causation_id nullable +traceparent nullable, validated +tracestate nullable, validated +content_type +envelope_bytes BYTEA, exact UTF-8 wire document +envelope_sha256 +envelope_schema_hash +payload_schema_hash +schema_set_hash +contract_catalog_revision +publication_epoch +dispatch_authority VARCHAR + CHECK: LEGACY_POLLING | POLLING_V2 | CDC +transaction_resource_id +``` + +규칙: + +- INSERT-only after migration cutover; +- identity/order unique constraint; +- event bytes/metadata immutable; +- `JSONB`나 재직렬화 가능한 `TEXT`를 wire authority로 사용하지 않음; +- `BYTEA`와 hash가 polling/CDC의 exact byte authority; +- polling status/attempt/owner 없음; +- CDC source predicate가 이 table의 INSERT만 받음; +- delete는 retention maintenance뿐이며 connector behavior를 test함. + +### 18.2 Delivery control + +목표 `outbox_delivery` conceptual columns: + +```text +event_id FK +delivery_generation +authority_status CURRENT | SUPERSEDED +superseded_by_generation nullable +dispatch_profile_id +destination_binding_revision +state +claim_count +publication_attempt_count +first_attempt_at +next_attempt_at +claim_token +claim_owner +claim_until +last_outcome_certainty +last_failure_class +last_failure_stage +provider_generation +provider_record_reference +delivery_recorded_at +terminal_at +row_version +created_at delivery generation DB creation time +automatic_attempt_deadline DB time, immutable per generation +updated_at +``` + +primary identity: + +```text +(event_id, delivery_generation) +``` + +`UNIQUE(event_id) WHERE authority_status='CURRENT'`로 event당 authoritative delivery +generation을 정확히 하나만 허용한다. requeue transaction은 current row를 lock하고 audit를 +append한 뒤 기존 row를 `SUPERSEDED`로 바꾸고 `delivery_generation + 1`, `CURRENT`, `READY` row를 +삽입한다. 새 row의 `created_at`과 `automatic_attempt_deadline`은 같은 DB transaction에서 +§18.9대로 계산한다. `superseded_by_generation`은 새 generation을 가리킨다. update와 insert 중 +하나라도 실패하면 transaction 전체가 rollback한다. + +first baseline은 event 하나에 logical destination 하나다. multi-destination fan-out card를 나중에 +추가하면 destination을 delivery identity와 current-authority constraint에 포함하고 한 destination +성공이 다른 destination을 완료시키지 않도록 별도 설계한다. + +### 18.3 Append-only attempt journal + +`outbox_delivery_attempt_observation`은 delivery control과 별도인 append-only audit다. + +```text +event_id +delivery_generation +publication_attempt_id +observation_sequence +observation_type ATTEMPT_ADMITTED | OUTCOME_OBSERVED | LATE_ACK_OBSERVED +claim_token_digest +producer_generation +destination_binding_revision +observed_at +acceptance_certainty +retry_disposition +failure_class +failure_stage +provider_record_reference +``` + +흐름: + +1. local admission permit를 먼저 확보한다; +2. 한 짧은 claim transaction에서 한 row만 claim하고 valid remaining lease를 확인한 뒤 + `claim_count + 1`, `publication_attempt_id`, `publication_attempt_count + 1`, + `ATTEMPT_ADMITTED`를 함께 기록한다; +3. 이 durable marker 이후에만 Kafka send를 호출한다; +4. marker 뒤 process crash는 실제 send 전이어도 안전하게 `INDETERMINATE`로 복구한다; +5. provider outcome observation과 Tx C state transition은 같은 short transaction에서 기록한다; +6. deadline 뒤 ACK는 §15.2의 bounded source/drain 경계를 통해 성공적으로 persisted된 경우에만 + `LATE_ACK_OBSERVED`를 append하고 delivery state를 뒤집지 않는다. + +raw claim token은 journal/log/metric에 복제하지 않는다. attempt observation retention은 operator +reconciliation과 delivery retention보다 짧을 수 없다. +`LATE_ACK_OBSERVED`는 성공적으로 capture됐을 때 durable한 진단 사실이지만 late callback capture +자체는 crash-proof하지 않다. admission/outcome journal만 publication state machine의 필수 +evidence다. +first profile에는 claim만 commit하고 나중에 queue에서 send하는 중간 상태가 없다. lease reclaim +수와 실제 publication admission 수는 별도 counter로 관측한다. + +### 18.4 State + +target polling state: + +```text +READY +CLAIMED +RETRY_WAIT +DELIVERY_RECORDED +EXHAUSTED +HOLD +SKIPPED +COMPENSATED +LEGACY_RECORDED_UNVERIFIED +LEGACY_ACCEPTED_UNVERIFIED +``` + +`EXHAUSTED`는 “broker에 절대 전달되지 않았다”는 뜻이 아니다. 정해진 attempt/elapsed budget 안에 +delivery recording을 완료하지 못해 자동 처리를 중단했다는 뜻이다. 마지막 certainty가 +`INDETERMINATE`이면 이미 전달되었을 수 있다. + +`EXHAUSTED`와 `HOLD`는 automation-terminal이지만 ordering/retention 관점에서는 unresolved다. +`HOLD`는 ACK destination mismatch, invariant violation 또는 audited operator pause 때문에 +자동 재시도를 허용하지 않는 상태다. +`LEGACY_RECORDED_UNVERIFIED`는 current `void KafkaSender` normal return을 보존하는 migration-only +상태이며 broker ACK, offset, `delivery_recorded_at`을 채우지 않는다. downstream reconciliation과 +operator approval 뒤 `LEGACY_ACCEPTED_UNVERIFIED`로만 전이할 수 있고 이 상태도 broker ACK를 +뜻하지 않는다. + +consumer-side Kafka DLT와 producer-side `EXHAUSTED`를 둘 다 “dead letter”라고 부르지 않는다. +legacy `DEAD/OUTBOX_DEAD_LETTER` vocabulary는 migration alias로만 유지하고 runbook을 분리한다. + +### 18.5 State machine + +```mermaid +stateDiagram-v2 + [*] --> READY + READY --> CLAIMED: claim(token, lease) + RETRY_WAIT --> CLAIMED: due + claim(token, lease) + CLAIMED --> DELIVERY_RECORDED: broker ACK + valid-lease token CAS + CLAIMED --> RETRY_WAIT: retryable/indeterminate + token CAS + CLAIMED --> EXHAUSTED: permanent/budget exhausted + token CAS + CLAIMED --> HOLD: ACK mismatch/invariant + token CAS + CLAIMED --> CLAIMED: lease expired + new token reclaim + EXHAUSTED --> NEW_READY: audited supersede + new generation row + HOLD --> NEW_READY: audited supersede + new generation row + EXHAUSTED --> SKIPPED: audited disposition + EXHAUSTED --> COMPENSATED: audited disposition + HOLD --> SKIPPED: audited disposition + HOLD --> COMPENSATED: audited disposition + LEGACY_RECORDED_UNVERIFIED --> LEGACY_ACCEPTED_UNVERIFIED: reconciliation + approval + state "READY (generation + 1)" as NEW_READY + DELIVERY_RECORDED --> [*] + SKIPPED --> [*] + COMPENSATED --> [*] + LEGACY_ACCEPTED_UNVERIFIED --> [*] +``` + +실제로 EXHAUSTED/HOLD row를 READY로 UPDATE하지 않는다. operator requeue는 같은 immutable +event를 참조하는 `deliveryGeneration + 1` current row와 audit record를 만들고 이전 row를 +`SUPERSEDED` authority로 바꾸는 한 transaction이다. 상태 diagram의 `NEW_READY` 화살표는 이전 +row의 state overwrite가 아니라 이 authority handoff를 뜻한다. + +### 18.6 Claim token와 valid-lease CAS + +active worker가 소유한 renew와 outcome transition은 다음 조건을 가진다. + +```text +WHERE event_id = ? + AND delivery_generation = ? + AND authority_status = 'CURRENT' + AND state = 'CLAIMED' + AND claim_token = ? + AND claim_owner = ? + AND claim_until > database_now +``` + +affected row가 정확히 1이 아니면 stale-owner conflict다. stale worker는 broker ACK를 늦게 받아도 +새 owner의 state를 `DELIVERY_RECORDED/RETRY_WAIT/EXHAUSTED`로 덮지 못한다. 새 worker가 아직 +reclaim하지 않았더라도 lease가 만료된 old owner는 terminal state를 기록할 수 없다. + +claim token은 추측 불가능한 opaque value이고 metric tag가 아니다. `row_version`은 JPA optimistic +locking 보조 수단일 뿐 claim token을 대체하지 않는다. worker renew와 worker-owned outcome +transition은 DB time으로 valid lease를 검사한다. + +다른 mutation은 active-worker predicate를 흉내 내지 않고 각자 다음 fence를 사용한다. + +| Mutation | Required predicate/fence | +| --- | --- | +| initial claim | `CURRENT` + `READY` 또는 due `RETRY_WAIT` + ordering eligibility + expected `row_version`; row lock 안에서 새 token/owner/DB-time lease 설정 | +| expired reclaim | `CURRENT` + `CLAIMED` + `claim_until <= database_now` + expected `row_version`; 이전 token을 새 opaque token으로 교체 | +| worker renew/outcome | 위의 current token/owner + `claim_until > database_now` predicate | +| operator HOLD/SKIP/COMPENSATE/legacy accept | `CURRENT` + expected generation/state/row_version + active unexpired claim 없음 + authorization/audit record in same transaction | +| requeue | current row lock + expected generation/state/row_version + active unexpired claim 없음; old authority `SUPERSEDED`와 new `CURRENT/READY` insert를 same transaction | + +각 update의 affected row는 정확히 1이어야 한다. operator가 live worker의 token을 무시하고 raw +status를 덮어쓰지 않는다. 긴급 HOLD가 필요하면 신규 claim을 먼저 fence하고 active worker +drain 또는 lease expiry 뒤 operator CAS를 수행한다. + +### 18.7 Claim eligibility + +후보: + +- READY; +- `RETRY_WAIT AND next_attempt_at <= database_now`; +- `CLAIMED AND claim_until <= database_now`. + +ordering-required scope에서는 더 작은 aggregate order event의 +`authority_status=CURRENT` generation이 +`DELIVERY_RECORDED`, audited `SKIPPED/COMPENSATED` 또는 audited +`LEGACY_ACCEPTED_UNVERIFIED`가 아니면 claim하지 않는다. +`EXHAUSTED/HOLD/LEGACY_RECORDED_UNVERIFIED`를 단순 terminal로 보고 통과시키지 않는다. query는 +stable total order와 `FOR UPDATE SKIP LOCKED`를 사용한다. + +### 18.8 First claim/lease strategy + +first implementation은 `postgresql-per-record-jit-claim.v1` 하나로 고정한다. + +```text +local admission permit reserve +-> one eligible row JIT claim +-> valid remaining lease check +-> attempt admission journal +-> one send/observe/Tx C +-> permit release +``` + +- publish 시작 전 remaining lease가 attempt budget보다 작으면 send하지 않는다; +- renew도 claim token CAS다; +- lease expiry 뒤 late sender는 authoritative state를 바꾸지 못한다; +- duplicate publication 가능성은 남으므로 consumer inbox가 필요하다. + +```text +claimLease > + admission-after-claim reserve + + max.block.ms + + delivery.timeout.ms + + callback/TxC reserve + + scheduling safety margin +``` + +batch/window와 partition-key-aware concurrent claim은 throughput evidence가 필요할 때 별도 profile로 +추가한다. + +### 18.9 Retry time + +retry due와 lease는 database time을 사용한다. backoff는 bounded exponential + jitter를 사용할 수 +있지만 다음을 descriptor에 고정한다. + +- claim count와 publication attempt count; +- delivery-generation DB-created-at 기준 maximum automatic publication age; +- minimum/maximum delay; +- jitter source/range; +- failure-class override; +- operator hold; +- destination backlog capacity. + +현재 fixed `maxAttempts=3`을 영구 정본으로 보지 않는다. real fault/capacity evidence로 first R2 +profile 값을 고정한다. + +initial generation은 DB-authoritative `outbox_event.created_at`에서 automatic publication age를 +시작한다. audited requeue가 만든 새 generation은 그 delivery row의 DB `created_at`에서 새롭지만 +여전히 finite한 한-generation attempt budget을 시작하되, 원본 event의 same-ID requeue horizon을 +넘지 못한다. + +```text +initial generation: + automaticAttemptDeadline = + min( + outbox_event.created_at + profile.maximumAutomaticPublicationAge, + outbox_event.created_at + contract.sameEventRequeueHorizon + ) + +requeue generation: + automaticAttemptDeadline = + min( + outbox_delivery.created_at + profile.maximumAutomaticPublicationAge, + outbox_event.created_at + contract.sameEventRequeueHorizon + ) +``` + +계산 결과를 `outbox_delivery.automatic_attempt_deadline`에 immutable하게 저장해 profile reload로 +기존 generation의 deadline이 움직이지 않게 한다. claim 시 database time이 deadline 이상이거나 +한 full attempt budget이 남지 않으면, 첫 시도 전 backlog row라도 send하지 않고 `EXHAUSTED`로 +fenced transition한다. `first_attempt_at`은 관측값일 뿐 budget을 새로 시작하지 않는다. + +따라서 원본 event의 initial generation이 age로 EXHAUSTED된 뒤라도 requeue horizon 안에서 승인된 +operator requeue는 새 generation에 한 번의 bounded automatic window를 부여한다. 그 window도 +`requeueDeadline`에서 잘리며 horizon 뒤 same-ID resend는 여전히 금지한다. 오래된 event를 장애 +복구 직후 발행해야 하면 이 audited requeue 또는 새 compensation/corrected event를 선택한다. + +### 18.10 Leader election + +PostgreSQL row claim과 token CAS가 correctness를 제공한다. single leader는 scheduler +amplification을 줄이는 efficiency option일 수 있지만 correctness의 유일한 근거가 아니다. + +현재 `OutboxLeaderElectionToken` marker와 “leader election” test 이름이 실제 consensus leader를 +증명한다고 표현하지 않는다. 여러 instance가 claim에 참여하는 profile이라면 +`multi-worker-row-partitioning`처럼 정확히 이름 붙인다. + +## 19. Polling transaction, crash, disposition과 retention + +### 19.1 Crash matrix + +| Crash/failure point | Persisted state | Broker 가능성 | Recovery | +| --- | --- | --- | --- | +| business write 전 | 없음 | 없음 | caller retry | +| business write 후 outbox append 전, same tx rollback | 없음 | 없음 | caller retry | +| event/delivery commit 후 claim 전 | READY | 없음 | normal claim | +| claim commit 후 send 전 crash | CLAIMED | 없음 | lease expiry/reclaim | +| send request 뒤 ACK 전 connection loss | CLAIMED | accepted 가능 | indeterminate + reclaim | +| broker ACK 뒤 delivery CAS 전 crash | CLAIMED | accepted | reclaim, duplicate 가능 | +| ACK 뒤 stale token | 새 owner state | accepted | late owner state mutation 거부 | +| definite transient rejection | RETRY_WAIT | 미수락 확정 | due retry | +| permanent definite rejection | EXHAUSTED | 미수락 확정 | operator remediation | +| DELIVERY_RECORDED commit 뒤 process crash | DELIVERY_RECORDED | accepted | no automatic re-send | +| shutdown timeout 중 unresolved send | CLAIMED | accepted 가능 | lease reclaim, duplicate 가능 | + +이 표는 “중복 없음”이 아니라 중복 발생 지점과 authoritative recovery를 고정한다. + +### 19.2 Transaction boundaries + +```text +Tx A: business write + outbox_event + outbox_delivery +Tx B: one-row JIT claim + valid lease + publication attempt admission observation +No DB Tx: broker publish/ACK wait +Tx C: outcome observation + valid-lease token-CAS DELIVERY_RECORDED/RETRY_WAIT/EXHAUSTED +``` + +broker call을 Tx B/C 안에 넣어 DB connection/row lock을 ACK timeout 동안 잡지 않는다. +first relay command invocation은 최대 한 record만 처리해 per-record `REQUIRES_NEW` loop를 만들지 +않는다. scheduler가 bounded rate로 다음 invocation을 요청하고, 각 invocation은 Tx B와 Tx C를 +순차로 열되 capacity relation을 test한다. + +### 19.3 Append disabled/misconfigured + +R2 deployment에서 active durable contract가 하나라도 있으면 dispatch는 `polling` 또는 `cdc`여야 +한다. + +- `polling`인데 ACK-aware producer/topic binding이 없으면 startup fail; +- `cdc`인데 external connector expected-state/evidence가 없으면 deployment gate fail; +- `disabled`인데 durable contract binding이 있으면 startup fail; +- empty contract catalog + disabled는 resource 0. + +현재 `.env`처럼 relay enabled + broker blank로 모든 row를 DEAD에 보내는 조합은 target에서 +허용하지 않는다. + +### 19.4 Exhausted head disposition + +strict ordered aggregate head가 EXHAUSTED이면 operator는 다음 중 하나를 선택한다. + +- REMEDIATE_AND_REQUEUE: 원인 수정 뒤 새 delivery generation; +- SKIP_WITH_GAP: business owner 승인과 reason/audit 뒤 후행 release; +- HOLD: 후행 계속 차단; +- COMPENSATE: 별도 compensating integration event. + +raw SQL로 `status=PUBLISHED`를 설정해 skip을 숨기지 않는다. 모든 disposition은: + +```text +operator identity +authorization +reason code/text bound +incident/change reference +old/new generation +payload/schema hash +affected ordering scope +timestamp +approval when destructive +``` + +를 immutable audit로 남긴다. + +첫 R2의 operator control surface는 기존 `adapter:inbound:web` leaf의 인증된 internal HTTP +endpoint 하나로 고정한다. + +```text +POST /internal/operations/messaging/outbox/{eventId}/dispositions +permission: outbox:disposition +destructive permission for SKIP/COMPENSATE: outbox:disposition:destructive +required: Idempotency-Key, expected deliveryGeneration, expected rowVersion, + disposition, bounded reason, incident/change reference +``` + +- web request/auth principal은 inbound DTO에서 application의 + `ApplyOutboxDispositionCommand`로 mapping하고 application에 web/security 타입을 넘기지 않는다; +- application-core는 `ApplyOutboxDispositionUseCase`와 + `OutboxDispositionPort`를 소유한다; +- use case는 existing framework-free `@RequiresPermission("outbox:disposition")` contract를 + 사용하고 destructive operation은 `AuthorizationPort`로 추가 permission을 검증한다; +- use case가 permission, allowed source state, requeue horizon, ordering impact, + destructive approval reference와 compensation event reference를 검증한다; +- persistence adapter는 §18.6 operator CAS, authority handoff와 immutable audit를 한 transaction에 + 구현한다; +- REQUEUE는 old authority supersede + new generation insert, HOLD/SKIP/COMPENSATE는 expected + current row transition이다; +- controller가 repository/entity를 직접 호출하거나 app-bootstrap이 policy를 구현하지 않는다; +- endpoint는 management/public business API와 구분한 internal network policy, strong + authentication, rate bound와 audit를 요구하고 OpenAPI/public-path/security snapshot test에 + 포함한다; +- raw SQL과 writable Actuator endpoint는 대체 control surface가 아니다. + +`COMPENSATED`는 “보상할 예정”이 아니다. feature owner가 만든 immutable compensating event +reference가 같은 transaction에서 검증·audit된 뒤에만 기록한다. `SKIP`과 `COMPENSATE`는 +destructive permission과 승인 reference 없이는 실패한다. + +### 19.5 Replay/requeue + +producer-side requeue는 같은 event ID와 document를 새 delivery generation으로 다시 publish한다. +한 transaction에서 기존 current generation의 authority를 `SUPERSEDED`로 넘기고 새 +`CURRENT/READY` generation을 만든다. 새 business event를 만들지 않는다. 이미 consumer effect가 +적용되었을 수 있으므로 duplicate를 전제로 한다. + +same-event requeue는 무기한 허용하지 않는다. + +```text +requeueDeadline = + outbox_event.created_at(DB time) + contract.sameEventRequeueHorizon +``` + +- horizon은 finite이고 contract/catalog hash에 포함한다; +- required consumer가 존재하면 horizon은 모든 required consumer의 inbox/dedupe archive coverage + 중 최솟값 이하여야 한다; +- producer-only first R2는 end-to-end duplicate absorption을 주장하지 않더라도 deadline을 + enforce하고 operator에게 downstream dedupe 확인 책임을 노출한다; +- deadline 이후 같은 event ID generation 생성은 fail-closed다; +- 오래된 EXHAUSTED/HOLD row는 audit/retention 때문에 남을 수 있지만 same-ID resend 대상은 + 아니다. business owner는 `SKIP`, 검증된 새 compensation/corrected event 또는 별도 durable + dedupe-archive card를 선택한다. + +payload를 수정해야 하면 기존 event를 바꾸지 않고 새 event ID/contract version을 가진 corrected +또는 compensating event를 만든다. + +### 19.6 Retention + +polling retention 조건: + +```text +the unique CURRENT authoritative generation resolved as + DELIVERY_RECORDED or audited SKIPPED/COMPENSATED/LEGACY_ACCEPTED_UNVERIFIED +AND no active claim/requeue +AND publication audit retention elapsed +AND operator/legal hold 없음 +AND configured replay horizon elapsed +``` + +삭제 순서는 delivery/audit FK와 partition strategy가 결정한다. cascade가 audit를 조용히 +없애지 않도록 test한다. reaper는 claim/requeue와 CAS로 경합하고 event를 먼저 지우지 않는다. +CURRENT generation이 `EXHAUSTED`, `HOLD`, `LEGACY_RECORDED_UNVERIFIED`이거나 unresolved attempt +observation이 있으면 automation-terminal이어도 삭제하지 않는다. superseded generation과 그 +authority-handoff audit도 current generation의 전체 retention 조건이 충족되기 전에 따로 +삭제하지 않는다. + +Kafka topic retention이 consumer replay source라 해도 outbox event retention과 동일한 기간이라고 +가정하지 않는다. + +### 19.7 Partitioning + +`outbox_event`는 occurred/created time 기준 range partition을 사용할 수 있다. 하지만 strict +aggregate ordering query, active delivery FK와 cleanup을 함께 benchmark한다. + +closed partition 삭제는: + +- polling에서는 terminal/replay 조건; +- CDC에서는 connector checkpoint proof + +가 다르다. polling `DELIVERY_RECORDED` status를 CDC cleanup proof로 재사용하지 않는다. + +### 19.8 Backlog capacity + +durable API는 broker outage 중 DB에 event를 안전하게 쌓을 수 있으므로 Kafka 순간 장애만으로 +모든 write endpoint readiness를 즉시 내릴 필요는 없다. + +대신 다음을 구분한다. + +- relay readiness: producer/topic에 의존; +- write admission readiness: DB free space, oldest age, backlog count/growth, retention/SLO; +- direct required producer readiness: Kafka에 직접 의존; +- liveness: 외부 dependency와 무관. + +backlog capacity/SLO threshold를 넘으면 새 durable writes를 받을지 degrade할지는 deployment +policy로 명시한다. + +## 20. Best-effort publication + +### 20.1 정확한 의미 + +best-effort는 다음만 보장한다. + +```text +closed contract validation ++ bounded local/producer attempt ++ outcome observation +- durable persistence +- automatic replay +- business transaction atomicity +``` + +first provider는 같은 ACK-aware Kafka gateway를 사용할 수 있다. failure를 caller에게 전파하지 +않더라도 metric/log에는 +ACKNOWLEDGED/ACKNOWLEDGED_MISMATCH/REJECTED/INDETERMINATE를 정확히 기록한다. + +### 20.2 Naming + +`MessagePublisher`처럼 durability가 모호한 이름은 migration 동안 유지할 수 있으나 target +application-facing 이름은 `BestEffort...`를 포함한다. durable event는 `Outbox...` contract를 +사용한다. + +### 20.3 Failure policy + +- non-critical telemetry-like side effect만 fail-open을 선택한다; +- failure를 삼킨다고 outbox가 자동으로 대신하지 않는다; +- caller가 같은 semantic event를 best-effort와 outbox로 동시에 보내지 않는다; +- disabled best-effort binding은 호출 시 fail-fast하고 silent no-op이 아니다; +- business correctness가 delivery에 의존하면 best-effort를 선택할 수 없다. + +### 20.4 Async optional card + +caller latency를 위해 local enqueue 뒤 즉시 반환하는 truly asynchronous best-effort card를 +나중에 추가할 수 있다. 그 card는 결과를 `ENQUEUED`로만 표현하고 broker ACK/durability를 +주장하지 않는다. bounded queue, drop policy, shutdown drain과 loss metric을 별도 evidence로 +가져야 한다. + +## 21. Activation, configuration과 expected state + +### 21.1 Canonical target shape + +다음은 설계 목표 shape이며 현재 `application.yml`에 그대로 추가하라는 뜻이 아니다. first R2 +구현이 존재할 때 구현된 필드만 live configuration으로 추가한다. + +```yaml +app: + messaging: + expected-state: ACTIVE + + publication: + producer-provider: kafka-spring + producer-profile: acknowledged-idempotent-v1 + serialization-profile: json-schema-envelope-v1 + topic-profile: externally-provisioned-and-validated-v1 + ordering-profile: per-key-normal-path-sequence-detectable-v1 + compression-profile: none-v1 + + outbox: + dispatch-mode: polling + polling-profile: postgresql-polling-v2 + claim-profile: postgresql-per-record-jit-claim-v1 + transaction-resource-id: primary-jpa + operator-control-profile: authenticated-internal-web-disposition-v1 + claim-lease: 90s + maximum-relay-attempts: 5 + maximum-automatic-publication-age: 15m + same-event-requeue-horizon: 7d + + kafka: + cluster-id: primary + bootstrap-servers: + - kafka-1.example.internal:9093 + - kafka-2.example.internal:9093 + security-profile: kafka-sasl-ssl-scram-sha-512-v1 + secret-reference: secret://messaging/kafka/producer + producer: + admission-timeout: 1s + delivery-timeout: 60s + request-timeout: 35s + max-block-timeout: 5s + application-attempt-budget: 68s + buffer-memory-bytes: 33554432 + maximum-request-bytes: 4194304 + maximum-admitted-records: 1 + + destinations: + portfolio-domain-events: + binding-revision: portfolio-domain-events-r1 + topic: portfolio.domain-events.v1 + expected-partitions: 12 + minimum-replication-factor: 3 + minimum-in-sync-replicas: 2 + maximum-record-bytes: 1048576 + maximum-envelope-bytes: 786432 + required: true +``` + +숫자는 설명을 위한 candidate다. implementation plan에서 adopted Kafka/Broker version, +Testcontainers/fault/capacity evidence로 기본값과 상한을 고정한다. candidate도 protocol/header +headroom과 §16.6 budget 관계를 만족하도록 서로 같은 1 MiB 값을 복제하지 않는다. + +### 21.2 Expected state + +```text +DISABLED +ACTIVE +``` + +`DISABLED`: + +- active contract/destination 0; +- producer factory/template/AdminClient 0; +- polling scheduler 0; +- listener container 0; +- secret refresh 0; +- network connection 0. + +`ACTIVE`: + +- exact selected tuple가 모두 known/release-eligible; +- active contract가 모두 compiled; +- required security material이 resolved; +- runtime state는 `STARTING | ACTIVE_NOT_READY | ACTIVE_READY`; +- required destination/security/topology attestation이 fresh할 때만 `ACTIVE_READY`. + +`enabled=true`와 provider 이름을 여러 곳에서 조합하지 않는다. + +static schema/card/security/deadline conflict는 resource 생성 전 startup failure다. exact tuple은 +유효하지만 broker/topic이 일시적으로 unavailable한 경우 first profile은 context를 +`ACTIVE_NOT_READY`로 시작하고 scheduler admission을 막은 채 bounded backoff로 재-attest한다. +credential 누락, plaintext downgrade, unknown topic binding처럼 static/authorization failure를 +transient로 숨기지 않는다. `QUALIFICATION_ONLY`는 test harness mode이지 deployment expected +state가 아니다. + +### 21.3 Cross-field validation + +최소 startup failure: + +- ACTIVE + provider blank/unknown; +- ACTIVE + empty bootstrap server; +- ACTIVE + empty contract/destination; +- ACTIVE + unqualified card; +- polling + ACK-aware producer 없음; +- polling + delivery schema/claim settings 없음; +- polling + claim profile가 per-record JIT가 아님; +- polling + authenticated operator disposition control 없음; +- business/outbox transaction resource identity 불일치; +- CDC + polling scheduler active; +- DB publication epoch와 expected authority 불일치; +- disabled + active durable contract; +- production + plaintext; +- production + literal credential; +- idempotence와 충돌하는 `acks/retries/max.in.flight`; +- delivery/request/linger deadline 관계 위반; +- attempt budget과 claim lease 관계 위반; +- automatic publication/requeue/inbox dedupe horizon 관계 위반; +- contract bytes > destination/provider/topic bound; +- ordering-required + null key; +- duplicate topic/binding/schema ID; +- schema/catalog/evidence hash mismatch; +- legacy와 target activation이 동시에 설정됨. + +### 21.4 Typed settings + +raw `Map kafkaProperties`를 R2 public config로 노출하지 않는다. first profile이 +실제로 support하는 setting만 typed field로 제공한다. + +Kafka client upgrade로 새 setting이 필요하면: + +1. threat/guarantee 영향 검토; +2. typed setting/validation; +3. effective config assertion; +4. fault/security/compatibility test; +5. card version 또는 evidence fingerprint update + +를 함께 수행한다. + +### 21.5 Legacy migration + +현재: + +```text +APP_MESSAGING_BROKER +APP_MESSAGING_KAFKA_BROKERS +ca-skeleton.outbox.relay-enabled +``` + +목표 migration: + +- legacy 값은 R0 `external-kafka-sender-legacy.v1` descriptor와 endpoint seed로만 해석한다; +- target R2 exact tuple은 새 contract/destination/security/dispatch 설정을 모두 명시해야 한다; +- target key와 legacy key가 동시에 존재하면 fail; +- `broker=kafka`가 `kafka-spring` R2를 자동 의미하지 않는다; +- `relay-enabled=true/false`는 `dispatch-mode`로 대체한다; +- warning과 removal release를 명시한다; +- legacy seam 사용은 descriptor에 R0로 노출한다; +- runbook/.env/README/env registry를 같은 변경에서 갱신한다. + +조용한 precedence는 없다. + +### 21.6 Environment registry + +새 environment key는 `docs/registries/env-keys.yaml`에: + +- owner; +- type/default/allowed values; +- secret classification; +- validation; +- compatibility impact; +- required test + +를 등록한다. + +logical destination/topic key는 fork가 실제 contract를 추가할 때 등록한다. skeleton은 존재하지 +않는 sample production destination을 global env registry에 강제로 추가하지 않는다. + +### 21.7 Secret reference + +configuration에는 secret value가 아니라 reference만 둔다. + +```text +secret://messaging/kafka/producer +``` + +secret resolver가 반환하는 material은: + +- char/byte lifecycle을 제한; +- log/toString/config dump에서 redact; +- generation과 expiry만 sanitized descriptor에 노출; +- rotation 실패 시 old generation 사용 가능 기간을 bounded policy로 관리한다. + +### 21.8 Compiled runtime descriptor + +startup 뒤 sanitized endpoint는 다음을 보여준다. + +```text +expectedState +runtimeState +semantic/provider/dispatch/serialization/topic/security card IDs +provider/client versions +contract catalog hash +schema set hash +destination IDs +destination binding revisions +transaction resource ID +publication epoch/dispatch authority +settings digest +producer generation +readiness level +evidence fingerprint/status +explicit non-guarantees +runbook IDs +``` + +bootstrap server, topic이 민감한 deployment에서는 hash/alias만 노출한다. credential, raw headers, +payload는 절대 노출하지 않는다. + +### 21.9 Future consumer/CDC settings + +consumer와 CDC는 §24–§27의 contract가 구현될 때만 typed setting을 추가한다. 지금 live YAML에: + +```text +consumer.enabled +inbox.provider +cdc.enabled +schemaRegistry.url +``` + +같은 미구현 switch를 먼저 만들지 않는다. + +## 22. Security와 topic governance + +### 22.1 Threat model + +| Threat | Control | +| --- | --- | +| arbitrary topic publish | closed destination binding | +| broker MITM | TLS hostname verification + trusted CA | +| credential leak | secret reference, redaction, generation rotation | +| over-privileged principal | destination별 least-privilege ACL | +| plaintext downgrade | production startup fail | +| payload/header injection | schema/header allowlist + byte bounds | +| cross-tenant leak | tenant-aware contract/key/auth, no dynamic topic | +| replay abuse | audited replay authorization/rate bound | +| poison/oversized record | writer validation + consumer hardening | +| dependency compromise | lock/SBOM/signature/vulnerability gate | +| topic policy drift | startup/pre-deploy attestation | +| DLT sensitive-data accumulation | restricted ACL, retention, redaction policy | + +### 22.2 Network profile + +허용 profile: + +```text +local-plaintext-v1 local/dev only +tls-server-auth-v1 controlled non-production or explicit policy +sasl-ssl-scram-v1 production candidate +sasl-ssl-oauth-v1 future/qualified candidate +mtls-v1 deployment requirement가 있을 때 +``` + +production은 `SSL` 또는 `SASL_SSL`만 허용한다. PLAIN/SCRAM credential을 TLS 없이 사용하지 +않는다. custom trust-all, hostname verification disable, insecure callback handler를 금지한다. + +first production reference는 `SASL_SSL + SCRAM-SHA-512`로 고정한다. OAuth, mTLS와 +server-auth-only TLS는 future profile이며 first tuple의 보장을 자동 상속하지 않는다. + +### 22.3 TLS + +- endpoint hostname verification 활성; +- protocol/cipher allowlist는 platform security policy와 정렬; +- truststore/keystore location과 password를 secret material로 취급; +- certificate expiry/chain/hostname negative test; +- rotation generation swap; +- emergency revocation runbook; +- clock skew와 certificate validity 관측; +- local self-signed CA는 explicit dev test profile에만 허용. + +### 22.4 SASL + +mechanism은 typed allowlist다. JAAS literal string을 일반 application YAML/log에 노출하지 않는다. + +- SCRAM: username/password secret generation과 broker-side iteration/security 정책; +- OAUTHBEARER: issuer/audience/token endpoint TLS, token refresh deadline, secret/key rotation; +- GSSAPI: 실제 platform 요구와 qualification이 있을 때만; +- PLAIN: SASL_SSL에서만 explicit qualification. + +auth refresh thread/resource도 disabled profile에서 0이어야 한다. + +### 22.5 ACL + +producer principal의 최소 권한: + +```text +Describe on required cluster/topic scope +DescribeConfigs on exact production topics +Write on exact production topics +IdempotentWrite/transactional permissions only when adopted version/profile requires +``` + +기본 금지: + +```text +Create +Delete +Alter +Write to wildcard all topics +consumer Read +Connect internal topic access +``` + +AdminClient attestation 때문에 broker-wide config나 ACL enumeration 권한을 요구하지 않는다. +§13.5에서 runtime으로 확인할 수 없는 policy는 fresh signed/provenance-attested deployment +provisioning evidence로 보완하고 descriptor에 verification source를 기록한다. + +consumer, DLT publisher, Connect worker는 서로 다른 principal/ACL을 사용한다. + +### 22.6 Topic provisioning + +production topic은 infrastructure-as-code가 만든다. + +- topic name/config review; +- partition/RF/min ISR; +- retention/cleanup; +- max message bytes; +- quota; +- ACL; +- ownership/contact; +- change/rollback record. + +application startup은 validate하지 create/alter하지 않는다. + +### 22.7 Data classification + +contract descriptor는 payload sensitivity를 분류한다. + +- credential/token/password를 event payload로 보내지 않는다; +- 필요한 personal data만 최소화; +- tenant/user raw identity를 partition key로 쓸 때 bounded digest/pseudonymization 검토; +- topic/DLT/outbox/inbox retention과 data deletion 법적 요구를 맞춘다; +- encryption-at-rest는 broker/DB/platform control과 evidence로 관리; +- payload encryption field-level card는 key lifecycle과 consumer authorization을 함께 설계할 때만 + 추가한다. + +### 22.8 Header/tracing security + +- W3C `traceparent`/`tracestate` grammar와 size를 검증; +- baggage는 default propagation하지 않음; +- inbound credential/auth/cookie/header forwarding 금지; +- exception stack/Java class를 header에 넣지 않음; +- DLT header는 원본 allowlist + safe failure code만; +- repeated retry/DLT로 header가 무한 증식하지 않게 canonical rewrite. + +### 22.9 Tenant isolation + +tenant-aware deployment는 다음을 명시한다. + +- event에 tenant metadata가 필요한지; +- partition key에 tenant dimension 포함 여부; +- topic을 tenant별로 나눌지 shared로 둘지; +- producer/consumer ACL isolation; +- inbox unique scope; +- metric/log pseudonymization; +- replay authorization. + +request tenant input으로 topic을 동적 생성하지 않는다. tenant topic isolation은 finite +provisioned catalog로만 허용한다. + +## 23. Observability, health와 readiness + +### 23.1 관측 단위 + +다음을 분리한다. + +```text +business event append +polling claim +logical publication attempt +Kafka physical request/retry +broker acknowledgement +delivery state transition +consumer receive +application effect +offset commit +CDC source/connector checkpoint +``` + +한 `publish latency`에 queue/admission/broker/DB transition을 모두 합쳐 원인을 숨기지 않는다. + +### 23.2 Producer/outbox metrics + +최소 후보: + +```text +messaging.producer.attempts +messaging.producer.ack.latency +messaging.producer.queue.time +messaging.producer.outcome +messaging.producer.indeterminate +messaging.producer.buffer.available +messaging.producer.inflight +messaging.producer.throttle +messaging.producer.generation + +outbox.append.total +outbox.delivery.claim.total +outbox.delivery.claim.conflict +outbox.delivery.lease.expired +outbox.delivery.outcome +outbox.delivery.exhausted +outbox.backlog.count +outbox.backlog.oldest.age +outbox.ordering.blocked.count +outbox.replay.total +``` + +exact metric name은 metrics registry naming convention에 맞춰 구현 계획에서 확정한다. + +### 23.3 Metric tag + +허용 후보: + +```text +provider_id +logical_destination_id +contract_id catalog budget 안에서만 +outcome +certainty +failure_stage +failure_class +security_profile +dispatch_profile +``` + +금지: + +```text +event_id +aggregate_id +partition_key +tenant_id +user_id +correlation_id +physical offset +exception message +payload/schema hash +raw topic when not finite catalog +``` + +현재 `event_type cardinality_limit=50` 문서 값만 있고 runtime enforcement가 없는 상태를 +readiness evidence로 보지 않는다. compiled catalog cardinality와 global meter filter를 함께 +test한다. + +### 23.4 Tracing + +producer span: + +```text +logical publish span + -> Kafka client send observation + -> delivery-state DB span +``` + +consumer span: + +```text +Kafka receive/process span + -> application use-case span + -> inbox/business DB span + -> offset commit observation +``` + +Spring Kafka Micrometer Observation을 단일 instrumentation owner로 선택하고 manual trace header +writer와 중복하지 않는다. trace propagation은 W3C allowlist를 사용한다. payload, key, tenant, +event ID를 span attribute로 기본 기록하지 않는다. + +### 23.5 Logs + +정상 record마다 INFO log를 남기지 않는다. structured warning/error의 safe field: + +```text +error code/category +provider/logical destination/contract +outcome/certainty/failure stage +attempt count/delivery generation +opaque event ID와 correlation ID는 approved error log에서만 +runbook link +``` + +payload, raw key/header, credential, full broker config는 금지한다. exception cause는 logging +framework throwable로만 연결하고 message-derived arbitrary field를 만들지 않는다. + +확인된 persistence transition이 canonical ERROR 한 번을 소유한다. producer callback, relay, +report adapter가 같은 failure를 ERROR 세 번 남기지 않는다. + +### 23.6 Audit + +다음은 일반 log가 아니라 durable audit가 필요하다. + +- destination/topic binding 변경; +- capability/profile/security generation 변경; +- outbox requeue/skip/hold/compensate; +- consumer replay; +- group/consumer identity migration; +- CDC slot/offset reset; +- polling/CDC cutover/rollback; +- ACL/secret emergency action. + +### 23.7 Liveness + +Kafka/PostgreSQL/Connect outage가 JVM liveness를 내리지 않는다. liveness는 process/event-loop +deadlock 같은 내부 생존성만 본다. + +### 23.8 Startup/readiness + +role별 readiness: + +| Role | Readiness | +| --- | --- | +| direct required producer | provider/topic/security가 unavailable이면 DOWN | +| polling relay | producer + DB claim path + contract catalog | +| durable write API | DB/outbox append + backlog capacity; 순간 Kafka outage와 분리 가능 | +| optional best-effort producer | app readiness와 분리, descriptor DEGRADED | +| future required consumer | listener assignment/contract/inbox path | +| future CDC deployment | connector task/slot/offset/WAL/topic | + +continuous broker probe 하나로 모든 role을 동시에 DOWN시키지 않는다. + +### 23.9 Readiness hysteresis + +단일 transient timeout으로 readiness가 flap하지 않게: + +- startup hard failure와 runtime degradation을 구분; +- consecutive failure/success 또는 freshness window; +- last successful metadata/ACK/connector progress timestamp; +- backlog/SLO threshold; +- manual maintenance state; +- recovery proof + +를 descriptor에 둔다. 오래된 success를 영구 healthy로 사용하지 않는다. + +### 23.10 Alerts/dashboard + +최소 dashboard: + +- publish ACK/error/indeterminate rate와 latency; +- producer buffer/admission/throttle; +- outbox backlog/oldest age/state/lease conflict; +- destination/contract별 bounded view; +- consumer phase에는 lag/rebalance/retry/DLT/inbox duplicate; +- CDC phase에는 connector state/LSN lag/WAL retained bytes/offset progress/queue. + +alert는 runbook ID와 guarantee impact를 포함한다. stub runbook에 alert 이름만 있는 상태는 +operational evidence가 아니다. + +## 24. Future inbound Kafka consumer + +### 24.1 Phase와 module gate + +consumer는 first producer/polling R2와 별도 phase/card다. 구현 시작 전에: + +1. `modules.json`에 inbound Kafka leaf 추가; +2. settings include/mapping; +3. app-bootstrap allowed edge; +4. nearest `CLAUDE.md`; +5. architecture tests; +6. focused test path + +를 먼저 승인한다. + +기존 19-leaf topology는 producer/polling phase까지 유지하고 consumer phase에서 정확히 20개로 +registry migration한다. + +### 24.2 Baseline listener configuration + +첫 consumer card: + +```text +record listener +enable.auto.commit = false +AckMode = MANUAL_IMMEDIATE +asyncAcks = false +syncCommits = true +syncCommitTimeout = finite explicit value +max.poll.records = 1 +bounded concurrency +bounded fetch/message bytes +finite max.poll.interval.ms +finite session/heartbeat/request timeout +DefaultErrorHandler.ackAfterHandle = false +DefaultErrorHandler.commitRecovered = false +DefaultErrorHandler.resetStateOnRecoveryFailure = false +default logging/no-op recoverer = forbidden +``` + +batch listener는 unfinished record를 건너뛰는 offset high-water, partial failure와 memory bound를 +별도 증명하기 전에는 baseline이 아니다. + +first card는 handler, DB transaction, retry/DLT wait와 `Acknowledgment.acknowledge()`를 모두 +listener/consumer thread에서 동기 실행한다. off-thread worker가 ACK하지 않는다. +`MANUAL_IMMEDIATE`의 immediate 의미는 listener thread 호출과 explicit synchronous commit +profile에서만 주장한다. async handoff/batch는 별도 card다. + +expected decode/application outcomes는 listener가 typed result로 처리하고 §24.5의 explicit +ACK/DLT/HOLD 결정을 수행한다. container `DefaultErrorHandler`는 listener가 놓친 unexpected +exception의 seek/redelivery safety net일 뿐 disposition owner가 아니다. + +- `setAckAfterHandle(false)`와 `setCommitRecovered(false)`를 explicit effective assertion으로 + 고정한다; +- retry exhaustion 뒤 정상 반환하는 Spring default logging recoverer, + `CommonLoggingErrorHandler`와 no-op recoverer를 금지한다; +- unexpected exception의 bounded retry가 소진되면 custom terminal recoverer가 application + `HoldUnexpectedConsumerFailureUseCase`를 호출해 stable record identity, failed offset, + failure-class와 attempt evidence를 durable consumer HOLD로 기록한다. 성공하면 listener thread가 + failed offset으로 seek하고 해당 partition을 pause한 뒤 recoverer가 반환한다. assignment + callback은 §25.7과 같은 durable HOLD를 재적용하므로 restart/rebalance도 자동 재시작 경로가 + 아니다; +- durable HOLD 기록/seek/pause 중 하나라도 실패하면 recoverer는 예외를 던지고 별도 + `recoveryFailed` lifecycle listener가 container를 bounded stop하며 readiness를 DOWN으로 + 만든다. `resetStateOnRecoveryFailure=false`를 effective assertion으로 고정해 stop과 경합해도 + 전체 backoff cycle을 다시 시작하지 않는다. stop이 lifecycle deadline 안에 완료되지 않으면 + process liveness를 fail-closed하고 source offset은 commit하지 않는다; +- baseline은 key/value `byte[]` deserializer를 사용하므로 content decode failure는 listener 안의 + explicit poison path를 탄다. framework-level deserializer를 나중에 쓰면 동일 no-commit + invariant를 별도 evidence로 증명한다; +- DLT 성공은 error handler의 “recovered” 반환이 아니라 §25.7 ACK-aware DLT gateway 성공 뒤 + listener thread의 명시적 source ACK로만 표현한다; +- recoverer/DLT가 실패하거나 indeterminate이면 source commit 0이며, durable HOLD partition 또는 + stopped container라는 terminal automation state가 반드시 관찰돼야 한다. + +### 24.3 Receive sequence + +```text +1. ConsumerRecord receive +2. key/header/value byte bounds +3. header allowlist + envelope decode +4. envelope/payload schema/version validation +5. destination/subscription/contract allowlist +6. application command mapping +7. consume use case / MessageConsumptionExecutor +8. APPLIED 또는 DUPLICATE commit 확인 +9. acknowledgement +10. offset commit result observation +``` + +Kafka SDK type은 step 6에서 끝난다. application command는 provider-neutral event metadata와 +typed payload만 가진다. + +### 24.4 Deserialization failure + +listener method 전에 발생하는 deserializer exception도 다룬다. + +- byte[]로 먼저 받고 bounded envelope codec에서 decode하는 방식을 baseline 후보로 한다; +- framework deserializer를 쓰면 `ErrorHandlingDeserializer`/동등 error path를 명시한다; +- trusted Java package/default typing으로 arbitrary class를 만들지 않는다; +- malformed UTF-8/schema/unknown version은 무한 retry하지 않는다; +- DLT publish ACK 전 source offset을 진행하지 않는다. + +### 24.5 Ack result + +| Application result | Listener | +| --- | --- | +| APPLIED | ACK | +| DUPLICATE with same document hash/effect generation | ACK | +| RETRYABLE_FAILURE | no ACK, bounded retry | +| REJECTED/PERMANENT, reorder-tolerant subscription | DLT ACK 뒤 source ACK | +| REJECTED/PERMANENT, strict ordered subscription | idempotent quarantine 뒤 partition HOLD | +| IDENTITY_COLLISION | idempotent quarantine 뒤 subscription policy | +| DB commit outcome unknown | no ACK, retry; inbox로 reconcile | +| listener shutdown/revoke before commit | no ACK | + +ack 호출 뒤 offset commit failure도 관측한다. commit failure는 record redelivery를 만들 수 있으며 +inbox가 business duplicate를 막아야 한다. + +strict ordered subscription은 DLT ACK만으로 gap을 승인하지 않는다. operator가 audited +`ADVANCE_WITH_GAP` 또는 compensation을 승인한 뒤에만 source offset을 진행한다. + +### 24.6 Bounded processing + +- handler + bounded retry/backoff + DB pool/lock/deadlock retry + GC/scheduler reserve + + synchronous offset commit의 worst case가 `max.poll.interval.ms` 안에 들어야 한다; +- handler concurrency는 partition ordering, DB pool, executor queue에 맞춘다; +- one in-flight per partition가 first ordered baseline이다; +- first baseline은 async executor를 사용하지 않는다; +- queue saturation 때 container/partition pause로 poll heartbeat를 유지; +- capacity 회복 때 resume; +- pause가 buffer/fetch memory를 무한하게 만들지 않게 monitoring한다. + +### 24.7 Rebalance + +first synchronous card에서 rebalance callback은 long-running handler drain 장소가 아니다. handler +budget이 poll membership deadline 안에서 끝나야 하며 callback은 finite한 다음 작업만 한다. + +1. revoked partition의 신규 dispatch 중단; +2. 이미 commit된 APPLIED/DUPLICATE offset만 callback budget 안에서 commit 시도; +3. commit-failed/rebalance-in-progress는 redelivery로 분류; +4. 미완료 work는 ACK하지 않음; +5. resource/context 정리와 assignment generation 갱신. + +consumer는 thread-safe하다고 가정하지 않는다. cooperative assignor/static membership는 +rebalance evidence를 통과한 optional profile이다. off-thread processing을 추가하면 continued +polling, per-partition unfinished high-water와 listener-thread ordered ACK handoff를 별도 설계한다. + +### 24.8 Shutdown + +```text +readiness DOWN +-> listener pause/new dispatch stop +-> active DB transaction bounded drain +-> eligible ACK/commit +-> unresolved no-ACK +-> container close +-> DLT producer close +``` + +shutdown timeout 뒤 unfinished record를 ACK하지 않는다. + +### 24.9 External side effect + +consumer handler가 DB inbox transaction 안에서 HTTP/email/object storage side effect를 직접 +수행하면 same-store atomicity가 없다. + +기본 pattern: + +```text +inbox + business state + follow-up outbox intent + same DB transaction + +external side effect + 별도 durable worker/provider +``` + +외부 side effect를 반드시 inline 수행해야 하면 idempotency/reconciliation/compensation을 +feature-specific design으로 추가하고 inbox만으로 exactly-once라고 표현하지 않는다. + +## 25. Inbox, retry, DLT, replay와 Kafka EOS + +### 25.1 Inbox contract + +`application-core`는 framework-free `InboxStorePort`와 `MessageConsumptionExecutor`를 소유한다. +PostgreSQL provider는 persistence adapter가 구현한다. + +conceptual `inbox_consumption`: + +```text +consumer_id +event_id +tenant_scope NOT NULL canonical scope +contract_id +payload_version +document_sha256 +effect_contract_version +effect_generation default 0 +source_reference safe bounded diagnostic +applied_at +created_at + +PK/UNIQUE (consumer_id, effect_generation, tenant_scope, event_id) +``` + +tenant-disabled deployment도 non-null canonical system scope를 사용한다. 일반 nullable UNIQUE에 +dedupe를 맡기지 않는다. + +### 25.2 Same transaction algorithm + +```text +tx.inWrite: + validate handler/contract + INSERT ... ON CONFLICT DO NOTHING RETURNING inbox identity + inserted: + execute business mutation + optional follow-up outbox append + commit -> APPLIED + no returned row: + load existing bounded metadata + same document hash/effect version/generation -> DUPLICATE + mismatch -> IDENTITY_COLLISION +``` + +business mutation이 실패하면 inbox insert도 rollback한다. `PROCESSING` row를 먼저 별도 transaction에 +commit해 영구 stuck 상태를 만들지 않는다. plain INSERT unique exception을 catch한 뒤 같은 +PostgreSQL/JPA transaction을 계속 사용하지 않는다. native +`ON CONFLICT DO NOTHING RETURNING` 또는 동일 의미의 검증된 atomic primitive를 사용하고 두 +consumer 동시 claim을 real PostgreSQL에서 test한다. + +### 25.3 Crash behavior + +| Point | Result | +| --- | --- | +| inbox insert 전 crash | redelivery, normal apply | +| insert 뒤 business mutation 전 crash/rollback | row 없음, redelivery | +| business + inbox commit 전 crash | rollback, redelivery | +| commit 뒤 ACK 전 crash | redelivery -> DUPLICATE -> ACK | +| ACK 뒤 offset commit response loss | redelivery 가능 -> DUPLICATE | + +### 25.4 Inbox retention + +inbox retention은 최소 다음보다 길어야 한다. + +```text +Kafka replayable retention +DLT retention +maximum audited replay horizon +maximum producer duplicate/requeue horizon +cross-region/cold-recovery horizon when applicable +``` + +inbox를 먼저 지우고 Kafka/DLT record를 다시 replay하면 effect가 재적용된다. cleanup은 consumer +contract version, legal retention과 archive policy를 검증한다. + +각 consumer card는 finite `dedupeHorizon`과 source/DLT/archive replay cutoff를 pin한다. +`dedupeHorizon`은 자신이 소비하는 모든 producer contract의 `sameEventRequeueHorizon` 이상이어야 +하며 release compiler가 compatibility matrix에서 이를 검증한다. +무한 Kafka retention, legal hold 또는 cold archive가 있으면 inbox도 보존하거나 별도 durable +dedupe archive를 제공해야 한다. purge cutoff보다 오래된 replay는 자동 earliest/apply가 아니라 +unsupported incident로 fail한다. + +### 25.5 Baseline retry + +첫 consumer card는 짧고 bounded한 blocking/seek retry다. + +- retryable failure class allowlist; +- small maximum attempts; +- total elapsed bound; +- backoff가 max.poll/rebalance와 호환; +- same partition ordering 유지; +- long dependency outage를 listener thread에서 오래 sleep하지 않음; +- remaining attempts/age가 끝나면 DLT/operator path. + +구체 횟수/시간은 handler SLO와 real fault test로 고정한다. + +### 25.6 Retry topic optional card + +non-blocking retry topic은 main record를 retry topic으로 publish하고 source offset을 진행한다. +Kafka ordering을 잃으므로: + +- unordered/explicitly reorder-tolerant contract만; +- original event ID/contract/exact document hash 유지; +- retry generation/attempt metadata bounded; +- retry/DLT topic provisioning/ACL/retention; +- retry publish ACK 뒤 source ACK; +- retry ACK 뒤 source commit crash가 duplicate retry record를 만들므로 stable retry identity/dedupe; +- container transaction과 adopted Spring Kafka version의 제약 검증; +- live/retry stream의 stale effect policy + +를 요구한다. + +### 25.7 DLT + +consumer DLT는 producer-side outbox `EXHAUSTED`와 다르다. + +future consumer tuple은 별도 +`kafka-consumer-dlt-acknowledged.v1` provider card를 반드시 선택한다. 이 provider는 inbound +Kafka leaf가 소유하며 outbound messaging leaf에 의존하지 않는다. 이는 §5의 HARD invariant 3에 +둔 consumer-processing-local publisher 예외이며 closed DLT/retry binding 외 publish에는 사용할 +수 없다. 최소 exact profile: + +```text +acks=all +enable.idempotence=true +retries=effectively-unbounded/MAX within finite delivery.timeout.ms +max.in.flight.requests.per.connection<=5 +finite admission/max.block/request/delivery/buffer/record/header bounds +ByteArraySerializer key/value with prevalidated DLT bytes +closed pre-provisioned DLT binding, auto-create disabled +SASL_SSL/SCRAM least-privilege Write/Describe ACL +future metadata ACK + expected topic verification +bounded producer generation rotation/shutdown +``` + +DLT gateway outcome도 `ACKNOWLEDGED`, `ACKNOWLEDGED_MISMATCH`, `REJECTED`, +`INDETERMINATE`를 구분한다. mismatch/indeterminate/timeout/close는 source ACK를 허용하지 +않는다. application outbox producer의 card/evidence를 이름만 재사용하지 않고 consumer leaf에서 +real broker/security/fault evidence를 별도로 만든다. 공통 구현 추출은 §31.5의 module-split +trigger가 실제로 충족될 때만 한다. + +DLT record: + +- stable `dltIdentity = + hash(clusterAlias, topic, partition, offset, consumerId, effectGeneration)`; +- original event ID/contract/version/key/value 또는 approved sanitized representation; +- original topic/partition/offset safe reference; +- bounded failure code/stage; +- first/last failure timestamp; +- consumer/effect contract version; +- replay generation; +- no raw credential; +- no unbounded stacktrace/header chain. + +first DLT publish와 source offset commit은 Kafka transaction으로 원자적이지 않다. DLT ACK 뒤 +source commit 전 crash/response loss는 duplicate DLT를 만든다. DLT tooling은 `dltIdentity`로 +dedupe하고 이 crash를 test한다. + +reorder-tolerant subscription의 source ACK 조건: + +```text +DLT producer future ACKNOWLEDGED +AND DLT metadata verified +THEN source acknowledgement +``` + +DLT publish가 실패/indeterminate면 source offset을 진행하지 않는다. + +quarantine profile은 source와 같거나 더 엄격한 sensitivity ACL, encryption-at-rest, finite byte +bound와 retention을 가진다. poison raw key/header/value를 재생 가능하게 보존할지 sanitized +non-replayable evidence만 보존할지는 contract별로 하나를 고정한다. sanitized mode는 자동 replay +불가를 descriptor에 노출한다. strict ordered subscription은 successful quarantine 뒤 partition을 +listener thread에서 failed offset으로 seek한 뒤 pause/HOLD하고 audited disposition 전 source ACK를 +하지 않는다. + +strict-order HOLD는 container memory에만 두지 않는다. application-core가 +`ConsumerPartitionHoldPort`와 hold/disposition use case를 소유하고 persistence adapter가 다음 +durable control을 구현한다. + +```text +logical_subscription_id +consumer_id +effect_generation +cluster_alias +topic_binding_revision +partition +failed_offset +event_id +document_sha256 +state HOLD | ADVANCED_WITH_GAP | COMPENSATED | RELEASED_FOR_RETRY +reason/incident/approval +row_version +created_at/updated_at + +UNIQUE(logical_subscription_id, effect_generation, + cluster_alias, topic_binding_revision, partition) +``` + +- quarantine ACK와 HOLD insert는 동일한 provider transaction이 아니므로 source ACK는 여전히 + 하지 않으며, 두 결과를 reconciliation 가능한 stable identities로 기록한다; +- assignment callback은 dispatch 전에 application hold query를 호출하고 held partition을 + failed offset에 seek/pause한다; +- restart/rebalance/new pod도 durable HOLD를 다시 적용하며 in-memory pause 소실로 poison을 + 진행하지 않는다; +- operator disposition은 expected row version CAS, permission, approval/audit를 요구한다; +- `ADVANCED_WITH_GAP` 또는 verified compensation 뒤에만 listener thread가 failed offset 이후로 + 명시적 commit/resume한다; +- HOLD partition lag는 정상 retry lag와 분리하고 required subscription readiness를 DEGRADED/DOWN + 정책에 따라 표시한다. + +### 25.8 Replay + +live consumer group offset을 임의 rewind하지 않는다. 별도 replay job/group은: + +```text +replayOperationId +source (DLT/topic/archive) +contract/version allowlist +time/partition/offset/event-id scope +target consumer/effect version +reuse or new replay generation +dry-run count/hash +rate/concurrency limit +operator/approver/reason +start/stop/progress/result +``` + +를 가진다. + +기본 replay는 같은 inbox identity를 사용하므로 이미 APPLIED event는 DUPLICATE가 된다. 의도적으로 +effect를 다시 적용하려면 unique key에 참여하는 새 `effectGeneration`, business owner 승인, +compensation 위험을 명시한다. consumer/effect generation별 durable replay lease는 overlapping +replay job, live replay와 inbox cleanup race를 막는다. + +### 25.9 Offset out of range + +topic retention 뒤 offset이 사라졌을 때 자동 earliest/latest reset으로 data gap을 숨기지 않는다. +`auto.offset.reset`은 profile에 explicit하며 required consumer의 offset out-of-range는 startup 또는 +runtime incident다. replay/archive/bootstrap 절차를 선택한다. + +### 25.10 Kafka EOS optional card + +DB-free Kafka consume-process-produce는 Kafka transaction으로: + +```text +input read_committed +process +output records + source offsets in one Kafka transaction +``` + +을 구성할 수 있다. + +이는: + +- DB write; +- HTTP/email/storage side effect; +- PostgreSQL inbox; +- 다른 non-transactional system + +을 포함하지 않는다. 해당 card만 “Kafka transaction 범위의 exactly-once processing”이라고 제한해 +표현한다. + +Spring의 DB/Kafka transaction synchronization은 commit 순서를 조정할 뿐 distributed atomic +commit이 아니다. 두 번째 commit failure compensation을 별도 설계해야 한다. + +## 26. Future PostgreSQL Debezium CDC + +### 26.1 위치 + +CDC는 application process 안의 scheduler가 아니다. + +```text +PostgreSQL logical decoding +-> replication slot/publication +-> Debezium PostgreSQL connector +-> Outbox Event Router +-> Kafka Connect producer +-> Kafka topic +``` + +Java repository는 immutable event schema/contract와 deployment expected-state descriptor를 +제공한다. connector worker/image/config는 deployment asset이다. + +### 26.2 Prerequisite + +first future CDC qualification target는 다음 exact family다. + +```text +PostgreSQL 16 + pgoutput +Debezium PostgreSQL/Outbox Event Router 3.6.0.Final +Kafka Connect worker exact patch/image digest pinned by the implementation plan +snapshot.mode = no_data for cutover connectors +publication.autocreate.mode = disabled +production publication = exact outbox table + CDC row filter + INSERT only +partition_key_text VARCHAR + StringConverter key +envelope_bytes BYTEA + Debezium BinaryDataConverter value +header.converter = Kafka SimpleHeaderConverter +binary.handling.mode = bytes +errors.tolerance = none +transforms.outbox.table.op.invalid.behavior = fatal +skipped.operations = t +ordering = commit-order/detectable-sequence, strict aggregate order unsupported +``` + +resolved Connect/Kafka/plugin patch와 image digest가 없으면 이 card는 +`not-implemented`다. floating `stable/current` documentation은 discovery일 뿐 evidence가 아니다. + +CDC card를 활성화하기 전: + +- `outbox_event` 신규 row가 insert-only; +- legacy status UPDATE writer 0; +- connector invalid UPDATE behavior가 fatal/alert로 검증; +- event ID/key/envelope/schema fields가 CDC mapping 가능; +- event row에 `publication_epoch`와 `dispatch_authority=CDC`가 존재; +- PostgreSQL production publication이 exact outbox table의 + `WHERE (dispatch_authority = 'CDC')` row filter와 `publish='insert'`만 사용; +- polling/CDC wire golden parity; +- PostgreSQL logical replication prerequisites; +- dedicated publication/slot; +- Connect internal topics; +- connector/task security; +- snapshot/cutover/retention runbook; +- real end-to-end evidence + +를 모두 만족한다. + +현 mutable V3 table에 connector만 붙이는 것은 금지한다. strict aggregate ordering contract도 +§26.10의 별도 serialization/reorder card 없이 first CDC profile에 bind하지 못한다. + +### 26.3 Connector mapping + +Outbox Event Router mapping은 최소 다음을 고정한다. + +```text +event ID column -> canonical `id` header +partition_key_text VARCHAR -> StringConverter -> exact US-ASCII Kafka key bytes +envelope_bytes BYTEA -> Kafka value bytes +occurredAt column -> record timestamp policy +contract/version -> bounded headers when required +logical destination -> closed route mapping +traceparent/tracestate columns -> validated bounded headers +``` + +Debezium default `aggregateType -> dynamic topic`를 그대로 사용하지 않는다. closed logical +destination/topic allowlist와 route regex/replacement를 exact config로 관리한다. first CDC +profile은 finite DB CHECK/catalog value, exact-match route, pre-provisioned topic, +auto-create disabled와 connector ACL을 모두 사용한다. unknown route는 다른 topic으로 fallback하지 +않고 connector를 실패시킨다. + +EventRouter는 heartbeat/schema/transaction/tombstone 같은 non-outbox record에 적용하지 않는다. +exact source-topic/table SMT predicate를 사용한다. production authority filter는 scripting SMT가 +아니라 PostgreSQL 16 publication row filter로 고정한다. + +```sql +CREATE PUBLICATION +FOR TABLE ONLY .outbox_event +WHERE (dispatch_authority = 'CDC') +WITH (publish = 'insert', publish_via_partition_root = true); +``` + +`publication.autocreate.mode=disabled`와 pinned `publication.name`을 사용한다. startup/deployment +attestation은 `pg_publication`, `pg_publication_tables`/row-filter catalog를 읽어 exact table, +row filter, `pubinsert=true`, `pubupdate/pubdelete/pubtruncate=false`, +`publish_via_partition_root=true`와 다른 connector publication이 섞이지 않았음을 확인한다. +`dispatch_authority`는 PostgreSQL user-defined enum이 아니라 bounded `VARCHAR` + CHECK로 저장해 +PostgreSQL row-filter의 built-in type/operator 제약 안에 둔다. +outbox를 실제로 partition하지 않는 implementation에서도 이 값을 pin해 future partition +동작을 조용히 바꾸지 않는다. first profile은 Debezium scripting Filter SMT/plugin을 요구하지 +않는다. + +UPDATE/TRUNCATE 또는 unexpected operation을 한 설정으로 뭉뚱그리지 않는다. + +- UPDATE는 `transforms.outbox.table.op.invalid.behavior=fatal`로 EventRouter가 connector를 + 중지하게 한다. INSERT-only publication 때문에 정상적으로 관찰될 수 없고, publication drift에 + 대한 defense-in-depth다. 기본값 `warn`은 허용하지 않는다; +- DELETE/TRUNCATE는 production publication에서 publish하지 않는다. §26.13의 승인된 retention + DELETE가 production event/tombstone을 emit하지 않음을 검증한다; +- `skipped.operations=t`도 connector defense-in-depth로 pin한다. runtime role의 + DELETE/TRUNCATE privilege 제거와 migration gate/audit가 주 방어선이며, 예상 밖 + UPDATE/DELETE/TRUNCATE가 DB audit에서 발견되면 connector를 중지하고 + `DATA_GAP_SUSPECTED`로 전이한다; +- `errors.tolerance=none`은 converter/SMT가 실제로 throw한 오류를 skip/DLQ로 우회하지 않는 + 정책이지 UPDATE/TRUNCATE 자체의 분류 설정이 아니다. + +polling-era event는 PostgreSQL publication row filter에서 production CDC source에 들어오지 +않는다. shadow connector만 별도 insert-only publication/slot과 격리 topic에서 authority 전체를 +비교할 수 있다. + +### 26.4 Insert-only behavior + +- application은 event row를 UPDATE하지 않는다; +- polling state는 delivery table에만 있다; +- cleanup DELETE는 retention proof 뒤에만 실행하고 INSERT-only publication의 production output + 0을 검증; +- update event가 관찰되면 + `transforms.outbox.table.op.invalid.behavior=fatal`로 warning 없이 stop/alert; +- CDC connector는 outbox table만 capture하도록 include list/predicate를 제한한다. +- runtime role의 UPDATE/DELETE/TRUNCATE/DDL privilege를 제거하고 migration role만 별도 승인; +- exact publication row filter + `publish=insert`, `skipped.operations=t`, table list, + partition-root behavior를 config/catalog attestation으로 pin; +- DROP/DETACH/TRUNCATE는 logical decoding alert에만 의존하지 않고 migration gate/audit에서 차단. + +### 26.5 Payload + +first CDC profile은 PostgreSQL `binary.handling.mode=bytes`로 읽은 `BYTEA envelope_bytes`를 +EventRouter 결과 value로 만들고 +`value.converter=io.debezium.converters.BinaryDataConverter`로 exact bytes를 emit한다. +heartbeat 같은 non-outbox record는 BinaryDataConverter가 처리할 수 없으므로 공식 +`value.converter.delegate.converter.type=org.apache.kafka.connect.json.JsonConverter`와 +`value.converter.delegate.converter.type.schemas.enable=false` 설정을 pin한다. delegate가 만든 +record는 exact source-table predicate와 closed route에 의해 production event topic으로 들어갈 수 +없어야 한다. `JsonConverter`/String expansion으로 event envelope 자체를 다시 직렬화하는 +profile은 first profile이 아니다. + +key는 `partition_key_text VARCHAR(64)`를 EventRouter key field로 선택하고 +`key.converter=org.apache.kafka.connect.storage.StringConverter`로 직렬화한다. polling +producer가 쓰는 US-ASCII bytes와 동일함을 golden vector로 검증한다. event ID의 EventRouter 기본 +`id` header를 canonical header로 채택하며 alias를 하나 더 남기지 않는다. +`header.converter=org.apache.kafka.connect.storage.SimpleHeaderConverter`를 default에 맡기지 않고 +explicit pin한다. event ID/contract/version/trace header source column은 bounded canonical +ASCII/UTF-8 STRING Connect type으로 유지하고 null/optional placement을 exact config로 고정한다. +polling의 exact UTF-8 header bytes와 adopted Kafka Connect version의 +SimpleHeaderConverter output을 golden test로 비교한다. key/header binary representation과 full +SMT/converter chain을 golden test로 고정한다. + +다음이 polling과 같아야 한다. + +- event ID; +- key bytes; +- contract/payload/envelope versions; +- exact value bytes와 `envelope_sha256`; +- required headers; +- record timestamp policy. + +malformed JSON은 writer admission에서 거부되어야 한다. CDC converter가 malformed string을 정상 +string value로 우회시키는 profile은 qualification 실패다. + +### 26.6 Offset와 internal topics + +Kafka Connect distributed mode는 최소: + +- config storage topic; +- offset storage topic; +- status storage topic + +의 partition/RF/cleanup/ACL을 운영 profile로 고정한다. offset reset/alter는 destructive audited +operation이다. connector REST API 노출과 authorization도 제한한다. + +connector/task restart 뒤 마지막 committed offset부터 중복 change event가 재방출될 수 있다. +consumer inbox가 이를 흡수해야 한다. + +source connector producer도 application producer card의 보장을 자동 상속하지 않는다. exact +Connect worker/connector profile은 최소 다음을 별도로 pin한다. + +```text +acks=all +enable.idempotence=true +max.in.flight.requests.per.connection<=5 +finite delivery/request/max.block/buffer/request bounds +SASL_SSL/SCRAM credential and least-privilege exact-topic ACL +topic auto-creation disabled +errors.tolerance=none +converter/SMT failure -> task FAILED, no skip +``` + +Connect internal topic producer/consumer 권한과 outbox destination 권한은 분리한다. + +### 26.7 Replication slot와 WAL + +monitor: + +```text +slot exists/active +confirmed_flush_lsn/restart_lsn +current WAL LSN +retained WAL bytes +connector source lag +last event/heartbeat +database disk free +publication/table inclusion +slot catalog state +``` + +low-traffic database는 heartbeat/action query가 WAL progress에 미치는 영향을 exact connector +version으로 검증한다. + +slot drop/recreate는 이전 LSN history를 복구하지 못할 수 있고 silent gap을 만들 수 있다. 자동 +recreate 뒤 healthy 표시를 금지한다. + +`max_slot_wal_keep_size`, database disk free admission과 operator emergency threshold를 finite +deployment 값으로 pin한다. cap 초과로 required WAL segment가 제거되면 slot/card는 +`DATA_GAP_SUSPECTED`이며 resnapshot/reconciliation 전 READY로 복귀하지 않는다. monitoring만 +있고 WAL/disk bound가 없는 profile은 R2가 아니다. + +### 26.8 Snapshot + +first shadow와 production cutover connector는 `snapshot.mode=no_data`를 사용한다. 각 connector의 +unique logical slot을 writes-frozen boundary에서 생성하고 slot consistent point 이후 INSERT만 +stream한다. existing outbox history를 snapshot으로 다시 publish하지 않는다. initial snapshot이 +필요한 다른 profile은 historical duplicate scope와 inbox coverage를 별도 card로 증명한다. + +snapshot/restart 중 duplicate, schema change, queue saturation, connector crash를 test한다. + +### 26.9 PostgreSQL 16 failover limitation + +repository의 first CDC database target은 PostgreSQL 16이다. 이 profile은 PostgreSQL 17+의 +failover logical slot continuity를 주장하지 않는다. + +- primary failover 시 connector/card는 즉시 NOT_READY; +- 새 primary의 slot, Connect offset과 source LSN을 수동 reconcile; +- missing/ahead/behind 또는 WAL loss가 있으면 `DATA_GAP_SUSPECTED`; +- resnapshot/audited backfill/inbox reconciliation 전 writes 재개 조건을 runbook으로 판단; +- automatic slot recreate와 no-gap 표현 금지. + +PostgreSQL 17+ synchronized failover slot은 별도 future card와 real failover evidence가 있을 때만 +추가한다. + +### 26.10 Ordering compatibility + +logical decoding은 transaction commit order를 emit한다. application이 allocate한 +`aggregateSequence`와 commit order는 두 transaction이 역순 commit하면 다를 수 있다. + +따라서 first CDC card는: + +- strict aggregate ordering guarantee를 제공하지 않는다; +- key와 aggregate sequence를 보존해 gap/regression을 탐지 가능하게 한다; +- shadow compare도 global/aggregate sequence order가 아니라 source offset, event set, key와 exact + bytes를 비교한다. + +strict ordered CDC가 필요하면 같은 aggregate transaction serialization으로 +`sequence order == commit order`를 증명하거나 Kafka 전 reorder/consumer sequence gate를 가진 +별도 card가 필요하다. + +### 26.11 DDL와 source limitations + +qualification은 최소 다음을 다룬다. + +- logical decoding이 DDL event를 직접 제공하지 않는 한계; +- outbox schema migration과 rolling application; +- partition root publication behavior; +- primary key/replica identity 변경; +- TOAST/unchanged value behavior가 envelope column에 미치는 영향; +- delete/tombstone; +- TRUNCATE/DROP/DETACH와 `skipped.operations`; +- connector/plugin/JDBC/PostgreSQL version compatibility. + +### 26.12 CDC readiness + +CDC readiness는 polling metric을 재사용하지 않는다. + +```text +connector/task RUNNING +AND slot/publication valid +AND WAL retained bytes within bound +AND topic/security/schema attested +AND last cutover epoch/watermark reconciled +AND expected connector name/config/image/SMT hash matches +AND expected task count/source producer profile matches +AND last successful source interaction/offset commit is fresh for observed source activity +``` + +idle database에서 LSN이 움직이지 않는다는 이유만으로 DOWN시키지 않는다. worker/task liveness, +last source interaction, actual source activity, Kafka offset commit freshness와 measured lag를 +분리한다. `RUNNING`이나 stale prior attestation만으로 no-gap/READY를 주장하지 않는다. +application liveness와 분리한다. + +### 26.13 CDC retention proof + +time partition이 오래됐다는 이유만으로 삭제하지 않는다. + +closed partition의 모든 source transaction/batch와 destination partition이: + +1. persisted Connect source offset과 slot checkpoint에서 coverage됐고; +2. expected Kafka destination의 event ID/hash manifest에서 관찰됐고; +3. replay retention이 지났고; +4. connector offset/slot continuity가 검증되었고; +5. legal/operator hold가 없다는 + +구체적 proof를 남긴 뒤 purge한다. 한 sentinel의 한 Kafka partition 관찰이나 +`confirmed_flush_lsn` 하나만으로 다른 destination partition coverage를 추론하지 않는다. + +## 27. Polling/CDC shadow, cutover와 rollback + +### 27.1 Mutual exclusion은 두 층이다 + +application setting 하나만으로 외부 Connect deployment를 막을 수 없다. + +두 층을 모두 사용한다. + +1. database publication epoch/dispatch authority fence; +2. infrastructure authority: deployment replica state, connector state, producer/connector ACL. + +같은 production topic에 polling producer principal과 CDC connector principal의 Write authority를 +동시에 열지 않는다. + +`outbox_publication_epoch` conceptual control row: + +```text +epoch_id monotonic PK +authority LEGACY_POLLING | POLLING_V2 | CDC +state PREPARED | ACTIVE | RETIRED +source_boundary +settings_digest +activated_at +activated_by/approved_by +row_version +``` + +정확히 한 ACTIVE row를 partial unique constraint로 보호한다. append adapter는 business +transaction 안에서 ACTIVE row를 `FOR SHARE`로 읽고 event에 `epoch_id/authority`를 기록한다. +`POLLING_V2`면 같은 transaction에서 delivery row를 만들고, `CDC`면 만들지 않는다. epoch +activation은 같은 row를 `FOR UPDATE`로 전환하므로 concurrent append와 serializes한다. + +old binary가 cached config로 다른 mode를 쓰지 못하도록: + +- target append는 DB epoch를 authority로 사용; +- activation 전 old writer/relay binary 0을 deployment fingerprint로 확인; +- DB trigger/constraint가 stale epoch, CDC event의 delivery row, polling event의 delivery 누락을 + 거부; +- active authority와 맞지 않는 legacy status mutation을 DB guard가 거부한다. + +application setting은 expected epoch/authority assertion일 뿐 DB truth를 덮지 않는다. + +### 27.2 Shadow qualification + +shadow CDC는: + +- production과 다른 topic; +- production consumer가 읽지 않는 group; +- same immutable event source; +- same schema/key mapping; +- event ID/count/exact byte hash/key/source-offset/lag compare; +- bounded retention와 restricted ACL + +을 사용한다. + +shadow record를 production effect에 적용하지 않는다. shadow 성공은 cutover rehearsal/evidence지 +production CDC ACTIVE가 아니다. + +topology는 두 connector/두 slot으로 고정한다. + +```text +shadow connector + shadow logical slot + shadow topic +production connector + production logical slot + production topic +``` + +slot이나 Connect offset을 공유·복사·재사용하지 않는다. shadow connector의 advanced offset을 +production topic으로 retarget하지 않는다. production connector/slot은 writes-frozen cutover에서 +새로 만든다. + +### 27.3 Cutover invariant + +cutover는 다음을 증명해야 한다. + +```text +모든 pre-cutover event가 polling 또는 reconciled duplicate로 처리 +AND 모든 post-cutover event가 CDC source boundary에 포함 +AND 같은 event가 두 authority에서 production topic으로 발행되는 window가 없음 +AND rollback boundary가 기록됨 +``` + +### 27.4 Polling -> CDC high-level sequence + +exact Debezium 3.6.0.Final/PostgreSQL 16/Connect worker runbook이 세부 명령을 소유한다. 고수준 +순서: + +1. 별도 shadow connector/slot/topic을 `no_data`로 qualification; +2. bounded write maintenance 시작, 신규 business transaction admission 중단, active write drain; +3. polling new claim 중단, active attempt drain, indeterminate/backlog reconcile; +4. polling producer production Write ACL revoke, producer close, old relay fence 확인; +5. writes가 frozen인 상태에서 exact CDC row filter + INSERT-only인 dedicated production + publication과 **새 production slot** 생성; +6. slot creation이 반환한 consistent point/source boundary와 empty Connect offset identity 기록; +7. `snapshot.mode=no_data`, `publication.autocreate.mode=disabled`, pinned publication, + exact source-table SMT predicate, StringConverter-key/BinaryDataConverter-value chain인 + production connector를 준비하고 exact topic Write ACL만 부여; +8. DB transaction에서 new CDC epoch ACTIVE 전환과 CDC-authority cutover sentinel INSERT를 + 원자적으로 수행; sentinel에는 polling delivery row가 생기지 않음; +9. production connector를 start/resume하여 unique slot boundary부터 stream; +10. production topic에서 sentinel exact event ID/hash를 확인하고 persisted Connect offset, + slot LSN과 reconcile; +11. target application expected epoch를 확인한 뒤 regular writes 재개; +12. event set/key/hash/source lag와 consumer inbox duplicate를 관측; +13. rollback window 동안 polling artifacts와 production slot/offset을 보존. + +`pg_current_wal_lsn()`을 application transaction에서 읽은 값이나 shadow connector offset을 commit +boundary로 추정하지 않는다. authoritative boundary는 writes-frozen 상태에서 생성한 production +logical slot consistent point, persisted Connect source offset과 CDC-epoch marker transaction의 +correlation evidence다. + +### 27.5 CDC -> polling high-level sequence + +1. incident/cutback 승인, 신규 business write admission fence와 active write transaction drain; +2. ACTIVE epoch를 `FOR SHARE`로 잡고 있던 writer가 0이고 writes가 frozen임을 DB session/epoch + evidence로 확인; +3. current CDC epoch의 **마지막** transaction으로 controlled rollback-boundary sentinel INSERT; +4. connector가 sentinel을 Kafka에 emit하고 sentinel transaction end LSN까지의 source offset과 + slot high-water coverage가 persisted됐는지 확인; +5. connector pause/stop, exact offset/LSN 기록, production Write ACL revoke; +6. polling producer/topic/security를 attest하되 scheduler는 아직 정지; +7. DB transaction에서 새 POLLING_V2 epoch ACTIVE 전환과 polling sentinel + event+delivery INSERT; +8. polling producer Write ACL grant와 scheduler start; +9. polling sentinel `DELIVERY_RECORDED`와 Kafka record 확인; +10. target application expected epoch 확인 뒤 regular writes 재개; +11. suspected CDC gap만 immutable event에서 audited delivery generation으로 backfill; +12. duplicate/inbox/reconciliation 확인. + +sentinel event ID만 보였다는 이유로 connector를 멈추지 않는다. writes-frozen boundary, +sentinel transaction end LSN, persisted Connect source offset와 slot state가 같은 high-water를 +가리켜야 한다. maintenance 전에 시작한 transaction이 sentinel 뒤 commit할 수 있는 상태에서는 +step 3으로 진행하지 않는다. + +CDC-era event 전체를 무조건 polling delivery로 backfill하지 않는다. 이미 emit된 event를 대량 +duplicate할 수 있기 때문이다. 범위와 certainty를 계산한 audited backfill만 허용한다. + +### 27.6 Rollback + +cutover 실패 시: + +- 어느 authority가 마지막으로 production Write를 가졌는지; +- 마지막 confirmed event ID/WAL/offset; +- indeterminate range; +- consumer inbox coverage; +- duplicate-safe replay 범위; +- slot/offset 보존 여부 + +를 먼저 판정한다. “둘 다 켜서 빨리 복구”는 허용하지 않는다. + +### 27.7 Automatic failover 금지 + +CDC connector health DOWN을 감지해 application이 polling을 자동 활성화하지 않는다. external +connector와 in-process scheduler 사이 split-brain을 만들기 때문이다. bounded backlog가 source +DB에 남고 required WAL이 finite retention bound 안에 있는 동안 operator-run cutover/rollback을 +수행한다. WAL loss가 생기면 automatic failover가 아니라 `DATA_GAP_SUSPECTED` reconciliation이다. + +## 28. Test strategy + +### 28.1 원칙 + +messaging readiness는 fake 하나나 happy-path broker 하나로 증명하지 않는다. + +```text +pure contract/property ++ application state machine ++ real PostgreSQL ++ real Kafka ++ security/topology ++ fault/crash/lifecycle ++ compatibility += exact card evidence +``` + +unit test는 빠른 feedback이고 real-service test는 실제 guarantee evidence다. 둘 중 하나가 다른 +하나를 대체하지 않는다. + +### 28.2 Current characterization + +첫 변경 전에 현재 behavior를 고정한다. + +- broker blank -> disabled sentinel; +- broker selected + sender 없음 -> startup failure; +- broker ID mismatch -> startup failure; +- current sender normal return -> current PUBLISHED transition; +- current exception -> FAILED/DEAD; +- ACK-to-mark failure -> IN_FLIGHT/reclaim duplicate possibility; +- same-transaction append rollback; +- current timestamp FIFO; +- current runbook/config drift 목록. + +characterization은 current behavior를 정당화하는 것이 아니라 migration 중 accidental loss를 +방지한다. + +### 28.3 Contract catalog unit/property + +- duplicate contract/destination/schema IDs; +- unknown provider/card; +- maximum bound intersection; +- ordering-required + null key; +- deterministic partition key golden vectors; +- tenant/no-tenant scope; +- aggregate sequence/eventIndex uniqueness; +- tenant ACTIVE/DISABLED non-null scope uniqueness; +- stable catalog/schema/settings digest; +- config cannot relax code maximum; +- legacy + target conflict; +- disabled resource-0 descriptor; +- unsupported card rejection. + +### 28.4 JSON Schema v1 + +최소: + +- Draft 2020-12 meta-schema validation; +- immutable `$id`와 checksum; +- no remote `$ref`; +- offline meta-schema/vocabulary registry, duplicate `$id`, cyclic `$ref`, pathological regex; +- valid/invalid envelope golden corpus; +- contract별 valid/invalid payload; +- required/null/missing; +- unknown property; +- duplicate JSON key; +- invalid UTF-8/unpaired surrogate; +- depth/string/array/object/number bound; +- timestamp/format assertion; +- exact UTF-8 bytes; +- envelope/header mismatch; +- N/N-1 rolling vectors와 replay horizon 전체 version vectors; +- retired/future payload version; +- parser CPU/memory/time bound. + +official JSON Schema Test Suite 또는 Bowtie 호환성 증거를 adopted validator에 대해 추가한다. +그 결과가 모든 custom contract compatibility를 대신한다고 주장하지 않는다. + +### 28.5 Application-core + +- business + event append same transaction; +- validation failure rolls back business write; +- publication outcome exhaustive mapping; +- ACKNOWLEDGED/ACKNOWLEDGED_MISMATCH/REJECTED/INDETERMINATE; +- acceptance certainty와 retry disposition의 독립 mapping; +- permanent failure no repeated retry; +- combined attempt/elapsed budget; +- report only after persisted transition; +- reporter failure containment; +- exhausted ordered head blocking; +- audited requeue/hold/skip/compensate policy와 authorization; +- same-event requeue horizon cutoff; +- operator endpoint unauthenticated/forbidden/destructive-permission, idempotency와 stale + generation/row-version rejection; +- late-completion bounded source/drain race, duplicate drain과 persistence failure; +- no provider/SDK type in contract; +- disabled active-contract failure. + +future consumer: + +- APPLIED/DUPLICATE/RETRY/REJECTED mapping; +- inbox same transaction; +- payload collision; +- follow-up outbox same transaction; +- external side effect prohibited/defaulted to outbox. + +### 28.6 PostgreSQL polling integration + +real PostgreSQL Testcontainers lane: + +- forward-only migration from V3; +- business + event + delivery commit/rollback; +- same transaction resource identity mismatch startup rejection; +- immutable event update rejection after cutover; +- exact `BYTEA` round trip와 envelope hash; +- delivery FK/unique/order constraints; +- tenant scope nullable-dedupe 공격; +- two or more workers disjoint claim; +- same aggregate same timestamp with sequence/eventIndex total order; +- different aggregates parallel progress; +- stale token cannot ACK/FAIL/DEAD; +- expired-but-not-yet-reclaimed token cannot record delivery; +- lease expiry during send; +- same-token renew; +- database time authority; +- initial automatic publication age starts at event DB created_at; +- requeue generation deadline is + `min(generation DB created_at + maximum age, event created_at + same-event requeue horizon)`; +- ACK-to-mark crash; +- append-only attempt admission/outcome/late-ACK journal; +- late observation DB commit before source ACK, commit-to-ACK crash duplicate absorption; +- late observation queue overflow/drop does not mutate delivery state; +- claim count와 publication attempt count; +- mark transaction failure; +- exhausted head/fairness/hot aggregate; +- one CURRENT generation partial-unique constraint; +- concurrent requeue generation race와 atomic authority handoff rollback; +- EXHAUSTED/HOLD supersede 뒤 old generation이 다시 claim되지 않음; +- legacy/v2 relay authority epoch and no dual claim/send; +- legacy PUBLISHED -> unverified migration only; +- reaper vs claim/requeue; +- retention partition/FK/audit; +- DB pool/batch capacity. + +Docker unavailable이면 required R2 lane는 skip이 아니라 failure다. + +### 28.7 Real Kafka producer integration + +real Kafka broker에서: + +- successful send returns actual topic/partition/offset metadata; +- metadata destination mismatch fatal incident; +- `acks=all` effective config; +- idempotence conflict startup failure; +- stable key/partition; +- record/header too large; +- missing topic with auto-create disabled; +- unauthorized Write/Describe; +- broker unavailable before send; +- leader move/retriable error; +- response loss/deadline/late ACK; +- local buffer saturation/max-block; +- broker throttle; +- retry ordering; +- old/new producer generation barrier와 forced indeterminate order downgrade; +- fatal/defunct producer generation recreation; +- no per-message flush; +- producer generation rotation; +- graceful drain/forced close; +- unresolved shutdown -> indeterminate; +- duplicate after ACK-to-DB gap; +- low-cardinality metric/trace/log. + +single-node Testcontainers Kafka는 HA/min ISR/leader-failover 전체 증거가 아니다. R2 topology +profile에 필요한 multi-broker scenario는 별도 lane에서 수행한다. + +### 28.8 Security integration + +- trusted TLS success; +- untrusted CA failure; +- hostname mismatch failure; +- expired/not-yet-valid certificate; +- SASL valid/invalid credential; +- least-privilege producer ACL; +- denied Create/Delete/Alter; +- secret absent/expired; +- credential rotation old/new generation; +- production plaintext startup rejection; +- config/log/descriptor secret redaction. + +### 28.9 Topic conformance + +- expected partition/RF/min ISR; +- cleanup/retention/max bytes drift; +- topic missing; +- partition expansion mismatch; +- auto-create disabled; +- wrong cluster/topic binding; +- provisioning evidence/card fingerprint; +- readiness recovery after operator correction. + +### 28.10 Fault/crash matrix + +process kill/fault injection 지점: + +```text +after DB event commit +after claim commit +before Kafka send +after request write +after broker append before ACK receipt +after ACK before DB transition +during DB transition commit +after DB success before scheduler result +during shutdown +``` + +각 시나리오는 DB state, broker observed event IDs, duplicates, late callback, claim token과 recovery를 +검증한다. + +### 28.11 Capacity and soak + +- sustained throughput와 backlog drain; +- hot aggregate; +- many bounded contracts/destinations; +- producer buffer/memory/GC; +- DB claim query/index; +- poll batch vs claim lease; +- broker throttle/outage/recovery storm; +- retry amplification; +- shutdown under load; +- metric cardinality; +- long-running secret generation rotation. + +benchmark 숫자는 repository/device 일반 성능 주장으로 사용하지 않고 selected deployment +capacity evidence로 기록한다. + +### 28.12 Future consumer integration + +inbound card가 구현될 때: + +- auto commit disabled/manual immediate ACK; +- max.poll.records=1 synchronous listener-thread ACK, wrong-thread ACK rejection; +- `ackAfterHandle=false`, `commitRecovered=false`, + `resetStateOnRecoveryFailure=false`, default logging recoverer absent; +- unexpected exception retry exhaustion -> durable HOLD + seek/pause + source commit 0; +- recoverer/HOLD persistence failure -> bounded container stop/readiness DOWN, no retry-cycle reset; +- explicit sync commit timeout/failure/redelivery; +- commit 후 ACK; +- commit 뒤 ACK 전 crash duplicate; +- same event duplicate no side effect; +- two consumers concurrent `ON CONFLICT DO NOTHING RETURNING`; +- tenant scope/effect generation uniqueness; +- same event different hash quarantine; +- malformed bytes/schema/unknown version; +- pause/resume under bounded saturation; +- max.poll interval; +- rebalance revoke/assign while active; +- partition ordering; +- short retry; +- DLT exact ACK-aware provider effective config/metadata mismatch/rotation/shutdown; +- DLT ACK success/failure/indeterminate; +- DLT ACK 뒤 source commit crash의 duplicate DLT identity; +- strict ordered durable quarantine HOLD, restart/reassignment reapply와 approved gap CAS; +- header sanitization/growth; +- inbox retention/replay; +- live/replay overlap, effect-generation lease와 cleanup race; +- offset out of range; +- graceful shutdown; +- TLS/SASL/ACL; +- real consumer lag/metrics/trace. + +### 28.13 Future CDC integration + +exact PostgreSQL + Kafka + Connect + Debezium image set: + +- insert-only event mapping; +- `table.op.invalid.behavior=fatal` UPDATE stop과 `skipped.operations=t`/DB-audit TRUNCATE policy; +- exact CDC-authority row-filtered INSERT-only PostgreSQL publication catalog; +- polling-authority row 0 emission, approved retention DELETE output 0; +- polling/CDC key/envelope golden parity; +- `partition_key_text`/StringConverter key parity, + `BYTEA`/Debezium BinaryDataConverter exact value bytes, canonical `id` header와 alias 0; +- pinned SimpleHeaderConverter와 polling/CDC exact header-byte parity; +- non-outbox heartbeat/schema/tombstone predicate; +- unknown route + auto-create disabled + ACL failure; +- `errors.tolerance=none` poison/converter failure; +- source producer idempotence/security/effective config; +- connector task crash/restart duplicate; +- Connect internal offset commit failure after Kafka append; +- Connect offset commit/replay; +- slot missing/drop/recreate; +- WAL retained/disk threshold; +- heartbeat low traffic; +- snapshot/no-data mode; +- existing history not republished by cutover `no_data`; +- schema migration during connector lifecycle; +- partitioned table mapping; +- PostgreSQL 16 failover -> NOT_READY/DATA_GAP_SUSPECTED, no no-gap claim; +- reverse commit-order aggregate sequence incompatibility; +- shadow event ID/count/hash; +- cutover sentinel; +- cutover crash/abort after every numbered step; +- polling/CDC Write authority exclusivity; +- publication epoch stale-writer/old-binary fence; +- rollback and audited backfill; +- retention checkpoint proof. + +### 28.14 Compatibility + +matrix: + +```text +old producer -> new consumer +new producer -> old live consumer +old polling binary -> new DB schema +new polling binary -> compatibility DB schema +old/new Kafka client -> selected broker +old/new connector -> selected PostgreSQL/Kafka +rolling credential/schema/catalog generation +``` + +unsupported combination을 명시하고 자동 fallback하지 않는다. + +### 28.15 Observability contract + +test는 metric이 “존재한다”뿐 아니라: + +- logical vs physical attempt 구분; +- outcome/certainty 정확성; +- ACK latency boundary; +- event/tenant/key/payload tag 부재; +- catalog cardinality enforcement; +- one canonical ERROR; +- trace context validation; +- readiness role 분리; +- stale health/hysteresis; +- sanitized capability descriptor + +를 검증한다. + +## 29. Gradle, CI, dependency와 supply chain + +### 29.1 Dependency ownership + +| Dependency | Owner | +| --- | --- | +| Spring Kafka/Kafka producer client | `adapter:outbound:messaging` | +| JSON/schema validator runtime | outbound messaging; future inbound leaf도 자기 runtime 소유 | +| envelope schema resource/vocabulary | `shared-contract` when truly generic | +| PostgreSQL/JPA/Flyway | `adapter:outbound:persistence-jpa` | +| future Kafka listener client | `adapter:inbound:messaging-kafka` | +| Kafka Connect/Debezium | deployment/integration-test asset | +| Testcontainers Kafka | provider/integration test configuration | +| Testcontainers PostgreSQL | persistence/bootstrap integration test | +| Micrometer/Spring observation composition | adapter/bootstrap ownership에 맞춤 | + +application/domain은 Kafka/Jackson/schema validator에 의존하지 않는다. + +### 29.2 Spring Boot BOM + +Spring Boot 4.0.0 BOM이 관리하는 Spring Kafka/Kafka client 조합을 first implementation +candidate로 사용한다. 실제 resolved version은 dependency lock과 evidence fingerprint로 기록한다. + +direct version override는: + +- Boot/Spring Kafka compatibility; +- Kafka broker protocol compatibility; +- CVE/license; +- tests/locks/SBOM + +을 함께 통과할 때만 허용한다. + +### 29.3 Architecture change + +producer/polling phase는 현재 19 leaf를 유지한다. + +first R2 operator surface는 existing +`adapter-inbound-web -> application-core`와 +`adapter-outbound-persistence-jpa -> application-core` edges를 composition root에서 조립한다. +web leaf가 persistence leaf/repository/entity에 직접 의존하지 않으므로 새 project edge가 없다. + +standalone sample이 real messaging example을 실행하는 phase에는 leaf 수를 늘리지 않고 +`sample-portfolio`의 allowed dependency/runtime dependency에 +`adapter-outbound-messaging` edge만 추가한다. provider qualification 자체는 adapter +test-source contract로 가능하므로 이 sample edge가 production R2의 필수 전제는 아니다. + +consumer phase는: + +- registry에 20번째 leaf; +- settings include/mapping; +- app-bootstrap dependency; +- architecture tests/fixtures; +- module lockfile; +- documentation + +을 한 migration으로 추가한다. inbound leaf에서 outbound/persistence adapter로 edge를 만들지 않는다. + +### 29.4 Proposed tasks + +구현 계획은 repository task naming convention을 확인한 뒤 정확한 이름을 확정한다. 목표 lane: + +```text +:adapter:outbound:messaging:test +:application-core:test +:adapter:outbound:persistence-jpa:test +:app-bootstrap:test + +verifyMessagingContracts +verifyMessagingJsonSchemaV1 +verifyMessagingPollingOutboxR2 +verifyMessagingKafkaProducerR2 +verifyMessagingSecurityR2 +verifyMessagingReleaseProfile + +future: +verifyMessagingConsumerR2 +verifyMessagingCdcR2 +``` + +기존 공통 gate: + +```text +test +check +verifyCleanArchitectureDependencies +verifyEnvKeys +verifyPublicPathSnapshot +dependency lock/SBOM/vulnerability/license gates +``` + +### 29.5 CI lanes + +PR blocking: + +- pure/unit/property; +- architecture/dependency/env/schema registry contract; +- JSON schema/golden/N/N-1; +- real PostgreSQL polling baseline; +- pinned real Kafka producer baseline; +- docs/config/runbook drift checks. + +production-readiness: + +- TLS/SASL/ACL; +- topic conformance; +- selected RF/min ISR의 multi-broker leader loss, below-min-ISR rejection과 recovery; +- multi-worker stale-token/crash matrix; +- response-loss/Toxiproxy; +- bounded shutdown/rotation; +- metrics/readiness artifact. + +nightly/R3: + +- prolonged leader churn과 repeated multi-broker failure; +- rolling broker/client upgrade; +- partition migration; +- soak/capacity; +- future consumer rebalance storm; +- future CDC restart/slot/failover. + +### 29.6 No silent skip + +developer local test는 Docker 없음 등을 명시적으로 보고할 수 있다. 그러나 selected R2 release gate는: + +- required service unavailable; +- image pull failure; +- test skipped by assumption; +- credential fixture missing; +- no matching test; +- stale evidence + +를 PASS로 변환하지 않는다. + +### 29.7 Evidence artifact + +sanitized artifact: + +```text +git commit/source digest supplied by human/CI +commands and timestamps +task/test counts +broker/client/Spring/PostgreSQL/Connect/Debezium versions +image digests +effective non-secret settings +topology/security profile +schema/catalog/settings hashes +fault scenarios/results +readiness/card status +skips/failures +unsupported claims +runbook links +``` + +CI artifact 없이 문서 표를 수동으로 R2로 바꾸지 않는다. + +### 29.8 Supply chain + +- Gradle dependency locks; +- SBOM; +- vulnerability severity/suppression policy; +- license allowlist; +- Kafka/Connect/Debezium container digest pin; +- connector plugin inventory/classloader compatibility; +- JSON schema validator dependency review; +- image signature/provenance where platform supports; +- upgrade cadence/runbook. + +## 30. Implementation and migration sequence + +### 30.1 Phase 0 — Truth and characterization + +목표: + +- 이 설계 검토/승인; +- current ACK overclaim 표시; +- fake-only/real-service status 정리; +- current tests characterization; +- README/YAML/env/runbook drift 목록; +- implementation plan 작성. + +완료 기준: + +- §0 status ledger 갱신; +- current code behavior test; +- no implementation/R2 overclaim; +- human-only git policy 유지. + +### 30.2 Phase 1 — Contract, catalog, envelope and JSON Schema + +추가: + +- application event metadata/value types; +- application-owned contract contribution SPI와 합법적인 sample/test composition; +- logical destination/contract descriptors; +- validated integration-event port; +- envelope v1/payload schema resources; +- schema/catalog checksum manifest; +- JSON codec/validator; +- sample payload contract/golden vectors; +- shared-contract/messaging leaf `CLAUDE.md` ownership update; +- compatibility/no-remote-ref/resource-bound tests; +- current raw payload/eventType migration adapter. + +이 phase만으로 Kafka/polling R2가 아니다. + +### 30.3 Phase 2 — Immutable event and polling delivery v2 + +기존 V3 migration을 수정하지 않고 새 forward-only migration을 추가한다. + +base template의 기본 migration card는 +`ADDITIVE_IN_PLACE_EMPTY_OR_DRAINED_V3.v1`이다. production data가 없거나 verified drain/reset +maintenance로 legacy row가 0인 새 템플릿 출발점을 대상으로 한다. 이 기본 card에서 P2의 +rolling-safe 고수준 순서: + +1. row/data classification preflight가 empty-or-drained 조건을 fail-closed로 확인; +2. existing `outbox_event`에 immutable v2 metadata를 additive하게 추가하고 legacy columns는 + compatibility를 위해 유지; +3. `outbox_delivery`, append-only attempt journal, disposition audit, + `outbox_publication_epoch` 생성; +4. known legacy event type alias/catalog와 empty/drained evidence를 기록; +5. compatibility release가 legacy required columns와 v2 immutable metadata를 채우되 + `LEGACY_POLLING` control만 사용; + retained V3 compatibility projection은 `event_type=contract_id`, `payload=exact v1 envelope + UTF-8 text`, `status=PENDING`, `attempt_count=0`, `next_attempt_at=occurred_at`, + `idempotency_key=event_id`로 고정한다. canonical metadata가 있는 row의 legacy publisher는 + `OutboxEnvelopeJson`으로 다시 감싸지 않는다. compiled logical-destination binding, stored + partition-key bytes와 immutable `envelope_bytes`를 byte-for-byte passthrough한다. canonical + metadata가 없는 true legacy row만 기존 v0 wrapper를 사용한다; +6. compatibility binary의 legacy relay query가 ACTIVE + `LEGACY_POLLING` epoch/generation만 처리하도록 fence; +7. v2 constraint/index/trigger, one-CURRENT authority와 migration rollback을 rehearsal하되 active + legacy status mutation을 아직 막지 않음; +8. old pre-fence binary를 0으로 만들고 no-dual-writer probe 확인; +9. `LEGACY_POLLING` epoch와 legacy relay를 계속 ACTIVE로 유지; +10. P3 ACK-aware producer/new relay/operator tool이 scheduler-disabled 상태로 배포·attest되기 전 + `POLLING_V2` authority 전환과 v2 claim을 금지. + +P2는 schema/control-plane candidate일 뿐 publish path cutover가 아니다. + +live non-empty deployment는 이 base card를 통과시켜 자동 backfill하지 않는다. +row volume, active state distribution, lock/replication budget, data classification, maintenance +window와 rollback rehearsal을 입력으로 다음 중 하나를 **별도 deployment migration design과 +승인 gate**에서 고른다. + +```text +LIVE_ADDITIVE_BACKFILL_IN_PLACE.v1 +COPY_AND_CUTOVER_WITH_RECONCILIATION.v1 +``` + +두 live card 모두 이 문서의 stable event identity, legacy ACK non-overclaim, relay authority fence, +same-transaction/no-dual-write invariant를 따라야 하지만 어느 것이 안전한지는 실제 database +evidence 없이 base template가 추측하지 않는다. 따라서 이 선택은 구현 계획에 숨겨 둔 선택이 +아니라 명시적으로 차단된 deployment-specific gate다. + +모든 migration card는: + +- dual-write gap 없음; +- same transaction; +- rollback; +- old/new binary compatibility; +- relay authority가 어떤 순간에도 정확히 하나; +- legacy/v2 query generation과 backlog handoff watermark; +- no event identity change; +- backup/rollback + +을 증명한다. + + + +### 30.4 Legacy event migration + +현재 pending row는 hand-written envelope/eventType/raw payload다. + +base template default: + +- verified empty/drained V3이면 legacy payload transform 없이 additive migration; +- drain/reset은 production data 삭제를 뜻하지 않으며 non-production 또는 승인된 maintenance + 범위만 대상으로 evidence를 남김. + +live-data deployment gate: + +- live data면 `legacy-envelope-v0` read-only publisher; +- known contract는 승인된 live migration card에서만 v1로 deterministic transform하되 original + hash/audit 보존; +- unknown contract는 자동 topic publish하지 않고 operator quarantine. + +이미 발행된 event의 wire contract를 몰래 바꾸지 않는다. + +현재 `PUBLISHED`는 broker ACK가 아니라 `void KafkaSender` 정상 반환이다. migration은 +`ackObservedAt`, provider metadata 또는 `DELIVERY_RECORDED`를 조작해 채우지 않는다. 운영자는 +event 범위별로: + +- downstream reconciliation 뒤 legacy terminal로 수용; +- duplicate-aware requeue; +- quarantine/hold + +중 하나를 audit한다. legacy `IN_FLIGHT`는 old relay fence와 drain/expiry 전 변환하지 않는다. + +final fenced reconciliation의 기본 상태 매핑은 다음과 같다. + +```text +legacy PENDING -> READY +legacy FAILED -> RETRY_WAIT (preserved finite due/budget, 없으면 reviewed DB-time due) +legacy DEAD -> EXHAUSTED (acceptance certainty를 fabricated definite rejection으로 만들지 않음) +legacy PUBLISHED -> LEGACY_RECORDED_UNVERIFIED +legacy IN_FLIGHT -> HOLD + remaining-indeterminate audit +``` + +이미 provisional delivery가 있으면 final legacy state와 expected row version을 대조해 같은 current +generation을 migration-only CAS로 맞추고, 없으면 정확히 하나를 insert한다. duplicate CURRENT, +unknown status/contract, event/hash mismatch는 자동 추측하지 않고 cutover transaction을 +rollback한다. 이 매핑은 migration에서만 허용되며 정상 v2 worker state machine을 우회하는 일반 +운영 API가 아니다. + +### 30.5 Phase 3 — Spring Kafka producer and polling reference path + +구현: + +- Spring Kafka dependency/lock; +- explicit producer factory/template; +- typed provider settings/compiler; +- ACK-aware gateway/outcome; +- topic attestation; +- finite producer/admission/deadline; +- polling relay outcome/state update; +- late-completion source/drain/attempt observation; +- application disposition use case + authenticated inbound-web operator endpoint; +- producer generation/lifecycle; +- best-effort migration; +- config/env/README/runbook update; +- real Kafka/PostgreSQL happy/failure tests. + +P3 cutover는 다음 순서를 고정한다. + +1. ACK-aware producer와 v2 relay를 scheduler-disabled로 배포; +2. contract/catalog/schema, producer/topic/security와 transaction-resource preflight; +3. operator disposition endpoint의 auth/CAS/audit negative test; +4. bounded write maintenance를 시작해 신규 business transaction admission을 막고 active writer와 + ACTIVE epoch `FOR SHARE` holder가 0이 될 때까지 drain; +5. old legacy relay 신규 claim 중단, IN_FLIGHT maximum budget drain, remaining indeterminate audit; +6. old producer close/Write fence와 DB legacy mutation guard 활성; +7. 하나의 cutover DB transaction을 열어 ACTIVE legacy epoch를 `FOR UPDATE`로 잠그고, writes와 + legacy mutation이 fenced된 snapshot에서 fixed legacy handoff watermark를 기록; +8. 같은 transaction에서 pre-backfill 뒤 watermark까지 생긴 delta를 포함해 모든 legacy event를 + final reconcile한다. 각 row는 final legacy state에 대응하는 정확히 한 CURRENT v2 delivery + (`READY/RETRY_WAIT/EXHAUSTED/HOLD/LEGACY_RECORDED_UNVERIFIED`)를 가지며 active claim은 0이어야 + 한다. row count, event ID/hash manifest와 unmapped/duplicate count 0을 assertion; +9. 같은 transaction에서 `LEGACY_POLLING -> POLLING_V2` ACTIVE epoch 전환과 v2 cutover sentinel + event+delivery INSERT 뒤 commit. reconciliation/manifest/switch 중 하나라도 실패하면 전체 + rollback; +10. v2 relay만 start하고 claim token/sequence/valid-lease CAS 사용; +11. canonical append path가 만든 v2 sentinel ACK/`DELIVERY_RECORDED`, legacy writer 0, + no-dual-send와 fresh readiness를 확인한 뒤 expected FROZEN generation/epoch/evidence CAS로 + write admission을 `OPEN(generation+1)`하고 business writes 재개; +12. rollback window 뒤 obsolete legacy columns 제거는 별도 later forward migration. + +write maintenance는 process-local boolean이 아니다. 모든 `TransactionPort.inWrite`는 같은 +transaction에서 PostgreSQL admission singleton을 `FOR KEY SHARE`로 잡고 OPEN/fence generation을 +확인한다. freeze transaction은 그 row를 `FOR UPDATE`로 잡아 기존 share holder가 commit/rollback할 +때까지 기다린 뒤 FROZEN generation을 durable하게 기록한다. 새 writer는 그 뒤 fail-closed +rollback한다. live node lease와 deployment instance inventory가 expected source/fence protocol로 +일치하지 않거나 legacy relay/reaper/producer Write의 zero-active/negative-Write probe가 없으면 +one-shot precondition evidence를 만들지 않는다. + +epoch commit 뒤에도 admission은 자동으로 열리지 않는다. v2 sentinel이 +`DELIVERY_RECORDED`이고, frozen 상태에서 maintenance-only canonical append canary가 exact +envelope/projection/delivery를 만들고, topic/security/readiness가 fresh한 경우에만 application +resume use case가 expected FROZEN generation + `POLLING_V2` epoch + evidence digest CAS로 +`OPEN(generation+1)`을 기록한다. ordinary `TransactionPort.inWrite`는 그 전까지 계속 거부된다. +resume mismatch/replay/failure는 FROZEN을 유지한다. epoch commit 뒤 runner가 실패하면 별도 +`resume-polling-v2-writes` one-shot recovery operation만 같은 증거를 다시 검증할 수 있고 raw SQL +status update는 금지한다. + +cutover는 authenticated public/web endpoint가 아니라 non-web one-shot maintenance runner가 +opaque approval evidence ID와 expected target/source/epoch를 받아 수행한다. operation ID와 +evidence consumption은 DB에서 재실행을 막고, 실패는 non-zero exit와 immutable audit를 남긴다. +epoch commit 전 실패는 DB transaction rollback만으로 끝내지 않는다. 먼저 모든 fence를 유지한 +채 fresh evidence/approval deadline 안에서 bounded forward retry할 수 있다. abort-to-legacy를 +선택하면 exact epoch가 여전히 `LEGACY_POLLING`이고 v2 business send/sentinel authority가 없으며 +inventory가 보존됐음을 확인한다. 외부 ACL을 바꾸기 전에 DB에서 exact cutover attempt를 잠그고 +`CUTOVER_PENDING -> RECOVERING_LEGACY`로 CAS하면서 recovery operation/lease/evidence digest를 +결합한다. 이 전이는 같은 attempt를 모든 forward finalizer에서 원자적으로 무효화하며, lease가 +만료돼도 `CUTOVER_PENDING`으로 돌아가지 않고 recovery-only takeover만 허용한다. 반대로 +finalizer는 epoch transaction 안에서 `CUTOVER_PENDING -> FINALIZING_V2`를 먼저 CAS하고 +성공 commit에서만 `CONSUMED_V2`로 바꾼다. 따라서 두 분기는 동일 attempt에서 함께 진행될 수 없다. + +상호 배제 범위는 attempt 하나가 아니라 outbox authority 전체다. 모든 attempt는 상수 +`OUTBOX_PUBLICATION` authority scope, ACTIVE legacy epoch, FROZEN fence generation과 target binding을 +저장하고, partial unique constraint는 이 scope에 nonterminal +`CUTOVER_PENDING|FINALIZING_V2|RECOVERING_LEGACY` row를 정확히 하나만 허용한다. evidence 생성, +finalization, recovery prepare/completion은 모두 write-admission singleton `FOR UPDATE` 뒤 ACTIVE +epoch `FOR UPDATE`의 동일 lock order를 사용하고 exact generation/target/sole-attempt를 검증한다. +따라서 recovery 중 별도 attempt를 만들어 v2 epoch를 commit할 수 없다. 같은 attempt뿐 아니라 +서로 다른 attempt의 생성/finalization과 recovery 사이 양방향 경쟁도 real PostgreSQL 시험으로 +고정한다. + +recovery claim이 commit된 뒤에만 외부 provisioning이 legacy principal Write를 재부여하고 fresh +positive probe를 만든다. 그 다음 exact attempt/owner/lease와 ACTIVE +`LEGACY_POLLING` epoch를 다시 잠가 검증한다. 불일치하면 legacy Write를 즉시 다시 revoke하고 fresh +negative probe를 만든 뒤 business writes를 FROZEN으로 유지한다. 검증이 성공하면 immutable +recovery/ACL audit 아래 in-process legacy Write fence, reaper, relay를 generation-CAS로 다시 열고 +마지막에 write admission을 `OPEN(generation+1)`로 CAS하면서 attempt를 +`RECOVERED_LEGACY`로 끝낸다. 어느 단계든 실패하면 business writes는 FROZEN을 유지하고 이미 연 +legacy component를 다시 fence하거나 safe degraded state로 둔 채 recovery-only 재시도한다. +forward-finalization 대 recovery-claim의 양방향 lock-order 경쟁과 각 외부 mutation/crash +경계를 real PostgreSQL 계약 시험으로 고정한다. raw SQL이나 단순 boolean toggle은 금지한다. + +epoch commit 뒤에는 business v2 send 여부와 무관하게 reverse epoch/legacy reactivation을 first +R2에서 지원하지 않는다. admission을 닫고 backlog/schema/epoch/audit를 보존한 채 forward-fix한다. + +qualification fixture의 broker/principal은 `qualificationEnvironmentIdentity`, 실제 target은 +`deploymentBindingIdentity`로 별도 기록한다. 공통 비교 대상은 capability/profile, supported +broker/client version constraint, settings/catalog/schema와 scenario contract다. 실제 cutover는 +target cluster/topic/principal/secret generation에 대한 fresh topology/security/ACL attestation과 +legacy principal negative-Write probe를 별도 요구하며 fixture identity를 target evidence로 +재사용하지 않는다. + +large live-data card는 bulk pre-backfill을 별도 bounded batch로 수행할 수 있지만, step 7 +transaction 안의 fixed watermark, final delta, manifest assertion과 authority switch는 한 fenced +atomic unit에 남긴다. 그 transaction의 lock/statement/replication budget이 evidence로 안전하지 +않으면 `COPY_AND_CUTOVER_WITH_RECONCILIATION.v1` 또는 더 긴 maintenance를 다시 승인하며 부분 +authority switch를 허용하지 않는다. + +이 phase 끝은 R1/R2 candidate다. security/fault/release evidence 없이 R2 완료가 아니다. + +### 30.6 Phase 4 — R2 qualification and rollout + +- production TLS/SASL_SSL; +- least-privilege ACL; +- topic topology policy; +- selected RF/min ISR multi-broker failure/recovery; +- secret/certificate rotation; +- fault/crash/late ACK/stale token; +- multi-worker/capacity/shutdown; +- observability/readiness/card registry; +- no-skip CI; +- sanitized evidence artifact; +- canary/rollback rehearsal; +- runbook completion. + +exact first tuple만 R2로 승격한다. + +### 30.7 Phase 5 — Inbound Kafka and inbox + +- registry 19 -> 20 migration; +- inbound listener leaf; +- consumer contract/catalog decoder; +- manual ACK/bounded processing/rebalance; +- application `MessageConsumptionExecutor`; +- PostgreSQL inbox; +- DLT/replay tooling; +- security/observability; +- real Kafka/PostgreSQL consumer evidence. + +producer/polling R2와 별도 card/status다. + +### 30.8 Phase 6 — CDC + +- insert-only source enforcement; +- deployment connector/slot/publication/internal topics; +- exact Debezium mapping; +- shadow topic; +- fault/WAL/offset/failover; +- cutover/rollback rehearsal; +- CDC retention proof; +- separate readiness/evidence. + +polling R2를 제거하지 않는다. deployment가 둘 중 하나를 선택하되 같은 production destination에서 +동시 활성화하지 않는다. + +### 30.9 Phase 7 — Optional cards + +실제 요구와 evidence가 있을 때: + +- retry topic; +- Kafka EOS; +- schema registry; +- Avro/Protobuf; +- compaction; +- claim-check/large message; +- multi-cluster; +- alternate provider; +- module/artifact split. + +### 30.10 Rollout + +first polling rollout: + +1. selected migration card preflight와 forward schema migration; +2. compatibility append + legacy relay-fence binary; +3. contract/catalog/schema artifact; +4. provisional delivery backfill/legacy unverified reconciliation; +5. ACK-aware producer + v2 relay의 **disabled** canary startup; +6. producer/topic/security/transaction-resource/operator-tool preflight; +7. business write admission freeze + active writer drain; +8. old relay stop, IN_FLIGHT drain, producer Write/DB legacy mutation fence; +9. 한 fenced DB transaction에서 ACTIVE epoch lock + fixed handoff watermark + final delta + reconciliation + count/hash manifest assertion; +10. 같은 transaction에서 atomic `LEGACY_POLLING -> POLLING_V2` authority switch + v2 sentinel + insert 뒤 commit; +11. v2 relay start와 sentinel ACK/`DELIVERY_RECORDED`, legacy writer 0 확인 뒤 writes 재개; +12. bounded subset/destination enable; +13. backlog/duplicate/indeterminate/no-dual-authority observation; +14. full enable; +15. rollback window 뒤 legacy seam/config removal. + +rollback은 DB schema를 destructive downgrade하지 않는다. authority epoch, producer Write ACL과 +relay fence를 먼저 판정하고 new relay disable/backlog preservation을 사용한다. 이미 v2 record가 +Kafka에 갈 수 있는 시점부터 old/new relay를 동시에 켜는 rollback은 금지한다. + +### 30.11 Status update discipline + +각 phase가 끝날 때 §0에: + +- implemented capability; +- not implemented; +- evidence/card level; +- exact test command/result; +- known limitation; +- next optional candidates + +를 갱신한다. 본문 설계 문장을 “구현됨”으로 다시 쓰지 않는다. + +## 31. Completion criteria and extension ledger + +### 31.1 Design complete + +설계 완료 조건: + +- 사용자 review/approval; +- architecture/module ownership 확정; +- first tuple 확정; +- producer/outbox/consumer/CDC guarantee와 non-guarantee 명시; +- base template migration card 확정과 live-data deployment approval gate 명시; +- migration/test/runbook/evidence 계획; +- unresolved decision이 implementation plan에 숨지 않음; +- LLM Wiki capture. + +현재 문서 상태는 사용자 승인에 따라 +`상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수`다. + +### 31.2 First R2 implementation complete + +정본 완료 판정은 §10.5 machine registry의 exact selected tuple이 모두 +`release-eligible`이고 required scenario/evidence fingerprint가 PASS인 경우뿐이다. 아래는 drift를 +막기 위한 human review checklist다. + +1. raw event type -> topic 제거; +2. closed contract/destination catalog; +3. envelope/payload schema v1; +4. append-before-persist validation; +5. immutable event + delivery split; +6. aggregate sequence + normal-path key order/non-guarantee; +7. claim token + unexpired-lease CAS와 JIT claim; +8. ACK-aware Spring Kafka producer; +9. ACK/ACK-mismatch/REJECTED/INDETERMINATE와 attempt journal; +10. legal late-completion observation drain과 bounded-loss semantics; +11. authenticated operator disposition use case/control surface; +12. finite effective config; +13. combined retry/requeue horizon budget; +14. production security/ACL/topic conformance; +15. multi-broker RF/min ISR failure evidence; +16. disabled resource 0; +17. bounded shutdown/rotation; +18. real Kafka/PostgreSQL/fault/security tests; +19. no-skip release gate; +20. descriptor/card/evidence artifact; +21. runbook; +22. legacy drift/migration; +23. Wiki capture. + +### 31.3 Consumer/inbox complete + +별도 조건: + +- inbound leaf/registry; +- manual ACK after commit; +- inbox + business same transaction; +- duplicate/collision behavior; +- bounded poll/backpressure/rebalance; +- DLT ACK ordering; +- replay audit; +- security/retention; +- real fault evidence. + +producer R2만으로 이 조건을 충족했다고 표시하지 않는다. + +### 31.4 CDC complete + +별도 조건: + +- insert-only source; +- exact connector mapping; +- slot/offset/WAL/security; +- duplicate/restart/failover; +- shadow/cutover/rollback; +- authority exclusivity; +- retention proof; +- real multi-service evidence. + +event/delivery table split만으로 CDC ready라고 표시하지 않는다. + +### 31.5 추가 가능한 card + +| 추가 요구 | 추가 card/설계 | 안정적으로 유지할 것 | +| --- | --- | --- | +| consumer | inbound Kafka + inbox | event ID, envelope, logical destination | +| CDC | Debezium dispatch | immutable event, key/wire semantics | +| binary schema | Avro/Protobuf registry | contract ID, payload version/outcome | +| delayed retry | retry topic | event ID/inbox, explicit ordering loss | +| Kafka-only workflow | transactional EOS | DB/non-Kafka scope 제외 | +| large payload | object-storage claim check | event identity/schema/security | +| alternate broker | provider card | semantic contract/evidence rule | +| multi-cluster | replication/failover card | no global ordering/exactly-once overclaim | +| topic compaction | contract-specific compacted log | key/tombstone/replay semantics | + +outbound messaging leaf 분리는 다음 trigger 전에는 하지 않는다. + +- 두 번째 broker provider가 독립 dependency/release lifecycle을 가짐; +- codec/schema runtime을 inbound leaf도 재사용해야 하지만 `shared-contract` purity로 수용할 수 + 없음; +- AdminClient/topology attestor가 독립 deployment artifact가 됨; +- producer와 compiler의 dependency/security ownership이 별도 release를 요구함. + +trigger가 생기면 registry leaf/edge migration과 동일 semantic card/evidence compatibility를 +함께 설계한다. package가 많다는 이유만으로 선제 분리하지 않는다. + +### 31.6 금지하는 완료 표현 + +다음 표현은 조건 없이 사용하지 않는다. + +- “Kafka 지원 완료” — seam인지 real selected card인지 명시; +- “broker ACK” — actual future metadata evidence 필요; +- “exactly once” — boundary를 좁힌 Kafka EOS card 외 금지; +- “중복 안전” — 해당 consumer/inbox evidence 필요; +- “strict FIFO” — sequence/key/topology뿐 아니라 failure/rotation/consumer order gate evidence 필요; +- “CDC ready” — connector/slot/offset/cutover evidence 필요; +- “DLT 처리 완료” — DLT publish와 business remediation 구분; +- “security ready” — TLS/SASL/ACL/rotation negative test 필요; +- “production ready” — exact R2 card/evidence 필요; +- “test passed” — command/result/skip을 기록. + +### 31.7 구현 보고 template + +후속 구현 완료 보고는 최소 다음을 포함한다. + +```text +이번에 구현된 card/phase +변경 파일 +architecture/dependency 변화 +실행한 focused/real-service/common gate +test count/result/skip +evidence fingerprint/artifact +아직 미구현인 card +현재 보장과 non-guarantee +runbook +LLM Wiki capture +남은 위험 +``` + +## 32. Required runbooks + +### 32.1 First R2 baseline + +1. `producer-unavailable-or-unauthorized` + - DNS/network/TLS/SASL/ACL/topic/min ISR 분기; + - direct/relay/write role별 guarantee 영향; + - safe pause/recovery proof. +2. `outbox-backlog-and-stale-lease` + - oldest age/count/growth; + - claim owner/token/lease conflict; + - hot/stuck aggregate; + - capacity/scale 한계. +3. `delivery-indeterminate-and-duplicate-burst` + - ACK loss/late ACK/mark failure; + - suspected event ID range; + - downstream inbox/reconciliation; + - resend duplicate 경고. +4. `schema-poison-or-record-too-large` + - contract/version/byte diagnosis; + - retry 중단; + - corrected/compensating event; + - payload 원문 log 금지. +5. `terminal-delivery-disposition` + - hold/requeue/skip/compensate; + - business-owner 승인; + - aggregate 후행 영향; + - audit/rollback. +6. `topic-policy-or-partition-change` + - topic drift; + - partition expansion order risk; + - new topic migration/cutback. +7. `shutdown-deploy-and-secret-rotation` + - new claim stop/drain; + - cert/secret expiry; + - old/new generation; + - forced timeout/rollback. +8. `legacy-to-v2-relay-authority-cutover` + - compatibility binary/fingerprint; + - legacy claim stop/IN_FLIGHT drain; + - DB epoch/legacy mutation fence; + - backlog watermark/no-dual-send probe; + - rollback stop condition. + +### 32.2 Future consumer + +- consumer lag/max.poll/rebalance loop; +- retry exhaustion/DLT publish failure; +- DLT duplicate identity/quarantine capacity/strict-order HOLD; +- schema/deserialization poison; +- inbox collision/retention; +- inbox purge vs live/replay effect-generation lease; +- audited replay; +- offset out of range; +- consumer identity/group migration; +- shutdown with active handler. + +### 32.3 Future CDC + +- connector/task down; +- WAL growth/disk pressure; +- slot missing/ahead/behind; +- offset reset/alter; +- snapshot restart/schema drift; +- PostgreSQL primary failover; +- polling -> CDC cutover; +- CDC -> polling rollback; +- shadow/production connector와 unique slot ownership; +- cutover 단계별 abort/authority proof; +- PostgreSQL 16 failover DATA_GAP_SUSPECTED recovery; +- WAL emergency without automatic slot drop; +- CDC retention/partition purge. + +### 32.4 Common structure + +모든 runbook: + +```text +detection/trigger +blast radius +current guarantee degradation +safe first response +diagnosis evidence +non-destructive mitigation +destructive action approval boundary +reconciliation +recovery proof +rollback +audit/post-incident +related metrics/errors/cards +``` + +### 32.5 Existing runbook migration + +`outbox-publish-failed.md`와 `outbox-dead-letter.md`는 현재 stub다. first implementation에서: + +- removed `APP_MESSAGING_KAFKA_ENABLED` 제거; +- 실제 class/settings/table/state 이름; +- producer EXHAUSTED와 consumer DLT 구분; +- “consumer dedupe가 현재 안전 보장” 표현 제거; +- raw SQL status rewrite 제거; +- token/generation/audit operator tool; +- real dashboard/alert link; +- split event/delivery query; +- indeterminate/duplicate 절차 + +로 갱신한다. + +## 33. Primary references + +구현은 실제 BOM/lock에 resolve된 version 문서를 우선한다. 아래는 설계 시 확인한 official primary +reference다. + +### Spring Boot and Spring Kafka + +- [Spring Boot 4.0 — Apache Kafka Support](https://docs.spring.io/spring-boot/4.0/reference/messaging/kafka.html) +- [Spring Kafka 4.0 — Sending Messages](https://docs.spring.io/spring-kafka/reference/4.0/kafka/sending-messages.html) +- [Spring Kafka 4.0 — Message Listener Containers](https://docs.spring.io/spring-kafka/reference/4.0/kafka/receiving-messages/message-listener-container.html) +- [Spring Kafka 4.0 — Pausing and Resuming Listener Containers](https://docs.spring.io/spring-kafka/reference/4.0/kafka/pause-resume.html) +- [Spring Kafka 4.0 — Handling Exceptions](https://docs.spring.io/spring-kafka/reference/4.0/kafka/annotation-error-handling.html) +- [Spring Kafka 4.0 — DefaultErrorHandler API](https://docs.spring.io/spring-kafka/docs/4.0.x/api/org/springframework/kafka/listener/DefaultErrorHandler.html) +- [Spring Kafka 4.0 — Retry Topic Pattern](https://docs.spring.io/spring-kafka/reference/4.0/retrytopic/how-the-pattern-works.html) +- [Spring Kafka 4.0 — Transactions](https://docs.spring.io/spring-kafka/reference/4.0/kafka/transactions.html) +- [Spring Kafka 4.0 — Exactly Once Semantics](https://docs.spring.io/spring-kafka/reference/4.0/kafka/exactly-once.html) +- [Spring Kafka 4.0 — Monitoring](https://docs.spring.io/spring-kafka/reference/4.0/kafka/micrometer.html) +- [Spring Kafka 4.0 — Testing Applications](https://docs.spring.io/spring-kafka/reference/4.0/testing.html) + +### Apache Kafka + +- [Kafka 4.1 Producer Configs](https://kafka.apache.org/41/configuration/producer-configs/) +- [Kafka 4.1 Topic Configs](https://kafka.apache.org/41/configuration/topic-configs/) +- [Kafka 4.1 Consumer Configs](https://kafka.apache.org/41/configuration/consumer-configs/) +- [Kafka 4.1 Design — Message Delivery Semantics](https://kafka.apache.org/41/design/design/#message-delivery-semantics) +- [Kafka 4.1 Security Overview](https://kafka.apache.org/41/security/security-overview/) +- [Kafka 4.1 Kafka Connect User Guide](https://kafka.apache.org/41/kafka-connect/user-guide/) +- [Kafka 4.1 Kafka Connect Configs](https://kafka.apache.org/41/configuration/kafka-connect-configs/) +- [Kafka 4.1 KafkaProducer API](https://kafka.apache.org/41/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html) +- [Kafka 4.1 UnknownTopicOrPartitionException](https://kafka.apache.org/41/javadoc/org/apache/kafka/common/errors/UnknownTopicOrPartitionException.html) +- [Kafka 4.1 NotEnoughReplicasAfterAppendException](https://kafka.apache.org/41/javadoc/org/apache/kafka/common/errors/NotEnoughReplicasAfterAppendException.html) + +### JSON and trace contract + +- [JSON Schema Draft 2020-12](https://json-schema.org/draft/2020-12) +- [JSON Schema Core 2020-12](https://json-schema.org/draft/2020-12/json-schema-core) +- [JSON Schema Validation 2020-12](https://json-schema.org/draft/2020-12/json-schema-validation) +- [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) +- [RFC 8259 — The JavaScript Object Notation Data Interchange Format](https://www.rfc-editor.org/rfc/rfc8259) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) + +### Debezium, Kafka Connect and PostgreSQL CDC + +- [Debezium 3.6 Release Series](https://debezium.io/releases/3.6/) +- [Debezium 3.6 Outbox Event Router](https://debezium.io/documentation/reference/3.6/transformations/outbox-event-router.html) +- [Debezium 3.6 PostgreSQL Connector](https://debezium.io/documentation/reference/3.6/connectors/postgresql.html) +- [PostgreSQL 16 Logical Decoding Concepts](https://www.postgresql.org/docs/16/logicaldecoding-explanation.html) +- [PostgreSQL 16 Logical Replication Security](https://www.postgresql.org/docs/16/logical-replication-security.html) +- [PostgreSQL 16 CREATE PUBLICATION](https://www.postgresql.org/docs/16/sql-createpublication.html) +- [PostgreSQL 16 Logical Replication Row Filters](https://www.postgresql.org/docs/16/logical-replication-row-filter.html) +- [PostgreSQL 16 INSERT / ON CONFLICT](https://www.postgresql.org/docs/16/sql-insert.html) +- [PostgreSQL 16 Unique Constraints](https://www.postgresql.org/docs/16/ddl-constraints.html) + +### Test infrastructure + +- [Testcontainers for Java — Kafka Module](https://java.testcontainers.org/modules/kafka/) +- [Testcontainers for Java — PostgreSQL Module](https://java.testcontainers.org/modules/databases/postgres/) + +문서 링크는 executable evidence가 아니다. implementation 시 exact dependency/image version, +effective config, tests와 runbook으로 다시 확인한다. + +## 34. Review 이후 다음 단계 + +base template의 핵심 architecture option과 empty/drained V3 기본 migration card는 확정됐다. +사용자에게 구현 세부 선택을 더 요구하지 않는다. 다만 실제 live non-empty database에 적용하는 +시점에는 §30.3의 row/lock/data evidence를 수집한 뒤 live migration card를 별도 승인해야 한다. +그것은 지금 숨겨 둔 선택이 아니라 deployment-specific safety gate다. review에서 방향 수정이 +없으면 다음 순서로 진행한다. + +1. P0–P4 first R2 실행 계획과 독립 review 완료; +2. `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 계획 실행; +3. test-first로 contract/schema/polling/producer를 단계 구현; +4. exact first tuple R2 evidence 뒤 consumer/inbox 계획; +5. consumer evidence 뒤 CDC qualification/cutover 계획. + +repository의 human-only commit 정책에 따라 agent는 stage/commit/amend/push하지 않는다. 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 c26b8a8e..12f3c0b0 100644 --- a/src/app-bootstrap/README.md +++ b/src/app-bootstrap/README.md @@ -816,6 +816,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결 BOM 이 관리. - **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated loggers / loggers 쓰기 거부)를 검증한다. +- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity, + wildcard-free compatibility, evidence task/scenario/runbook 선언을 + `MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며 + 모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다. + +### Messaging qualification scaffold + +`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다. +현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer, +CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과 +`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest +validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와 +무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*` +root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed +guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는 +다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread, +network 또는 다른 런타임 resource를 생성하지 않는다. ### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용) ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지 diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index d4e20894..ba9173dc 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -96,6 +96,8 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' // test-only: jackson-databind for the deserialization-policy boundary test. See README. testImplementation 'org.springframework.boot:spring-boot-starter-json' + // test-only: parses checked-in Messaging capability registries for the fail-closed drift gate. + testImplementation 'org.yaml:snakeyaml' // sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it. sampleFixture project(':sample-portfolio') // test-only: ArchUnit violation fixtures intentionally import forbidden types. See README. diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index ba407f58..fdad4c1f 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=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -57,6 +58,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOf com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture com.puppycrawl.tools:checkstyle:13.5.0=checkstyle @@ -346,7 +348,7 @@ org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClas org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath 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 514af5dc..6f3f67cc 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 @@ -7,6 +7,8 @@ import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.EvaluationResult; import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; +import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; +import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging.GenericTypeLeakingAdapterFixture; import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase; import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository; import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort; @@ -85,6 +87,10 @@ class ArchitectureViolationFixtureTest { private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY = new ClassFileImporter().importClasses(CleanProjectionQueryPort.class); + private static final JavaClasses GENERIC_ADAPTER_LEAK_ONLY = + new ClassFileImporter() + .importClasses(GenericTypeLeakingAdapterFixture.class, RawExternalResponseFixture.class); + // feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation // branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a // silently broken branch cannot pass vacuously via the other one in the shared pool. @@ -397,6 +403,20 @@ class ArchitectureViolationFixtureTest { .isTrue(); } + @Test + void outboundAdapterMethodReturnsOnlyDomainCatchesGenericAdapterLeakInIsolation() { + EvaluationResult result = + CleanArchitectureTest + .MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS + .evaluate(GENERIC_ADAPTER_LEAK_ONLY); + + assertThat(result.hasViolation()) + .as( + "MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS " + + "must recursively catch List (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 f259fa97..93a599ba 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 @@ -1272,6 +1272,62 @@ class CleanArchitectureTest { + "response surfaces.") .allowEmptyShould(true); + @ArchTest + static final ArchRule + MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS = + methods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..adapter.outbound.messaging..") + .and() + .areDeclaredInClassesThat() + .areNotAnnotatedWith("org.springframework.context.annotation.Configuration") + .and() + .areDeclaredInClassesThat() + .areNotAnnotatedWith( + "org.springframework.boot.context.properties.ConfigurationProperties") + .and() + .arePublic() + .and() + .areNotStatic() + .should( + notReturnAdapterTypesIncludingGenericArguments( + "..adapter.outbound..", + "..adapter.inbound.web..", + "..adapter.outbound.persistence..")) + .as( + "B7 messaging hardening: every public non-static messaging method must keep " + + "adapter-local types out of direct and recursive generic return positions; " + + "narrow public static composition bridges are the only allowed adapter-local " + + "return seam") + .allowEmptyShould(true); + + private static ArchCondition 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/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 93651035..856128c8 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 @@ -62,6 +66,10 @@ Package root: `dev.caskeleton.application`. | `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. | | `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. | | `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. | +| `messaging.contract.IntegrationEventContractContribution

` | 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. | ## Naming convention @@ -189,3 +197,6 @@ cd src ./gradlew :application-core:test ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' ``` + +The messaging types above establish semantic contract R1 only. They do not claim JSON Schema +qualification, Kafka publication, durable outbox persistence or any messaging R2 capability. diff --git a/src/application-core/README.md b/src/application-core/README.md index 124f0c41..f2b88b43 100644 --- a/src/application-core/README.md +++ b/src/application-core/README.md @@ -16,6 +16,34 @@ --- +## 메시징 semantic contract R1 + +`messaging.contract`와 `messaging.event`는 feature가 integration event를 동적 JSON이나 provider +타입으로 넘기지 않게 만드는 application 경계다. + +- `IntegrationPayload` 구현은 feature가 소유한 불변 typed record다. +- `IntegrationEventContractContribution`은 contract ID와 payload version을 분리하고, exact final + record type token, 실제 record component 순서, repository-local schema resource/hash와 + provider-neutral `ContractDescriptor`만 기여한다. assignable-type 탐색, `Class.forName`, Java + class-name routing, `Map`, raw JSON string/tree는 이 SPI에 들어오지 않는다. +- `IntegrationEventDraft`는 canonical event/aggregate/order/correlation identity와 typed payload를 + 보유한다. tenant가 없는 모드도 null 대신 canonical system tenant scope를 + `AggregateIdentity`에 넣어 dedupe/order identity가 PostgreSQL nullable uniqueness에 기대지 + 않게 한다. +- `IntegrationEventEncoderPort` 뒤의 adapter가 deterministic encoding과 schema validation을 + 수행하고 `ValidatedIntegrationEvent`를 돌려준다. 결과는 logical destination, exact US-ASCII + partition key, exact encoded envelope bytes, schema/envelope hash와 catalog/binding revision을 + defensive copy로 보존한다. +- `ContractDescriptor`는 owner module, logical destination, serializer ID, ordering requirement, + payload/envelope byte limit, sensitivity classification, same-event requeue horizon만 표현한다. + physical topic, Kafka cluster/security topology는 deployment binding의 책임이다. + +이 단계의 완성 범위는 **framework-free semantic contract R1**이다. JSON Schema validator와 +deterministic writer, Kafka ACK producer, PostgreSQL outbox append/relay는 후속 R2 작업이며 여기서 +구현되었거나 검증됐다고 주장하지 않는다. + +--- + ## 유스케이스 계약 (usecase / command / query / capability) ### UseCase / CommandUseCase / QueryUseCase 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 f7730f9c..392136ec 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; @@ -561,6 +672,35 @@ class PublishPendingOutboxEventsUseCaseTest { } } + static final class MutableClock extends Clock { + private Instant current; + private final ZoneId zone; + + MutableClock(Instant current, ZoneId zone) { + this.current = current; + this.zone = zone; + } + + void advance(Duration duration) { + current = current.plus(duration); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId requestedZone) { + return new MutableClock(current, requestedZone); + } + + @Override + public Instant instant() { + return current; + } + } + /** Deterministic RandomGenerator that always returns 0 — produces zero jitter. */ static final class ZeroRandom implements RandomGenerator { @Override diff --git a/src/build.gradle b/src/build.gradle index ead48df9..1a2186df 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,4 +1,9 @@ import groovy.json.JsonSlurper +import groovy.json.JsonOutput +import groovy.xml.XmlSlurper +import java.security.MessageDigest +import java.time.Duration +import java.time.Instant import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.tasks.bundling.AbstractArchiveTask @@ -45,6 +50,115 @@ ext.releaseVersion = releaseVersion ext.sourceRevision = sourceRevision ext.traceableVersion = traceableVersion +// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed. +// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid, +// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass. +Map> 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 @@ -287,6 +401,394 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { } } +// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes. +// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties +// and every selected Task 3-6 test have passed in the current invocation. +def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence') +def registerMessagingQualificationTest = { + Project owner, String taskName, List 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/gradlew.bat b/src/gradlew.bat index e509b2dd..c4bdd3ab 100644 --- a/src/gradlew.bat +++ b/src/gradlew.bat @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega 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 a7330a63..ccc27cd3 100644 --- a/src/shared-contract/CLAUDE.md +++ b/src/shared-contract/CLAUDE.md @@ -23,6 +23,9 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat `tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist, and the span-error-recording seam (feature-distributed-tracing-contract; the OTel tracer runtime is a fork-activated seam, so these stay Java-stdlib-only). +- Generic messaging envelope schema resources under `contracts/messaging/envelope/`. + They own transport-neutral envelope metadata only; feature payload schemas remain in their + feature-owner modules. Resource presence is not runtime discovery or registration. ## Allowed - Java standard library ONLY. No Spring, no Jackson, no JPA imports. @@ -30,6 +33,8 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat ## Forbidden - business/domain concept (domain error codes belong in the consuming module). - framework imports (HTTP status is expressed as transport-neutral `int`; the web module maps it). +- claiming Draft 2020-12 validator compatibility before the messaging adapter's Task 6 validator + qualification exists. ## Test ```bash diff --git a/src/shared-contract/README.md b/src/shared-contract/README.md index 4409d688..e04a9dce 100644 --- a/src/shared-contract/README.md +++ b/src/shared-contract/README.md @@ -14,6 +14,20 @@ persistence·outbound)이 프레임워크 충돌 없이 이 타입들을 공유 --- +## messaging — generic envelope schema resource + +- `contracts/messaging/envelope/v1.schema.json` 은 비즈니스 필드를 모르는 공통 envelope v1의 + 저장소 소유 Draft 2020-12 리소스다. root와 aggregate metadata는 닫혀 있고, `payload`는 object + 크기만 제한한다. 실제 payload 필드와 닫힘 정책은 feature owner의 별도 schema가 소유한다. +- `.schema.sha256`은 schema 파일의 exact bytes SHA-256이다. 같은 version의 schema bytes를 바꾸면 + digest도 의도적으로 갱신하고 compatibility 검토를 다시 해야 한다. +- 이 단계의 테스트는 UTF-8, 정본 구조, digest, remote `$ref` 금지만 JDK로 검사한다. Task 6의 + 실제 Draft 2020-12 validator가 붙기 전까지 validator compatibility가 증명된 것은 아니다. +- classpath resource가 존재한다는 사실은 runtime discovery나 자동 등록을 의미하지 않는다. + contract catalog와 encoder가 명시적으로 조립되기 전에는 어떤 publisher도 이 리소스를 읽지 않는다. + +--- + ## error — 에러 코드 계약 ### ApiErrorCode (인터페이스) 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; + } +}