merge: integrate messaging R2 polling producer

# Conflicts:
#	docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md
#	src/app-bootstrap/gradle.lockfile
#	src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java
#	src/build.gradle
This commit is contained in:
donghyeon-ka
2026-08-01 00:04:02 +09:00
92 changed files with 10591 additions and 54 deletions
+500
View File
@@ -1,5 +1,8 @@
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.xml.XmlSlurper
import java.time.Duration
import java.time.Instant
import org.gradle.api.artifacts.dsl.LockMode
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.tasks.bundling.AbstractArchiveTask
@@ -47,6 +50,115 @@ ext.releaseVersion = releaseVersion
ext.sourceRevision = sourceRevision
ext.traceableVersion = traceableVersion
// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed.
// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid,
// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass.
Map<String, List<String>> messagingVerificationSkeletons = [
'verifyMessagingPollingOutboxR2': [
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json'
],
'verifyMessagingKafkaProducerR2': [
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json'
],
'verifyMessagingSecurityR2': [
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
],
'verifyMessagingReleaseProfile': [
'build/messaging-evidence/contracts-schema/manifest.json',
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json',
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
],
'verifyMessagingTargetBindingPreflight': [
'app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json'
],
'verifyMessagingTargetBinding': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json'
],
'verifyMessagingDeploymentCutover': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
'app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json'
],
'verifyMessagingCleanupTargetBinding': [
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json'
],
'verifyMessagingFinalR2Profile': [
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json',
'build/messaging-evidence/final-r2-profile/manifest.json'
]
]
Closure<Void> messagingFailClosedEvidenceGuard = { String taskName, List<String> relativePaths ->
List<File> evidenceFiles = relativePaths.collect { rootProject.file(it) }
List<String> violations = evidenceFiles.findAll { !it.isFile() }.collect {
"missing evidence ${rootProject.relativePath(it)}"
}
String expectedSourceDigest = providers.gradleProperty('messagingSourceDigest').getOrElse('')
String expectedProfileHash = providers.gradleProperty('messagingProfileHash').getOrElse('')
if (expectedSourceDigest.isBlank()) {
violations << 'missing -PmessagingSourceDigest=sha256:<exact-source-digest>'
}
if (expectedProfileHash.isBlank()) {
violations << 'missing -PmessagingProfileHash=sha256:<exact-profile-hash>'
}
evidenceFiles.findAll { it.isFile() }.each { File evidenceFile ->
try {
def manifest = new JsonSlurper().parse(evidenceFile)
if (manifest.sourceDigest != expectedSourceDigest) {
violations << "${rootProject.relativePath(evidenceFile)} has wrong source digest"
}
if (manifest.hashes?.profile != expectedProfileHash) {
violations << "${rootProject.relativePath(evidenceFile)} has mismatched profile hash"
}
if ((manifest.counts?.skipped ?: 0) != 0 || !(manifest.skips instanceof List) ||
!manifest.skips.isEmpty()) {
violations << "${rootProject.relativePath(evidenceFile)} contains skipped evidence"
}
if ((manifest.counts?.failed ?: 0) != 0 || !(manifest.failures instanceof List) ||
!manifest.failures.isEmpty()) {
violations << "${rootProject.relativePath(evidenceFile)} contains failed evidence"
}
try {
Instant generatedAt = Instant.parse(manifest.generatedAt as String)
if (generatedAt.isBefore(Instant.now().minus(Duration.ofHours(24))) ||
generatedAt.isAfter(Instant.now().plus(Duration.ofMinutes(5)))) {
violations << "${rootProject.relativePath(evidenceFile)} is stale or future-dated"
}
} catch (RuntimeException ignored) {
violations << "${rootProject.relativePath(evidenceFile)} has invalid generatedAt"
}
} catch (RuntimeException ignored) {
violations << "${rootProject.relativePath(evidenceFile)} is not valid JSON evidence"
}
}
// Task 2 intentionally has no matching qualification Test tasks or complete schema validator.
// This unconditional violation prevents hand-written evidence from manufacturing an R2 PASS.
violations << 'qualification producer/tests and common-schema validator are not implemented'
throw new GradleException(
"${taskName}: FAIL_CLOSED — no R2 claim is available:\n ${violations.join('\n ')}")
}
messagingVerificationSkeletons.each { String taskName, List<String> evidencePaths ->
tasks.register(taskName) {
group = 'verification'
description = "Fail-closed Messaging qualification skeleton for ${taskName}."
inputs.files(evidencePaths.collect { rootProject.file(it) }).optional()
outputs.upToDateWhen { false }
doLast {
messagingFailClosedEvidenceGuard(taskName, evidencePaths)
}
}
}
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
@@ -289,6 +401,394 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
}
}
// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes.
// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties
// and every selected Task 3-6 test have passed in the current invocation.
def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence')
def registerMessagingQualificationTest = {
Project owner, String taskName, List<String> patterns, String resultDirectory ->
owner.tasks.register(taskName, Test) {
group = 'verification'
description = 'Runs exact Messaging Task 3-6 qualification tests without broad discovery.'
testClassesDirs = owner.sourceSets.test.output.classesDirs
classpath = owner.sourceSets.test.runtimeClasspath
useJUnitPlatform()
filter {
patterns.each { includeTestsMatching(it) }
failOnNoMatchingTests = true
}
failOnNoDiscoveredTests = true
reports.junitXml.required = true
reports.junitXml.outputLocation =
messagingEvidenceResultRoot.map { it.dir(resultDirectory) }
reports.html.required = false
binaryResultsDirectory =
layout.buildDirectory.dir("test-results/messaging-evidence-binary/${resultDirectory}")
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
}
def messagingApplicationQualification = registerMessagingQualificationTest(
project(':application-core'),
'messagingApplicationContractQualificationTest',
[
'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest',
'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest',
'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest'
],
'application')
def messagingSharedQualification = registerMessagingQualificationTest(
project(':shared-contract'),
'messagingSharedSchemaQualificationTest',
['dev.caskeleton.shared.contract.messaging.MessagingEnvelopeSchemaResourceTest'],
'shared')
def messagingSampleQualification = registerMessagingQualificationTest(
project(':sample-portfolio'),
'messagingSampleContractQualificationTest',
['dev.caskeleton.sample.portfolio.application.event.WorkLogReservedContractContributionTest'],
'sample')
def messagingCompiledQualification = registerMessagingQualificationTest(
project(':adapter:outbound:messaging'),
'messagingCompiledContractsQualificationTest',
[
'dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistryTest',
'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompilerTest',
'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigestTest',
'dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompilerTest',
'dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1Test'
],
'compiled')
def messagingJsonSchemaQualification = registerMessagingQualificationTest(
project(':adapter:outbound:messaging'),
'messagingJsonSchemaV1QualificationTest',
[
'dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistryTest',
'dev.caskeleton.adapter.outbound.messaging.envelope.JsonSchemaIntegrationEventEncoderTest',
'dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdversarialCorpusTest',
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidatorTest'
],
'json-schema')
def messagingEvidenceFile = layout.buildDirectory.file(
'messaging-evidence/contracts-schema/manifest.json')
def messagingProfileFile = file('config/messaging/profile-compatibility.yaml')
def messagingDigestProperty = { String propertyName ->
String value = providers.gradleProperty(propertyName).getOrElse('')
if (!(value ==~ /sha256:[a-f0-9]{64}/)) {
throw new GradleException(
"-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.")
}
value
}
def messagingSha256Bytes = { byte[] bytes ->
'sha256:' + java.util.HexFormat.of().formatHex(
MessageDigest.getInstance('SHA-256').digest(bytes))
}
def messagingSha256FileSet = { String domain, List<File> files ->
MessageDigest digest = MessageDigest.getInstance('SHA-256')
digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8))
digest.update((byte) 0)
files.sort { rootProject.relativePath(it) }.each { File input ->
if (!input.isFile()) {
throw new GradleException(
"Messaging evidence input is missing: ${rootProject.relativePath(input)}")
}
byte[] path = rootProject.relativePath(input)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)
byte[] content = input.bytes
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array())
digest.update(path)
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array())
digest.update(content)
}
'sha256:' + java.util.HexFormat.of().formatHex(digest.digest())
}
def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') {
group = 'verification'
outputs.upToDateWhen { false }
doLast {
File output = messagingEvidenceFile.get().asFile
if (output.exists() && !output.delete()) {
throw new GradleException("Could not delete stale Messaging evidence ${output}")
}
messagingDigestProperty('messagingSourceDigest')
messagingDigestProperty('messagingArtifactDigest')
String suppliedProfile = messagingDigestProperty('messagingProfileHash')
String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes)
if (suppliedProfile != exactProfile) {
throw new GradleException(
"messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.")
}
}
}
[
messagingApplicationQualification,
messagingSharedQualification,
messagingSampleQualification,
messagingCompiledQualification,
messagingJsonSchemaQualification
].each {
it.configure {
dependsOn prepareMessagingContractEvidence
}
}
def messagingEvidenceFromXml = { List<String> resultDirectories ->
List<Map<String, String>> cases = []
resultDirectories.each { String directory ->
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
fileTree(resultDirectory).matching { include 'TEST-*.xml' }.files.sort().each { File xml ->
def suite = new XmlSlurper(false, false).parse(xml)
suite.testcase.each { testCase ->
boolean failed = !testCase.failure.isEmpty() || !testCase.error.isEmpty()
boolean skipped = !testCase.skipped.isEmpty()
String simpleClass = testCase.@classname.text().tokenize('.').last()
String rawId = "${simpleClass}.${testCase.@name.text()}"
String scenarioId = rawId
.replace('()', '')
.replaceAll('[^A-Za-z0-9._:-]', '-')
.replaceAll('-+', '-')
cases << [id: scenarioId, failed: failed.toString(), skipped: skipped.toString()]
}
}
}
if (cases.isEmpty()) {
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
}
List<String> scenarioIds = cases.collect { it.id }.sort()
if (scenarioIds.toSet().size() != scenarioIds.size()) {
throw new GradleException('Messaging qualification scenario IDs are not unique.')
}
int failed = cases.count { it.failed == 'true' }
int skipped = cases.count { it.skipped == 'true' }
[
scenarioIds: scenarioIds,
counts: [
executed: cases.size(),
passed: cases.size() - failed - skipped,
failed: failed,
skipped: skipped
]
]
}
def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer ->
Set<String> exactRootKeys = [
'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds',
'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips',
'unsupportedClaims'
] as Set
Set<String> exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set
Set<String> exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set
List<String> violations = []
if (manifest.keySet() != exactRootKeys) {
violations << 'root fields do not match the common manifest schema'
}
if (manifest.schemaVersion != 1 || manifest.producerTask != expectedProducer) {
violations << 'schemaVersion or producerTask is wrong'
}
['sourceDigest', 'artifactDigest'].each { String field ->
if (!(manifest[field] instanceof String) ||
!(manifest[field] ==~ /sha256:[a-f0-9]{64}/)) {
violations << "${field} is not a canonical SHA-256"
}
}
if (!(manifest.scenarioIds instanceof List) || manifest.scenarioIds.isEmpty() ||
manifest.scenarioIds.toSet().size() != manifest.scenarioIds.size() ||
manifest.scenarioIds.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'scenarioIds violate the common schema'
}
if (!(manifest.counts instanceof Map) || manifest.counts.keySet() != exactCountKeys ||
!(manifest.counts.executed instanceof Integer) || manifest.counts.executed < 1 ||
manifest.counts.values().any { !(it instanceof Integer) || it < 0 } ||
manifest.counts.executed !=
manifest.counts.passed + manifest.counts.failed + manifest.counts.skipped) {
violations << 'counts are invalid or inconsistent'
}
if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 ||
manifest.failures != [] || manifest.skips != []) {
violations << 'failed or skipped qualification cannot produce PASS evidence'
}
if (!(manifest.hashes instanceof Map) || manifest.hashes.keySet() != exactHashKeys ||
manifest.hashes.values().any {
!(it instanceof String) || !(it ==~ /sha256:[a-f0-9]{64}/)
}) {
violations << 'hashes violate the common schema'
}
if (!(manifest.command instanceof String) || manifest.command.isBlank() ||
manifest.command.length() > 2048) {
violations << 'command is missing or unbounded'
}
try {
Instant.parse(manifest.generatedAt as String)
} catch (RuntimeException ignored) {
violations << 'generatedAt is not UTC date-time evidence'
}
if (!(manifest.unsupportedClaims instanceof List) ||
manifest.unsupportedClaims.toSet().size() != manifest.unsupportedClaims.size() ||
manifest.unsupportedClaims.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'unsupportedClaims violate the common schema'
}
if (!violations.isEmpty()) {
throw new GradleException(
"Messaging evidence fails the common schema structural validator:\n " +
violations.join('\n '))
}
}
def writeMessagingEvidence = {
String producerTask, List<String> resultDirectories, List<String> commandTasks ->
Map result = messagingEvidenceFromXml(resultDirectories)
Map manifest = [
schemaVersion: 1,
sourceDigest: messagingDigestProperty('messagingSourceDigest'),
artifactDigest: messagingDigestProperty('messagingArtifactDigest'),
producerTask: producerTask,
scenarioIds: result.scenarioIds,
counts: result.counts,
command: './gradlew ' + commandTasks.join(' ') +
' -PmessagingSourceDigest=<sha256> -PmessagingArtifactDigest=<sha256> ' +
'-PmessagingProfileHash=<exact-sha256> --console=plain',
generatedAt: Instant.now().toString(),
hashes: [
profile: messagingSha256Bytes(messagingProfileFile.bytes),
catalog: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.catalog.v1',
[file('config/messaging/readiness-cards.yaml')]),
schema: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.schema-set.v1',
[
file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'),
file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json')
] + fileTree(
'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12'
).files.toList()),
settings: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.settings.v1',
[
file('adapter/outbound/messaging/build.gradle'),
file('adapter/outbound/messaging/gradle.lockfile')
])
],
failures: [],
skips: [],
unsupportedClaims: [
'consumer-compatibility-full-suite',
'durable-outbox-r2',
'kafka-acknowledged-r2',
'regex-engine-timeout',
'remote-schema-resolution'
]
]
validateMessagingEvidenceStructure(manifest, producerTask)
File commonSchema =
file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
if (!commonSchema.isFile()) {
throw new GradleException('Common Messaging evidence schema is missing.')
}
File output = messagingEvidenceFile.get().asFile
output.parentFile.mkdirs()
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
Map reloaded = new JsonSlurper().parse(output) as Map
validateMessagingEvidenceStructure(reloaded, producerTask)
logger.lifecycle(
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
}
def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') {
group = 'verification'
description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.'
dependsOn messagingJsonSchemaQualification
dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph')
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingJsonSchemaV1',
['json-schema'],
[':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingJsonSchemaV1'])
}
}
def validateMessagingJsonSchemaV1EvidenceManifestSchema =
tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingJsonSchemaV1
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingJsonSchemaV1.configure {
finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema
}
def verifyMessagingContracts = tasks.register('verifyMessagingContracts') {
group = 'verification'
description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.'
dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema
dependsOn messagingApplicationQualification
dependsOn messagingSharedQualification
dependsOn messagingSampleQualification
dependsOn messagingCompiledQualification
dependsOn messagingJsonSchemaQualification
dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph')
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingContracts',
['application', 'shared', 'sample', 'compiled', 'json-schema'],
[
':application-core:messagingApplicationContractQualificationTest',
':shared-contract:messagingSharedSchemaQualificationTest',
':sample-portfolio:messagingSampleContractQualificationTest',
':adapter:outbound:messaging:messagingCompiledContractsQualificationTest',
':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingContracts'
])
}
}
def validateMessagingContractsEvidenceManifestSchema =
tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingContracts
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingContracts.configure {
finalizedBy validateMessagingContractsEvidenceManifestSchema
}
// One explicit command regenerates every module's Gradle-default lockfile.
tasks.register('resolveAndLockAll') {
group = 'build setup'