# Messaging Platform 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. **Goal:** Java/Spring Backend Skeleton에 evidence 기반 publish 결과, duplicate-safe consumer settlement, Kafka·RabbitMQ Stable Adapter, Outbox·Inbox Reliability, Pulsar·NATS Experimental Adapter, Admin Plane, 보안·관측성·장애 검증을 갖춘 Messaging 플랫폼을 구현한다. **Architecture:** `messaging-core-api`가 브로커 중립 공개 계약을 소유하고 `messaging-transport-spi`를 Kafka·RabbitMQ·Pulsar·NATS adapter가 구현한다. 일반 서비스는 M1 Typed Publisher·Handler만 사용하며, M2 Advanced, M3 Native Capability, M4 Admin Plane을 별도 모듈·권한으로 격리한다. publish 결과는 `CONFIRMED`, `REJECTED`, `AMBIGUOUS`와 broker evidence를 보존하고, consumer는 handler 성공 뒤에만 settlement하며 retry·DLQ·redrive에서 logical `messageId`를 유지한다. **Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common compatibility line with Spring 7.0 compatibility jobs, Spring Kafka, Spring AMQP, Apache Pulsar Client, NATS Java Client, Spring JDBC/JPA, PostgreSQL 16, Flyway, Jackson, optional Avro and Protobuf, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. ## Global Constraints - `messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message`, Reactor 의존성을 넣지 않는다. - Core 비동기 계약은 `CompletionStage`다. Blocking과 Reactor facade는 별도 모듈에서 제공한다. - 일반 업무 모듈의 기본 진입점은 M1 Typed Publisher·Handler다. - M2는 Batch, Manual Settlement, Pause/Resume, Delayed Delivery, Replay Request만 제공한다. - M3는 broker-native capability를 typed interface로만 제공하며 raw broker client를 반환하지 않는다. - M4 Admin Plane은 별도 credential과 security chain을 사용한다. - 공통 API에 `EXACTLY_ONCE`, `GLOBAL_ORDERING`, DB+broker 원자 transaction 옵션을 만들지 않는다. - Stable Adapter는 Kafka 4.2+·4.3.x와 RabbitMQ 4.3.x다. - Kafka Share Group, Pulsar 4.0 LTS·4.2, NATS JetStream 2.14.x는 Experimental이다. - Stable Kafka producer는 `enable.idempotence=true`, `acks=all`, `max.in.flight.requests.per.connection<=5`를 강제한다. - Rabbit publisher는 correlated confirm, return, `mandatory=true`를 강제한다. - durable Rabbit work queue 기본은 quorum queue다. - publish timeout 또는 connection loss 뒤 broker 처리 여부를 확정할 수 없으면 `AMBIGUOUS`다. - publish retry, redelivery, retry destination, DLQ, redrive는 같은 logical `messageId`를 유지한다. - M1 consumer는 handler 성공 이전에 source settlement를 수행하지 않는다. - DLQ·retry destination publish가 confirm되기 전에 source를 ACK하지 않는다. - strict ordering destination에 reorder 가능한 retry 전략을 설정하면 startup을 실패시킨다. - JSON은 Stable 기본 codec, Avro·Protobuf는 선택 Stable, Raw Bytes는 M2, Java Serialization은 비지원이다. - Domain·Integration Event에 CloudEvents 1.0.2 compatible profile을 선택 제공한다. - logical payload 기본 최대는 1,048,576 bytes, global hard maximum은 8,388,608 bytes다. - header 총 크기는 32,768 bytes, 개수 64개, key 128 bytes, value 4,096 bytes다. - 1 MiB 초과 payload는 Claim Check를 기본으로 사용한다. - production topology는 IaC 생성 + application validate-only다. - producer, consumer, admin credential을 분리하고 production TLS·broker authentication을 필수화한다. - metric label에 message ID, actual key, payload, unbounded tenant ID, credential을 기록하지 않는다. - Outbox ambiguous publish는 같은 message ID로 재시도한다. - Inbox row와 business side effect는 같은 PostgreSQL transaction에서 commit한다. - 각 Task는 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. - 각 Task는 독립적으로 검토 가능한 하나의 커밋으로 종료한다. - 테스트 코드에 사용되는 `*Fixture`, `*Harness`, `Fake*`, `Test*` support type은 별도 경로가 명시되지 않으면 해당 Task의 listed test file 하단에 package-private top-level type으로 함께 작성한다. --- ## 1. 확정 파일 구조 ```text backend-skeleton/ ├── settings.gradle.kts ├── build.gradle.kts ├── build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts ├── modules/messaging/ │ ├── messaging-core-api/ │ ├── messaging-schema-api/ │ ├── messaging-schema-json/ │ ├── messaging-schema-avro/ │ ├── messaging-schema-protobuf/ │ ├── messaging-cloudevents/ │ ├── messaging-policy/ │ ├── messaging-transport-spi/ │ ├── messaging-observability/ │ ├── messaging-security/ │ ├── messaging-kafka/ │ ├── messaging-kafka-share-experimental/ │ ├── messaging-rabbit/ │ ├── messaging-reliability-api/ │ ├── messaging-outbox-jpa/ │ ├── messaging-inbox-jpa/ │ ├── messaging-claim-check/ │ ├── messaging-admin-api/ │ ├── messaging-admin-runtime/ │ ├── messaging-pulsar-experimental/ │ ├── messaging-nats-experimental/ │ ├── messaging-spring-cloud-stream-bridge/ │ ├── messaging-spring-boot-starter/ │ └── messaging-testkit/ ├── infra/messaging/ │ ├── kafka/ │ ├── rabbitmq/ │ ├── pulsar/ │ ├── nats/ │ ├── postgres/ │ ├── toxiproxy/ │ └── tls/ ├── docs/messaging/ │ ├── support-matrix.md │ ├── configuration-reference.md │ ├── delivery-guarantees.md │ ├── retry-dlq-redrive.md │ ├── outbox-inbox.md │ ├── security.md │ ├── operations.md │ ├── migration-guide.md │ └── experimental-policy.md └── docs/superpowers/specs/2026-08-10-messaging-platform-design.md ``` ## 2. 핵심 패키지 ```text io.backend.skeleton.messaging.api io.backend.skeleton.messaging.api.delivery io.backend.skeleton.messaging.api.destination io.backend.skeleton.messaging.api.error io.backend.skeleton.messaging.api.header io.backend.skeleton.messaging.api.publish io.backend.skeleton.messaging.api.settlement io.backend.skeleton.messaging.schema io.backend.skeleton.messaging.schema.json io.backend.skeleton.messaging.schema.avro io.backend.skeleton.messaging.schema.protobuf io.backend.skeleton.messaging.cloudevents io.backend.skeleton.messaging.policy io.backend.skeleton.messaging.transport io.backend.skeleton.messaging.observation io.backend.skeleton.messaging.security io.backend.skeleton.messaging.kafka io.backend.skeleton.messaging.rabbit io.backend.skeleton.messaging.reliability io.backend.skeleton.messaging.outbox io.backend.skeleton.messaging.inbox io.backend.skeleton.messaging.claimcheck io.backend.skeleton.messaging.admin io.backend.skeleton.messaging.pulsar io.backend.skeleton.messaging.nats io.backend.skeleton.messaging.streambridge io.backend.skeleton.messaging.autoconfigure io.backend.skeleton.messaging.testkit ``` --- ### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 **Files:** - Modify: `settings.gradle.kts` - Create: `build-logic/src/main/kotlin/messaging-library-conventions.gradle.kts` - Create: `modules/messaging/messaging-core-api/build.gradle.kts` - Create: `modules/messaging/messaging-schema-api/build.gradle.kts` - Create: `modules/messaging/messaging-schema-json/build.gradle.kts` - Create: `modules/messaging/messaging-schema-avro/build.gradle.kts` - Create: `modules/messaging/messaging-schema-protobuf/build.gradle.kts` - Create: `modules/messaging/messaging-cloudevents/build.gradle.kts` - Create: `modules/messaging/messaging-policy/build.gradle.kts` - Create: `modules/messaging/messaging-transport-spi/build.gradle.kts` - Create: `modules/messaging/messaging-observability/build.gradle.kts` - Create: `modules/messaging/messaging-security/build.gradle.kts` - Create: `modules/messaging/messaging-kafka/build.gradle.kts` - Create: `modules/messaging/messaging-kafka-share-experimental/build.gradle.kts` - Create: `modules/messaging/messaging-rabbit/build.gradle.kts` - Create: `modules/messaging/messaging-reliability-api/build.gradle.kts` - Create: `modules/messaging/messaging-outbox-jpa/build.gradle.kts` - Create: `modules/messaging/messaging-inbox-jpa/build.gradle.kts` - Create: `modules/messaging/messaging-claim-check/build.gradle.kts` - Create: `modules/messaging/messaging-admin-api/build.gradle.kts` - Create: `modules/messaging/messaging-admin-runtime/build.gradle.kts` - Create: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts` - Create: `modules/messaging/messaging-nats-experimental/build.gradle.kts` - Create: `modules/messaging/messaging-spring-cloud-stream-bridge/build.gradle.kts` - Create: `modules/messaging/messaging-spring-boot-starter/build.gradle.kts` - Create: `modules/messaging/messaging-testkit/build.gradle.kts` - Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/ModuleSmokeTest.java` **Interfaces:** - Produces every Gradle project path used by later tasks. - `messaging-core-api` has no Spring or broker dependency. - Java toolchain is 21 and all tests use JUnit Platform. - [ ] **Step 1: Write the failing core module smoke test** ```java package io.backend.skeleton.messaging.api; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class ModuleSmokeTest { @Test void coreApiModuleLoads() { assertThat(ModuleSmokeTest.class.getPackageName()) .isEqualTo("io.backend.skeleton.messaging.api"); } } ``` - [ ] **Step 2: Register module paths and verify configuration fails before build files exist** Add to `settings.gradle.kts`: ```kotlin include( ":modules:messaging:messaging-core-api", ":modules:messaging:messaging-schema-api", ":modules:messaging:messaging-schema-json", ":modules:messaging:messaging-schema-avro", ":modules:messaging:messaging-schema-protobuf", ":modules:messaging:messaging-cloudevents", ":modules:messaging:messaging-policy", ":modules:messaging:messaging-transport-spi", ":modules:messaging:messaging-observability", ":modules:messaging:messaging-security", ":modules:messaging:messaging-kafka", ":modules:messaging:messaging-kafka-share-experimental", ":modules:messaging:messaging-rabbit", ":modules:messaging:messaging-reliability-api", ":modules:messaging:messaging-outbox-jpa", ":modules:messaging:messaging-inbox-jpa", ":modules:messaging:messaging-claim-check", ":modules:messaging:messaging-admin-api", ":modules:messaging:messaging-admin-runtime", ":modules:messaging:messaging-pulsar-experimental", ":modules:messaging:messaging-nats-experimental", ":modules:messaging:messaging-spring-cloud-stream-bridge", ":modules:messaging:messaging-spring-boot-starter", ":modules:messaging:messaging-testkit" ) ``` Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: FAIL because one or more registered module directories or build files are missing. - [ ] **Step 3: Add the convention plugin and module build files** `messaging-library-conventions.gradle.kts`: ```kotlin plugins { `java-library` } java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } withSourcesJar() } tasks.withType().configureEach { useJUnitPlatform() } dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.assertj:assertj-core") } ``` Every module build file starts with: ```kotlin plugins { id("messaging-library-conventions") } ``` Add only the dependencies required by that module. `messaging-core-api` remains dependency-free except test libraries. - [ ] **Step 4: Run the complete module smoke build** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: PASS with one test and zero failures. - [ ] **Step 5: Commit** ```bash git add settings.gradle.kts build-logic modules/messaging git commit -m "build: add messaging platform modules" ``` --- ### Task 2: 핵심 식별자와 보장 Enum 구현 **Files:** - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/UuidV7.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageId.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageType.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/SchemaVersion.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ProducerId.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CorrelationId.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/CausationId.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryGuarantee.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ProcessingGuarantee.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/OrderingScope.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/ExternalSideEffectGuarantee.java` - Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/CoreValueTypesTest.java` **Interfaces:** - Produces immutable value types used by every later task. - `MessageId.newId()` uses the local `UuidV7.next()` implementation created in this Task. - No enum contains `EXACTLY_ONCE` or `GLOBAL`. - [ ] **Step 1: Write failing value-type tests** ```java package io.backend.skeleton.messaging.api; import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee; import io.backend.skeleton.messaging.api.delivery.OrderingScope; import org.junit.jupiter.api.Test; import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class CoreValueTypesTest { @Test void messageTypeRejectsBlankValue() { assertThatThrownBy(() -> new MessageType(" ")) .isInstanceOf(IllegalArgumentException.class); } @Test void schemaVersionMustBePositive() { assertThatThrownBy(() -> new SchemaVersion(0)) .isInstanceOf(IllegalArgumentException.class); } @Test void guaranteeEnumsDoNotAdvertiseUnsupportedSemantics() { assertThat(Arrays.stream(DeliveryGuarantee.values()).map(Enum::name)) .doesNotContain("EXACTLY_ONCE"); assertThat(Arrays.stream(OrderingScope.values()).map(Enum::name)) .doesNotContain("GLOBAL"); } } ``` - [ ] **Step 2: Run the focused test and verify missing types** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test --tests '*CoreValueTypesTest' ``` Expected: FAIL because the value types and enums do not exist. - [ ] **Step 3: Implement the value types and exact enum constants** ```java package io.backend.skeleton.messaging.api; public record MessageType(String value) { public MessageType { if (value == null || value.isBlank() || value.length() > 240) { throw new IllegalArgumentException("messageType must contain 1 to 240 characters"); } } } ``` ```java package io.backend.skeleton.messaging.api; public record SchemaVersion(int value) { public SchemaVersion { if (value < 1) { throw new IllegalArgumentException("schemaVersion must be positive"); } } } ``` ```java package io.backend.skeleton.messaging.api.delivery; public enum DeliveryGuarantee { AT_MOST_ONCE, AT_LEAST_ONCE } ``` ```java package io.backend.skeleton.messaging.api.delivery; public enum OrderingScope { NONE, DESTINATION, PARTITION, KEY } ``` Implement the remaining records with null, blank, and length validation. Implement `MessageId` as a non-null UUID value and provide `newId()`. - [ ] **Step 4: Run tests and architecture compilation** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: PASS with no unsupported guarantee constants. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-core-api git commit -m "feat: add messaging core value types" ``` --- ### Task 3: 제한형 Header와 MessageEnvelope 구현 **Files:** - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderName.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/HeaderValue.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/MessageHeaders.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/header/ReservedHeaders.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/ContentType.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TenantContext.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/TraceContext.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/MessageEnvelope.java` - Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/MessageEnvelopeTest.java` **Interfaces:** - Consumes Task 2 value types. - Produces `MessageEnvelope` and immutable `MessageHeaders` for publisher, schema, and adapters. - Enforces 64 headers, 32 KiB total, 128-byte keys, 4 KiB values, reserved and secret header rejection. - [ ] **Step 1: Write failing envelope policy tests** ```java package io.backend.skeleton.messaging.api; import io.backend.skeleton.messaging.api.header.HeaderName; import io.backend.skeleton.messaging.api.header.HeaderValue; import io.backend.skeleton.messaging.api.header.MessageHeaders; import org.junit.jupiter.api.Test; import java.util.Map; import static org.assertj.core.api.Assertions.assertThatThrownBy; class MessageEnvelopeTest { @Test void applicationCannotSetReservedHeader() { assertThatThrownBy(() -> MessageHeaders.application(Map.of( new HeaderName("msg.id"), new HeaderValue("forged")))) .isInstanceOf(IllegalArgumentException.class); } @Test void secretHeadersAreRejected() { assertThatThrownBy(() -> MessageHeaders.application(Map.of( new HeaderName("Authorization"), new HeaderValue("Bearer secret")))) .isInstanceOf(IllegalArgumentException.class); } @Test void payloadCannotBeNull() { assertThatThrownBy(() -> TestEnvelopeFactory.envelope(null)) .isInstanceOf(NullPointerException.class); } } ``` - [ ] **Step 2: Run test and confirm missing envelope policy** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test --tests '*MessageEnvelopeTest' ``` Expected: FAIL because header and envelope types do not exist. - [ ] **Step 3: Implement immutable header limits and envelope validation** ```java package io.backend.skeleton.messaging.api.header; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; public final class MessageHeaders { private static final int MAX_COUNT = 64; private static final int MAX_TOTAL_BYTES = 32_768; private static final Set SECRET_NAMES = Set.of( "authorization", "proxy-authorization", "cookie", "set-cookie", "access_token", "refresh_token", "api_key", "password", "client_secret"); private final Map values; private MessageHeaders(Map values) { this.values = Map.copyOf(values); } public static MessageHeaders application(Map input) { if (input.size() > MAX_COUNT) { throw new IllegalArgumentException("message header count exceeds 64"); } int bytes = 0; Map copy = new LinkedHashMap<>(); for (Map.Entry entry : input.entrySet()) { String normalized = entry.getKey().value().toLowerCase(); if (ReservedHeaders.isReserved(normalized) || SECRET_NAMES.contains(normalized)) { throw new IllegalArgumentException("message header is not allowed: " + normalized); } bytes += entry.getKey().value().getBytes(StandardCharsets.UTF_8).length; bytes += entry.getValue().value().getBytes(StandardCharsets.UTF_8).length; copy.put(entry.getKey(), entry.getValue()); } if (bytes > MAX_TOTAL_BYTES) { throw new IllegalArgumentException("message header bytes exceed 32768"); } return new MessageHeaders(copy); } public Map asMap() { return values; } } ``` Implement `MessageEnvelope` as the exact record from the design and validate all required values with `Objects.requireNonNull`. - [ ] **Step 4: Run focused and full core tests** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: PASS and all reserved/secret header tests succeed. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-core-api git commit -m "feat: add message envelope and header policy" ``` --- ### Task 4: 논리 Destination과 Capability 모델 구현 **Files:** - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationName.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationKind.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessageDestination.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/ConfirmationRequirement.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/MessagingCapabilities.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilities.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/destination/CapabilityRegistry.java` - Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/destination/DestinationCapabilityTest.java` **Interfaces:** - Consumes Task 2 identifiers and guarantees. - Produces logical destination and capability contracts used by policy and adapters. - [ ] **Step 1: Write failing capability tests** ```java package io.backend.skeleton.messaging.api.destination; import io.backend.skeleton.messaging.api.MessageType; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class DestinationCapabilityTest { @Test void logicalDestinationDoesNotContainBrokerSpecificAddress() { MessageDestination destination = new MessageDestination<>( new DestinationName("order-events"), new MessageType("order.created"), String.class); assertThat(destination.name().value()).isEqualTo("order-events"); } @Test void destinationNameRejectsBrokerSeparatorsAndWhitespace() { assertThatThrownBy(() -> new DestinationName("topic://orders")) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run focused test and observe missing types** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test --tests '*DestinationCapabilityTest' ``` Expected: FAIL because destination types do not exist. - [ ] **Step 3: Implement exact capability contracts** ```java package io.backend.skeleton.messaging.api.destination; public record MessagingCapabilities( boolean brokerAcknowledgement, boolean replicationOrPersistenceEvidence, boolean perMessageSettlement, boolean batchSettlement, boolean orderedStream, boolean keyedOrdering, boolean replay, boolean delayedDelivery, boolean brokerTransaction, boolean deduplicatedPublish, boolean nativeDeadLetter, boolean topologyManagement) { } ``` ```java package io.backend.skeleton.messaging.api.destination; public interface CapabilityRegistry { DestinationCapabilities capabilities(DestinationName destination); } ``` Implement `DestinationName` with `[a-z0-9][a-z0-9.-]{0,159}` validation. Implement `DestinationKind` with `ASYNC_COMMAND`, `DOMAIN_EVENT`, `INTEGRATION_EVENT`, `WORK_QUEUE`, `PUBLISH_SUBSCRIBE`, `EVENT_STREAM`, `REQUEST_REPLY`. Implement `MessageDestination` as an immutable record requiring name, message type, and payload type. - [ ] **Step 4: Run core tests** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-core-api git commit -m "feat: add logical destination capabilities" ``` --- ### Task 5: Schema API와 JSON Codec 구현 **Files:** - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodec.java` - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/EncodedMessage.java` - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaReference.java` - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibility.java` - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/MessageCodecRegistry.java` - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/RawBytesMessageCodec.java` - Create: `modules/messaging/messaging-schema-json/src/main/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodec.java` - Test: `modules/messaging/messaging-schema-json/src/test/java/io/backend/skeleton/messaging/schema/json/JacksonMessageCodecTest.java` **Interfaces:** - Consumes `MessageType`, `SchemaVersion`, `ContentType`. - Produces codec SPI and JSON Stable codec. - Rejects unknown message types, oversized encoded payloads, trailing data, and excessive nesting. - [ ] **Step 1: Write failing JSON round-trip and limit tests** ```java package io.backend.skeleton.messaging.schema.json; import io.backend.skeleton.messaging.api.MessageType; import io.backend.skeleton.messaging.api.SchemaVersion; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class JacksonMessageCodecTest { record OrderCreated(String orderId, long amount) {} @Test void roundTripsRegisteredType() { JacksonMessageCodec codec = JacksonMessageCodec.testingDefault( new MessageType("order.created"), OrderCreated.class); byte[] encoded = codec.encode( new MessageType("order.created"), new SchemaVersion(1), new OrderCreated("o-1", 1000)).bytes(); assertThat(codec.decode( new MessageType("order.created"), new SchemaVersion(1), encoded, OrderCreated.class)).isEqualTo(new OrderCreated("o-1", 1000)); } @Test void rejectsPayloadOverOneMibibyte() { JacksonMessageCodec codec = JacksonMessageCodec.testingDefault( new MessageType("text.large"), String.class); String value = "a".repeat(1_048_577); assertThatThrownBy(() -> codec.encode( new MessageType("text.large"), new SchemaVersion(1), value)) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run JSON module test and verify failure** Run: ```bash ./gradlew :modules:messaging:messaging-schema-json:test --tests '*JacksonMessageCodecTest' ``` Expected: FAIL because codec classes are missing. - [ ] **Step 3: Implement the schema SPI and Jackson codec** ```java package io.backend.skeleton.messaging.schema; import io.backend.skeleton.messaging.api.ContentType; import io.backend.skeleton.messaging.api.MessageType; import io.backend.skeleton.messaging.api.SchemaVersion; public interface MessageCodec { ContentType contentType(); EncodedMessage encode(MessageType type, SchemaVersion version, Object payload); T decode( MessageType type, SchemaVersion version, byte[] encoded, Class payloadType); } ``` Configure Jackson with a closed message-type registry, maximum nesting depth 100, trailing token rejection, unknown subtype rejection, and encoded byte limit 1,048,576 by default. Implement `RawBytesMessageCodec` as an explicit M2 codec that copies the input bytes, enforces the same byte limit, and is never selected as a default codec. - [ ] **Step 4: Run schema tests** Run: ```bash ./gradlew :modules:messaging:messaging-schema-api:test :modules:messaging:messaging-schema-json:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-json git commit -m "feat: add messaging schema and json codec" ``` --- ### Task 6: CloudEvents Event Profile 구현 **Files:** - Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventMapper.java` - Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/DefaultCloudEventMapper.java` - Create: `modules/messaging/messaging-cloudevents/src/main/java/io/backend/skeleton/messaging/cloudevents/CloudEventExtensions.java` - Test: `modules/messaging/messaging-cloudevents/src/test/java/io/backend/skeleton/messaging/cloudevents/CloudEventMappingTest.java` **Interfaces:** - Consumes `MessageEnvelope` and schema encoded payload. - Produces CloudEvents 1.0.2 compatible mapping for events only. - Does not map null data to a Kafka tombstone. - [ ] **Step 1: Write failing CloudEvents mapping test** ```java package io.backend.skeleton.messaging.cloudevents; import io.cloudevents.CloudEvent; import org.junit.jupiter.api.Test; import java.net.URI; import static org.assertj.core.api.Assertions.assertThat; class CloudEventMappingTest { @Test void mapsLogicalIdentityAndExtensions() { DefaultCloudEventMapper mapper = new DefaultCloudEventMapper(); CloudEvent event = mapper.toCloudEvent( CloudEventFixture.orderCreatedEnvelope(), URI.create("urn:service:order-api")); assertThat(event.getId()).isEqualTo( CloudEventFixture.orderCreatedEnvelope().messageId().value().toString()); assertThat(event.getType()).isEqualTo("order.created"); assertThat(event.getExtension("schemaversion")).isEqualTo("1"); } } ``` - [ ] **Step 2: Run focused test and verify missing mapper** Run: ```bash ./gradlew :modules:messaging:messaging-cloudevents:test --tests '*CloudEventMappingTest' ``` Expected: FAIL because mapping classes do not exist. - [ ] **Step 3: Implement exact mapping rules** ```java package io.backend.skeleton.messaging.cloudevents; import io.backend.skeleton.messaging.api.MessageEnvelope; import io.cloudevents.CloudEvent; import java.net.URI; public interface CloudEventMapper { CloudEvent toCloudEvent(MessageEnvelope envelope, URI source); MessageEnvelope fromCloudEvent(CloudEvent event); } ``` Map `messageId→id`, `producer/source→source`, `messageType→type`, `occurredAt→time`, `contentType→datacontenttype`, and the four documented extensions. Reject command envelopes without `occurredAt` when event mode is requested. - [ ] **Step 4: Run tests** Run: ```bash ./gradlew :modules:messaging:messaging-cloudevents:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-cloudevents git commit -m "feat: add cloudevents message profile" ``` --- ### Task 7: Publisher API, 결과 Evidence, 안정 예외 구현 **Files:** - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/MessagePublisher.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishOptions.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishResult.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishCompletion.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishEvidence.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/TransmissionEvidence.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/ConfirmationLevel.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/RoutingOutcome.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BrokerPosition.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureCategory.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/FailureDescriptor.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingConfigurationException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagingCapabilityUnavailableException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageValidationException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSerializationException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSchemaIncompatibleException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTooLargeException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHeaderRejectedException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishRejectedException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishAmbiguousException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessagePublishTimeoutException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRoutingException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthenticationException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageAuthorizationException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageConsumerException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageHandlerTimeoutException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageSettlementUnknownException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRetryExhaustedException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageDeadLetterException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageRedriveException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageTopologyException.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBrokerUnavailableException.java` - Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/publish/PublishResultTest.java` **Interfaces:** - Consumes destination and envelope contracts. - Produces the stable publish contract used by all adapters and Outbox. - `AMBIGUOUS` is a first-class completion and cannot be marked as retryable success. - [ ] **Step 1: Write failing publish result invariant tests** ```java package io.backend.skeleton.messaging.api.publish; import org.junit.jupiter.api.Test; import java.time.Duration; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; class PublishResultTest { @Test void confirmedResultRequiresBrokerAcceptance() { assertThatThrownBy(() -> new PublishResult( PublishCompletion.CONFIRMED, new PublishEvidence(false, TransmissionEvidence.TRANSMITTED, false, ConfirmationLevel.NONE), RoutingOutcome.UNKNOWN, Optional.empty(), 1, Duration.ofMillis(10), Optional.empty())) .isInstanceOf(IllegalArgumentException.class); } @Test void ambiguousResultCannotClaimReplicationConfirmation() { assertThatThrownBy(() -> new PublishResult( PublishCompletion.AMBIGUOUS, new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED, true, ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK), RoutingOutcome.UNKNOWN, Optional.empty(), 1, Duration.ofSeconds(5), Optional.empty())) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run focused test and confirm missing result model** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test --tests '*PublishResultTest' ``` Expected: FAIL because publish contracts are missing. - [ ] **Step 3: Implement publish API and invariants** ```java package io.backend.skeleton.messaging.api.publish; import io.backend.skeleton.messaging.api.MessageEnvelope; import io.backend.skeleton.messaging.api.destination.MessageDestination; import java.util.concurrent.CompletionStage; public interface MessagePublisher { CompletionStage publish( MessageDestination destination, MessageEnvelope message, PublishOptions options); } ``` Implement `PublishResult` constructor validation: ```java if (completion == PublishCompletion.CONFIRMED && !evidence.brokerAccepted()) { throw new IllegalArgumentException("confirmed publish requires broker acceptance"); } if (completion == PublishCompletion.AMBIGUOUS && evidence.confirmationLevel() != ConfirmationLevel.NONE) { throw new IllegalArgumentException("ambiguous publish cannot claim confirmation"); } ``` Add the full stable exception hierarchy from the design with sanitized metadata and no payload fields. - [ ] **Step 4: Run core tests** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-core-api git commit -m "feat: add publish evidence contract" ``` --- ### Task 8: Consumer Delivery, HandleResult, Settlement 계약 구현 **Files:** - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageHandler.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/MessageDelivery.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryMetadata.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/DeliveryContext.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/HandleResult.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementController.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementResult.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/SettlementCompletion.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/settlement/ManualMessageHandler.java` - Test: `modules/messaging/messaging-core-api/src/test/java/io/backend/skeleton/messaging/api/delivery/ConsumerContractTest.java` **Interfaces:** - Consumes envelope, destination, broker position. - Produces M1 handler result and M2 settlement contracts. - M1 exposes no broker ACK handle. - [ ] **Step 1: Write failing consumer contract tests** ```java package io.backend.skeleton.messaging.api.delivery; import io.backend.skeleton.messaging.api.error.FailureCategory; import io.backend.skeleton.messaging.api.error.FailureDescriptor; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class ConsumerContractTest { @Test void retryCarriesStableFailureCategory() { FailureDescriptor failure = new FailureDescriptor( FailureCategory.PROCESSING_TRANSIENT, "DOWNSTREAM_TIMEOUT", true, "downstream timed out", java.util.Optional.of("TimeoutException")); HandleResult result = new HandleResult.Retry(failure); assertThat(((HandleResult.Retry) result).failure().retryable()).isTrue(); } @Test void deliveryMetadataCountsInitialDeliveryAsAttemptOne() { DeliveryMetadata metadata = DeliveryMetadataFixture.initial(); assertThat(metadata.deliveryAttempt()).isEqualTo(1); } } ``` - [ ] **Step 2: Run test and verify missing delivery types** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test --tests '*ConsumerContractTest' ``` Expected: FAIL because delivery and settlement contracts are missing. - [ ] **Step 3: Implement exact interfaces and sealed results** ```java package io.backend.skeleton.messaging.api.delivery; import java.util.concurrent.CompletionStage; public interface MessageHandler { CompletionStage handle(MessageDelivery delivery); } ``` ```java package io.backend.skeleton.messaging.api.delivery; import io.backend.skeleton.messaging.api.error.FailureDescriptor; public sealed interface HandleResult permits HandleResult.Success, HandleResult.Retry, HandleResult.DeadLetter, HandleResult.Reject { record Success() implements HandleResult {} record Retry(FailureDescriptor failure) implements HandleResult {} record DeadLetter(FailureDescriptor failure) implements HandleResult {} record Reject(FailureDescriptor failure) implements HandleResult {} } ``` Implement `SettlementController` with `ack`, `retry`, `deadLetter`, `reject` returning `CompletionStage` and no native broker parameters. - [ ] **Step 4: Run core tests** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-core-api git commit -m "feat: add consumer delivery and settlement contracts" ``` --- ### Task 9: Destination Profile, Retry Policy, Startup Validator 구현 **Files:** - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfile.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PhysicalDestination.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SchemaPolicy.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ProducerPolicy.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/ConsumerPolicy.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryPolicy.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryMode.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/OrderingImpact.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterPolicy.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadPolicy.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DestinationProfileValidator.java` - Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DestinationProfileValidatorTest.java` **Interfaces:** - Consumes destination guarantees and capabilities from Tasks 2 and 4. - Produces immutable destination profiles used by adapters and starter configuration. - Enforces ordering, confirmation, payload, DLQ, retry cycle, production topology, and capability rules. - [ ] **Step 1: Write failing startup validation tests** ```java package io.backend.skeleton.messaging.policy; import io.backend.skeleton.messaging.api.delivery.OrderingScope; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class DestinationProfileValidatorTest { private final DestinationProfileValidator validator = new DestinationProfileValidator(); @Test void strictOrderingRejectsRetryDestination() { DestinationProfile profile = DestinationProfileFixtures.kafkaOrdered( new RetryPolicy( RetryMode.RETRY_DESTINATION, 5, java.time.Duration.ofSeconds(1), java.time.Duration.ofMinutes(1), 2.0, true, OrderingImpact.PRESERVE, java.util.Set.of(), java.util.Set.of())); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("ordering"); } @Test void payloadAboveHardLimitIsRejected() { DestinationProfile profile = DestinationProfileFixtures.withPayloadLimit(8_388_609); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("8388608"); } @Test void dlqCannotPointToItself() { DestinationProfile profile = DestinationProfileFixtures.selfReferencingDlq(); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("dead letter"); } } ``` - [ ] **Step 2: Run policy tests and verify missing profile model** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test --tests '*DestinationProfileValidatorTest' ``` Expected: FAIL because the profile and validator types are missing. - [ ] **Step 3: Implement profile records and exact validation rules** ```java package io.backend.skeleton.messaging.policy; import io.backend.skeleton.messaging.api.delivery.DeliveryGuarantee; import io.backend.skeleton.messaging.api.delivery.ExternalSideEffectGuarantee; import io.backend.skeleton.messaging.api.delivery.OrderingScope; import io.backend.skeleton.messaging.api.destination.DestinationKind; import io.backend.skeleton.messaging.api.destination.DestinationName; public record DestinationProfile( DestinationName name, String broker, DestinationKind kind, PhysicalDestination physical, SchemaPolicy schema, DeliveryGuarantee deliveryGuarantee, OrderingScope orderingScope, ExternalSideEffectGuarantee externalSideEffectGuarantee, ProducerPolicy producer, ConsumerPolicy consumer, RetryPolicy retry, DeadLetterPolicy deadLetter, PayloadPolicy payload, boolean production) { } ``` Implement these exact guards: ```java if (profile.retry().orderingImpact() == OrderingImpact.PRESERVE && profile.retry().mode() == RetryMode.RETRY_DESTINATION) { throw new IllegalArgumentException("retry destination cannot preserve ordering"); } if (profile.payload().maxBytes() > 8_388_608) { throw new IllegalArgumentException("payload maximum exceeds 8388608 bytes"); } if (profile.deadLetter().enabled() && profile.deadLetter().destination().equals(profile.name())) { throw new IllegalArgumentException("dead letter destination cannot reference itself"); } ``` Add graph validation for retry and DLQ cycles and require a key resolver when `OrderingScope.KEY` is configured. - [ ] **Step 4: Run policy tests** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-policy git commit -m "feat: add messaging destination policies" ``` --- ### Task 10: Transport SPI와 Immutable Runtime Registry 구현 **Files:** - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingTransport.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishRequest.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportPublishResult.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerSpec.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportConsumerRegistration.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/TransportSettlement.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntime.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeLease.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistry.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/DefaultMessagingRuntimeRegistry.java` - Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingRuntimeRegistryTest.java` **Interfaces:** - Consumes Core publish, delivery, schema, and destination policy contracts. - Produces the adapter SPI and atomic runtime generation replacement used for credential and topology rotation. - Does not return native client objects. - [ ] **Step 1: Write failing runtime generation tests** ```java package io.backend.skeleton.messaging.transport; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class MessagingRuntimeRegistryTest { @Test void replacingRuntimeReturnsNewGenerationAndKeepsOldUntilReleased() { DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); MessagingRuntime second = MessagingRuntimeFixtures.runtime("kafka-primary", 2); registry.install(first); MessagingRuntimeLease lease = registry.acquire("kafka-primary"); registry.install(second); assertThat(lease.runtime().generation()).isEqualTo(1); assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(2); lease.close(); assertThat(first.isClosed()).isTrue(); } } ``` - [ ] **Step 2: Run focused test and verify missing runtime SPI** Run: ```bash ./gradlew :modules:messaging:messaging-transport-spi:test --tests '*MessagingRuntimeRegistryTest' ``` Expected: FAIL because runtime registry types are missing. - [ ] **Step 3: Implement transport interfaces and reference-counted runtime generations** ```java package io.backend.skeleton.messaging.transport; import java.util.concurrent.CompletionStage; public interface MessagingTransport extends AutoCloseable { CompletionStage publish(TransportPublishRequest request); TransportConsumerRegistration register(TransportConsumerSpec spec); String brokerName(); long generation(); @Override void close(); } ``` ```java package io.backend.skeleton.messaging.transport; public interface MessagingRuntimeRegistry { void install(MessagingRuntime runtime); MessagingRuntimeLease acquire(String brokerName); } ``` Use an atomic map swap and reference count. Mark old runtimes draining after replacement and close them only after the last lease closes or the configured drain deadline expires. - [ ] **Step 4: Run transport SPI tests** Run: ```bash ./gradlew :modules:messaging:messaging-transport-spi:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-transport-spi git commit -m "feat: add messaging transport runtime spi" ``` --- ### Task 11: 공통 Security·Redaction·Observation Primitive 구현 **Files:** - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerCredentialProfile.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerSecurityProfile.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialProvider.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/DestinationAccessPolicy.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/MessageSecurityValidator.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingObservation.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTags.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingRedactor.java` - Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingRedactorTest.java` - Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/MessageSecurityValidatorTest.java` **Interfaces:** - Consumes envelope and destination identity. - Produces low-cardinality observation context, redaction, credential abstraction, and destination ACL validation. - Actual broker TLS/auth integration is implemented in Task 31. - [ ] **Step 1: Write failing redaction and forbidden-header tests** ```java package io.backend.skeleton.messaging.observation; import org.junit.jupiter.api.Test; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class MessagingRedactorTest { @Test void removesMessageIdentityAndSecretsFromDiagnosticMap() { MessagingRedactor redactor = new MessagingRedactor(); Map sanitized = redactor.sanitize(Map.of( "messageId", "0190f4aa-0000-7000-8000-000000000001", "Authorization", "Bearer secret", "destinationProfile", "order-events")); assertThat(sanitized) .containsEntry("destinationProfile", "order-events") .doesNotContainKeys("messageId", "Authorization"); } } ``` ```java package io.backend.skeleton.messaging.security; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class MessageSecurityValidatorTest { @Test void productionBrokerWithoutTlsIsRejected() { BrokerSecurityProfile profile = BrokerSecurityProfileFixtures.productionWithoutTls(); assertThatThrownBy(() -> new MessageSecurityValidator().validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("TLS"); } } ``` - [ ] **Step 2: Run both modules and verify missing primitives** Run: ```bash ./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test ``` Expected: FAIL because security and observation types are missing. - [ ] **Step 3: Implement closed credential profiles and low-cardinality tags** ```java package io.backend.skeleton.messaging.security; public sealed interface BrokerCredentialProfile permits BrokerCredentialProfile.SaslScram, BrokerCredentialProfile.OAuth2, BrokerCredentialProfile.MutualTls, BrokerCredentialProfile.UsernamePassword, BrokerCredentialProfile.Nkey { String credentialId(); record SaslScram(String credentialId) implements BrokerCredentialProfile {} record OAuth2(String credentialId) implements BrokerCredentialProfile {} record MutualTls(String credentialId) implements BrokerCredentialProfile {} record UsernamePassword(String credentialId) implements BrokerCredentialProfile {} record Nkey(String credentialId) implements BrokerCredentialProfile {} } ``` ```java package io.backend.skeleton.messaging.observation; public record MessagingTags( String broker, String destinationProfile, String operation, String outcome, String failureCategory, String retryStage) { } ``` Implement redaction using a fixed denylist for message ID, keys, credentials, payload, cookies, authorization, and exception messages. Validate production TLS and the separation of producer, consumer, and admin credential IDs. - [ ] **Step 4: Run security and observability tests** Run: ```bash ./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-observability:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-security modules/messaging/messaging-observability git commit -m "feat: add messaging security and observation primitives" ``` --- ### Task 12: Broker-neutral Contract Testkit 기반 구현 **Files:** - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterHarness.java` - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/MessagingAdapterContract.java` - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractMessage.java` - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/FaultController.java` - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/ContractAssertions.java` - Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryMessagingHarness.java` - Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/InMemoryHarnessContractTest.java` **Interfaces:** - Consumes Core API and transport SPI. - Produces the common adapter suite invoked by Kafka, Rabbit, Pulsar, and NATS tests. - Contract covers confirmation, ambiguity, redelivery, settlement, DLQ failure, identity preservation, payload/header limits, and shutdown. - [ ] **Step 1: Write failing in-memory harness contract** ```java package io.backend.skeleton.messaging.testkit; import org.junit.jupiter.api.Nested; class InMemoryHarnessContractTest { @Nested class Contract extends MessagingAdapterContract { @Override protected MessagingAdapterHarness harness() { return InMemoryMessagingHarness.create(); } } } ``` The abstract contract must contain concrete `@Test` methods named: ```text publishesAndConfirms returnsAmbiguousWhenConfirmIsLost redeliversWhenSettlementIsLost preservesMessageIdAcrossRetryAndDlq keepsSourceUnsettledWhenDlqPublishFails rejectsOversizedPayloadBeforeTransport stopsAcceptingNewWorkDuringShutdown ``` - [ ] **Step 2: Run testkit tests and verify missing abstract suite** Run: ```bash ./gradlew :modules:messaging:messaging-testkit:test ``` Expected: FAIL because the testkit contract is missing. - [ ] **Step 3: Implement the abstract contract and deterministic in-memory harness** ```java package io.backend.skeleton.messaging.testkit; import org.junit.jupiter.api.Test; public abstract class MessagingAdapterContract { protected abstract MessagingAdapterHarness harness(); @Test void publishesAndConfirms() { MessagingAdapterHarness harness = harness(); ContractAssertions.assertConfirmed( harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join()); } @Test void returnsAmbiguousWhenConfirmIsLost() { MessagingAdapterHarness harness = harness(); harness.faults().dropPublishConfirmation(); ContractAssertions.assertAmbiguous( harness.publish(ContractMessage.orderCreated()).toCompletableFuture().join()); } } ``` Implement the remaining named tests with deterministic latches. The in-memory harness exists only to validate the contract itself and is not a production adapter. - [ ] **Step 4: Run testkit tests** Run: ```bash ./gradlew :modules:messaging:messaging-testkit:test ``` Expected: PASS with every abstract contract method executed by the in-memory harness. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-testkit git commit -m "test: add messaging adapter contract suite" ``` --- ### Task 13: Retry Decision Engine 구현 **Files:** - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryContext.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecision.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/RetryDecisionEngine.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DefaultRetryDecisionEngine.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/BackoffCalculator.java` - Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/RetryDecisionEngineTest.java` **Interfaces:** - Consumes Task 9 policies, Task 8 failure descriptors, and Task 4 capabilities. - Produces `RetryInline`, `PauseAndRetry`, `PublishToRetryDestination`, `DeadLetter`, or `Reject` decisions. - [ ] **Step 1: Write failing retry decision tests** ```java package io.backend.skeleton.messaging.policy; import io.backend.skeleton.messaging.api.error.FailureCategory; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RetryDecisionEngineTest { private final DefaultRetryDecisionEngine engine = new DefaultRetryDecisionEngine( new BackoffCalculator()); @Test void deserializationFailureGoesDirectlyToParking() { RetryDecision decision = engine.decide( RetryContextFixtures.failure(FailureCategory.DESERIALIZATION, 1)); assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class); } @Test void transientOrderedKafkaFailureUsesPauseStrategy() { RetryDecision decision = engine.decide( RetryContextFixtures.orderedKafkaTransient(1)); assertThat(decision).isInstanceOf(RetryDecision.PauseAndRetry.class); } @Test void exhaustedAttemptGoesToDeadLetter() { RetryDecision decision = engine.decide( RetryContextFixtures.transientAtMaximumAttempt()); assertThat(decision).isInstanceOf(RetryDecision.DeadLetter.class); } } ``` - [ ] **Step 2: Run focused tests and verify missing engine** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test --tests '*RetryDecisionEngineTest' ``` Expected: FAIL because retry decision types are missing. - [ ] **Step 3: Implement deterministic decision and exponential jittered backoff** ```java package io.backend.skeleton.messaging.policy; public interface RetryDecisionEngine { RetryDecision decide(RetryContext context); } ``` Implement the decision order exactly: ```text non-retryable category → parking or reject attempt >= maxAttempts → dead letter ordering preserve + Kafka partition capability → pause and retry retry destination mode + reorder allowed → publish to retry destination inline or blocking mode → retry inline otherwise → dead letter ``` Backoff is `min(maxDelay, initialDelay * multiplier^(attempt-1))` and applies full jitter when enabled. - [ ] **Step 4: Run policy tests** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-policy git commit -m "feat: add messaging retry decision engine" ``` --- ### Task 14: 공통 DLQ·Parking Orchestrator 구현 **Files:** - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterMetadata.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterEnvelopeFactory.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestrator.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/DeadLetterResult.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/SourceSettlement.java` - Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/DeadLetterOrchestratorTest.java` **Interfaces:** - Consumes `MessagePublisher`, `MessageDelivery`, failure descriptor, and source settlement callback. - Produces the publish-confirm-before-source-settlement invariant used by every adapter. - [ ] **Step 1: Write failing DLQ sequencing tests** ```java package io.backend.skeleton.messaging.policy; import org.junit.jupiter.api.Test; import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; class DeadLetterOrchestratorTest { @Test void settlesSourceOnlyAfterDlqConfirmation() { AtomicBoolean sourceSettled = new AtomicBoolean(false); FakePublisher publisher = FakePublisher.confirming(); DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator(publisher); orchestrator.deadLetter( DeadLetterFixtures.delivery(), DeadLetterFixtures.failure(), () -> sourceSettled.set(true)).toCompletableFuture().join(); assertThat(publisher.confirmObservedBefore(sourceSettled)).isTrue(); } @Test void keepsSourceUnsettledWhenDlqPublishFails() { AtomicBoolean sourceSettled = new AtomicBoolean(false); DeadLetterOrchestrator orchestrator = new DeadLetterOrchestrator( FakePublisher.ambiguous()); DeadLetterResult result = orchestrator.deadLetter( DeadLetterFixtures.delivery(), DeadLetterFixtures.failure(), () -> sourceSettled.set(true)).toCompletableFuture().join(); assertThat(result.sourceSettled()).isFalse(); assertThat(sourceSettled).isFalse(); } } ``` - [ ] **Step 2: Run focused test and verify missing orchestrator** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test --tests '*DeadLetterOrchestratorTest' ``` Expected: FAIL because DLQ orchestration types are missing. - [ ] **Step 3: Implement confirmed publish before settlement** ```java package io.backend.skeleton.messaging.policy; import java.util.concurrent.CompletionStage; public interface SourceSettlement { CompletionStage settle(); } ``` `DeadLetterOrchestrator.deadLetter` must: ```text create dead-letter envelope with original messageId publish to configured DLQ if PublishCompletion.CONFIRMED then call source settlement if REJECTED or AMBIGUOUS then do not call source settlement return a result containing DLQ publish result and settlement state ``` Do not copy the full stack trace or secret headers into the dead-letter envelope. - [ ] **Step 4: Run policy tests** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-policy git commit -m "feat: add confirmed dead letter orchestration" ``` --- ### Task 15: Kafka Test Topology와 Stable Profile Guard 구현 **Files:** - Modify: `modules/messaging/messaging-kafka/build.gradle.kts` - Create: `infra/messaging/kafka/docker-compose.yml` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBrokerProfile.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidator.java` - Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProfileValidatorTest.java` - Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerFixture.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaContainerSmokeTest.java` **Interfaces:** - Consumes destination policy and security profile. - Produces validated Kafka client configuration and Testcontainers topology for later adapter tests. - Stable profile requires idempotence, `acks=all`, max in-flight at most five, manual consumer commit, and TLS/auth in production. - [ ] **Step 1: Write failing Kafka profile guard tests** ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class KafkaProfileValidatorTest { private final KafkaProfileValidator validator = new KafkaProfileValidator(); @Test void stableProducerRequiresIdempotenceAndAcksAll() { KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer( false, "1", 5); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("idempotence"); } @Test void stableProducerRejectsTooManyInFlightRequests() { KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withProducer( true, "all", 6); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("max.in.flight"); } @Test void consumerAutoCommitIsForbidden() { KafkaBrokerProfile profile = KafkaBrokerProfileFixtures.withAutoCommit(true); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("auto commit"); } } ``` - [ ] **Step 2: Run Kafka profile tests and verify failure** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' ``` Expected: FAIL because profile classes are missing. - [ ] **Step 3: Implement validation and the Kafka 4.3.x container fixture** ```java package io.backend.skeleton.messaging.kafka; public final class KafkaProfileValidator { public void validate(KafkaBrokerProfile profile) { if (profile.stable() && (!profile.enableIdempotence() || !"all".equals(profile.acks()))) { throw new IllegalArgumentException( "stable Kafka producer requires idempotence and acks=all"); } if (profile.maxInFlightRequestsPerConnection() > 5) { throw new IllegalArgumentException( "max.in.flight.requests.per.connection must be at most 5"); } if (profile.enableAutoCommit()) { throw new IllegalArgumentException("consumer auto commit is forbidden"); } } } ``` Create a KRaft-based Kafka 4.3.x Testcontainer fixture. Expose a factory for producer, consumer, topic creation, broker stop, and broker restart. Keep image tags centralized in the fixture. - [ ] **Step 4: Run Kafka profile and container smoke tests** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProfileValidatorTest' --tests '*KafkaContainerSmokeTest' ``` Expected: PASS and the broker reports a 4.3.x version. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka infra/messaging/kafka git commit -m "feat: add kafka stable profile guard" ``` --- ### Task 16: Kafka Producer Adapter와 Publish Evidence 구현 **Files:** - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaMessagingTransport.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishMapper.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapper.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPosition.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPublishFailureClassifier.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaProducerContractTest.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaHeaderMapperTest.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaPublishAmbiguityIT.java` **Interfaces:** - Consumes transport SPI, schema codec, policy profile, and Kafka profile. - Produces Stable Kafka publish implementation and `KafkaPosition(topic, partition, offset)`. - Maps serializer and authorization failures to `REJECTED`; post-send confirmation loss to `AMBIGUOUS`. - [ ] **Step 1: Write failing producer contract and header mapping tests** ```java package io.backend.skeleton.messaging.kafka; import io.backend.skeleton.messaging.testkit.MessagingAdapterContract; import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness; import org.junit.jupiter.api.Nested; class KafkaProducerContractTest { @Nested class Contract extends MessagingAdapterContract { @Override protected MessagingAdapterHarness harness() { return KafkaHarnessFixture.publisherHarness(); } } } ``` ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class KafkaHeaderMapperTest { @Test void writesLogicalIdentityAndSchemaHeaders() { var headers = new KafkaHeaderMapper().toKafkaHeaders( KafkaFixtures.orderCreatedEnvelope()); assertThat(new String(headers.lastHeader("msg.type").value())) .isEqualTo("order.created"); assertThat(new String(headers.lastHeader("msg.schema-version").value())) .isEqualTo("1"); } } ``` - [ ] **Step 2: Run Kafka producer tests and verify missing adapter** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaHeaderMapperTest' ``` Expected: FAIL because the transport and mapper are missing. - [ ] **Step 3: Implement publish mapping and failure classification** ```java package io.backend.skeleton.messaging.kafka; import io.backend.skeleton.messaging.api.publish.PublishCompletion; import io.backend.skeleton.messaging.api.publish.PublishResult; import io.backend.skeleton.messaging.transport.MessagingTransport; import io.backend.skeleton.messaging.transport.TransportPublishRequest; import java.util.concurrent.CompletionStage; public final class KafkaMessagingTransport implements MessagingTransport { private final KafkaTemplate template; private final KafkaPublishMapper mapper; private final KafkaPublishFailureClassifier failures; @Override public CompletionStage publish( TransportPublishRequest request) { ProducerRecord record = mapper.toRecord(request); return template.send(record).handle((result, error) -> { if (error != null) { return failures.classify(request, error); } return KafkaPublishMapper.confirmed(result.getRecordMetadata()); }); } } ``` The failure classifier must return `AMBIGUOUS` for delivery timeout or connection failure after the record entered the producer, and `REJECTED` for serialization, invalid topic, authentication, authorization, and producer fencing. - [ ] **Step 4: Run producer contract and ambiguity integration tests** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaProducerContractTest' --tests '*KafkaPublishAmbiguityIT' ``` Expected: PASS. The ambiguity test drops the confirmation path after the broker accepted the record and receives `PublishCompletion.AMBIGUOUS`. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka git commit -m "feat: add kafka publish evidence adapter" ``` --- ### Task 17: Kafka Consumer Group, Partition Coordinator, Contiguous Offset Commit 구현 **Files:** - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeliveryMapper.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionWorkCoordinator.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/PartitionOffsetTracker.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTracker.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementCommand.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSettlementQueue.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/ContiguousPartitionOffsetTrackerTest.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerSettlementIT.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRebalanceIT.java` **Interfaces:** - Consumes `MessageHandler`, retry decision, transport consumer SPI, and Kafka profile. - Produces a Stable traditional consumer-group implementation. - Handler workers never call Kafka consumer methods directly; the poll thread drains settlement commands. - [ ] **Step 1: Write failing contiguous offset and settlement-order tests** ```java package io.backend.skeleton.messaging.kafka; import org.apache.kafka.common.TopicPartition; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class ContiguousPartitionOffsetTrackerTest { @Test void commitsOnlyThroughHighestContiguousCompletedOffset() { TopicPartition partition = new TopicPartition("orders", 0); ContiguousPartitionOffsetTracker tracker = new ContiguousPartitionOffsetTracker(); tracker.delivered(partition, 10); tracker.delivered(partition, 11); tracker.delivered(partition, 12); tracker.completed(partition, 10); tracker.completed(partition, 12); assertThat(tracker.highestContiguousCompleted(partition)).hasValue(10); tracker.completed(partition, 11); assertThat(tracker.highestContiguousCompleted(partition)).hasValue(12); } } ``` ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class KafkaConsumerSettlementIT { @Test void doesNotCommitOffsetBeforeHandlerSuccess() { KafkaConsumerHarness harness = KafkaConsumerHarness.startPausedHandler(); harness.publish("order.created", "o-1"); harness.awaitHandlerStarted(); assertThat(harness.committedOffset()).isEmpty(); harness.completeHandlerSuccessfully(); assertThat(harness.awaitCommittedOffset()).hasValue(1L); } } ``` - [ ] **Step 2: Run focused tests and verify missing coordinator** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT' ``` Expected: FAIL because consumer coordinator classes are missing. - [ ] **Step 3: Implement partition pause, bounded workers, and poll-thread settlement** ```java package io.backend.skeleton.messaging.kafka; import org.apache.kafka.common.TopicPartition; import java.util.OptionalLong; public interface PartitionOffsetTracker { void delivered(TopicPartition partition, long offset); void completed(TopicPartition partition, long offset); OptionalLong highestContiguousCompleted(TopicPartition partition); } ``` Implement `PartitionWorkCoordinator` with one in-flight handler per partition for strict ordering. The poll thread must: ```text poll records record delivered offsets pause partitions with in-flight work dispatch handler to bounded worker drain settlement queue on every poll commit highest contiguous completed offset + 1 resume partition after terminal settlement ``` On rebalance revoke, stop dispatching new work, drain completed settlements within the revoke deadline, and leave unfinished offsets uncommitted for redelivery. - [ ] **Step 4: Run consumer and rebalance tests** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*ContiguousPartitionOffsetTrackerTest' --tests '*KafkaConsumerSettlementIT' --tests '*KafkaConsumerRebalanceIT' ``` Expected: PASS. No offset beyond a gap is committed and unfinished deliveries are redelivered after rebalance. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka git commit -m "feat: add kafka consumer settlement coordinator" ``` --- ### Task 18: Kafka Ordered Retry, Retry Topic, DLT 구현 **Files:** - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryExecutor.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaPartitionRetryScheduler.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicPublisher.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaDeadLetterPublisher.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaRetryMetadataMapper.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaOrderedRetryIT.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaRetryTopicIdentityIT.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaDltFailureIT.java` **Interfaces:** - Consumes Task 13 retry decisions and Task 14 DLQ orchestrator. - Produces `PAUSE_PARTITION` retry for order-preserving destinations and retry-topic flow for reorder-allowed destinations. - Preserves logical message ID and settles the source only after retry/DLT confirmation. - [ ] **Step 1: Write failing ordering and identity tests** ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class KafkaRetryTopicIdentityIT { @Test void retryTopicPreservesOriginalMessageIdAndIncrementsAttempt() { KafkaRetryHarness harness = KafkaRetryHarness.start(); var original = harness.publishFailingMessage(); var retried = harness.awaitRetryTopicRecord(); assertThat(retried.messageId()).isEqualTo(original.messageId()); assertThat(retried.deliveryAttempt()).isEqualTo(2); } } ``` ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class KafkaDltFailureIT { @Test void sourceOffsetRemainsUncommittedWhenDltPublishCannotBeConfirmed() { KafkaRetryHarness harness = KafkaRetryHarness.withUnavailableDlt(); harness.publishPermanentlyFailingMessage(); assertThat(harness.awaitSourceOffsetCommit()).isEmpty(); assertThat(harness.sourcePartitionPaused()).isTrue(); } } ``` - [ ] **Step 2: Run tests and verify missing retry implementation** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT' ``` Expected: FAIL because Kafka retry executors are missing. - [ ] **Step 3: Implement the two retry paths and confirmed DLT flow** `KafkaRetryExecutor` dispatches by decision: ```java return switch (decision) { case RetryDecision.PauseAndRetry retry -> partitionRetryScheduler.schedule(delivery, retry.delay()); case RetryDecision.PublishToRetryDestination retry -> retryTopicPublisher.publish(delivery, retry.destination(), retry.delay()); case RetryDecision.DeadLetter deadLetter -> deadLetterPublisher.publish(delivery, deadLetter.failure()); case RetryDecision.Reject reject -> settlement.reject(reject.failure()); case RetryDecision.RetryInline inline -> partitionRetryScheduler.schedule(delivery, inline.delay()); }; ``` For retry-topic and DLT paths, wait for `PublishCompletion.CONFIRMED` before placing a source commit command. On `REJECTED` or `AMBIGUOUS`, leave the source uncommitted and pause the partition. - [ ] **Step 4: Run retry integration suite** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaOrderedRetryIT' --tests '*KafkaRetryTopicIdentityIT' --tests '*KafkaDltFailureIT' ``` Expected: PASS. Ordered retry never processes a later record first; retry-topic mode explicitly allows reorder and preserves identity. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka git commit -m "feat: add kafka retry and dead letter flows" ``` --- ### Task 19: Kafka Native Transaction Capability 구현 **Files:** - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalProcessor.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalDelivery.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionalPublisher.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/SpringKafkaTransactionalProcessor.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTransactionProfileValidator.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionIT.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTransactionFencingIT.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReadCommittedIT.java` **Interfaces:** - Consumes Kafka adapter and M3 capability injection. - Produces Kafka-only read-process-write transaction support. - Explicitly excludes external DB and HTTP side effects from the guarantee. - [ ] **Step 1: Write failing commit, abort, and read-committed tests** ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class KafkaTransactionIT { @Test void commitsOutputAndInputOffsetTogether() { KafkaTransactionHarness harness = KafkaTransactionHarness.start(); harness.publishInput("i-1"); harness.processSuccessfully(); assertThat(harness.readCommittedOutput()).containsExactly("i-1-processed"); assertThat(harness.inputOffsetCommitted()).isTrue(); } @Test void abortHidesOutputAndLeavesInputOffsetUncommitted() { KafkaTransactionHarness harness = KafkaTransactionHarness.start(); harness.publishInput("i-2"); harness.processWithFailure(); assertThat(harness.readCommittedOutput()).isEmpty(); assertThat(harness.inputOffsetCommitted()).isFalse(); } } ``` - [ ] **Step 2: Run transaction tests and verify missing native capability** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT' ``` Expected: FAIL because transactional interfaces are missing. - [ ] **Step 3: Implement M3 transaction boundary and profile guard** ```java package io.backend.skeleton.messaging.kafka; import java.util.concurrent.CompletionStage; public interface KafkaTransactionalProcessor { CompletionStage process( KafkaTransactionalDelivery delivery, KafkaTransactionalPublisher publisher); } ``` Use Spring Kafka transaction management so consumed offsets and produced Kafka records commit or abort together. Require a unique transactional ID prefix per instance. Reject registration when the destination profile declares `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL`; that path belongs to the Inbox recipe, not Kafka transaction. - [ ] **Step 4: Run transaction, abort, fencing, and `read_committed` tests** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaTransactionIT' --tests '*KafkaTransactionFencingIT' --tests '*KafkaReadCommittedIT' ``` Expected: PASS. A fenced producer fails permanently and aborted output is invisible to `read_committed` consumers. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka git commit -m "feat: add kafka transactional capability" ``` --- ### Task 20: Kafka Replay, Seek, Topology Validation Admin Capability 구현 **Files:** - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayCapability.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlanner.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaTopologyInspector.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaOffsetResetExecutor.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaReplayPlannerTest.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTopologyValidationIT.java` **Interfaces:** - Consumes Admin API contracts implemented later through an internal adapter interface. - Produces read-only replay planning, isolated replay consumer groups, topology inspection, and guarded offset reset. - Existing production group reset is never performed without an approved M4 request. - [ ] **Step 1: Write failing replay plan safety tests** ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class KafkaReplayPlannerTest { @Test void createsIsolatedConsumerGroupByDefault() { KafkaReplayPlan plan = new KafkaReplayPlanner().plan( KafkaReplayRequest.byTimestamp( "order.events.v1", Instant.parse("2026-08-01T00:00:00Z"))); assertThat(plan.consumerGroup()).startsWith("replay-"); assertThat(plan.mutatesProductionOffsets()).isFalse(); } @Test void productionOffsetResetRequiresApproval() { assertThatThrownBy(() -> new KafkaReplayPlanner().plan( KafkaReplayRequest.productionResetWithoutApproval("order-projection"))) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run replay tests and verify missing planner** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' ``` Expected: FAIL because replay classes are missing. - [ ] **Step 3: Implement isolated replay and topology inspection** `KafkaReplayPlanner` supports: ```text seek by absolute offset seek by timestamp replay to isolated consumer group replay to a new destination approved production offset reset ``` `KafkaTopologyInspector` returns partitions, replication factor, minimum ISR, retention, cleanup policy, and topic configuration drift. It performs no mutation. - [ ] **Step 4: Run replay and topology tests** Run: ```bash ./gradlew :modules:messaging:messaging-kafka:test --tests '*KafkaReplayPlannerTest' --tests '*KafkaTopologyValidationIT' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka git commit -m "feat: add kafka replay and topology capability" ``` --- ### Task 21: Kafka Share Group Experimental Adapter 구현 **Files:** - Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareWorkQueueCapability.java` - Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareGroupRegistrar.java` - Create: `modules/messaging/messaging-kafka-share-experimental/src/main/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidator.java` - Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareProfileValidatorTest.java` - Test: `modules/messaging/messaging-kafka-share-experimental/src/test/java/io/backend/skeleton/messaging/kafka/share/KafkaShareDeliveryIT.java` **Interfaces:** - Consumes M1 handler and Kafka experimental client capability. - Produces work-queue style record settlement only. - Rejects ordered-stream profiles and remains disabled unless `backend.messaging.experimental.kafka-share=true`. - [ ] **Step 1: Write failing ordering guard test** ```java package io.backend.skeleton.messaging.kafka.share; import io.backend.skeleton.messaging.api.delivery.OrderingScope; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class KafkaShareProfileValidatorTest { @Test void rejectsOrderedStreamUse() { KafkaShareProfile profile = KafkaShareProfileFixtures.profile(OrderingScope.KEY); assertThatThrownBy(() -> new KafkaShareProfileValidator().validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("ordered stream"); } } ``` - [ ] **Step 2: Run experimental module tests and verify failure** Run: ```bash ./gradlew :modules:messaging:messaging-kafka-share-experimental:test ``` Expected: FAIL because the experimental adapter is missing. - [ ] **Step 3: Implement record-level work queue registration and explicit acknowledgement mapping** ```java package io.backend.skeleton.messaging.kafka.share; public interface KafkaShareWorkQueueCapability { AutoCloseable register( MessageDestination destination, MessageHandler handler, KafkaShareOptions options); } ``` Allow only `DestinationKind.WORK_QUEUE` and `OrderingScope.NONE`. Map share delivery attempt to `DeliveryMetadata.deliveryAttempt` and preserve `messageId` through redelivery. - [ ] **Step 4: Run validator and Kafka 4.2+ Share Group integration tests** Run: ```bash ./gradlew :modules:messaging:messaging-kafka-share-experimental:test ``` Expected: PASS. The module does not participate in the default starter classpath. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-kafka-share-experimental git commit -m "feat: add experimental kafka share groups" ``` --- ### Task 22: RabbitMQ Test Topology와 Stable Profile Guard 구현 **Files:** - Modify: `modules/messaging/messaging-rabbit/build.gradle.kts` - Create: `infra/messaging/rabbitmq/docker-compose.yml` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBrokerProfile.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitTopologyProfile.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidator.java` - Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerFixture.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitContainerSmokeTest.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProfileValidatorTest.java` **Interfaces:** - Consumes destination and security policies. - Produces Rabbit 4.3.x connection/topology configuration and Testcontainers fixture. - Stable durable work queue requires correlated confirm, returns, mandatory publish, manual ACK, and quorum queue. - [ ] **Step 1: Write failing Rabbit profile guard tests** ```java package io.backend.skeleton.messaging.rabbit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class RabbitProfileValidatorTest { private final RabbitProfileValidator validator = new RabbitProfileValidator(); @Test void stablePublisherRequiresCorrelatedConfirmAndReturns() { RabbitBrokerProfile profile = RabbitProfileFixtures.withPublisher(false, false, false); assertThatThrownBy(() -> validator.validate(profile)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("confirm"); } @Test void durableWorkQueueRequiresQuorumQueue() { RabbitTopologyProfile topology = RabbitProfileFixtures.classicDurableWorkQueue(); assertThatThrownBy(() -> validator.validate(topology)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("quorum"); } @Test void autoAckIsForbiddenForAtLeastOnce() { RabbitTopologyProfile topology = RabbitProfileFixtures.autoAckTopology(); assertThatThrownBy(() -> validator.validate(topology)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("manual acknowledgement"); } } ``` - [ ] **Step 2: Run Rabbit profile tests and verify missing model** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' ``` Expected: FAIL because Rabbit profile classes are missing. - [ ] **Step 3: Implement exact Stable profile rules and Rabbit 4.3.x fixture** ```java package io.backend.skeleton.messaging.rabbit; public final class RabbitProfileValidator { public void validate(RabbitBrokerProfile profile) { if (profile.stable() && (!profile.correlatedConfirms() || !profile.publisherReturns() || !profile.mandatory())) { throw new IllegalArgumentException( "stable Rabbit publisher requires correlated confirm, returns, and mandatory publish"); } } public void validate(RabbitTopologyProfile topology) { if (topology.durableWorkQueue() && topology.queueType() != RabbitQueueType.QUORUM) { throw new IllegalArgumentException("durable work queue requires quorum queue"); } if (topology.atLeastOnce() && topology.autoAck()) { throw new IllegalArgumentException("at-least-once requires manual acknowledgement"); } } } ``` Create a RabbitMQ 4.3.x container fixture with management API support and helpers for quorum queue creation, node pause/restart, connection blocking, and queue depth inspection. - [ ] **Step 4: Run profile and container smoke tests** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProfileValidatorTest' --tests '*RabbitContainerSmokeTest' ``` Expected: PASS and the server reports a 4.3.x version. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-rabbit infra/messaging/rabbitmq git commit -m "feat: add rabbit stable profile guard" ``` --- ### Task 23: Rabbit Publisher Confirm·Return Evidence Adapter 구현 **Files:** - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishTracker.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishOutcome.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitHeaderMapper.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishFailureClassifier.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitPublishReference.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitProducerContractTest.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitUnroutableIT.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConfirmLossIT.java` **Interfaces:** - Consumes transport SPI, schema codec, and Rabbit profile. - Produces publish result by joining publisher confirm and mandatory return. - Confirm ACK with unroutable return is `REJECTED`, never confirmed success. - [ ] **Step 1: Write failing producer contract and unroutable tests** ```java package io.backend.skeleton.messaging.rabbit; import io.backend.skeleton.messaging.testkit.MessagingAdapterContract; import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness; import org.junit.jupiter.api.Nested; class RabbitProducerContractTest { @Nested class Contract extends MessagingAdapterContract { @Override protected MessagingAdapterHarness harness() { return RabbitHarnessFixture.publisherHarness(); } } } ``` ```java package io.backend.skeleton.messaging.rabbit; import io.backend.skeleton.messaging.api.publish.PublishCompletion; import io.backend.skeleton.messaging.api.publish.RoutingOutcome; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RabbitUnroutableIT { @Test void confirmedButUnroutableMessageIsRejected() { var result = RabbitHarnessFixture.publishToUnboundRoutingKey(); assertThat(result.completion()).isEqualTo(PublishCompletion.REJECTED); assertThat(result.routingOutcome()).isEqualTo(RoutingOutcome.UNROUTABLE); } } ``` - [ ] **Step 2: Run producer tests and verify missing tracker** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' ``` Expected: FAIL because Rabbit transport and confirm tracker are missing. - [ ] **Step 3: Implement correlation of confirm and return** ```java package io.backend.skeleton.messaging.rabbit; public record RabbitPublishOutcome( RabbitConfirmOutcome confirm, RoutingOutcome routing, java.util.Optional replyCode, java.util.Optional replyText) { } ``` `RabbitPublishTracker` must create one pending state per publish sequence, accept a return callback before confirm, and complete only when the result is determinable: ```text return received + confirm ACK → REJECTED/UNROUTABLE confirm ACK + no return by callback ordering boundary → CONFIRMED/ROUTED confirm NACK → REJECTED channel close or timeout before terminal evidence → AMBIGUOUS ``` Use sanitized reply metadata and never include body content in exceptions. - [ ] **Step 4: Run confirm, return, and connection-loss tests** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitProducerContractTest' --tests '*RabbitUnroutableIT' --tests '*RabbitConfirmLossIT' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-rabbit git commit -m "feat: add rabbit publish confirm evidence" ``` --- ### Task 24: Rabbit Consumer Manual ACK, Prefetch, Async Settlement 구현 **Files:** - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryMapper.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeliveryCoordinator.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementQueue.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSettlementCommand.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerSettlementIT.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitPrefetchIT.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitAckLossIT.java` **Interfaces:** - Consumes M1 handler, retry engine, and Rabbit container factory. - Produces manual-ack consumer flow with bounded concurrency and prefetch. - Channel and delivery tag are never exposed to application handlers. - [ ] **Step 1: Write failing settlement and prefetch tests** ```java package io.backend.skeleton.messaging.rabbit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RabbitConsumerSettlementIT { @Test void messageRemainsUnackedUntilHandlerSuccess() { RabbitConsumerHarness harness = RabbitConsumerHarness.startPausedHandler(); harness.publish("work-1"); harness.awaitHandlerStarted(); assertThat(harness.unackedCount()).isEqualTo(1); assertThat(harness.readyCount()).isZero(); harness.completeHandlerSuccessfully(); assertThat(harness.awaitUnackedCount()).isZero(); } } ``` ```java package io.backend.skeleton.messaging.rabbit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RabbitPrefetchIT { @Test void unackedDeliveriesNeverExceedConfiguredPrefetch() { RabbitConsumerHarness harness = RabbitConsumerHarness.withPrefetch(4); harness.publishRange(10); harness.blockAllHandlers(); assertThat(harness.awaitUnackedCount()).isLessThanOrEqualTo(4); } } ``` - [ ] **Step 2: Run consumer tests and verify missing coordinator** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' ``` Expected: FAIL because the consumer implementation is missing. - [ ] **Step 3: Implement manual ACK after handler completion and channel-safe settlement queue** `RabbitDeliveryCoordinator` must: ```text receive delivery on listener container thread map envelope and delivery metadata dispatch handler to bounded executor place terminal settlement command in a channel-bound queue execute ACK/NACK/reject on the owning consumer channel context ``` If the channel closes after handler success but before ACK confirmation, return `SettlementCompletion.UNKNOWN` and allow broker redelivery. The handler is never reinvoked locally as a substitute for broker redelivery. - [ ] **Step 4: Run settlement, prefetch, and ACK-loss tests** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitConsumerSettlementIT' --tests '*RabbitPrefetchIT' --tests '*RabbitAckLossIT' ``` Expected: PASS. ACK loss causes redelivery with `redelivered=true`. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-rabbit git commit -m "feat: add rabbit consumer settlement flow" ``` --- ### Task 25: Rabbit Retry Queue, Confirmed DLQ, Request–Reply M2 구현 **Files:** - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryExecutor.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRetryTopology.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitDeadLetterPublisher.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitNativeDeadLetterCapability.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyClient.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRetryIdentityIT.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitDlxTargetOutageIT.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitRequestReplyTimeoutIT.java` **Interfaces:** - Consumes Task 13 retry decisions and Task 14 DLQ orchestration. - Produces retry queue with bounded attempt metadata, platform-managed confirmed DLQ, optional native quorum at-least-once DLX, and limited request–reply. - [ ] **Step 1: Write failing retry identity and DLQ outage tests** ```java package io.backend.skeleton.messaging.rabbit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RabbitRetryIdentityIT { @Test void retryQueuePreservesLogicalMessageIdentity() { RabbitRetryHarness harness = RabbitRetryHarness.start(); var first = harness.publishFailingWork(); var retry = harness.awaitRetryDelivery(); assertThat(retry.messageId()).isEqualTo(first.messageId()); assertThat(retry.deliveryAttempt()).isEqualTo(2); } } ``` ```java package io.backend.skeleton.messaging.rabbit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RabbitDlxTargetOutageIT { @Test void sourceMessageIsNotLostWhenDeadLetterTargetIsUnavailable() { RabbitRetryHarness harness = RabbitRetryHarness.withUnavailableDlqTarget(); harness.publishPermanentFailure(); assertThat(harness.sourceUnackedOrReadyCount()).isEqualTo(1); assertThat(harness.deadLetterCount()).isZero(); } } ``` - [ ] **Step 2: Run retry and request–reply tests and verify failure** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT' ``` Expected: FAIL because retry and request–reply types are missing. - [ ] **Step 3: Implement bounded retry routing and request–reply lifecycle** Retry queue rules: ```text increment platform delivery attempt preserve original messageId encode retry delay in topology policy, not arbitrary message expiration cap cycle using platform attempt plus x-death inspection confirm retry publish before source ACK ``` Native quorum at-least-once dead-letter capability must validate `dead-letter-strategy=at-least-once`, target availability policy, and overflow compatibility before activation. `RabbitRequestReplyClient` requires correlation ID and finite timeout, removes late replies, and never reuses request–reply as the default RPC mechanism. - [ ] **Step 4: Run Rabbit retry, DLQ, and request–reply tests** Run: ```bash ./gradlew :modules:messaging:messaging-rabbit:test --tests '*RabbitRetryIdentityIT' --tests '*RabbitDlxTargetOutageIT' --tests '*RabbitRequestReplyTimeoutIT' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-rabbit git commit -m "feat: add rabbit retry dlq and request reply" ``` --- ### Task 26: Reliability API와 Outbox·Inbox Flyway Schema 구현 **Files:** - Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/ReliableMessagePublisher.java` - Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/IdempotentMessageHandler.java` - Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/TransactionalMessageAction.java` - Create: `modules/messaging/messaging-reliability-api/src/main/java/io/backend/skeleton/messaging/reliability/InboxResult.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/db/migration/messaging-outbox/V1__create_messaging_outbox.sql` - Create: `modules/messaging/messaging-inbox-jpa/src/main/resources/db/migration/messaging-inbox/V1__create_messaging_inbox.sql` - Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxSchemaIT.java` - Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxSchemaIT.java` **Interfaces:** - Consumes Core destination and envelope types. - Produces broker-neutral reliability APIs and the exact PostgreSQL schema from the design. - Uses PostgreSQL 16 Testcontainers and Flyway migration locations scoped to each module. - [ ] **Step 1: Write failing schema tests** ```java package io.backend.skeleton.messaging.outbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class OutboxSchemaIT extends PostgreSqlReliabilityFixture { @Test void createsOutboxWithUniqueLogicalMessageIdAndPollIndex() { migrate("classpath:db/migration/messaging-outbox"); assertThat(columnNames("messaging_outbox")) .contains("message_id", "destination", "payload", "status", "lease_owner", "lease_until", "version"); assertThat(uniqueConstraintColumns("messaging_outbox")) .containsExactlyInAnyOrder("message_id"); assertThat(indexNames("messaging_outbox")) .contains("ix_messaging_outbox_poll", "ix_messaging_outbox_lease"); } } ``` ```java package io.backend.skeleton.messaging.inbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class InboxSchemaIT extends PostgreSqlReliabilityFixture { @Test void usesConsumerNameAndMessageIdAsPrimaryKey() { migrate("classpath:db/migration/messaging-inbox"); assertThat(primaryKeyColumns("messaging_inbox")) .containsExactly("consumer_name", "message_id"); } } ``` - [ ] **Step 2: Run reliability schema tests and verify missing migrations** Run: ```bash ./gradlew :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test ``` Expected: FAIL because migration files and reliability APIs are missing. - [ ] **Step 3: Implement exact APIs and SQL migrations** ```java package io.backend.skeleton.messaging.reliability; public interface ReliableMessagePublisher { void addToOutbox( MessageDestination destination, MessageEnvelope message); } ``` Create `messaging_outbox` and `messaging_inbox` exactly as defined in the design. Use `bytea` payload, `jsonb` headers, UUID message IDs, `timestamptz`, poll/lease/expiry indexes, and no broker-specific columns. - [ ] **Step 4: Run schema and API tests** Run: ```bash ./gradlew :modules:messaging:messaging-reliability-api:test :modules:messaging:messaging-outbox-jpa:test :modules:messaging:messaging-inbox-jpa:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-reliability-api modules/messaging/messaging-outbox-jpa modules/messaging/messaging-inbox-jpa git commit -m "feat: add messaging reliability schema" ``` --- ### Task 27: Transactional Outbox Repository와 Ambiguous-safe Relay 구현 **Files:** - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRecord.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxStatus.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRepository.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepository.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRelay.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxRetryScheduler.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxCleanupJob.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/OutboxProperties.java` - Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/JdbcOutboxRepositoryIT.java` - Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxRelayCrashIT.java` - Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/OutboxAmbiguousPublishIT.java` **Interfaces:** - Consumes `MessagePublisher`, `MessageCodecRegistry`, and reliability API. - Produces `SELECT FOR UPDATE SKIP LOCKED` leasing, same-message-ID retry, and PENDING/CLAIMED/PUBLISHED/RETRYABLE_FAILURE/PARKED transitions. - [ ] **Step 1: Write failing lease and ambiguity tests** ```java package io.backend.skeleton.messaging.outbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class JdbcOutboxRepositoryIT extends OutboxPostgresFixture { @Test void concurrentRelaysClaimDisjointRows() throws Exception { insertPendingMessages(200); var first = java.util.concurrent.CompletableFuture.supplyAsync( () -> repository("relay-a").claimBatch(100)); var second = java.util.concurrent.CompletableFuture.supplyAsync( () -> repository("relay-b").claimBatch(100)); var firstIds = first.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet()); var secondIds = second.get().stream().map(OutboxRecord::id).collect(java.util.stream.Collectors.toSet()); assertThat(firstIds).doesNotContainAnyElementsOf(secondIds); assertThat(firstIds).hasSize(100); assertThat(secondIds).hasSize(100); } } ``` ```java package io.backend.skeleton.messaging.outbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class OutboxAmbiguousPublishIT extends OutboxPostgresFixture { @Test void retriesAmbiguousPublishWithSameLogicalMessageId() { var row = insertPendingMessage(); FakeMessagePublisher publisher = FakeMessagePublisher.ambiguousThenConfirmed(); OutboxRelay relay = relay(publisher); relay.runOnce(); relay.runOnce(); assertThat(publisher.publishedMessageIds()) .containsExactly(row.messageId(), row.messageId()); assertThat(find(row.id()).status()).isEqualTo(OutboxStatus.PUBLISHED); } } ``` - [ ] **Step 2: Run focused tests and verify missing repository** Run: ```bash ./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*JdbcOutboxRepositoryIT' --tests '*OutboxAmbiguousPublishIT' ``` Expected: FAIL because repository and relay classes are missing. - [ ] **Step 3: Implement lease query, state transitions, and relay** The claim query is exact: ```sql select id from messaging_outbox where status in ('PENDING', 'RETRYABLE_FAILURE') and available_at <= now() and coalesce(next_attempt_at, available_at) <= now() and (lease_until is null or lease_until < now()) order by created_at for update skip locked limit :batch_size ``` Within the claim transaction, set `status='CLAIMED'`, increment attempts, set lease owner and lease until. Relay mapping: ```text CONFIRMED → PUBLISHED, published_at, clear lease REJECTED retryable → RETRYABLE_FAILURE, next_attempt_at REJECTED permanent → PARKED AMBIGUOUS → RETRYABLE_FAILURE with same messageId ``` Default batch is 100, lease 30 seconds, poll interval 500 ms. A shutdown releases leases owned by the current instance. `OutboxCleanupJob` deletes or archives only `PUBLISHED` rows older than the configured audit retention and never removes active or parked rows. - [ ] **Step 4: Run repository, crash, lease-expiry, and ambiguity tests** Run: ```bash ./gradlew :modules:messaging:messaging-outbox-jpa:test ``` Expected: PASS. A process stop after DB commit and before publish resumes later; a stop after broker acceptance and before confirm can duplicate publish but keeps the same message ID. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-outbox-jpa git commit -m "feat: add transactional outbox relay" ``` --- ### Task 28: Inbox Transactional Idempotent Consumer 구현 **Files:** - Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRepository.java` - Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/JdbcInboxRepository.java` - Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandler.java` - Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxRetentionPolicy.java` - Create: `modules/messaging/messaging-inbox-jpa/src/main/java/io/backend/skeleton/messaging/inbox/InboxCleanupJob.java` - Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/TransactionalInboxHandlerIT.java` - Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxConcurrentDuplicateIT.java` - Test: `modules/messaging/messaging-inbox-jpa/src/test/java/io/backend/skeleton/messaging/inbox/InboxRollbackIT.java` **Interfaces:** - Consumes `IdempotentMessageHandler`, `MessageDelivery`, and Spring transaction management. - Produces exactly one DB mutation for duplicate deliveries when the mutation and Inbox insert share the same transaction. - [ ] **Step 1: Write failing duplicate and rollback tests** ```java package io.backend.skeleton.messaging.inbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class TransactionalInboxHandlerIT extends InboxPostgresFixture { @Test void duplicateDeliveryRunsBusinessMutationOnce() { var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000001"); TransactionalInboxHandler handler = handlerThatIncrementsBusinessCounter(); handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join(); handler.handleOnce("order-projection", delivery, businessAction()).toCompletableFuture().join(); assertThat(businessCounter()).isEqualTo(1); assertThat(inboxRowCount()).isEqualTo(1); } } ``` ```java package io.backend.skeleton.messaging.inbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class InboxRollbackIT extends InboxPostgresFixture { @Test void businessFailureRollsBackInboxInsert() { var delivery = InboxFixtures.delivery("0190f4aa-0000-7000-8000-000000000002"); assertThatThrownBy(() -> handler().handleOnce( "order-projection", delivery, failingAction()).toCompletableFuture().join()) .hasCauseInstanceOf(IllegalStateException.class); assertThat(inboxRowCount()).isZero(); assertThat(businessCounter()).isZero(); } } ``` - [ ] **Step 2: Run Inbox tests and verify missing implementation** Run: ```bash ./gradlew :modules:messaging:messaging-inbox-jpa:test --tests '*TransactionalInboxHandlerIT' --tests '*InboxRollbackIT' ``` Expected: FAIL because Inbox repository and handler are missing. - [ ] **Step 3: Implement insert-first transactional deduplication** ```java package io.backend.skeleton.messaging.inbox; public interface InboxRepository { boolean insertIfAbsent( String consumerName, MessageDelivery delivery, java.time.Instant expiresAt); } ``` `TransactionalInboxHandler.handleOnce` runs inside one DB transaction: ```text insert Inbox row with ON CONFLICT DO NOTHING if inserted=false return InboxResult.DUPLICATE execute business action commit Inbox row and business mutation together return InboxResult.PROCESSED ``` Calculate `expiresAt` from broker retention + DLQ retention + maximum replay/redrive horizon + configured safety margin. Reject a configured Inbox retention shorter than that horizon. - [ ] **Step 4: Run duplicate, concurrent, rollback, and cleanup tests** Run: ```bash ./gradlew :modules:messaging:messaging-inbox-jpa:test ``` Expected: PASS. Two concurrent deliveries produce one business mutation, and a failed business transaction leaves no Inbox row. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-inbox-jpa git commit -m "feat: add transactional inbox consumer" ``` --- ### Task 29: Claim Check API와 Integrity·Retention Guard 구현 **Files:** - Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckReference.java` - Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckStore.java` - Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPublisher.java` - Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolver.java` - Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckPolicy.java` - Create: `modules/messaging/messaging-claim-check/src/main/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckIntegrityException.java` - Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckResolverTest.java` - Test: `modules/messaging/messaging-claim-check/src/test/java/io/backend/skeleton/messaging/claimcheck/ClaimCheckRetentionValidatorTest.java` **Interfaces:** - Consumes the future objectstorage/fileserver port through `ClaimCheckStore` without importing provider types. - Produces large-payload upload, reference envelope, checksum validation, and retention validation. - Never places a public signed URL in the message. - [ ] **Step 1: Write failing integrity and URL-rejection tests** ```java package io.backend.skeleton.messaging.claimcheck; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.assertj.core.api.Assertions.assertThatThrownBy; class ClaimCheckResolverTest { @Test void rejectsPublicHttpUrlAsObjectIdentifier() { assertThatThrownBy(() -> new ClaimCheckReference( "object-storage", "https://storage.example.com/signed?token=secret", 2048, "SHA-256", "abc", "application/octet-stream", Instant.now().plusSeconds(3600))) .isInstanceOf(IllegalArgumentException.class); } @Test void rejectsChecksumMismatch() { ClaimCheckResolver resolver = ClaimCheckFixtures.resolverReturning("tampered"); assertThatThrownBy(() -> resolver.resolve(ClaimCheckFixtures.reference()).toCompletableFuture().join()) .hasCauseInstanceOf(ClaimCheckIntegrityException.class); } } ``` - [ ] **Step 2: Run claim-check tests and verify missing types** Run: ```bash ./gradlew :modules:messaging:messaging-claim-check:test ``` Expected: FAIL because claim-check types are missing. - [ ] **Step 3: Implement internal object identifiers, checksum, size, and retention validation** ```java package io.backend.skeleton.messaging.claimcheck; public record ClaimCheckReference( String store, String objectId, long size, String checksumAlgorithm, String checksum, String contentType, java.time.Instant expiresAt) { public ClaimCheckReference { if (objectId.contains("://")) { throw new IllegalArgumentException("claim check objectId must not be a public URL"); } } } ``` `ClaimCheckPublisher` writes the payload, computes SHA-256, and publishes only the reference. `ClaimCheckResolver` streams the object, enforces declared size, and verifies checksum before returning it to the handler. Validate object expiry against message retention, retry, DLQ, and redrive horizon. - [ ] **Step 4: Run claim-check tests** Run: ```bash ./gradlew :modules:messaging:messaging-claim-check:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-claim-check git commit -m "feat: add messaging claim check" ``` --- ### Task 30: Avro·Protobuf Codec와 Schema Registry Compatibility Gate 구현 **Files:** - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaRegistry.java` - Create: `modules/messaging/messaging-schema-api/src/main/java/io/backend/skeleton/messaging/schema/SchemaCompatibilityValidator.java` - Create: `modules/messaging/messaging-schema-avro/src/main/java/io/backend/skeleton/messaging/schema/avro/AvroMessageCodec.java` - Create: `modules/messaging/messaging-schema-protobuf/src/main/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufMessageCodec.java` - Create: `modules/messaging/messaging-schema-avro/src/test/resources/schemas/order.created/v1.avsc` - Create: `modules/messaging/messaging-schema-protobuf/src/test/proto/order_created_v1.proto` - Test: `modules/messaging/messaging-schema-avro/src/test/java/io/backend/skeleton/messaging/schema/avro/AvroCompatibilityTest.java` - Test: `modules/messaging/messaging-schema-protobuf/src/test/java/io/backend/skeleton/messaging/schema/protobuf/ProtobufCompatibilityTest.java` **Interfaces:** - Extends Task 5 codec SPI. - Produces optional Avro and Protobuf Stable codecs and registry compatibility validation. - Raw bytes remain M2 and bypass attempts are audited. - [ ] **Step 1: Write failing backward compatibility tests** ```java package io.backend.skeleton.messaging.schema.avro; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class AvroCompatibilityTest { @Test void optionalFieldWithDefaultIsBackwardCompatible() { var validator = AvroSchemaFixture.validator(); assertThat(validator.isBackwardTransitiveCompatible( AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithOptionalCurrency())) .isTrue(); } @Test void requiredFieldWithoutDefaultIsRejected() { var validator = AvroSchemaFixture.validator(); assertThat(validator.isBackwardTransitiveCompatible( AvroSchemaFixture.v1(), AvroSchemaFixture.v2WithRequiredCurrency())) .isFalse(); } } ``` - [ ] **Step 2: Run codec modules and verify missing implementations** Run: ```bash ./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test ``` Expected: FAIL because codecs and registry interfaces are missing. - [ ] **Step 3: Implement codecs and compatibility policies** ```java package io.backend.skeleton.messaging.schema; public interface SchemaRegistry { SchemaReference register( MessageType type, SchemaVersion version, byte[] schema, SchemaCompatibility compatibility); byte[] resolve(SchemaReference reference); } ``` Avro default is `BACKWARD_TRANSITIVE`. Protobuf must preserve unknown fields and reject enum evolution without an unknown-value strategy documented in the generated type. Both codecs enforce the same 1 MiB default encoded size and 8 MiB hard maximum. - [ ] **Step 4: Run schema compatibility and golden-message tests** Run: ```bash ./gradlew :modules:messaging:messaging-schema-avro:test :modules:messaging:messaging-schema-protobuf:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-schema-api modules/messaging/messaging-schema-avro modules/messaging/messaging-schema-protobuf git commit -m "feat: add avro and protobuf messaging codecs" ``` --- ### Task 31: Broker TLS·Authentication·ACL·Credential Rotation 통합 **Files:** - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntime.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistry.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerTlsPolicy.java` - Create: `modules/messaging/messaging-security/src/main/java/io/backend/skeleton/messaging/security/BrokerAclManifest.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaSecurityConfigurer.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitSecurityConfigurer.java` - Create: `infra/messaging/tls/generate-test-pki.sh` - Test: `modules/messaging/messaging-security/src/test/java/io/backend/skeleton/messaging/security/CredentialRuntimeRegistryTest.java` - Test: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaTlsAclIT.java` - Test: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitTlsAclIT.java` **Interfaces:** - Extends Task 11 security primitives and Task 10 runtime generations. - Produces TLS 1.2/1.3, hostname validation, SASL/SCRAM or OAuth/mTLS profiles, destination ACL manifests, and zero-downtime generation replacement. - [ ] **Step 1: Write failing rotation and ACL tests** ```java package io.backend.skeleton.messaging.security; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class CredentialRuntimeRegistryTest { @Test void newCredentialGenerationServesNewPublishesWhileOldGenerationDrains() { CredentialRuntimeRegistry registry = CredentialRuntimeFixtures.registry(); var oldLease = registry.acquire("kafka-producer"); registry.rotate(CredentialRuntimeFixtures.generation(2)); assertThat(oldLease.runtime().generation()).isEqualTo(1); assertThat(registry.acquire("kafka-producer").runtime().generation()).isEqualTo(2); oldLease.close(); assertThat(registry.closedGenerations()).contains(1L); } } ``` ```java package io.backend.skeleton.messaging.kafka; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class KafkaTlsAclIT { @Test void producerCredentialCannotResetConsumerOffsets() { KafkaSecureHarness harness = KafkaSecureHarness.start(); assertThatThrownBy(() -> harness.resetOffsetsWithProducerCredential()) .isInstanceOf(org.apache.kafka.common.errors.AuthorizationException.class); } } ``` - [ ] **Step 2: Run security integration tests and verify failure** Run: ```bash ./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test --tests '*KafkaTlsAclIT' :modules:messaging:messaging-rabbit:test --tests '*RabbitTlsAclIT' ``` Expected: FAIL because broker security integration and PKI fixtures are missing. - [ ] **Step 3: Implement fail-closed TLS, identity separation, and runtime rotation** `BrokerTlsPolicy` permits TLS 1.2 and 1.3, requires hostname verification, and has no trust-all flag. `CredentialRuntimeRegistry` follows the reference-counted generation design from Task 10. Create separate identities: ```text messaging-producer messaging-consumer messaging-admin ``` Kafka producer identity gets write-only topic permissions, consumer gets read/group permissions, and admin gets topology/offset permissions. Rabbit identities get vhost configure/write/read permissions according to role. Startup validation compares expected ACL manifest with a dry-run or describe result where the broker supports it. - [ ] **Step 4: Run TLS, hostname mismatch, ACL, and rotation tests** Run: ```bash ./gradlew :modules:messaging:messaging-security:test :modules:messaging:messaging-kafka:test :modules:messaging:messaging-rabbit:test --tests '*Tls*' --tests '*Acl*' --tests '*Credential*' ``` Expected: PASS. Unknown CA, hostname mismatch, expired certificate, and unauthorized destination fail closed without retry storms. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-security modules/messaging/messaging-kafka modules/messaging/messaging-rabbit infra/messaging/tls git commit -m "feat: add messaging transport security" ``` --- ### Task 32: Metrics, Tracing, Audit, Cardinality Guard 구현 **Files:** - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingMetrics.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingTracer.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditEvent.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/MessagingAuditSink.java` - Create: `modules/messaging/messaging-observability/src/main/java/io/backend/skeleton/messaging/observation/DefaultMessagingObservationConvention.java` - Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingMetricCardinalityTest.java` - Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingTraceLinkTest.java` - Test: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/MessagingSecretLeakTest.java` **Interfaces:** - Extends Task 11 primitives. - Produces logical publish/consume observations, physical attempt metrics, asynchronous span links, and admin audit events. - Internal semantic model is versioned independently of OpenTelemetry exporter names. - [ ] **Step 1: Write failing cardinality and trace-link tests** ```java package io.backend.skeleton.messaging.observation; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class MessagingMetricCardinalityTest { @Test void tenThousandMessageIdsDoNotCreateNewMetricSeries() { TestMeterRegistry registry = new TestMeterRegistry(); MessagingMetrics metrics = new MessagingMetrics(registry); for (int index = 0; index < 10_000; index++) { metrics.publishConfirmed( "kafka", "order-events", "order.created", "0190f4aa-0000-7000-8000-" + String.format("%012d", index)); } assertThat(registry.seriesCount("messaging.publish")) .isLessThanOrEqualTo(2); } } ``` ```java package io.backend.skeleton.messaging.observation; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class MessagingTraceLinkTest { @Test void consumerSpanLinksToProducerContextInsteadOfAssumingDirectParent() { TestTracing tracing = TestTracing.create(); MessagingTracer tracer = new MessagingTracer(tracing.tracer()); tracer.consume(MessagingObservationFixtures.delivery()).close(); assertThat(tracing.finishedConsumerSpan().links()).hasSize(1); assertThat(tracing.finishedConsumerSpan().parentSpanId()).isNull(); } } ``` - [ ] **Step 2: Run observability tests and verify missing implementation** Run: ```bash ./gradlew :modules:messaging:messaging-observability:test ``` Expected: FAIL because metrics and tracing classes are missing. - [ ] **Step 3: Implement bounded tags and observation lifecycle** Implement counters/timers for publish, confirmation, ambiguity, receive, processing, settlement, redelivery, retry, DLQ, redrive, backlog, schema failure, Outbox, and Inbox. Allowed tags are broker, destination profile, operation, bounded message type, outcome, failure category, retry stage, and schema codec. `MessagingTracer` creates a producer span, stores trace context in reserved headers, and creates a consumer processing span with a link to that context. Admin audit events include operator, approval ID, dry-run flag, operation, target profile, count, and result, but no payload. - [ ] **Step 4: Run cardinality, trace, and secret leak tests** Run: ```bash ./gradlew :modules:messaging:messaging-observability:test ``` Expected: PASS. Static log scanning finds no payload, token, or full message ID. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-observability git commit -m "feat: add messaging metrics tracing and audit" ``` --- ### Task 33: Topology Manifest, Broker Inspectors, Validate-only Runtime 구현 **Files:** - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManifest.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/DestinationTopology.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyManagementMode.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidationReport.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/TopologyIssue.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/TopologyValidator.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/BrokerTopologyInspector.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/CompositeTopologyValidator.java` - Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/TopologyValidatorTest.java` - Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/ProductionAutoCreateGuardTest.java` **Interfaces:** - Consumes Kafka and Rabbit read-only topology inspectors. - Produces manifest-based drift reports and environment-specific management modes. - Production auto-create is rejected before application startup. - [ ] **Step 1: Write failing topology drift and production guard tests** ```java package io.backend.skeleton.messaging.admin; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class TopologyValidatorTest { @Test void reportsKafkaPartitionAndRabbitQueueTypeDrift() { TopologyValidator validator = TopologyFixtures.validatorWithDrift(); TopologyValidationReport report = validator.validate(); assertThat(report.errors()) .extracting(TopologyIssue::code) .contains("KAFKA_PARTITION_MISMATCH", "RABBIT_QUEUE_TYPE_MISMATCH"); } @Test void productionRejectsAutoCreateMode() { assertThatThrownBy(() -> TopologyManifestFixtures.production( TopologyManagementMode.AUTO_CREATE_DEV)) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run admin topology tests and verify missing contracts** Run: ```bash ./gradlew :modules:messaging:messaging-admin-runtime:test ``` Expected: FAIL because topology contracts and validator are missing. - [ ] **Step 3: Implement manifest, inspectors, and drift severity** `TopologyValidationReport` contains `INFO`, `WARNING`, and `ERROR` issues with stable codes. Kafka inspection covers partition count, replication factor, minimum ISR, retention, and cleanup policy. Rabbit inspection covers exchange type, queue type, durability, bindings, dead-letter strategy, overflow, and consumer timeout. `AUTO_CREATE_DEV` may create only non-destructive local/test resources. `VALIDATE_ONLY` performs no mutation. `ADMIN_MANAGED` requires the Admin application. - [ ] **Step 4: Run topology unit and broker integration tests** Run: ```bash ./gradlew :modules:messaging:messaging-admin-runtime:test :modules:messaging:messaging-kafka:test --tests '*Topology*' :modules:messaging:messaging-rabbit:test --tests '*Topology*' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime modules/messaging/messaging-kafka modules/messaging/messaging-rabbit git commit -m "feat: add messaging topology validation" ``` --- ### Task 34: Replay·Redrive Admin Approval, Dry-run, Audit 구현 **Files:** - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayRequest.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayPlan.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveRequest.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedrivePlan.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/AdminApproval.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ReplayResult.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/RedriveResult.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedReplayPlan.java` - Create: `modules/messaging/messaging-admin-api/src/main/java/io/backend/skeleton/messaging/admin/ApprovedRedrivePlan.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/MessagingAdminService.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DefaultMessagingAdminService.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/DestructiveMessagingAdmin.java` - Create: `modules/messaging/messaging-admin-runtime/src/main/java/io/backend/skeleton/messaging/admin/AdminOperationIdempotencyStore.java` - Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/RedriveAdminTest.java` - Test: `modules/messaging/messaging-admin-runtime/src/test/java/io/backend/skeleton/messaging/admin/DestructiveAdminGuardTest.java` **Interfaces:** - Consumes topology inspectors, broker replay capabilities, publisher, and audit sink. - Produces dry-run planning, approval-bound execution, idempotent admin operations, and separate destructive interface. - [ ] **Step 1: Write failing approval and identity tests** ```java package io.backend.skeleton.messaging.admin; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class RedriveAdminTest { @Test void redriveKeepsOriginalMessageIdAndAddsRedriveId() { DefaultMessagingAdminService service = AdminFixtures.service(); RedrivePlan plan = service.planRedrive(AdminFixtures.redriveDryRun()); var result = service.executeRedrive(plan.approve(AdminFixtures.approval())); assertThat(result.published().getFirst().messageId()) .isEqualTo(AdminFixtures.originalMessageId()); assertThat(result.published().getFirst().redriveId()).isNotNull(); } @Test void executionWithoutApprovalIsRejected() { assertThatThrownBy(() -> AdminFixtures.service() .executeRedrive(AdminFixtures.unapprovedPlan())) .isInstanceOf(SecurityException.class); } } ``` - [ ] **Step 2: Run admin tests and verify missing implementation** Run: ```bash ./gradlew :modules:messaging:messaging-admin-runtime:test --tests '*RedriveAdminTest' --tests '*DestructiveAdminGuardTest' ``` Expected: FAIL because admin execution contracts are missing. - [ ] **Step 3: Implement plan-approve-execute and operation idempotency** Every admin operation follows: ```text plan with dry-run capture topology version and estimated count approve with approval ID, operator, reason, expiry revalidate topology version claim approval ID in idempotency store execute bounded batches write audit result ``` Redrive preserves original `messageId`, creates a new `redriveId`, increments `redriveCount`, and waits for target confirmation before marking source DLQ state. `DestructiveMessagingAdmin` is a separate bean and interface for offset reset, purge, and delete. - [ ] **Step 4: Run approval, duplicate execution, audit, and broker replay tests** Run: ```bash ./gradlew :modules:messaging:messaging-admin-runtime:test ``` Expected: PASS. The same approval cannot execute twice and app-role fixtures cannot obtain the destructive bean. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-admin-api modules/messaging/messaging-admin-runtime git commit -m "feat: add guarded replay and redrive admin" ``` --- ### Task 35: Spring Boot Starter, Properties, Auto-configuration, Actuator 구현 **Files:** - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingProperties.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingCoreAutoConfiguration.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/KafkaMessagingAutoConfiguration.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/RabbitMessagingAutoConfiguration.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingReliabilityAutoConfiguration.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingAdminAutoConfiguration.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpoint.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-valid.yml` - Create: `modules/messaging/messaging-spring-boot-starter/src/test/resources/application-invalid-ordering.yml` - Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingAutoConfigurationTest.java` - Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/MessagingEndpointTest.java` **Interfaces:** - Consumes every Stable module and optional Experimental modules by classpath/property. - Produces configuration binding, startup validation, runtime registry, actuator endpoints, and conditional adapter beans. - Experimental and Admin features are disabled by default. - [ ] **Step 1: Write failing valid/invalid ApplicationContextRunner tests** ```java package io.backend.skeleton.messaging.autoconfigure; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; class MessagingAutoConfigurationTest { private final ApplicationContextRunner runner = new ApplicationContextRunner() .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( MessagingCoreAutoConfiguration.class)); @Test void validConfigurationCreatesTypedPublisher() { runner.withPropertyValues( "backend.messaging.brokers.kafka-primary.type=KAFKA", "backend.messaging.destinations.order-events.broker=kafka-primary", "backend.messaging.destinations.order-events.kind=EVENT_STREAM") .run(context -> assertThat(context) .hasSingleBean(io.backend.skeleton.messaging.api.publish.MessagePublisher.class)); } @Test void invalidOrderingRetryConfigurationFailsStartup() { runner.withPropertyValues( "backend.messaging.destinations.order-events.ordering=KEY", "backend.messaging.destinations.order-events.retry.mode=RETRY_DESTINATION", "backend.messaging.destinations.order-events.retry.ordering-impact=PRESERVE") .run(context -> assertThat(context).hasFailed()); } } ``` - [ ] **Step 2: Run starter tests and verify missing auto-configuration** Run: ```bash ./gradlew :modules:messaging:messaging-spring-boot-starter:test ``` Expected: FAIL because properties and auto-configurations are missing. - [ ] **Step 3: Implement configuration binding, startup validation, and sanitized actuator output** ```java package io.backend.skeleton.messaging.autoconfigure; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties("backend.messaging") public record MessagingProperties( java.util.Map brokers, java.util.Map destinations, MessagingLimitsProperties limits, MessagingSecurityProperties security, MessagingObservabilityProperties observability, MessagingAdminProperties admin, MessagingExperimentalProperties experimental) { } ``` Auto-configuration order is core → schema → policy → security/observation → transport adapters → reliability → actuator. Admin and Experimental beans require explicit properties. `/actuator/messaging`, `/topology`, `/outbox`, `/capabilities` return only profile names, status, bounded counts, and capability enums. - [ ] **Step 4: Run starter, endpoint, and startup guard tests** Run: ```bash ./gradlew :modules:messaging:messaging-spring-boot-starter:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-spring-boot-starter git commit -m "feat: add messaging spring boot starter" ``` --- ### Task 36: Blocking·Reactive·Batch Facade와 Cancellation 계약 구현 **Files:** - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BlockingMessagePublisher.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchMessagePublisher.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishOptions.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishResult.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/BatchPublishItemResult.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/PublishRequest.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/publish/DelayedMessagePublisher.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageHandler.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchMessageDelivery.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/BatchDeliveryMetadata.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/delivery/PauseResumeController.java` - Modify: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaConsumerRegistrar.java` - Create: `modules/messaging/messaging-kafka/src/main/java/io/backend/skeleton/messaging/kafka/KafkaBatchConsumerRegistrar.java` - Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitConsumerRegistrar.java` - Create: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitBatchConsumerRegistrar.java` - Modify: `modules/messaging/messaging-rabbit/src/main/java/io/backend/skeleton/messaging/rabbit/RabbitMessagingTransport.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBlockingMessagePublisher.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/ReactiveMessagePublisher.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultReactiveMessagePublisher.java` - Create: `modules/messaging/messaging-spring-boot-starter/src/main/java/io/backend/skeleton/messaging/autoconfigure/DefaultBatchMessagePublisher.java` - Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/PublisherFacadeTest.java` - Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchPublisherTest.java` - Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/BatchConsumerFacadeTest.java` - Test: `modules/messaging/messaging-spring-boot-starter/src/test/java/io/backend/skeleton/messaging/autoconfigure/DelayedPublishCapabilityTest.java` **Interfaces:** - Consumes Core `MessagePublisher` and Spring Reactor dependency in the starter only. - Produces blocking and `Mono` facades, index-preserving batch publish, M2 batch consume, pause/resume, and capability-gated delayed publish. - Batch is explicitly non-transactional and receives no transparent whole-batch retry. - [ ] **Step 1: Write failing facade and batch-result tests** ```java package io.backend.skeleton.messaging.autoconfigure; import org.junit.jupiter.api.Test; import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; class PublisherFacadeTest { @Test void blockingFacadeHonorsFiniteTimeout() { DefaultBlockingMessagePublisher publisher = PublisherFacadeFixtures.neverCompletingBlocking(); org.assertj.core.api.Assertions.assertThatThrownBy(() -> publisher.publish( PublisherFacadeFixtures.destination(), PublisherFacadeFixtures.envelope(), PublisherFacadeFixtures.options(Duration.ofMillis(50)))) .isInstanceOf(io.backend.skeleton.messaging.api.error.MessagePublishTimeoutException.class); } @Test void reactiveCancellationCancelsUnderlyingStage() { var fixture = PublisherFacadeFixtures.cancellableReactive(); var subscription = fixture.publisher().publish( fixture.destination(), fixture.envelope(), fixture.options()) .subscribe(); subscription.dispose(); assertThat(fixture.underlyingCancelled()).isTrue(); } } ``` ```java package io.backend.skeleton.messaging.autoconfigure; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class BatchPublisherTest { @Test void preservesInputIndexAndIndividualAmbiguity() { DefaultBatchMessagePublisher publisher = PublisherFacadeFixtures.batchWithSecondAmbiguous(); var result = publisher.publish(PublisherFacadeFixtures.threeRequests(), PublisherFacadeFixtures.batchOptions()).toCompletableFuture().join(); assertThat(result.items()).extracting(BatchPublishItemResult::index) .containsExactly(0, 1, 2); assertThat(result.items().get(1).result().completion().name()) .isEqualTo("AMBIGUOUS"); } } ``` `BatchConsumerFacadeTest` must assert that Kafka commits only completed contiguous records and Rabbit settles each item or the whole batch according to the configured settlement capability. `DelayedPublishCapabilityTest` must assert that Kafka Stable rejects delayed delivery while Rabbit capability profiles accept it. - [ ] **Step 2: Run facade tests and verify missing implementations** Run: ```bash ./gradlew :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest' ``` Expected: FAIL because facades and batch types are missing. - [ ] **Step 3: Implement finite blocking wait, Reactor cancellation, and per-item batch completion** ```java package io.backend.skeleton.messaging.api.publish; public interface BlockingMessagePublisher { PublishResult publish( MessageDestination destination, MessageEnvelope message, PublishOptions options); } ``` `DefaultBlockingMessagePublisher` waits no longer than `PublishOptions.timeout` and translates timeout to `MessagePublishTimeoutException` without claiming the broker rejected the message. `DefaultReactiveMessagePublisher` uses `Mono.fromCompletionStage` with cancellation propagation where the transport supports it. `DefaultBatchMessagePublisher` assigns indexes, executes within the configured max in-flight limit, and returns every item result without retrying the whole batch. Implement `BatchMessageHandler` as `CompletionStage handle(BatchMessageDelivery batch)`. Add Kafka and Rabbit batch registrars behind M2 capability checks. Implement `PauseResumeController` without exposing native partition/channel objects. Implement `DelayedMessagePublisher` so an adapter lacking delayed-delivery capability throws `MessagingCapabilityUnavailableException`; Rabbit maps supported delayed profiles, Kafka Stable rejects them. - [ ] **Step 4: Run facade, cancellation, and batch tests** Run: ```bash ./gradlew :modules:messaging:messaging-core-api:test :modules:messaging:messaging-spring-boot-starter:test --tests '*PublisherFacadeTest' --tests '*BatchPublisherTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-core-api modules/messaging/messaging-spring-boot-starter git commit -m "feat: add messaging publisher facades" ``` --- ### Task 37: Debezium Outbox Event Router 선택 Integration 구현 **Files:** - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxProfile.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapper.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/java/io/backend/skeleton/messaging/outbox/DebeziumMappedRecord.java` - Create: `modules/messaging/messaging-outbox-jpa/src/main/resources/debezium/outbox-event-router.properties` - Create: `infra/messaging/kafka/debezium-compose.yml` - Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxRecordMapperTest.java` - Test: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/DebeziumOutboxIT.java` **Interfaces:** - Consumes the Outbox table and Kafka destination profile. - Produces a CDC relay option that uses outbox `message_id` as event identity and aggregate ID as optional Kafka key. - Does not implement or operate a generic CDC engine and cannot run concurrently with the polling relay for the same rows. - [ ] **Step 1: Write failing ID and routing-key mapping tests** ```java package io.backend.skeleton.messaging.outbox; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class DebeziumOutboxRecordMapperTest { @Test void mapsOutboxMessageIdAndAggregateIdWithoutCreatingNewIdentity() { OutboxRecord row = OutboxFixtures.orderCreatedRow(); DebeziumMappedRecord mapped = new DebeziumOutboxRecordMapper().map(row); assertThat(mapped.eventId()).isEqualTo(row.messageId().toString()); assertThat(mapped.key()).isEqualTo(row.aggregateId().orElseThrow()); } } ``` - [ ] **Step 2: Run Debezium tests and verify missing mapper** Run: ```bash ./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT' ``` Expected: FAIL because the Debezium profile and fixture are missing. - [ ] **Step 3: Implement the fixed Outbox Event Router mapping and exclusivity guard** Map: ```text message_id → event ID aggregate_id → event key destination → route field message_type → event type payload → event payload headers → additional fields limited by allowlist ``` At startup, fail if both `polling-relay.enabled=true` and `debezium-relay.enabled=true` for the same Outbox namespace. Keep the CDC container configuration in infra and label the integration optional. - [ ] **Step 4: Run mapper and end-to-end CDC tests** Run: ```bash ./gradlew :modules:messaging:messaging-outbox-jpa:test --tests '*DebeziumOutboxRecordMapperTest' --tests '*DebeziumOutboxIT' ``` Expected: PASS. Kafka receives the original message ID and aggregate key. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-outbox-jpa infra/messaging/kafka/debezium-compose.yml git commit -m "feat: add optional debezium outbox integration" ``` --- ### Task 38: Pulsar Experimental Adapter 구현 **Files:** - Modify: `modules/messaging/messaging-pulsar-experimental/build.gradle.kts` - Create: `infra/messaging/pulsar/docker-compose.yml` - Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagingTransport.java` - Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfile.java` - Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarProfileValidator.java` - Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionMode.java` - Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionCapability.java` - Create: `modules/messaging/messaging-pulsar-experimental/src/main/java/io/backend/skeleton/messaging/pulsar/PulsarMessagePosition.java` - Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarAdapterContractTest.java` - Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarSubscriptionGuardTest.java` - Test: `modules/messaging/messaging-pulsar-experimental/src/test/java/io/backend/skeleton/messaging/pulsar/PulsarTransactionIT.java` **Interfaces:** - Consumes Core transport SPI and policy contracts. - Produces Experimental typed publish/consume, Exclusive/Failover/Shared/Key_Shared, schema integration, redelivery, replay, and native transaction capability. - Module is absent unless the experimental property and dependency are both present. - [ ] **Step 1: Write failing Core Contract and subscription guard tests** ```java package io.backend.skeleton.messaging.pulsar; import io.backend.skeleton.messaging.testkit.MessagingAdapterContract; import io.backend.skeleton.messaging.testkit.MessagingAdapterHarness; import org.junit.jupiter.api.Nested; class PulsarAdapterContractTest { @Nested class Contract extends MessagingAdapterContract { @Override protected MessagingAdapterHarness harness() { return PulsarHarnessFixture.create(); } } } ``` ```java package io.backend.skeleton.messaging.pulsar; import io.backend.skeleton.messaging.api.delivery.OrderingScope; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class PulsarSubscriptionGuardTest { @Test void sharedSubscriptionRejectsKeyOrderingRequirement() { PulsarProfile profile = PulsarProfileFixtures.profile( PulsarSubscriptionMode.SHARED, OrderingScope.KEY); assertThatThrownBy(() -> new PulsarProfileValidator().validate(profile)) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run Pulsar tests and verify missing adapter** Run: ```bash ./gradlew :modules:messaging:messaging-pulsar-experimental:test ``` Expected: FAIL because the adapter is missing. - [ ] **Step 3: Implement publish, subscription mapping, schema, and transaction capability** Map Pulsar message ID to a `PulsarMessagePosition` without exposing client types. Use Shared only with `OrderingScope.NONE`, Key_Shared for key ordering, and Failover/Exclusive for single active processing profiles. Transaction capability may atomically include Pulsar publish and acknowledge only; reject external DB guarantee claims. - [ ] **Step 4: Run Core Contract against Pulsar 4.0 LTS and 4.2 profiles** Run: ```bash ./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.0 ./gradlew :modules:messaging:messaging-pulsar-experimental:test -PpulsarVersion=4.2 ``` Expected: PASS for the experimental support matrix, including transaction commit/abort and subscription redistribution. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-pulsar-experimental infra/messaging/pulsar git commit -m "feat: add experimental pulsar adapter" ``` --- ### Task 39: NATS JetStream Experimental Adapter 구현 **Files:** - Modify: `modules/messaging/messaging-nats-experimental/build.gradle.kts` - Create: `infra/messaging/nats/docker-compose.yml` - Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamTransport.java` - Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfile.java` - Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsJetStreamProfileValidator.java` - Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsAckMode.java` - Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingWorkflow.java` - Create: `modules/messaging/messaging-nats-experimental/src/main/java/io/backend/skeleton/messaging/nats/NatsStreamPosition.java` - Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAdapterContractTest.java` - Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsDeduplicationIT.java` - Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsAckSyncIT.java` - Test: `modules/messaging/messaging-nats-experimental/src/test/java/io/backend/skeleton/messaging/nats/NatsMaxDeliverParkingIT.java` **Interfaces:** - Consumes Core transport SPI and policy contracts. - Produces JetStream publish PubAck, `Nats-Msg-Id` dedupe, pull consumer, explicit ACK, AckSync, Nak delay, MaxDeliver advisory, work queue, and replay. - Does not claim multi-resource transaction or automatic native DLQ movement. - [ ] **Step 1: Write failing dedupe and AckSync tests** ```java package io.backend.skeleton.messaging.nats; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class NatsDeduplicationIT { @Test void sameMessageIdWithinWindowProducesOneStoredMessage() { NatsHarness harness = NatsHarness.start(); var message = NatsFixtures.orderCreated(); harness.publish(message); harness.publish(message); assertThat(harness.streamMessageCount()).isEqualTo(1); } } ``` ```java package io.backend.skeleton.messaging.nats; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class NatsAckSyncIT { @Test void ackSyncReturnsSettlementEvidence() { NatsHarness harness = NatsHarness.start(); var result = harness.consumeAndAckSync(); assertThat(result.completion().name()).isEqualTo("SETTLED"); assertThat(result.evidence().brokerAcknowledged()).isTrue(); } } ``` - [ ] **Step 2: Run NATS tests and verify missing adapter** Run: ```bash ./gradlew :modules:messaging:messaging-nats-experimental:test ``` Expected: FAIL because JetStream adapter types are missing. - [ ] **Step 3: Implement JetStream evidence and advisory-based parking** Use `messageId` as `Nats-Msg-Id`. Map PubAck to confirmed publish. Use pull consumers with explicit ACK and bounded `MaxAckPending`. Map `AckSync` server response to settlement evidence. On MaxDeliver advisory, publish to the configured parking destination, wait for PubAck, then terminate or mark the original according to the stream policy. - [ ] **Step 4: Run NATS 2.14.x Core Contract, dedupe-window, AckSync, and MaxDeliver tests** Run: ```bash ./gradlew :modules:messaging:messaging-nats-experimental:test ``` Expected: PASS. The module remains Experimental and disabled by default. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-nats-experimental infra/messaging/nats git commit -m "feat: add experimental nats jetstream adapter" ``` --- ### Task 40: Spring Cloud Stream Optional Bridge 구현 **Files:** - Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/MessagingBindingBridge.java` - Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamPublisherBridge.java` - Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/SpringCloudStreamConsumerBridge.java` - Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingCapabilityReport.java` - Create: `modules/messaging/messaging-spring-cloud-stream-bridge/src/main/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidator.java` - Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BindingProfileValidatorTest.java` - Test: `modules/messaging/messaging-spring-cloud-stream-bridge/src/test/java/io/backend/skeleton/messaging/streambridge/BridgePublishEvidenceTest.java` **Interfaces:** - Consumes Core API and Spring Cloud Stream binding interfaces. - Produces a migration/compatibility bridge only where binder callbacks can satisfy the requested evidence. - Never promotes binder-specific retry or DLQ semantics into the Core policy automatically. - [ ] **Step 1: Write failing evidence-capability guard test** ```java package io.backend.skeleton.messaging.streambridge; import io.backend.skeleton.messaging.api.destination.ConfirmationRequirement; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class BindingProfileValidatorTest { @Test void rejectsReplicationConfirmationWhenBinderCannotProveIt() { BindingCapabilityReport capabilities = BindingCapabilityReport.brokerAckOnly(); assertThatThrownBy(() -> new BindingProfileValidator().validate( ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK, capabilities)) .isInstanceOf(IllegalArgumentException.class); } } ``` - [ ] **Step 2: Run bridge tests and verify missing implementation** Run: ```bash ./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test ``` Expected: FAIL because bridge classes are missing. - [ ] **Step 3: Implement capability-reporting bridge and migration-only degradation rules** The bridge must expose a `BindingCapabilityReport` before registration. If the binder cannot distinguish confirm, routing, or settlement evidence required by a destination, reject the binding or label it `MIGRATION_ONLY`. Do not reuse binder retry settings as Core `RetryPolicy`; require explicit mapping and validation. - [ ] **Step 4: Run bridge capability and mapping tests** Run: ```bash ./gradlew :modules:messaging:messaging-spring-cloud-stream-bridge:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-spring-cloud-stream-bridge git commit -m "feat: add optional spring cloud stream bridge" ``` --- ### Task 41: Global Backpressure, Payload Guard, Graceful Shutdown 구현 **Files:** - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/MessagingAdmissionController.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/InFlightLimiter.java` - Create: `modules/messaging/messaging-policy/src/main/java/io/backend/skeleton/messaging/policy/PayloadLimitGuard.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingLifecycle.java` - Create: `modules/messaging/messaging-transport-spi/src/main/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinator.java` - Create: `modules/messaging/messaging-core-api/src/main/java/io/backend/skeleton/messaging/api/error/MessageBackpressureException.java` - Test: `modules/messaging/messaging-policy/src/test/java/io/backend/skeleton/messaging/policy/MessagingAdmissionControllerTest.java` - Test: `modules/messaging/messaging-transport-spi/src/test/java/io/backend/skeleton/messaging/transport/MessagingShutdownCoordinatorTest.java` **Interfaces:** - Consumes payload policy, adapter runtime, handler coordinator, Outbox relay, and admin runtime. - Produces bounded publish admission, handler concurrency, retry concurrency, and deterministic drain ordering. - [ ] **Step 1: Write failing overload and shutdown-order tests** ```java package io.backend.skeleton.messaging.policy; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatThrownBy; class MessagingAdmissionControllerTest { @Test void rejectsBeforeEncodingWhenPayloadExceedsDestinationLimit() { PayloadLimitGuard guard = new PayloadLimitGuard(1024, 8192); assertThatThrownBy(() -> guard.checkDeclaredSize(1025)) .isInstanceOf(io.backend.skeleton.messaging.api.error.MessageTooLargeException.class); } @Test void boundedProducerAdmissionRejectsAfterFiniteWait() { MessagingAdmissionController controller = new MessagingAdmissionController(1); var permit = controller.acquire(java.time.Duration.ofMillis(10)); assertThatThrownBy(() -> controller.acquire(java.time.Duration.ofMillis(10))) .isInstanceOf(io.backend.skeleton.messaging.api.error.MessageBackpressureException.class); permit.close(); } } ``` ```java package io.backend.skeleton.messaging.transport; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class MessagingShutdownCoordinatorTest { @Test void blocksAdmissionBeforePausingConsumersAndDrainingSettlements() { ShutdownOrderRecorder recorder = new ShutdownOrderRecorder(); new MessagingShutdownCoordinator(recorder.components()).shutdown(); assertThat(recorder.events()).containsExactly( "block-publish-admission", "block-new-handlers", "pause-consumers", "drain-handlers", "flush-settlements", "await-confirms", "release-outbox-leases", "close-transports"); } } ``` - [ ] **Step 2: Run policy and lifecycle tests and verify failure** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test --tests '*Admission*' --tests '*Shutdown*' ``` Expected: FAIL because admission and shutdown types are missing. - [ ] **Step 3: Implement bounded permits and the exact shutdown sequence** Use semaphore-style permits with finite acquire timeout for producer, handler, retry publish, and admin batch categories. Payload guard checks declared and encoded size. `MessagingShutdownCoordinator` uses Spring lifecycle phases and a default 30-second drain. After the deadline, unfinished handlers remain unsettled, unconfirmed publishes become ambiguous, and Outbox rows rely on lease expiry. - [ ] **Step 4: Run overload, cancellation, and shutdown integration tests** Run: ```bash ./gradlew :modules:messaging:messaging-policy:test :modules:messaging:messaging-transport-spi:test :modules:messaging:messaging-kafka:test --tests '*Shutdown*' :modules:messaging:messaging-rabbit:test --tests '*Shutdown*' ``` Expected: PASS. No new work begins after shutdown admission closes and unfinished consumer messages are redelivered rather than falsely settled. - [ ] **Step 5: Commit** ```bash git add modules/messaging/messaging-policy modules/messaging/messaging-transport-spi modules/messaging/messaging-core-api git commit -m "feat: add messaging backpressure and shutdown" ``` --- ### Task 42: Cross-broker 장애·보안·Reliability Contract Suite 구현 **Files:** - Create: `infra/messaging/toxiproxy/docker-compose.yml` - Create: `build-logic/src/main/kotlin/messaging-chaos-conventions.gradle.kts` - Modify: `build.gradle.kts` - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/NetworkFaultScenario.java` - Create: `modules/messaging/messaging-testkit/src/main/java/io/backend/skeleton/messaging/testkit/BrokerFailureMatrix.java` - Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/CrossBrokerContractSuite.java` - Create: `modules/messaging/messaging-kafka/src/test/java/io/backend/skeleton/messaging/kafka/KafkaChaosSuiteIT.java` - Create: `modules/messaging/messaging-rabbit/src/test/java/io/backend/skeleton/messaging/rabbit/RabbitChaosSuiteIT.java` - Create: `modules/messaging/messaging-outbox-jpa/src/test/java/io/backend/skeleton/messaging/outbox/ReliabilityCrashMatrixIT.java` - Create: `modules/messaging/messaging-observability/src/test/java/io/backend/skeleton/messaging/observation/SecretLeakStaticScanTest.java` **Interfaces:** - Consumes all Stable adapters, Reliability modules, security, observation, and Toxiproxy fixtures. - Produces the release-gating failure matrix required by the design. - [ ] **Step 1: Write failing matrix completeness test** ```java package io.backend.skeleton.messaging.testkit; import org.junit.jupiter.api.Test; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; class CrossBrokerContractSuite { @Test void stableReleaseIncludesEveryRequiredFailureScenario() { Set scenarios = BrokerFailureMatrix.requiredScenarioNames(); assertThat(scenarios).contains( "publish-confirm-loss", "consumer-settlement-loss", "leader-or-node-failover", "network-partition", "retry-exhausted", "dlq-target-outage", "schema-poison", "credential-rotation", "tls-hostname-mismatch", "acl-denied", "graceful-shutdown", "outbox-ambiguous-publish", "inbox-duplicate-delivery"); } } ``` - [ ] **Step 2: Run cross-broker tests and verify missing matrix** Run: ```bash ./gradlew :modules:messaging:messaging-testkit:test :modules:messaging:messaging-kafka:test --tests '*ChaosSuiteIT' :modules:messaging:messaging-rabbit:test --tests '*ChaosSuiteIT' ``` Expected: FAIL because fault scenarios and chaos suites are missing. - [ ] **Step 3: Implement deterministic network and process fault scenarios** The matrix injects: ```text connection refusal latency packet loss half-open connection confirmation path drop consumer settlement path drop Kafka leader stop Rabbit node stop or quorum minority PostgreSQL restart process kill at Outbox and Inbox crash points credential rotation certificate rotation ``` Each scenario asserts final state, allowed duplicate count, message identity, settlement state, metric tags, and secret-free logs. Keep broker-specific expected differences in adapter assertions, not the Core guarantee. Register the aggregate task in `messaging-chaos-conventions.gradle.kts`: ```kotlin tasks.register("messagingStableChaos") { group = "verification" dependsOn( ":modules:messaging:messaging-kafka:test", ":modules:messaging:messaging-rabbit:test", ":modules:messaging:messaging-outbox-jpa:test", ":modules:messaging:messaging-inbox-jpa:test" ) } ``` - [ ] **Step 4: Run the complete Stable failure matrix** Run: ```bash ./gradlew messagingStableChaos ``` Expected: PASS with Kafka, RabbitMQ, PostgreSQL, TLS, ACL, Outbox, and Inbox scenarios. Experimental adapters are not part of this Stable gate. - [ ] **Step 5: Commit** ```bash git add infra/messaging/toxiproxy modules/messaging/messaging-testkit modules/messaging/messaging-kafka modules/messaging/messaging-rabbit modules/messaging/messaging-outbox-jpa modules/messaging/messaging-observability git commit -m "test: add messaging stable failure matrix" ``` --- ### Task 43: 성능 인증, Compatibility Matrix, Resource Leak Gate 구현 **Files:** - Create: `modules/messaging/messaging-testkit/src/jmh/java/io/backend/skeleton/messaging/testkit/EnvelopeCodecBenchmark.java` - Create: `modules/messaging/messaging-kafka/src/jmh/java/io/backend/skeleton/messaging/kafka/KafkaPublishBenchmark.java` - Create: `modules/messaging/messaging-rabbit/src/jmh/java/io/backend/skeleton/messaging/rabbit/RabbitPublishBenchmark.java` - Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingLoadIT.java` - Create: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/ResourceLeakIT.java` - Create: `.github/workflows/messaging-compatibility.yml` - Create: `build-logic/src/main/kotlin/messaging-verification-conventions.gradle.kts` - Modify: `build.gradle.kts` - Create: `.github/workflows/messaging-performance.yml` - Create: `gradle/messaging-versions.properties` **Interfaces:** - Consumes all Stable modules and Experimental smoke tasks. - Produces reproducible latency, throughput, heap, thread, connection, backlog, retry-amplification, and compatibility reports. - [ ] **Step 1: Write failing performance-budget and leak tests** ```java package io.backend.skeleton.messaging.testkit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class ResourceLeakIT { @Test void repeatedStartupShutdownReturnsThreadsAndConnectionsToBaseline() { ResourceSnapshot before = ResourceSnapshot.capture(); for (int index = 0; index < 20; index++) { try (MessagingSystemFixture fixture = MessagingSystemFixture.startAndStop()) { fixture.publishAndConsume(100); } } ResourceSnapshot after = ResourceSnapshot.capture(); assertThat(after.nonDaemonThreadDelta(before)).isLessThanOrEqualTo(2); assertThat(after.openConnectionDelta(before)).isZero(); } } ``` ```java package io.backend.skeleton.messaging.testkit; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class MessagingLoadIT { @Test void retryAmplificationStaysWithinConfiguredBudget() { LoadReport report = MessagingLoadFixture.runWithTenPercentTransientFailures(); assertThat(report.physicalPublishes()) .isLessThanOrEqualTo(report.logicalPublishes() * 1.20); assertThat(report.maxHeapBytes()).isLessThanOrEqualTo(512L * 1024 * 1024); } } ``` - [ ] **Step 2: Run performance smoke tests and verify missing harness** Run: ```bash ./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingLoadIT' --tests '*ResourceLeakIT' ``` Expected: FAIL because performance and resource fixtures are missing. - [ ] **Step 3: Implement benchmark scenarios and CI version matrix** Measure: ```text p50 p95 p99 max publish confirmation latency consumer processing and settlement latency throughput producer buffered bytes consumer in-flight and backlog heap allocation and GC thread count broker connection/channel count retry amplification DLQ throughput Outbox age and relay throughput Inbox duplicate contention ``` Compatibility workflow runs Kafka 4.2 and 4.3.x, RabbitMQ 4.3.x, PostgreSQL 16, Spring 6.2 and 7.0 lines, plus non-blocking Experimental smoke for Pulsar 4.0/4.2 and NATS 2.14.x. Store baselines as versioned JSON and fail on agreed regression thresholds. Register aggregate tasks in `messaging-verification-conventions.gradle.kts`: ```kotlin tasks.register("messagingPerformance") { group = "verification" dependsOn( ":modules:messaging:messaging-testkit:test", ":modules:messaging:messaging-kafka:jmh", ":modules:messaging:messaging-rabbit:jmh" ) } tasks.register("messagingCompatibility") { group = "verification" dependsOn( ":modules:messaging:messaging-kafka:test", ":modules:messaging:messaging-rabbit:test", ":modules:messaging:messaging-pulsar-experimental:test", ":modules:messaging:messaging-nats-experimental:test" ) } ``` - [ ] **Step 4: Run performance certification and compatibility jobs locally where supported** Run: ```bash ./gradlew messagingPerformance messagingCompatibility ``` Expected: PASS with generated reports under `build/reports/messaging` and no resource leak. - [ ] **Step 5: Commit** ```bash git add modules/messaging .github/workflows gradle/messaging-versions.properties git commit -m "test: add messaging performance and compatibility gates" ``` --- ### Task 44: 지원 문서, Runbook, ADR, Release Gate 완성 **Files:** - Create: `docs/messaging/support-matrix.md` - Create: `docs/messaging/configuration-reference.md` - Create: `docs/messaging/delivery-guarantees.md` - Create: `docs/messaging/retry-dlq-redrive.md` - Create: `docs/messaging/outbox-inbox.md` - Create: `docs/messaging/security.md` - Create: `docs/messaging/operations.md` - Create: `docs/messaging/migration-guide.md` - Create: `docs/messaging/experimental-policy.md` - Create: `docs/adr/ADR-0041-messaging-core-native-adapters.md` - Create: `docs/adr/ADR-0042-no-generic-exactly-once.md` - Create: `docs/adr/ADR-0043-outbox-inbox-reliability.md` - Create: `docs/messaging/release-checklist.md` - Test: `modules/messaging/messaging-testkit/src/test/java/io/backend/skeleton/messaging/testkit/MessagingDocumentationContractTest.java` **Interfaces:** - Consumes every design decision and test report. - Produces implementation-independent usage, configuration, security, reliability, migration, operations, and release documentation. - Documentation contract verifies that every Stable and Experimental capability is classified and every dangerous operation has a runbook. - [ ] **Step 1: Write failing documentation contract** ```java package io.backend.skeleton.messaging.testkit; import org.junit.jupiter.api.Test; import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class MessagingDocumentationContractTest { @Test void supportMatrixClassifiesEveryAdapterAndCapability() throws Exception { String matrix = Files.readString(Path.of("docs/messaging/support-matrix.md")); assertThat(matrix).contains( "Kafka | Stable", "RabbitMQ | Stable", "Kafka Share Group | Experimental", "Pulsar | Experimental", "NATS JetStream | Experimental", "EXACTLY_ONCE | Unsupported"); } @Test void operationsRunbookCoversAmbiguousPublishAndDlqOutage() throws Exception { String runbook = Files.readString(Path.of("docs/messaging/operations.md")); assertThat(runbook).contains( "Ambiguous publish", "DLQ target outage", "Consumer settlement unknown", "Outbox backlog", "Replay and redrive approval"); } } ``` - [ ] **Step 2: Run documentation contract and verify missing files** Run: ```bash ./gradlew :modules:messaging:messaging-testkit:test --tests '*MessagingDocumentationContractTest' ``` Expected: FAIL because the documentation files do not exist. - [ ] **Step 3: Write exact operational and developer documentation** The documents must include: ```text M1–M4 API examples Kafka and Rabbit physical mapping examples PublishResult evidence interpretation Consumer duplicate and settlement rules Retry strategy selection table DLQ publish-before-ACK invariant Redrive approval workflow Outbox and Inbox crash diagrams Schema compatibility rules Claim Check lifecycle TLS/ACL credential separation Metric/tag allowlist and denylist Kafka and Rabbit failure runbooks Experimental feature enablement and rollback Spring Cloud Stream migration limitations ``` `release-checklist.md` links every completion criterion to a Gradle task or CI job. ADRs record the own-Core/native-adapter decision, the rejection of generic exactly-once, and the Outbox+Inbox reliability model. - [ ] **Step 4: Run full verification** Run: ```bash ./gradlew clean check messagingStableChaos messagingPerformance messagingCompatibility ``` Expected: PASS with zero test failures, zero architecture violations, and generated Stable release reports. - [ ] **Step 5: Commit** ```bash git add docs modules/messaging/messaging-testkit git commit -m "docs: complete messaging platform release guidance" ``` --- ## 3. Plan Self-Review Checklist ### Spec coverage - [ ] M1 Typed Publisher·Handler: Tasks 2–8, 35, 36 - [ ] M2 Batch·Manual Settlement·Replay: Tasks 8, 20, 34, 36 - [ ] M3 Kafka·Rabbit native capability: Tasks 19, 20, 25 - [ ] M4 Admin Plane: Tasks 33–35 - [ ] Publish evidence and ambiguity: Tasks 7, 10, 12, 16, 23 - [ ] Consumer duplicate and settlement: Tasks 8, 12, 17, 24, 28 - [ ] Retry·DLQ·redrive identity: Tasks 13, 14, 18, 25, 34 - [ ] Kafka Stable: Tasks 15–20 - [ ] RabbitMQ Stable: Tasks 22–25 - [ ] Outbox·Inbox·Claim Check: Tasks 26–29, 37 - [ ] JSON·Avro·Protobuf·CloudEvents: Tasks 5, 6, 30 - [ ] Security and ACL: Tasks 11, 31 - [ ] Observability and audit: Tasks 11, 32 - [ ] Topology and operations: Tasks 20, 33, 34 - [ ] Pulsar·NATS Experimental: Tasks 38, 39 - [ ] Spring Cloud Stream bridge: Task 40 - [ ] Backpressure and shutdown: Task 41 - [ ] Failure matrix and performance: Tasks 42, 43 - [ ] Documentation and release gate: Task 44 ### Placeholder scan The final document must contain no unresolved markers, deferred implementation phrases, or cross-task shorthand that omits required code. ### Type consistency - `MessagePublisher.publish` always returns `CompletionStage`. - `MessageHandler.handle` always returns `CompletionStage`. - `PublishCompletion` constants are `CONFIRMED`, `REJECTED`, `AMBIGUOUS`. - `SettlementCompletion` constants are `SETTLED`, `REJECTED`, `UNKNOWN`. - `RetryMode` constants match the design exactly. - `messageId` remains `MessageId` from Task 2 through Outbox, Inbox, DLQ, retry, replay, and redrive. - Stable modules never depend on Experimental modules. --- ## 4. Execution Handoff Plan execution begins only after the design and plan are reviewed in the target repository. **Recommended:** `superpowers:subagent-driven-development` - fresh implementation agent per Task - requirements review after each Task - code quality review after each Task - full Stable gate after Tasks 25, 35, 42, and 44 **Alternative:** `superpowers:executing-plans` - execute Tasks in the listed order - checkpoint after Foundation, Kafka, RabbitMQ, Reliability, Operations, Experimental, Release phases - never combine unreviewed Tasks into one commit