feat: add messaging R2 polling producer

This commit is contained in:
donghyeon-ka
2026-07-31 23:48:43 +09:00
parent b3add0162d
commit e5af291269
93 changed files with 19617 additions and 139 deletions
+17
View File
@@ -816,6 +816,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결
BOM 이 관리.
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
loggers / loggers 쓰기 거부)를 검증한다.
- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity,
wildcard-free compatibility, evidence task/scenario/runbook 선언을
`MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며
모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다.
### Messaging qualification scaffold
`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다.
현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer,
CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과
`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest
validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와
무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*`
root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed
guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는
다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread,
network 또는 다른 런타임 resource를 생성하지 않는다.
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
+2
View File
@@ -96,6 +96,8 @@ dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
// test-only: jackson-databind for the deserialization-policy boundary test. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-json'
// test-only: parses checked-in Messaging capability registries for the fail-closed drift gate.
testImplementation 'org.yaml:snakeyaml'
// sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
sampleFixture project(':sample-portfolio')
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README.
+3 -1
View File
@@ -9,6 +9,7 @@ ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,r
ch.qos.logback:logback-core:1.5.34=sampleFixture
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -57,6 +58,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOf
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
@@ -346,7 +348,7 @@ org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClas
org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -7,6 +7,8 @@ import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.EvaluationResult;
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging.GenericTypeLeakingAdapterFixture;
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
@@ -85,6 +87,10 @@ class ArchitectureViolationFixtureTest {
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class);
private static final JavaClasses GENERIC_ADAPTER_LEAK_ONLY =
new ClassFileImporter()
.importClasses(GenericTypeLeakingAdapterFixture.class, RawExternalResponseFixture.class);
// feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation
// branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a
// silently broken branch cannot pass vacuously via the other one in the shared pool.
@@ -397,6 +403,20 @@ class ArchitectureViolationFixtureTest {
.isTrue();
}
@Test
void outboundAdapterMethodReturnsOnlyDomainCatchesGenericAdapterLeakInIsolation() {
EvaluationResult result =
CleanArchitectureTest
.MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS
.evaluate(GENERIC_ADAPTER_LEAK_ONLY);
assertThat(result.hasViolation())
.as(
"MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS "
+ "must recursively catch List<RawExternalResponseFixture> (B7)")
.isTrue();
}
@Test
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
EvaluationResult result =
@@ -1272,6 +1272,62 @@ class CleanArchitectureTest {
+ "response surfaces.")
.allowEmptyShould(true);
@ArchTest
static final ArchRule
MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS =
methods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..adapter.outbound.messaging..")
.and()
.areDeclaredInClassesThat()
.areNotAnnotatedWith("org.springframework.context.annotation.Configuration")
.and()
.areDeclaredInClassesThat()
.areNotAnnotatedWith(
"org.springframework.boot.context.properties.ConfigurationProperties")
.and()
.arePublic()
.and()
.areNotStatic()
.should(
notReturnAdapterTypesIncludingGenericArguments(
"..adapter.outbound..",
"..adapter.inbound.web..",
"..adapter.outbound.persistence.."))
.as(
"B7 messaging hardening: every public non-static messaging method must keep "
+ "adapter-local types out of direct and recursive generic return positions; "
+ "narrow public static composition bridges are the only allowed adapter-local "
+ "return seam")
.allowEmptyShould(true);
private static ArchCondition<JavaMethod> notReturnAdapterTypesIncludingGenericArguments(
String... forbiddenPackages) {
DescribedPredicate<JavaClass> forbidden =
JavaClass.Predicates.resideInAnyPackage(forbiddenPackages);
return new ArchCondition<>(
"not return adapter-local types directly or through generic arguments") {
@Override
public void check(JavaMethod method, ConditionEvents events) {
for (JavaClass involved : method.getReturnType().getAllInvolvedRawTypes()) {
if (forbidden.test(involved)) {
events.add(
SimpleConditionEvent.violated(
method,
"Method "
+ method.getFullName()
+ " leaks "
+ involved.getName()
+ " through its return type (directly or as a generic argument)"
+ " — B7 permits only narrow static composition bridges for "
+ "adapter-local types"));
}
}
}
};
}
@ArchTest
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
classes()
@@ -0,0 +1,14 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging;
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
import java.util.List;
/**
* Negative B7 fixture proving adapter-local types cannot escape through generic return arguments.
*/
public class GenericTypeLeakingAdapterFixture {
public List<RawExternalResponseFixture> leakGeneric() {
return List.of(new RawExternalResponseFixture());
}
}
@@ -0,0 +1,510 @@
package dev.caskeleton.bootstrap.contract.messaging;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.error.YAMLException;
class MessagingCapabilityRegistryContractTest {
private static final Set<String> FIRST_TUPLE_CARD_IDS =
Set.of(
"messaging-outbox-publish.v1",
"kafka-spring-acknowledged-idempotent.v1",
"postgresql-polling-outbox.v2",
"postgresql-per-record-jit-claim.v1",
"json-schema-envelope.v1",
"external-topic-validated.v1",
"kafka-sasl-ssl-scram-sha-512.v1",
"kafka-compression-none.v1",
"per-key-normal-path-sequence-detectable.v1",
"same-postgresql-transaction-resource.v1",
"authenticated-internal-web-disposition.v1");
private static final Set<String> LEGAL_MATURITY =
Set.of("not-implemented", "implemented-candidate", "release-eligible");
private static final Set<String> REQUIRED_VERIFICATION_TASKS =
Set.of(
"verifyMessagingContracts",
"verifyMessagingJsonSchemaV1",
"verifyMessagingPollingOutboxR2",
"verifyMessagingKafkaProducerR2",
"verifyMessagingSecurityR2",
"verifyMessagingReleaseProfile",
"verifyMessagingTargetBindingPreflight",
"verifyMessagingTargetBinding",
"verifyMessagingDeploymentCutover",
"verifyMessagingCleanupTargetBinding",
"verifyMessagingFinalR2Profile");
private static final Set<String> FORBIDDEN_EXTENSION_TOKENS =
Set.of(
"consumer",
"inbox",
"cdc",
"debezium",
"connect",
"eos",
"exactly-once",
"schema-registry",
"schema_registry",
"avro",
"protobuf");
private static final List<String> COMPATIBILITY_SELECTOR_FIELDS =
List.of(
"semanticContractId",
"dispatchProfile",
"claimProfile",
"serializationProfile",
"producerProfile",
"securityProfile",
"topicProfile",
"compressionProfile",
"orderingProfile",
"transactionProfile",
"operatorControlProfile");
@Test
void yamlLoaderRejectsDuplicateKeys() {
String duplicateKeyFixture =
"""
schemaVersion: 1
cards:
- cardId: first.v1
maturity: not-implemented
maturity: release-eligible
""";
assertThatThrownBy(() -> readYaml(duplicateKeyFixture))
.isInstanceOf(YAMLException.class)
.hasMessageContaining("duplicate");
}
@Test
void readinessCardsContainExactlyTheFirstPollingProducerTuple() throws Exception {
Map<String, Object> registry = readYaml(requiredConfig("readiness-cards.yaml"));
List<Map<String, Object>> cards = rows(registry, "cards");
Set<String> cardIds = stringValues(cards, "cardId");
assertThat(cardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
assertThat(cards).hasSize(FIRST_TUPLE_CARD_IDS.size());
assertThat(cardIds).hasSameSizeAs(cards);
}
@Test
void onlyTheQualifiedJsonSchemaCardIsAnImplementedCandidate() throws Exception {
List<Map<String, Object>> cards =
rows(readYaml(requiredConfig("readiness-cards.yaml")), "cards");
for (Map<String, Object> card : cards) {
assertThat(card.get("maturity")).isIn(LEGAL_MATURITY);
assertThat(card.get("evidenceFingerprint")).isEqualTo("");
if ("json-schema-envelope.v1".equals(card.get("cardId"))) {
assertThat(card.get("maturity")).isEqualTo("implemented-candidate");
assertThat(card.get("schemaSetHash"))
.isEqualTo("sha256:42040504d5c204f9ee0e01bfa17fd9a03182db13f8f99e31b63ec79d5ecc40d0");
assertThat(card.get("settingsDigest"))
.isEqualTo("sha256:fcd849322d43a8d88c926a43160296339cbc6e945db81e46c513ab45d185b924");
} else {
assertThat(card.get("maturity")).isEqualTo("not-implemented");
assertThat(card.get("schemaSetHash")).isEqualTo("");
assertThat(card.get("settingsDigest")).isEqualTo("");
}
assertNonEmptyStringList(card, "evidenceTasks");
assertThat(stringList(card, "evidenceTasks"))
.as("evidenceTasks on %s must reference declared root tasks", card.get("cardId"))
.allMatch(REQUIRED_VERIFICATION_TASKS::contains);
assertNonEmptyStringList(card, "requiredScenarios");
assertNonEmptyStringList(card, "runbookIds");
}
}
@Test
void compatibilityProfilesAreExactUniqueAndWildcardFree() throws Exception {
Map<String, Object> registry = readYaml(requiredConfig("profile-compatibility.yaml"));
List<Map<String, Object>> profiles = rows(registry, "profiles");
assertThat(profiles).isNotEmpty();
assertUnique(profiles, "profileId");
assertThat(flattenStrings(registry))
.allSatisfy(
value -> {
assertThat(value).doesNotContain("*");
assertThat(value).doesNotContain("?");
});
for (Map<String, Object> profile : profiles) {
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("profileId"));
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
assertThat(selectedCardIds).allMatch(FIRST_TUPLE_CARD_IDS::contains);
for (String selectorField : COMPATIBILITY_SELECTOR_FIELDS) {
assertThat(profile.get(selectorField))
.as("%s must select one of selectedCardIds", selectorField)
.isIn(selectedCardIds);
}
assertNonEmptyStringList(profile, "requiredScenarios");
assertListIntegrityIfPresent(profile, "runbookIds");
}
}
@Test
void releaseAssertionsDeclareUniqueTasksScenariosRunbooksAndFailClosedEvidencePolicy()
throws Exception {
Map<String, Object> registry = readYaml(requiredConfig("release-profile-assertions.yaml"));
List<Map<String, Object>> profiles = rows(registry, "releaseProfiles");
assertThat(profiles).isNotEmpty();
assertUnique(profiles, "releaseProfileId");
Set<String> compatibilityProfileIds =
stringValues(
rows(readYaml(requiredConfig("profile-compatibility.yaml")), "profiles"), "profileId");
for (Map<String, Object> profile : profiles) {
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("releaseProfileId"));
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
assertThat(profile.get("compatibilityProfileId")).isIn(compatibilityProfileIds);
assertThat(profile.get("requiredCardMaturity")).isEqualTo("release-eligible");
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
assertListIntegrity(
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
assertThat(requiredEvidenceTasks)
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
assertNonEmptyStringList(profile, "requiredScenarios");
assertNonEmptyStringList(profile, "runbookIds");
Map<String, Object> evidencePolicy = map(profile, "evidencePolicy");
assertThat(evidencePolicy)
.containsEntry("rejectMissing", true)
.containsEntry("rejectSkipped", true)
.containsEntry("rejectStale", true)
.containsEntry("rejectWrongSource", true)
.containsEntry("rejectMismatchedProfile", true);
}
}
@Test
void registriesDoNotPredeclareFutureConsumerCdcEosOrSchemaRegistryRows() throws Exception {
Map<String, Object> readiness = readYaml(requiredConfig("readiness-cards.yaml"));
Map<String, Object> compatibility = readYaml(requiredConfig("profile-compatibility.yaml"));
Map<String, Object> releases = readYaml(requiredConfig("release-profile-assertions.yaml"));
List<String> rowIdentities = new ArrayList<>();
rowIdentities.addAll(stringValues(rows(readiness, "cards"), "cardId"));
rowIdentities.addAll(stringValues(rows(compatibility, "profiles"), "profileId"));
rowIdentities.addAll(stringValues(rows(releases, "releaseProfiles"), "releaseProfileId"));
for (Map<String, Object> profile : rows(compatibility, "profiles")) {
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
}
for (Map<String, Object> profile : rows(releases, "releaseProfiles")) {
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
}
for (String identity : rowIdentities) {
String normalized = identity.toLowerCase(java.util.Locale.ROOT);
assertThat(FORBIDDEN_EXTENSION_TOKENS)
.noneSatisfy(
token ->
assertThat(normalized)
.as(
"machine row '%s' must not predeclare future extension token '%s'",
identity, token)
.contains(token));
}
}
@Test
void commonEvidenceSchemaIsPayloadFreeAndKeepsAllFailClosedFields() throws Exception {
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
Map<String, Object> root =
new ObjectMapper().readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
assertThat(root.get("$schema")).isEqualTo("https://json-schema.org/draft/2020-12/schema");
assertThat(root).containsEntry("type", "object").containsEntry("additionalProperties", false);
assertThat(stringList(root, "required"))
.containsExactlyInAnyOrder(
"schemaVersion",
"sourceDigest",
"artifactDigest",
"producerTask",
"scenarioIds",
"counts",
"command",
"generatedAt",
"hashes",
"failures",
"skips",
"unsupportedClaims");
String schemaText = Files.readString(schema).toLowerCase(java.util.Locale.ROOT);
assertThat(schemaText)
.doesNotContain("\"payload\"")
.contains("\"profile\"")
.contains("\"catalog\"")
.contains("\"schema\"")
.contains("\"settings\"");
}
@Test
void commonEvidenceSchemaClosesNestedObjectsAndPreservesCoreFieldConstraints() throws Exception {
Map<String, Object> root = readJsonSchema();
Map<String, Object> properties = map(root, "properties");
Map<String, Object> definitions = map(root, "$defs");
assertThat(map(properties, "schemaVersion"))
.containsEntry("type", "integer")
.containsEntry("const", 1);
assertThat(map(properties, "sourceDigest")).containsEntry("$ref", "#/$defs/sha256");
assertThat(map(properties, "artifactDigest")).containsEntry("$ref", "#/$defs/sha256");
Map<String, Object> producerTask = map(properties, "producerTask");
assertThat(producerTask).containsEntry("type", "string");
assertThat(stringList(producerTask, "enum"))
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
assertArrayOfReference(
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
assertClosedObject(
map(properties, "counts"), Set.of("executed", "passed", "failed", "skipped"));
Map<String, Object> countProperties = map(map(properties, "counts"), "properties");
assertIntegerMinimum(countProperties, "executed", 1);
assertIntegerMinimum(countProperties, "passed", 0);
assertIntegerMinimum(countProperties, "failed", 0);
assertIntegerMinimum(countProperties, "skipped", 0);
assertThat(map(properties, "command")).containsEntry("type", "string");
assertThat(map(properties, "generatedAt"))
.containsEntry("type", "string")
.containsEntry("format", "date-time");
Map<String, Object> hashes = map(properties, "hashes");
assertClosedObject(hashes, Set.of("profile", "catalog", "schema", "settings"));
Map<String, Object> hashProperties = map(hashes, "properties");
for (String hashName : List.of("profile", "catalog", "schema", "settings")) {
assertThat(map(hashProperties, hashName)).containsEntry("$ref", "#/$defs/sha256");
}
assertArrayOfReference(map(properties, "failures"), "#/$defs/result", false, null);
assertArrayOfReference(map(properties, "skips"), "#/$defs/result", false, null);
assertArrayOfReference(map(properties, "unsupportedClaims"), "#/$defs/identifier", true, null);
assertThat(map(definitions, "sha256"))
.containsEntry("type", "string")
.containsEntry("pattern", "^sha256:[a-f0-9]{64}$");
assertThat(map(definitions, "identifier"))
.containsEntry("type", "string")
.containsEntry("pattern", "^[A-Za-z0-9][A-Za-z0-9._:-]*$");
Map<String, Object> result = map(definitions, "result");
assertClosedObject(result, Set.of("scenarioId", "reason"));
Map<String, Object> resultProperties = map(result, "properties");
assertThat(map(resultProperties, "scenarioId")).containsEntry("$ref", "#/$defs/identifier");
assertThat(map(resultProperties, "reason"))
.containsEntry("type", "string")
.containsEntry("minLength", 1);
}
@Test
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
assertThat(build).contains("messagingFailClosedEvidenceGuard");
assertThat(build)
.contains(
"messagingVerificationSkeletons.each",
"tasks.register(taskName)",
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)",
"qualification producer/tests and common-schema validator are not implemented",
"missing evidence",
"contains skipped evidence",
"is stale or future-dated",
"has wrong source digest",
"has mismatched profile hash");
for (String taskName : REQUIRED_VERIFICATION_TASKS) {
assertThat(build).contains("'" + taskName + "'");
}
}
@Test
void rootBuildSchemaValidatesEvidenceAndDeterministicallyLeavesCombinedEvidenceLast()
throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
assertThat(build)
.contains(
"MessagingEvidenceManifestSchemaValidator",
"validateMessagingJsonSchemaV1EvidenceManifestSchema",
"validateMessagingContractsEvidenceManifestSchema",
"verifyMessagingJsonSchemaV1.configure",
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
"verifyMessagingContracts.configure",
"finalizedBy validateMessagingContractsEvidenceManifestSchema");
}
private static Path requiredConfig(String relativePath) {
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
assertThat(path).as("required Messaging configuration %s", path).isRegularFile();
return path;
}
private static Path repositorySrcRoot() {
Path directory = Path.of("").toAbsolutePath();
for (int depth = 0; depth < 8 && directory != null; depth++) {
if (Files.isRegularFile(directory.resolve("settings.gradle"))
&& Files.isRegularFile(directory.resolve("config/architecture/modules.json"))) {
return directory;
}
directory = directory.getParent();
}
throw new IllegalStateException(
"Could not locate repository src root from test working directory");
}
@SuppressWarnings("unchecked")
private static Map<String, Object> readYaml(Path path) throws Exception {
try (InputStream input = Files.newInputStream(path)) {
return readYaml(input);
}
}
private static Map<String, Object> readYaml(String yaml) {
return readYaml((Object) yaml);
}
private static Map<String, Object> readYaml(InputStream yaml) {
return readYaml((Object) yaml);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> readYaml(Object yamlInput) {
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setAllowDuplicateKeys(false);
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions));
Object value =
yamlInput instanceof InputStream input ? yaml.load(input) : yaml.load((String) yamlInput);
assertThat(value).isInstanceOf(Map.class);
return (Map<String, Object>) value;
}
private static Map<String, Object> readJsonSchema() throws Exception {
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
return new ObjectMapper()
.readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> rows(Map<String, Object> root, String key) {
assertThat(root.get(key)).as("top-level '%s' rows", key).isInstanceOf(List.class);
return (List<Map<String, Object>>) root.get(key);
}
private static Set<String> stringValues(List<Map<String, Object>> rows, String key) {
Set<String> values = new LinkedHashSet<>();
for (Map<String, Object> row : rows) {
assertThat(row.get(key)).isInstanceOf(String.class);
values.add((String) row.get(key));
}
return values;
}
private static void assertUnique(List<Map<String, Object>> rows, String key) {
Set<String> values = stringValues(rows, key);
assertThat(values).hasSameSizeAs(rows);
}
private static void assertNonEmptyStringList(Map<String, Object> row, String key) {
assertListIntegrity(stringList(row, key), key, row);
}
private static void assertListIntegrityIfPresent(Map<String, Object> row, String key) {
if (row.containsKey(key)) {
assertListIntegrity(stringList(row, key), key, row);
}
}
private static void assertListIntegrity(List<String> values, String key, Object owner) {
assertThat(values)
.as("%s on %s", key, owner)
.isNotEmpty()
.doesNotHaveDuplicates()
.allSatisfy(value -> assertThat(value).isNotBlank());
}
@SuppressWarnings("unchecked")
private static List<String> stringList(Map<String, Object> row, String key) {
assertThat(row.get(key)).as("required list '%s' on %s", key, row).isInstanceOf(List.class);
List<Object> values = (List<Object>) row.get(key);
assertThat(values).allMatch(String.class::isInstance);
return values.stream().map(String.class::cast).toList();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Map<String, Object> row, String key) {
assertThat(row.get(key)).as("required map '%s' on %s", key, row).isInstanceOf(Map.class);
return (Map<String, Object>) row.get(key);
}
private static void assertClosedObject(Map<String, Object> schema, Set<String> required) {
assertThat(schema).containsEntry("type", "object").containsEntry("additionalProperties", false);
assertThat(stringList(schema, "required")).containsExactlyInAnyOrderElementsOf(required);
}
private static void assertArrayOfReference(
Map<String, Object> schema, String reference, boolean unique, Integer minimumItems) {
assertThat(schema).containsEntry("type", "array");
assertThat(map(schema, "items")).containsEntry("$ref", reference);
if (unique) {
assertThat(schema).containsEntry("uniqueItems", true);
}
if (minimumItems != null) {
assertThat(schema).containsEntry("minItems", minimumItems);
}
}
private static void assertIntegerMinimum(
Map<String, Object> properties, String field, int minimum) {
assertThat(map(properties, field))
.containsEntry("type", "integer")
.containsEntry("minimum", minimum);
}
private static List<String> flattenStrings(Object value) {
List<String> strings = new ArrayList<>();
flattenStrings(value, strings, new HashSet<>());
return strings;
}
private static void flattenStrings(
Object value, List<String> destination, Set<Object> visitedContainers) {
if (value instanceof String string) {
destination.add(string);
} else if (value instanceof Map<?, ?> map && visitedContainers.add(value)) {
map.forEach(
(key, nested) -> {
destination.add(String.valueOf(key));
flattenStrings(nested, destination, visitedContainers);
});
} else if (value instanceof Iterable<?> iterable && visitedContainers.add(value)) {
iterable.forEach(nested -> flattenStrings(nested, destination, visitedContainers));
}
}
}
@@ -53,7 +53,7 @@ class OutboxAppendTransactionalContractTest {
}
@Test
void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() {
void sameTransactionAppendRollbackRemovesOutboxRowCharacterization() {
Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
try (AnnotationConfigApplicationContext ctx =
@@ -318,6 +318,43 @@ class OutboxRowLifecycleContractTest {
}
}
@Test
void equalTimestampRowsAreClaimedTogetherLegacyFifoLimitationCharacterization() {
Instant occurredAt = Instant.now();
Clock clock = Clock.fixed(occurredAt.plusSeconds(1), ZoneOffset.UTC);
try (AnnotationConfigApplicationContext ctx =
OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) {
TransactionPort tx = ctx.getBean(TransactionPort.class);
OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class);
String aggregateId = "agg-fifo-tie-" + System.nanoTime();
String firstId = "fifo-tie-first-" + System.nanoTime();
String secondId = "fifo-tie-second-" + System.nanoTime();
tx.inWrite(
() -> {
append.append(newEventAt(firstId, aggregateId, occurredAt));
append.append(newEventAt(secondId, aggregateId, occurredAt));
return null;
});
OutboxRelayResult cycle =
OutboxContainerTestSupport.relayUseCase(ctx)
.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(cycle.outcomes())
.as(
"legacy FIFO compares only occurred_at with '<', so equal timestamps do not gate"
+ " either row")
.filteredOn(
outcome -> outcome.eventId().equals(firstId) || outcome.eventId().equals(secondId))
.extracting(OutboxRelayResult.EventOutcome::eventId)
.containsExactlyInAnyOrder(firstId, secondId);
}
}
// =========================================================================
// FIFO gate blocking scenarios (plan Task E)
//