import groovy.json.JsonSlurper import groovy.json.JsonOutput import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Jar import java.security.MessageDigest plugins { id 'org.springframework.boot' version '4.0.0' apply false id 'io.spring.dependency-management' version '1.1.6' apply false // feature-static-analysis-quality-contract — static analysis / code quality baseline. id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format) id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs) id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker } // feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer // release coordinate plus the source revision that produced it. The MAJOR.MINOR.PATCH base can be // supplied with -PreleaseVersion or RELEASE_VERSION. The revision can be supplied with // -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git commit. String releaseVersion = providers.gradleProperty('releaseVersion') .orElse(providers.environmentVariable('RELEASE_VERSION')) .getOrElse('0.0.1') if (!(releaseVersion ==~ /\d+\.\d+\.\d+/)) { throw new GradleException( "releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '${releaseVersion}'.") } def localGitRevision = providers.exec { commandLine 'git', 'rev-parse', '--short=12', 'HEAD' ignoreExitValue = true }.standardOutput.asText.map { it.trim() } String sourceRevision = providers.gradleProperty('gitRevision') .orElse(providers.environmentVariable('GIT_SHA')) .orElse(providers.environmentVariable('GITHUB_SHA')) .orElse(localGitRevision) .getOrElse('') if (!(sourceRevision ==~ /(?i)[0-9a-f]{7,40}/)) { throw new GradleException( "A 7-40 character hexadecimal source revision is required; use -PgitRevision= when Git metadata is unavailable.") } sourceRevision = sourceRevision.toLowerCase(Locale.ROOT).take(12) String traceableVersion = "${releaseVersion}+${sourceRevision}" ext.releaseVersion = releaseVersion ext.sourceRevision = sourceRevision ext.traceableVersion = traceableVersion // 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 // platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the // strict-locking blast radius to the grpc module alone. ext.grpcVersion = '1.68.1' ext.protobufVersion = '3.25.5' // Outbound objectstorage adapter (adapter:outbound:objectstorage) — the Spring Boot BOM does NOT // manage software.amazon.awssdk:* versions, and this repo has no version catalog. Pin the AWS SDK // v2 BOM here as the single SSOT so the objectstorage module imports software.amazon.awssdk:bom as // a platform at MODULE scope (not the shared dependencyManagement block below) — mirroring the grpc // approach above and keeping the strict-locking blast radius to the objectstorage module alone. ext.awsSdkVersion = '2.30.0' Closure isTraceableArchiveFor = { Jar archiveTask, String fileName -> String baseName = java.util.regex.Pattern.quote(archiveTask.archiveBaseName.get()) String classifier = archiveTask.archiveClassifier.orNull String classifierPart = classifier == null || classifier.isBlank() ? '' : "-${java.util.regex.Pattern.quote(classifier)}" fileName ==~ /^${baseName}-\d+\.\d+\.\d+\+[0-9a-f]{7,40}${classifierPart}\.jar$/ } Closure> staleTraceableArchivesFor = { Jar archiveTask -> File outputDir = archiveTask.destinationDirectory.get().asFile if (!outputDir.isDirectory()) { return [] } String currentName = archiveTask.archiveFileName.get() List stale = outputDir.listFiles({ File ignored, String fileName -> isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName } as FilenameFilter)?.toList() ?: [] stale.sort { it.name } } allprojects { group = 'dev.caskeleton' version = rootProject.ext.traceableVersion repositories { mavenCentral() } } configure(subprojects.findAll { it.childProjects.isEmpty() }) { apply plugin: 'java' apply plugin: 'io.spring.dependency-management' // feature-static-analysis-quality-contract — apply the static analysis baseline to every // module (D8: extend the existing subprojects {} block rather than a convention plugin). apply plugin: 'com.diffplug.spotless' // D1 formatter apply plugin: 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id) apply plugin: 'com.github.spotbugs' // D3 bytecode bug finder apply plugin: 'net.ltgt.errorprone' // D5 compile-time checker java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } // D8 — Gradle-default /gradle.lockfile files are Renovate-compatible. STRICT means a // missing or stale lock state fails resolution instead of silently selecting a new version. dependencyLocking { lockAllConfigurations() lockMode = LockMode.STRICT } // D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and // permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes. tasks.withType(AbstractArchiveTask).configureEach { preserveFileTimestamps = false reproducibleFileOrder = true dirPermissions { unix('755') } filePermissions { unix('644') } } // D1/D9 — a JAR is independently traceable even when copied out of its container/release. tasks.withType(Jar).configureEach { manifest { attributes( 'Implementation-Version': project.version.toString(), 'Build-Revision': rootProject.ext.sourceRevision ) } doFirst { staleTraceableArchivesFor(it).each { File stale -> logger.lifecycle("${path}: deleting stale traceable archive ${stale.name}") if (!stale.delete()) { throw new GradleException("${path}: failed to delete stale traceable archive ${stale}") } } } } // Official Gradle pattern: resolve every resolvable configuration while --write-locks is set. // This captures transitive compile/test/analysis dependencies, not only direct declarations. tasks.register('resolveAndLockAll') { group = 'build setup' description = 'Resolves every configuration and writes this project\'s dependency lock state.' notCompatibleWithConfigurationCache('Filters configurations at execution time') doFirst { if (!gradle.startParameter.writeDependencyLocks) { throw new GradleException("${path} requires the --write-locks command-line flag.") } } doLast { configurations.findAll { it.canBeResolved }.each { it.resolve() } } } // Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and // propagates a missing/stale lock entry as a non-zero build failure. tasks.register('verifyDependencyLocks') { group = 'verification' description = 'Resolves every configuration and fails when strict dependency locks drift.' notCompatibleWithConfigurationCache('Filters configurations at execution time') doLast { configurations.findAll { it.canBeResolved }.each { it.resolve() } } } // Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam // binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it // auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here. tasks.withType(JavaCompile).configureEach { options.compilerArgs << '-parameters' options.errorprone { disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5) } } // D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, // spotlessCheck (wired into check) verifies. CI must NEVER run spotlessApply. spotless { java { googleJavaFormat('1.35.0') importOrder() removeUnusedImports() } } // D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML. checkstyle { toolVersion = '13.5.0' configFile = rootProject.file('config/checkstyle/checkstyle.xml') configDirectory = rootProject.file('config/checkstyle') ignoreFailures = false // No warning-tier checks in the default build. Javadoc coverage is a documentation backlog, // not a signal to print on every migration/build run. maxWarnings = Integer.MAX_VALUE } // §4 routing — checkstyleMain blocking; checkstyleTest warning-only (test-helper exception). tasks.named('checkstyleTest') { ignoreFailures = true } // D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below. // reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings // block, which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP // defensive-copy noise on DI'd collaborators). effort left at default (UNSUPPORTED_IMPL_DECISION // — strictness is a user trade-off; default is functionally valid). Confirmed false positives // go in config/spotbugs/exclude.xml. spotbugs { toolVersion = '4.10.2' reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') excludeFilter = rootProject.file('config/spotbugs/exclude.xml') } // SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the // Spring Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management // overrides resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError. // Override the BOM-managed version property (the documented Spring mechanism). No production // module imports commons.lang3, so this only affects the SpotBugs tool classpath in practice. ext['commons-lang3.version'] = '3.20.0' // §4 routing — spotbugsMain blocking; spotbugsTest warning-only (test-source trade-off). tasks.named('spotbugsTest') { ignoreFailures = true } dependencyManagement { imports { mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES } } dependencies { if (project.path in [':domain-core', ':application-core', ':shared-contract']) { testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core' } else { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' } testRuntimeOnly 'org.junit.platform:junit-platform-launcher' spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0' // D4 code-level security errorprone 'com.google.errorprone:error_prone_core:2.49.0' // D5 compile-time checker } // feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky // quarantine bucket so a quarantined test can never block merge. Quarantined tests carry // JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking) // and their 14-day sunset is enforced by verifyQuarantineSunset. With zero quarantined tests // (the skeleton default) excludeTags is a no-op. tasks.named('test') { useJUnitPlatform { excludeTags 'quarantine' } } // feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY // @Tag("quarantine") tests, isolated from `check`, never blocking the build (ignoreFailures). // failOnNoDiscoveredTests=false so the empty bucket (skeleton ships zero flaky tests) passes. tasks.register('quarantineTest', Test) { group = 'verification' description = 'Flaky-test quarantine bucket (feature-ci-quality-gates-contract §4): runs only ' + '@Tag("quarantine") tests, non-blocking, isolated from the release gate.' testClassesDirs = sourceSets.test.output.classesDirs classpath = sourceSets.test.runtimeClasspath useJUnitPlatform { includeTags 'quarantine' } ignoreFailures = true failOnNoDiscoveredTests = false // Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result. outputs.upToDateWhen { false } // Pin UTC like the main test task for host-locale independence. jvmArgs '-Duser.timezone=UTC' } tasks.named('check') { dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies') dependsOn rootProject.tasks.named('verifyEnvKeys') dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars') dependsOn rootProject.tasks.named('verifyOneTypePerFile') dependsOn rootProject.tasks.named('verifyTrivyignore') dependsOn rootProject.tasks.named('verifyQuarantineSunset') } } // One explicit command regenerates every module's Gradle-default lockfile. tasks.register('resolveAndLockAll') { group = 'build setup' description = 'Regenerates dependency locks for all subprojects (requires --write-locks).' dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('resolveAndLockAll') } } tasks.register('verifyDependencyLocks') { group = 'verification' description = 'Verifies strict dependency lock state for all subprojects.' dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('verifyDependencyLocks') } } tasks.register('cleanStaleTraceableJars') { group = 'build' description = 'Deletes older git-revision JARs from build/libs so IDE runtime classpaths cannot load stale module artifacts.' doLast { int deleted = 0 subprojects.each { sub -> sub.tasks.withType(Jar).each { Jar archiveTask -> staleTraceableArchivesFor(archiveTask).each { File stale -> if (!stale.delete()) { throw new GradleException("cleanStaleTraceableJars: failed to delete ${stale}") } deleted++ logger.lifecycle("cleanStaleTraceableJars: deleted ${stale}") } } } logger.lifecycle("cleanStaleTraceableJars: deleted ${deleted} stale archive(s).") } } tasks.register('verifyNoStaleTraceableJars') { group = 'verification' description = 'Verifies build/libs does not retain older git-revision JARs that can poison IDE runtime classpaths.' dependsOn tasks.named('cleanStaleTraceableJars') doLast { List violations = [] subprojects.each { sub -> sub.tasks.withType(Jar).each { Jar archiveTask -> List staleJars = staleTraceableArchivesFor(archiveTask).collect { it.name } if (!staleJars.isEmpty()) { violations << ":${sub.name}:${archiveTask.name}: stale JAR(s) ${staleJars}; current archive is ${archiveTask.archiveFileName.get()}" } } } if (!violations.isEmpty()) { throw new GradleException( "verifyNoStaleTraceableJars: ${violations.size()} module(s) retain old traceable JARs.\n " + violations.join('\n ')) } logger.lifecycle('verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.') } } // feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a // separate task so the task name and exit code identify the failed phase without log archaeology. def repositoryDir = rootProject.projectDir.parentFile def baseComposeFile = new File(repositoryDir, 'docker-compose.yml') def localComposeFile = new File(repositoryDir, 'docker-compose.local.yml') def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath, '-f', localComposeFile.absolutePath] def bootstrapCompile = tasks.register('bootstrapCompile') { group = 'developer experience' description = 'Stage 1/5: compiles every main and test source set as a local sanity check.' dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') } } def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) { group = 'developer experience' description = 'Checks that the Docker CLI can reach a running Docker daemon.' commandLine 'docker', 'info' ignoreExitValue = true standardOutput = new ByteArrayOutputStream() errorOutput = new ByteArrayOutputStream() doLast { if (executionResult.get().exitValue != 0) { throw new GradleException( 'bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 ' + '`docker info`가 성공하는지 확인하세요.\n' + errorOutput.toString()) } } } bootstrapDockerPreflight.configure { dependsOn bootstrapCompile } def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) { group = 'developer experience' description = 'Stage 2/5: starts the local PostgreSQL dependency and waits for readiness.' commandLine composeCommand + ['up', '-d', '--wait', 'db'] } bootstrapDependencies.configure { dependsOn bootstrapCompile } bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight } def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) { group = 'developer experience' description = 'Stage 3/5: builds/starts the app; startup Flyway must finish before health is ready.' commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app'] } bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies } def bootstrapSampleContract = tasks.register('bootstrapSampleContract') { group = 'developer experience' description = 'Stage 4/5: verifies the delegated sample production-isolation/build contract.' dependsOn project(':app-bootstrap').tasks.named('bootstrapSampleContract') } bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart } def bootstrapSmoke = tasks.register('bootstrapSmoke') { group = 'developer experience' description = 'Stage 5/5: requires HTTP 200 and status=UP from GET /api/healthcheck.' doLast { URI endpoint = URI.create('http://localhost:8080/api/healthcheck') long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) String lastFailure = 'no response' while (System.nanoTime() < deadline) { HttpURLConnection connection = null try { connection = (HttpURLConnection) endpoint.toURL().openConnection() connection.connectTimeout = 2_000 connection.readTimeout = 2_000 connection.requestMethod = 'GET' int status = connection.responseCode String body = status >= 200 && status < 400 ? connection.inputStream.text : connection.errorStream?.text if (status == 200 && body != null && body.contains('"status":"UP"')) { logger.lifecycle('bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP.') return } lastFailure = "HTTP ${status}: ${body}" } catch (IOException ex) { lastFailure = ex.message } finally { connection?.disconnect() } sleep(1_000) } throw new GradleException( "bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}") } } bootstrapSmoke.configure { dependsOn bootstrapSampleContract } tasks.register('bootstrap') { group = 'developer experience' description = 'Runs the complete five-stage local bootstrap contract.' dependsOn bootstrapSmoke } // feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second // build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose. tasks.register('verifyReadmeCommands') { group = 'verification' description = 'Verifies README Gradle/Compose/Make commands refer to real tasks, files, and targets.' File readmeFile = project.hasProperty('readmeFile') ? file(project.property('readmeFile')) : new File(repositoryDir, 'README.md') inputs.file(readmeFile) doLast { if (!readmeFile.isFile()) { throw new GradleException("verifyReadmeCommands: missing ${readmeFile}") } List violations = [] boolean inShellBlock = false readmeFile.eachLine { String rawLine, int lineNumber -> String line = rawLine.trim() if (line == '```bash' || line == '```sh') { inShellBlock = true return } if (line == '```' && inShellBlock) { inShellBlock = false return } if (!inShellBlock || line.isEmpty() || line.startsWith('#')) { return } int gradleIndex = line.indexOf('./gradlew ') if (gradleIndex >= 0) { List tokens = line.substring(gradleIndex + './gradlew '.length()) .split(/\s+/).toList() List requestedTasks = [] for (String token : tokens) { if (token.startsWith('-') || token in ['&&', '||', '|']) { break } requestedTasks << token } if (requestedTasks.isEmpty()) { violations << "${readmeFile}:${lineNumber}: ./gradlew command has no task" } requestedTasks.each { String taskPath -> boolean exists if (taskPath.startsWith(':') && taskPath.count(':') >= 2) { int lastSeparator = taskPath.lastIndexOf(':') String projectPath = taskPath.substring(0, lastSeparator) String taskName = taskPath.substring(lastSeparator + 1) Project targetProject = rootProject.findProject(projectPath) exists = targetProject != null && targetProject.tasks.findByName(taskName) != null } else { exists = !rootProject.getTasksByName(taskPath, true).isEmpty() } if (!exists) { violations << "${readmeFile}:${lineNumber}: unknown Gradle task '${taskPath}' in `${line}`" } } } if (line.startsWith('docker compose ')) { List tokens = line.split(/\s+/).toList() int index = 2 while (index < tokens.size() && tokens[index].startsWith('-')) { String option = tokens[index++] if (option in ['-f', '--file', '--env-file', '-p', '--project-name']) { if (index >= tokens.size()) { violations << "${readmeFile}:${lineNumber}: '${option}' has no value in `${line}`" break } String value = tokens[index++] if (option in ['-f', '--file']) { File composeFile = new File(repositoryDir, value) if (!composeFile.isFile()) { violations << "${readmeFile}:${lineNumber}: missing Compose file '${value}'" } } } } Set supported = ['build', 'config', 'down', 'logs', 'ps', 'pull', 'restart', 'run', 'start', 'stop', 'up'] as Set if (index >= tokens.size() || !supported.contains(tokens[index])) { String actual = index < tokens.size() ? tokens[index] : '' violations << "${readmeFile}:${lineNumber}: unsupported Compose subcommand '${actual}'" } } if (line.startsWith('make ')) { File makefile = new File(repositoryDir, 'Makefile') String target = line.substring('make '.length()).split(/\s+/)[0] if (!makefile.isFile()) { violations << "${readmeFile}:${lineNumber}: make command documented but Makefile is absent" } else if (!(makefile.text =~ /(?m)^${java.util.regex.Pattern.quote(target)}\s*:/).find()) { violations << "${readmeFile}:${lineNumber}: unknown Make target '${target}'" } } } if (!violations.isEmpty()) { throw new GradleException( "verifyReadmeCommands: ${violations.size()} command drift violation(s):\n " + violations.join('\n ')) } logger.lifecycle("verifyReadmeCommands: OK — executable commands in ${readmeFile} resolve.") } } configure(subprojects.findAll { it.childProjects.isEmpty() }) { tasks.named('check') { dependsOn rootProject.tasks.named('verifyReadmeCommands') } } tasks.register('verifyCleanArchitectureDependencies') { group = 'verification' description = 'Verifies Clean Architecture project dependency direction.' File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') inputs.file(moduleRegistryFile) doLast { if (!moduleRegistryFile.isFile()) { throw new GradleException("Missing module registry: ${moduleRegistryFile}") } def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile) if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) { throw new GradleException("Module registry has no modules: ${moduleRegistryFile}") } Map modulesById = moduleRegistry.modules.collectEntries { module -> [(module.id as String): module] } if (modulesById.size() != moduleRegistry.modules.size()) { throw new GradleException('Module registry contains duplicate module ids.') } Map> allowedProjectDependencies = moduleRegistry.modules.collectEntries { module -> String moduleName = (module.gradle_path as String).replaceFirst('^:', '') Set allowed = (module.allowed_dependencies as List).collect { dependencyId -> def dependency = modulesById[dependencyId as String] if (dependency == null) { throw new GradleException( "Module registry '${module.id}' references unknown allowed dependency id '${dependencyId}'.") } (dependency.gradle_path as String).replaceFirst('^:', '') }.toSet() [(moduleName): allowed] } Set declaredModules = subprojects.findAll { it.childProjects.isEmpty() } .collect { it.path.replaceFirst('^:', '') }.toSet() Set governedModules = allowedProjectDependencies.keySet() Set missingFromBuild = governedModules - declaredModules Set missingFromPolicy = declaredModules - governedModules if (!missingFromBuild.isEmpty()) { throw new GradleException( "Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " + "Declared modules are ${declaredModules}." ) } if (!missingFromPolicy.isEmpty()) { throw new GradleException( "Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " + "Add an explicit dependency policy before using them." ) } allowedProjectDependencies.each { moduleName, allowed -> Project module = rootProject.project(":${moduleName}") Set actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly'] .collect { configurationName -> module.configurations.findByName(configurationName) } .findAll { it != null } .collectMany { configuration -> configuration.dependencies.withType(ProjectDependency).collect { dependency -> dependency.path.replaceFirst('^:', '') } } .toSet() if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) { throw new GradleException( "Module ':${moduleName}' has a forbidden production dependency on " + "':sample-portfolio'. The sample module may only be consumed through " + "non-production fixture configurations." ) } Set forbidden = actual - allowed if (!forbidden.isEmpty()) { throw new GradleException( "Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " + "Allowed dependencies are ${allowed}. " + "Production modules must not depend on ':sample-portfolio'; " + "all project edges must be explicitly registered." ) } } } } Set expectedJpaReadinessCardIds = [ 'jpa-observability-lifecycle', 'jpa-security-baseline', 'jpa-flyway-migration', 'jpa-transaction-runtime', 'jpa-aggregate-store', 'jpa-query-model', 'jpa-primary-foundation', 'jpa-idempotency-owner-safe-v2', 'jpa-outbox-storage-v2', 'jpa-outbox-polling-delivery-v2', 'jpa-outbox-cdc-retention-v1', 'jpa-inbox-same-store-v1', 'jpa-primary-replica', 'jpa-tenant-discriminator-rls', 'jpa-jdbc-efficiency-coordination' ] as Set Set expectedJpaOwnedMigrationCardIds = [ 'jpa-flyway-migration', 'jpa-idempotency-owner-safe-v2', 'jpa-outbox-storage-v2', 'jpa-outbox-polling-delivery-v2', 'jpa-inbox-same-store-v1', 'jpa-tenant-discriminator-rls', 'jpa-jdbc-efficiency-coordination' ] as Set Closure> validateJpaReadinessRegistry = { Map registry, String rawRegistry, Closure taskExists -> List violations = [] Set rootKeys = registry.keySet().collect { it as String }.toSet() Set expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set if (rootKeys != expectedRootKeys) { violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}" } if (registry['schema-version'] != 1) { violations << "schema-version must be integer 1; got ${registry['schema-version']}" } Map legacy = registry['legacy-adoption'] instanceof Map ? registry['legacy-adoption'] as Map : [:] Set expectedLegacyKeys = [ 'state', 'location', 'history-table', 'immutable-applied-versions', 'allowed-origin' ] as Set if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) { violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}" } if (legacy.state != 'transition-only') { violations << "legacy-adoption.state must be transition-only" } if (legacy.location != 'db/migration/postgresql') { violations << "legacy-adoption.location must be db/migration/postgresql" } if (legacy['history-table'] != 'flyway_schema_history') { violations << "legacy-adoption.history-table must be flyway_schema_history" } if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) { violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]" } if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') { violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED" } Map cards = registry.cards instanceof Map ? registry.cards as Map : [:] Set actualCardIds = cards.keySet().collect { it as String }.toSet() Set missingCards = expectedJpaReadinessCardIds - actualCardIds Set unknownCards = actualCardIds - expectedJpaReadinessCardIds if (!missingCards.isEmpty()) { violations << "missing card ids ${missingCards.toSorted()}" } if (!unknownCards.isEmpty()) { violations << "unknown card ids ${unknownCards.toSorted()}" } List rawCardKeys = [] def rawCardKeyMatcher = rawRegistry =~ /"(?jpa-[a-z0-9.-]+)"\s*:/ while (rawCardKeyMatcher.find()) { rawCardKeys << rawCardKeyMatcher.group('card') } Set duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll { String ignored, Integer count -> count > 1 }.keySet() if (!duplicateRawCardKeys.isEmpty()) { violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}" } Set allowedCardKeys = [ 'state', 'schema-stream', 'prerequisites', 'external-prerequisites', 'readiness-task', 'support-tasks', 'required-evidence', 'evidence', 'dispatch-modes', 'migration' ] as Set Set allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set Set allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set Map taskOwners = [:] Map migrationLocationOwners = [:] Map migrationHistoryOwners = [:] Map evidenceSelectorOwners = [:] Set actualOwnedMigrationCards = [] cards.each { String cardId, Object rawCard -> if (!(rawCard instanceof Map)) { violations << "${cardId}: card value must be an object" return } Map card = rawCard as Map Set unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys if (!unknownKeys.isEmpty()) { violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}" } String state = card.state as String String schemaStream = card['schema-stream'] as String if (!allowedStates.contains(state)) { violations << "${cardId}: invalid state '${state}'" } if (!allowedSchemaStreams.contains(schemaStream)) { violations << "${cardId}: invalid schema-stream '${schemaStream}'" } if (!(card.prerequisites instanceof List)) { violations << "${cardId}: prerequisites must be a list" } List prerequisites = card.prerequisites instanceof List ? (card.prerequisites as List).collect { it as String } : [] if (prerequisites.toSet().size() != prerequisites.size()) { violations << "${cardId}: duplicate prerequisites ${prerequisites}" } prerequisites.each { String prerequisite -> if (!cards.containsKey(prerequisite)) { violations << "${cardId}: unknown prerequisite '${prerequisite}'" } else if (state == 'selected' && ((cards[prerequisite] as Map).state as String) != 'selected') { violations << "${cardId}: selected card requires non-selected '${prerequisite}'" } } String readinessTask = card['readiness-task'] as String if (readinessTask == null || !readinessTask.startsWith(':')) { violations << "${cardId}: readiness-task must be an absolute Gradle task path" } List supportTasks = card['support-tasks'] instanceof List ? (card['support-tasks'] as List).collect { it as String } : [] if (supportTasks.toSet().size() != supportTasks.size()) { violations << "${cardId}: duplicate support-tasks ${supportTasks}" } ([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath -> if (!taskPath.startsWith(':')) { violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path" return } String previousOwner = taskOwners.putIfAbsent(taskPath, cardId) if (previousOwner != null) { violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}" } if (state == 'selected' && !taskExists(taskPath)) { violations << "${cardId}: selected task does not exist '${taskPath}'" } } List requiredEvidence = card['required-evidence'] instanceof List ? (card['required-evidence'] as List).collect { it as String } : [] if (requiredEvidence.isEmpty()) { violations << "${cardId}: required-evidence must be a non-empty list" } else { if (requiredEvidence.toSet().size() != requiredEvidence.size()) { violations << "${cardId}: duplicate required-evidence ${requiredEvidence}" } if (!requiredEvidence.contains('no-skip')) { violations << "${cardId}: required-evidence must include no-skip" } } Object migrationNode = card.migration Set allowedEvidenceClaims = requiredEvidence .findAll { String requirement -> requirement != 'no-skip' } .toSet() Map migrationForEvidence = migrationNode instanceof Map ? migrationNode as Map : [:] Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence'] if (lifecycleEvidenceNode instanceof List) { (lifecycleEvidenceNode as List).each { Object lifecycle -> allowedEvidenceClaims << "migration-lifecycle:${lifecycle as String}".toString() } } Object evidenceNode = card.evidence if (state == 'not-implemented') { if (evidenceNode != null) { violations << "${cardId}: not-implemented card forbids evidence" } } else if (!(evidenceNode instanceof Map)) { violations << "${cardId}: active card requires evidence" } else { Map evidence = evidenceNode as Map Set evidenceKeys = evidence.keySet().collect { it as String }.toSet() Set expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set if (evidenceKeys != expectedEvidenceKeys) { violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}" } List scenarios = evidence.scenarios instanceof List ? evidence.scenarios as List : [] if (!(evidence.scenarios instanceof List)) { violations << "${cardId}: evidence scenarios must be a list" } List taskClaims = evidence['task-claims'] instanceof List ? evidence['task-claims'] as List : [] if (!(evidence['task-claims'] instanceof List)) { violations << "${cardId}: evidence task-claims must be a list" } if (scenarios.isEmpty() && taskClaims.isEmpty()) { violations << "${cardId}: evidence must contain a scenario or task claim" } scenarios.eachWithIndex { Object rawScenario, int index -> if (!(rawScenario instanceof Map)) { violations << "${cardId}: evidence scenario ${index} must be an object" return } Map scenario = rawScenario as Map Set scenarioKeys = scenario.keySet().collect { it as String }.toSet() if (scenarioKeys != ['selector', 'covers'] as Set) { violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}" } String selector = scenario.selector as String if (selector == null || !(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) { violations << "${cardId}: invalid evidence selector '${selector}'" } else { String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId) if (previousOwner != null) { violations << "duplicate evidence selector '${selector}' owned by " + "${previousOwner} and ${cardId}" } } List covers = scenario.covers instanceof List ? (scenario.covers as List).collect { it as String } : [] if (covers.isEmpty()) { violations << "${cardId}: evidence scenario ${index} covers must be non-empty" } if (covers.toSet().size() != covers.size()) { violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}" } covers.each { String claim -> if (!allowedEvidenceClaims.contains(claim)) { violations << "${cardId}: evidence covers unknown requirement '${claim}'" } } } Set ownedTasks = ([readinessTask] + supportTasks) .findAll { it != null } .toSet() taskClaims.eachWithIndex { Object rawClaim, int index -> if (!(rawClaim instanceof Map)) { violations << "${cardId}: evidence task claim ${index} must be an object" return } Map claim = rawClaim as Map Set claimKeys = claim.keySet().collect { it as String }.toSet() if (claimKeys != ['task', 'covers'] as Set) { violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}" } String taskPath = claim.task as String if (!ownedTasks.contains(taskPath)) { violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'" } List covers = claim.covers instanceof List ? (claim.covers as List).collect { it as String } : [] if (covers.isEmpty()) { violations << "${cardId}: evidence task claim ${index} covers must be non-empty" } if (covers.toSet().size() != covers.size()) { violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}" } covers.each { String evidenceClaim -> if (!allowedEvidenceClaims.contains(evidenceClaim)) { violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'" } } } } if (schemaStream == 'owned') { actualOwnedMigrationCards << cardId if (!(migrationNode instanceof Map)) { violations << "${cardId}: owned schema-stream requires migration" } } else if (migrationNode != null) { violations << "${cardId}: schema-stream ${schemaStream} forbids migration" } if (migrationNode instanceof Map) { Map migration = migrationNode as Map Set expectedMigrationKeys = [ 'location', 'history-table', 'required-core-epoch', 'feature-revision', 'lifecycle-evidence' ] as Set Set migrationKeys = migration.keySet().collect { it as String }.toSet() if (migrationKeys != expectedMigrationKeys) { violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}" } String location = migration.location as String String historyTable = migration['history-table'] as String if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) { violations << "${cardId}: invalid migration location '${location}'" } else { String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId) if (previousOwner != null) { violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}" } } if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) { violations << "${cardId}: invalid migration history-table '${historyTable}'" } else { String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId) if (previousOwner != null) { violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}" } } Object coreEpoch = migration['required-core-epoch'] Object featureRevision = migration['feature-revision'] if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) { violations << "${cardId}: required-core-epoch must be a non-negative integer" } if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) { violations << "${cardId}: feature-revision must be a positive integer" } List lifecycleEvidence = migration['lifecycle-evidence'] instanceof List ? (migration['lifecycle-evidence'] as List).collect { it as String } : [] if (lifecycleEvidence.isEmpty()) { violations << "${cardId}: lifecycle-evidence must be a non-empty list" } else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) { violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}" } } if (card['external-prerequisites'] != null) { if (!(card['external-prerequisites'] instanceof List)) { violations << "${cardId}: external-prerequisites must be a list" } else { (card['external-prerequisites'] as List).eachWithIndex { Object rawExternal, int index -> if (!(rawExternal instanceof Map)) { violations << "${cardId}: external prerequisite ${index} must be an object" return } Map external = rawExternal as Map Set externalKeys = external.keySet() .collect { it as String } .toSet() if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) { violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}" } if (!((external.registry as String)?.startsWith('src/config/'))) { violations << "${cardId}: external prerequisite ${index} has invalid registry" } if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) { violations << "${cardId}: external prerequisite ${index} has invalid card-id" } if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) { violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness" } } } } } if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) { violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " + "got ${actualOwnedMigrationCards}" } Map visitState = [:].withDefault { 0 } Closure visitCard visitCard = { String cardId -> if (visitState[cardId] == 1) { violations << "readiness prerequisite cycle includes '${cardId}'" return } if (visitState[cardId] == 2 || !cards.containsKey(cardId)) { return } visitState[cardId] = 1 Map card = cards[cardId] as Map if (card.prerequisites instanceof List) { (card.prerequisites as List).each { Object prerequisite -> visitCard(prerequisite as String) } } visitState[cardId] = 2 } cards.keySet().each { Object cardId -> visitCard(cardId as String) } boolean pollingSelected = ((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected' boolean cdcSelected = ((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected' if (pollingSelected && cdcSelected) { violations << 'polling and CDC outbox delivery cards cannot both be selected' } violations } Closure jpaTaskExists = { String absoluteTaskPath -> int separator = absoluteTaskPath.lastIndexOf(':') if (separator < 0 || separator == absoluteTaskPath.length() - 1) { return false } String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator) String taskName = absoluteTaskPath.substring(separator + 1) Project targetProject = rootProject.findProject(projectPath) targetProject != null && targetProject.tasks.findByName(taskName) != null } def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') { group = 'verification' description = 'Mutation-tests the fail-closed JPA readiness registry validator.' File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml") inputs.file(registryFile) doLast { String raw = registryFile.getText('UTF-8') Map baseline = new JsonSlurper().parseText(raw) as Map Closure> copyRegistry = { new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map } Closure expectViolation = { String scenario, String expectedText, Closure mutation, Closure taskExists = { String ignored -> true } -> Map candidate = copyRegistry() mutation(candidate) List candidateViolations = validateJpaReadinessRegistry( candidate, JsonOutput.toJson(candidate), taskExists) if (!candidateViolations.any { String violation -> violation.contains(expectedText) }) { throw new GradleException( "verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " + "produce '${expectedText}'; got ${candidateViolations}") } } expectViolation('unknown-card', 'unknown card ids', { Map candidate -> (candidate.cards as Map)['jpa-primary-foundation-alias'] = (candidate.cards as Map)['jpa-primary-foundation'] }) expectViolation('duplicate-task', 'duplicate task', { Map candidate -> ((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] = ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task'] }) expectViolation('missing-prerequisite', 'unknown prerequisite', { Map candidate -> ((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites = ['jpa-does-not-exist'] }) expectViolation('cycle', 'prerequisite cycle', { Map candidate -> ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites = ['jpa-security-baseline'] }) expectViolation('duplicate-location', 'duplicate migration location', { Map candidate -> (((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration as Map).location = 'db/migration/jpa/core' }) expectViolation( 'missing-selected-task', 'selected task does not exist', { Map ignored -> }, { String taskPath -> taskPath != ':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest' }) expectViolation('missing-active-evidence', 'active card requires evidence', { Map candidate -> ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map) .remove('evidence') }) expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', { Map candidate -> ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [ scenarios: [[ selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', covers: ['not-a-card-requirement'] ]], 'task-claims': [] ] }) expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', { Map candidate -> Map card = (candidate.cards as Map)['jpa-observability-lifecycle'] as Map card.evidence = [ scenarios: [ [ selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', covers: ['real-postgresql'] ], [ selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql', covers: ['lifecycle'] ] ], 'task-claims': [] ] }) expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', { Map candidate -> ((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [ scenarios: [], 'task-claims': [[ task: ':test', covers: ['architecture'] ]] ] }) logger.lifecycle( 'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' + 'missing prerequisite, cycle, duplicate migration ownership, missing ' + 'selected task, and malformed evidence ownership all fail closed.') } } def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') { group = 'verification' description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.' dependsOn verifyJpaReadinessRegistryContract File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml") inputs.file(registryFile) doLast { if (!registryFile.isFile()) { throw new GradleException( "verifyJpaReadinessRegistry: missing registry ${registryFile}") } String raw = registryFile.getText('UTF-8') Map registry try { registry = new JsonSlurper().parseText(raw) as Map } catch (RuntimeException ex) { throw new GradleException( "verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML", ex) } List violations = validateJpaReadinessRegistry(registry, raw, jpaTaskExists) if (!violations.isEmpty()) { throw new GradleException( "verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " + violations.toSorted().join('\n ')) } logger.lifecycle( "verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " + "cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " + 'streams, acyclic prerequisites, unique tasks/locations/history tables, ' + 'and selected task existence verified.') } } configure(subprojects.findAll { it.childProjects.isEmpty() }) { tasks.named('check') { dependsOn verifyJpaReadinessRegistry } } def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') { group = 'verification' description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.' doLast { Project application = project(':application-core') List violations = [] ['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName -> def configuration = application.configurations.findByName(configurationName) if (configuration == null) { return } configuration.dependencies.each { dependency -> if (!(dependency instanceof ProjectDependency)) { violations << "${configurationName}: non-project production dependency " + "${dependency.group ?: ''}:${dependency.name}" } } } Closure forbiddenGroup = { String groupName -> groupName != null && ( groupName.startsWith('org.springframework') || groupName == 'org.slf4j' || groupName == 'ch.qos.logback' || groupName == 'org.apache.logging.log4j' || groupName == 'io.micrometer') } ['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath'] .each { configurationName -> def configuration = application.configurations.getByName(configurationName) configuration.incoming.resolutionResult.allComponents.each { component -> if (component.id instanceof ModuleComponentIdentifier && forbiddenGroup(component.id.group)) { violations << "${configurationName}: forbidden resolved dependency " + "${component.id.group}:${component.id.module}:${component.id.version}" } } } if (!violations.isEmpty()) { throw new GradleException( "verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " + violations.toSorted().join('\n ')) } logger.lifecycle( 'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.') } } project(':application-core').tasks.named('check') { dependsOn verifyApplicationCoreDependencyPurity } Closure>> loadRedisReadinessCards = { File registryFile -> if (!registryFile.isFile()) { throw new GradleException("Missing Redis readiness registry: ${registryFile}") } Map> cards = new LinkedHashMap<>() String currentCard = null boolean rootSeen = false boolean readingEvidence = false int lineNumber = 0 registryFile.eachLine('UTF-8') { String raw -> lineNumber++ if (raw.contains('\t')) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: tabs are not allowed") } String line = raw.stripTrailing() if (line.isBlank() || line.stripLeading().startsWith('#')) { return } if (!rootSeen) { if (line != 'cards:') { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: expected cards:") } rootSeen = true return } def cardMatch = line =~ /^ ([a-z][a-z0-9-]+):$/ if (cardMatch.matches()) { currentCard = cardMatch.group(1) if (cards.containsKey(currentCard)) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: duplicate card ID ${currentCard}") } cards[currentCard] = [requiredEvidence: []] readingEvidence = false return } if (currentCard == null) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: card field found before a card ID") } if (line.startsWith(' - ')) { if (!readingEvidence) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: list item is only valid under required-evidence") } String evidence = line.substring(8) if (!(evidence in [ 'standalone', 'security', 'sentinel', 'cluster', 'fault', 'compatibility', 'selected-topology' ])) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: unsupported required evidence ${evidence}") } if ((cards[currentCard].requiredEvidence as List).contains(evidence)) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: duplicate evidence ${evidence}") } (cards[currentCard].requiredEvidence as List) << evidence return } if (!line.startsWith(' ') || line.startsWith(' ')) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: unsupported indentation") } readingEvidence = false String field = line.substring(4) if (field == 'required-evidence:') { if (cards[currentCard].evidenceDeclared == true) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: duplicate required-evidence field") } cards[currentCard].evidenceDeclared = true readingEvidence = true return } int separator = field.indexOf(': ') if (separator < 1) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: expected field: value") } String name = field.substring(0, separator) String value = field.substring(separator + 2) if (value.isBlank()) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: ${name} must not be blank") } switch (name) { case 'state': if (cards[currentCard].state != null) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: duplicate state field") } if (!(value in ['selected', 'implemented-candidate', 'not-implemented'])) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: unsupported state ${value}") } cards[currentCard].state = value break case 'selected-topology': if (cards[currentCard].selectedTopology != null) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: duplicate selected-topology field") } if (!(value in ['standalone', 'sentinel', 'cluster'])) { throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: unsupported selected-topology ${value}") } cards[currentCard].selectedTopology = value break default: throw new GradleException( "Malformed Redis readiness registry at line ${lineNumber}: unknown field ${name}") } } if (!rootSeen) { throw new GradleException('Redis readiness registry is missing cards:') } Set expected = [ 'redis-cache', 'redis-edge-rate-limit', 'redis-request-replay-idempotency', 'redis-cache-refresh-soft-lease', 'redis-fenced-coordination', 'redis-session' ] as Set if (cards.keySet() != expected) { throw new GradleException( "Redis readiness registry cards must be exactly ${expected}; got ${cards.keySet()}") } cards.each { String cardId, Map card -> if (card.state == null) { throw new GradleException("Redis readiness card ${cardId} has no valid state") } if (card.state == 'not-implemented') { if (card.selectedTopology != null || card.evidenceDeclared == true || !(card.requiredEvidence as List).isEmpty()) { throw new GradleException( "Redis readiness card ${cardId} is not-implemented and must not declare topology or evidence") } return } if (card.selectedTopology == null || (card.requiredEvidence as List).isEmpty()) { throw new GradleException( "Redis readiness card ${cardId} requires topology and evidence") } if (!(card.requiredEvidence as List).contains('selected-topology')) { throw new GradleException( "Redis readiness card ${cardId} required-evidence must include selected-topology") } } cards } Map> redisReadinessCards = loadRedisReadinessCards( rootProject.file('config/redis/readiness-cards.yaml')) ext.redisReadinessCards = redisReadinessCards Map redisReadinessTaskStems = [ 'redis-cache' : 'Cache', 'redis-edge-rate-limit' : 'RateLimit', 'redis-request-replay-idempotency' : 'Idempotency', 'redis-cache-refresh-soft-lease' : 'SoftLease', 'redis-fenced-coordination' : 'FencedCoordination', 'redis-session' : 'Session' ] Map redisEvidenceTaskStems = [ standalone : 'Standalone', security : 'Security', sentinel : 'Sentinel', cluster : 'Cluster', fault : 'Fault', compatibility: 'Compatibility' ] Map redisPublicReadinessTasks = [ 'redis-cache' : 'redisCacheReadiness', 'redis-edge-rate-limit' : 'redisRateLimitReadiness', 'redis-request-replay-idempotency' : 'redisIdempotencyReadiness', 'redis-cache-refresh-soft-lease' : 'redisSoftLeaseReadiness', 'redis-fenced-coordination' : 'redisFencedCoordinationReadiness', 'redis-session' : 'redisSessionReadiness' ] Map> redisCapabilityMetadata = [ 'redis-cache' : [ providerIds : ['redis'], roles : ['CACHE'], programs : [ 'set-if-absent-with-ttl-v1', 'replace-if-observed-with-ttl-v1', 'region-generation-init-v1', 'region-generation-bump-v1' ], keyVersions : ['cache-key-v1'], codecVersions: ['cache-envelope-v2'], guarantees : ['bounded standalone semantic cache and generation fencing'], nonGuarantees: [ 'no multi-process L1 coherence or HA topology qualification', 'runtime-resolved image attestation and actual fault event timeline are not captured' ], requiredSettings: [ [name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum', constraint: 'disabled or redis; redis is required to activate this card'], [name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding', constraint: 'optional CACHE role with finite timeouts and bounds'], [name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference', type: 'secret-reference-name', constraint: 'nonblank reference name; resolved secret value is never evidence'] ] ], 'redis-edge-rate-limit' : [ providerIds : ['redis'], roles : ['COORDINATION'], programs : [ 'rate-fixed-window-v2', 'rate-sliding-counter-v2', 'rate-token-bucket-v2' ], keyVersions : ['rate-limit-key-v1'], codecVersions: ['rate-limit-reply-v2'], guarantees : ['atomic standalone quota evaluation with bounded state'], nonGuarantees: [ 'no Sentinel, Cluster, failover, or R3 qualification', 'runtime-resolved image attestation and actual fault event timeline are not captured' ], requiredSettings: [ [name: 'ca-skeleton.capabilities.rate-limit.provider', type: 'enum', constraint: 'disabled or redis; checked-in registry remains release authority'], [name: 'ca-skeleton.providers.redis.roles.coordination', type: 'role-binding', constraint: 'required COORDINATION role bound to one deployment'], [name: 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference', type: 'secret-reference-name', constraint: 'nonblank reference name; resolved secret value is never evidence'] ] ], 'redis-request-replay-idempotency' : [ providerIds : ['redis'], roles : ['COORDINATION'], programs : [ 'idempotency-claim-v1', 'idempotency-start-v1', 'idempotency-renew-v1', 'idempotency-complete-v1', 'idempotency-fail-v1', 'idempotency-release-v1', 'idempotency-inspect-v1' ], keyVersions : ['idempotency-key-v1'], codecVersions: ['idempotency-program-schema-v2'], guarantees : ['standalone request replay state transitions'], nonGuarantees: [ 'no cross-store exactly-once guarantee', 'runtime-resolved image attestation and actual fault event timeline are not captured' ], requiredSettings: [ [name: 'ca-skeleton.capabilities.idempotency.provider', type: 'enum', constraint: 'provider selection is explicit and exclusive'], [name: 'ca-skeleton.providers.redis.roles.coordination', type: 'role-binding', constraint: 'required COORDINATION role bound to one deployment'], [name: 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference', type: 'secret-reference-name', constraint: 'nonblank reference name; resolved secret value is never evidence'] ] ], 'redis-cache-refresh-soft-lease' : [ providerIds : ['redis'], roles : ['CACHE'], programs : ['cache-refresh-claim-v1', 'compare-and-delete-v1'], keyVersions : ['cache-refresh-key-v1'], codecVersions: ['cache-refresh-owner-v1'], guarantees : ['bounded duplicate refresh suppression'], nonGuarantees: [ 'not a correctness lock and no fencing token', 'runtime-resolved image attestation and actual fault event timeline are not captured' ], requiredSettings: [ [name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum', constraint: 'disabled or redis; redis activates the cache-owned soft lease'], [name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding', constraint: 'optional CACHE role with finite timeouts and bounds'], [name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference', type: 'secret-reference-name', constraint: 'nonblank reference name; resolved secret value is never evidence'] ] ], 'redis-fenced-coordination' : [ providerIds : [], roles : ['COORDINATION'], programs : [], keyVersions : [], codecVersions: [], guarantees : [], nonGuarantees: ['provider and stale fencing-token rejection are not implemented'], requiredSettings: [] ], 'redis-session' : [ providerIds : ['redis-session'], roles : ['SESSION'], programs : [ 'session-create-v1', 'session-inspect-v1', 'session-save-if-live-v1', 'session-touch-if-live-v1', 'session-tombstone-and-delete-v1', 'session-rotate-v1' ], keyVersions : ['session-key-v1'], codecVersions: ['session-envelope-v2', 'session-envelope-v1-read'], guarantees : ['standalone versioned session repository and stale-save rejection'], nonGuarantees: [ 'same-JVM two-client evidence is not multi-process or pod qualification', 'runtime-resolved image attestation and actual fault event timeline are not captured' ], requiredSettings: [ [name: 'ca-skeleton.security.auth-mode', type: 'enum', constraint: 'session mode is explicit'], [name: 'ca-skeleton.providers.redis.roles.session', type: 'role-binding', constraint: 'required SESSION role bound to one standalone deployment'], [name: 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference', type: 'secret-reference-name', constraint: 'nonblank reference name; resolved secret value is never evidence'] ] ] ] if (redisCapabilityMetadata.keySet() != redisReadinessCards.keySet()) { throw new GradleException( "Redis capability metadata IDs must equal readiness cards; metadata=${redisCapabilityMetadata.keySet()}, cards=${redisReadinessCards.keySet()}") } ext.redisCapabilityMetadata = redisCapabilityMetadata def fullGitRevision = providers.exec { commandLine 'git', 'rev-parse', 'HEAD' ignoreExitValue = true }.standardOutput.asText.map { it.trim() } String checkedOutHeadRevision = fullGitRevision.getOrElse('') if (!(checkedOutHeadRevision ==~ /[0-9a-f]{40}/)) { throw new GradleException( 'Redis evidence requires an exact 40-character checked-out Git HEAD.') } String redisEvidenceSourceRevision = providers.environmentVariable('GITHUB_SHA') .orElse(providers.environmentVariable('GIT_SHA')) .getOrElse(checkedOutHeadRevision) if (!(redisEvidenceSourceRevision ==~ /[0-9a-f]{40}/)) { throw new GradleException( 'Redis evidence requires the exact 40-character commit SHA from Git or GITHUB_SHA/GIT_SHA.') } if (redisEvidenceSourceRevision != checkedOutHeadRevision) { throw new GradleException( "Redis evidence source revision ${redisEvidenceSourceRevision} does not equal checked-out HEAD ${checkedOutHeadRevision}.") } ext.redisEvidenceSourceRevision = redisEvidenceSourceRevision def gitStatusPorcelain = providers.exec { commandLine 'git', 'status', '--porcelain', '--untracked-files=normal' ignoreExitValue = true }.standardOutput.asText.map { it } String redisEvidenceSourceTreeState = gitStatusPorcelain.getOrElse('').isBlank() ? 'CLEAN' : 'DIRTY' ext.redisEvidenceSourceTreeState = redisEvidenceSourceTreeState Closure redisSha256 = { File file -> if (!file.isFile()) { throw new GradleException("Redis evidence digest input is missing: ${file}") } MessageDigest digest = MessageDigest.getInstance('SHA-256') file.withInputStream { stream -> byte[] buffer = new byte[8192] int read while ((read = stream.read(buffer)) >= 0) { if (read > 0) { digest.update(buffer, 0, read) } } } "sha256:${digest.digest().encodeHex()}" } Closure redisAggregateSha256 = { Collection files -> MessageDigest digest = MessageDigest.getInstance('SHA-256') List sortedFiles = files.toSorted { rootProject.projectDir.toPath().relativize(it.toPath()).toString() } Set paths = new LinkedHashSet<>() sortedFiles.each { File file -> if (!file.isFile()) { throw new GradleException("Redis evidence digest input is missing: ${file}") } if (java.nio.file.Files.isSymbolicLink(file.toPath())) { throw new GradleException("Redis evidence digest input must not be a symlink: ${file}") } java.nio.file.Path normalized = file.toPath().toAbsolutePath().normalize() java.nio.file.Path root = rootProject.projectDir.toPath().toAbsolutePath().normalize() if (!normalized.startsWith(root)) { throw new GradleException("Redis evidence digest input escapes the source root: ${file}") } String relative = root.relativize(normalized).toString() if (!paths.add(relative)) { throw new GradleException("Duplicate Redis evidence digest path: ${relative}") } byte[] pathBytes = relative.getBytes('UTF-8') byte[] contentBytes = file.bytes digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(pathBytes.length).array()) digest.update(pathBytes) digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array()) digest.update(contentBytes) } "sha256:${digest.digest().encodeHex()}" } Closure> redisEvidenceDigests = { File registry = rootProject.file('config/redis/readiness-cards.yaml') File images = rootProject.file('gradle/redis-test-images.properties') File redisResourceRoot = rootProject.file( 'adapter/outbound/cache-redis/src/main/resources') File redisResourceDirectory = new File(redisResourceRoot, 'redis') List programAssets = fileTree(redisResourceDirectory) { include '*.json' include 'scripts/*.lua' }.files.toList() Set referencedScripts = new LinkedHashSet<>() Closure validateScriptReferences validateScriptReferences = { Object node -> if (node instanceof Map) { Map object = node as Map if (object.containsKey('scriptResource') || object.containsKey('sha256')) { if (!(object.scriptResource instanceof String) || !(object.sha256 instanceof String) || !(object.sha256 ==~ /[0-9a-f]{64}/)) { throw new GradleException( 'Redis program metadata must pair scriptResource with lowercase SHA-256') } File script = new File(redisResourceRoot, object.scriptResource as String) java.nio.file.Path normalized = script.toPath().toAbsolutePath().normalize() java.nio.file.Path resourceRoot = redisResourceRoot.toPath() .toAbsolutePath().normalize() if (!normalized.startsWith(resourceRoot) || !script.isFile() || java.nio.file.Files.isSymbolicLink(script.toPath())) { throw new GradleException( "Redis program script reference is missing or escapes resources: ${object.scriptResource}") } String actual = redisSha256(script).substring('sha256:'.length()) if (actual != object.sha256) { throw new GradleException( "Redis program script digest mismatch for ${object.scriptResource}") } referencedScripts.add(script.canonicalFile) } object.values().each { validateScriptReferences(it) } } else if (node instanceof Collection) { (node as Collection).each { validateScriptReferences(it) } } } programAssets.findAll { it.name.endsWith('.json') }.each { File manifest -> validateScriptReferences(new JsonSlurper().parse(manifest)) } Set allScripts = programAssets.findAll { it.name.endsWith('.lua') }.collect { it.canonicalFile } as Set if (referencedScripts != allScripts) { throw new GradleException( "Redis program bundle scripts must be referenced exactly; missing=${allScripts - referencedScripts}, unknown=${referencedScripts - allScripts}") } Map safeConfigurationProjection = redisCapabilityMetadata.collectEntries { String cardId, Map metadata -> [(cardId): [ readiness : redisReadinessCards[cardId].state, selectedTopology: redisReadinessCards[cardId].selectedTopology, providerIds : metadata.providerIds, roles : metadata.roles, programIds : metadata.programs, keyVersions : metadata.keyVersions, codecVersions : metadata.codecVersions, guarantees : metadata.guarantees, nonGuarantees : metadata.nonGuarantees, requiredSettings: metadata.requiredSettings, evidenceProfile : redisReadinessCards[cardId].requiredEvidence ]] } byte[] projectionBytes = JsonOutput.toJson(safeConfigurationProjection).getBytes('UTF-8') String configurationDigest = "sha256:${MessageDigest.getInstance('SHA-256') .digest(projectionBytes).encodeHex()}" [ registrySha256 : redisSha256(registry), imageRegistrySha256: redisSha256(images), programSetSha256 : redisAggregateSha256(programAssets), configurationSha256: configurationDigest ] } ext.redisEvidenceDigests = redisEvidenceDigests def redisControlDirectory = layout.buildDirectory.dir('redis-evidence/control') def redisCiMatrixFile = layout.buildDirectory.file( 'redis-evidence/control/redis-readiness-matrix.json') def verifyRedisReadinessRegistryStrictness = tasks.register( 'verifyRedisReadinessRegistryStrictness') { group = 'redis verification' description = 'Runs malformed registry fixtures through the CI/build canonical strict parser.' inputs.file rootProject.file('config/redis/readiness-cards.yaml') doLast { String canonical = rootProject.file('config/redis/readiness-cards.yaml').getText('UTF-8') Map malformed = [ wrongRoot: canonical.replaceFirst('cards:', 'capabilities:'), duplicateCard: canonical.replace( ' redis-cache:\n', ' redis-cache:\n redis-cache:\n'), duplicateField: canonical.replaceFirst( ' state: implemented-candidate', ' state: implemented-candidate\n state: implemented-candidate'), unknownField: canonical.replaceFirst( ' state: implemented-candidate', ' unknown-field: value\n state: implemented-candidate'), invalidState: canonical.replaceFirst( 'state: implemented-candidate', 'state: candidate'), invalidTopology: canonical.replaceFirst( 'selected-topology: standalone', 'selected-topology: replicated'), invalidEvidence: canonical.replaceFirst( ' - standalone', ' - unsupported'), duplicateEvidence: canonical.replaceFirst( ' - standalone', ' - standalone\n - standalone'), notImplementedMetadata: canonical.replace( ' redis-fenced-coordination:\n state: not-implemented\n', ' redis-fenced-coordination:\n' + ' state: not-implemented\n' + ' selected-topology: standalone\n' + ' required-evidence:\n' + ' - selected-topology\n'), notImplementedEmptyEvidence: canonical.replace( ' redis-fenced-coordination:\n state: not-implemented\n', ' redis-fenced-coordination:\n' + ' state: not-implemented\n' + ' required-evidence:\n'), missingTopologyMarker: canonical.replaceFirst( ' - selected-topology\\n', ''), unsupportedIndentation: canonical.replaceFirst( ' state: implemented-candidate', ' state: implemented-candidate'), extraCard: canonical + ' redis-unknown:\n' + ' state: not-implemented\n', missingCard: canonical.replace( ' redis-fenced-coordination:\n state: not-implemented\n', ''), blankCard: canonical.replaceFirst( ' redis-cache:', ' :') ] malformed.each { String fixtureName, String fixtureText -> File fixture = new File(temporaryDir, "${fixtureName}.yaml") fixture.setText(fixtureText, 'UTF-8') boolean rejected = false try { loadRedisReadinessCards(fixture) } catch (GradleException expected) { rejected = true } if (!rejected) { throw new GradleException( "Strict Redis readiness parser accepted malformed fixture ${fixtureName}") } } logger.lifecycle( "verifyRedisReadinessRegistryStrictness: rejected ${malformed.size()} malformed fixtures") } } def verifyRedisCapabilityMetadata = tasks.register('verifyRedisCapabilityMetadata') { group = 'redis verification' description = 'Validates generated card provider, program, setting, and non-guarantee truth.' File redisSource = rootProject.file( 'adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis') inputs.files fileTree(redisSource) { include '**/*.java' } inputs.files fileTree(rootProject.file( 'adapter/outbound/cache-redis/src/main/resources/redis')) { include '*.json' } doLast { Map> expectedProviders = [ 'redis-cache' : ['redis'], 'redis-edge-rate-limit' : ['redis'], 'redis-request-replay-idempotency': ['redis'], 'redis-cache-refresh-soft-lease' : ['redis'], 'redis-fenced-coordination' : [], 'redis-session' : ['redis-session'] ] redisCapabilityMetadata.each { String cardId, Map metadata -> if (metadata.providerIds != expectedProviders[cardId]) { throw new GradleException( "${cardId}: capability provider IDs do not match canonical selection values") } } String canonicalConfig = new File(redisSource, 'RedisCanonicalConfig.java') .getText('UTF-8') Map selectionContracts = [ 'redis-cache' : '"ca-skeleton.capabilities.cache.bindings.default", "redis"', 'redis-edge-rate-limit' : '"ca-skeleton.capabilities.rate-limit.provider", "redis"', 'redis-request-replay-idempotency': '"ca-skeleton.capabilities.idempotency.provider", "redis"', 'redis-cache-refresh-soft-lease' : '"ca-skeleton.capabilities.cache.bindings.default", "redis"', 'redis-session' : '"ca-skeleton.security.auth-mode", "redis-session"' ] selectionContracts.each { String cardId, String sourceContract -> if (!canonicalConfig.contains(sourceContract)) { throw new GradleException( "${cardId}: canonical provider selection contract is missing: ${sourceContract}") } } String programIdSource = new File(redisSource, 'RedisProgramId.java').getText('UTF-8') def programMatcher = programIdSource =~ /(?s)([A-Z][A-Z0-9_]+)\s*\(\s*"([^"]+)"/ Map enumByExternalId = new LinkedHashMap<>() programMatcher.each { ignored, String enumName, String externalId -> enumByExternalId[externalId] = enumName } Set claimedPrograms = redisCapabilityMetadata.values().collectMany { it.programs as List } as Set Set unknownPrograms = claimedPrograms.findAll { !enumByExternalId.containsKey(it) } as Set if (!unknownPrograms.isEmpty()) { throw new GradleException( "Redis capability cards claim unknown program IDs: ${unknownPrograms}") } Map> implementationSources = [ 'redis-cache': [ 'RedisStringCacheRegion.java', 'RedisCacheConsistencyStore.java', 'RedisAtomicPrimitives.java' ], 'redis-edge-rate-limit': [ 'RedisEdgeRateLimitProvider.java' ], 'redis-request-replay-idempotency': [ 'RedisIdempotencyStoreProvider.java' ], 'redis-cache-refresh-soft-lease': [ 'RedisCacheRefreshCoordinator.java' ], 'redis-session': [ 'RedisLuaVersionedSessionStore.java' ] ] implementationSources.each { String cardId, List sources -> String implementation = sources.collect { new File(redisSource, it).getText('UTF-8') }.join('\n') (redisCapabilityMetadata[cardId].programs as List).each { String programId -> String enumName = enumByExternalId[programId] if (!implementation.contains("RedisProgramId.${enumName}")) { throw new GradleException( "${cardId}: claimed program ${programId} is not referenced by its implementation") } } } Map settingSourceContracts = [ 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference': 'RedisCanonicalCacheSettings.java', 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference': 'RedisRateLimitSettings.java', 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference': 'RedisIdempotencySettings.java', 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference': 'RedisSessionSettings.java' ] settingSourceContracts.each { String settingName, String sourceName -> String source = new File(redisSource, sourceName).getText('UTF-8') String prefix = settingName.substring(0, settingName.lastIndexOf('.')) if (!source.contains("@ConfigurationProperties(prefix = \"${prefix}\")") || !source.contains('String keyHmacSecretReference')) { throw new GradleException( "Capability card setting is not backed by typed settings: ${settingName}") } } List declaredSettingNames = redisCapabilityMetadata.values().collectMany { (it.requiredSettings as List>).collect { setting -> setting.name } } if (declaredSettingNames.any { it.contains('.roles.CACHE') }) { throw new GradleException( 'Generated Redis setting names must use canonical lowercase map keys') } Map> expectedSettingNames = [ 'redis-cache': [ 'ca-skeleton.capabilities.cache.bindings.default', 'ca-skeleton.providers.redis.roles.cache', 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference' ] as Set, 'redis-edge-rate-limit': [ 'ca-skeleton.capabilities.rate-limit.provider', 'ca-skeleton.providers.redis.roles.coordination', 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference' ] as Set, 'redis-request-replay-idempotency': [ 'ca-skeleton.capabilities.idempotency.provider', 'ca-skeleton.providers.redis.roles.coordination', 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference' ] as Set, 'redis-cache-refresh-soft-lease': [ 'ca-skeleton.capabilities.cache.bindings.default', 'ca-skeleton.providers.redis.roles.cache', 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference' ] as Set, 'redis-fenced-coordination': [] as Set, 'redis-session': [ 'ca-skeleton.security.auth-mode', 'ca-skeleton.providers.redis.roles.session', 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference' ] as Set ] redisCapabilityMetadata.each { String cardId, Map metadata -> Set actual = (metadata.requiredSettings as List>) .collect { it.name } as Set if (actual != expectedSettingNames[cardId]) { throw new GradleException( "${cardId}: generated required settings are incomplete or non-canonical; expected=${expectedSettingNames[cardId]}, actual=${actual}") } } String bootstrapConfiguration = rootProject.file( 'app-bootstrap/src/main/resources/application.yml').getText('UTF-8') if (!bootstrapConfiguration.contains( 'ca-skeleton.providers.redis.roles.cache')) { throw new GradleException( 'Canonical lowercase ca-skeleton.providers.redis.roles.cache binding is missing') } Map fenced = redisCapabilityMetadata['redis-fenced-coordination'] if (!(fenced.providerIds as List).isEmpty() || !(fenced.programs as List).isEmpty() || !(fenced.guarantees as List).isEmpty()) { throw new GradleException( 'redis-fenced-coordination must not claim a provider, program, or guarantee') } } } tasks.register('writeRedisCiMatrix') { group = 'redis verification' description = 'Writes the strict, deterministic Redis readiness matrix consumed by CI.' dependsOn verifyRedisCapabilityMetadata dependsOn verifyRedisReadinessRegistryStrictness inputs.file rootProject.file('config/redis/readiness-cards.yaml') inputs.file rootProject.file('gradle/redis-test-images.properties') inputs.files fileTree(rootProject.file( 'adapter/outbound/cache-redis/src/main/resources/redis')) { include '*.json' include 'scripts/*.lua' } outputs.dir redisControlDirectory outputs.upToDateWhen { false } doLast { Closure> resolvedEvidence = { Map card -> Set resolved = new LinkedHashSet<>(card.requiredEvidence as List) if (resolved.remove('selected-topology')) { resolved.add(card.selectedTopology as String) } resolved.toList().sort() } List> selected = [] List> candidates = [] redisReadinessCards.each { String cardId, Map card -> Map entry = [ cardId : cardId, readinessTask : redisPublicReadinessTasks[cardId], selectedTopology : card.selectedTopology, resolvedEvidence : resolvedEvidence(card) ] if (card.state == 'selected') { selected << entry } if (card.state in ['selected', 'implemented-candidate']) { candidates << entry } } selected.sort { it.cardId } candidates.sort { it.cardId } Map matrix = [ schemaVersion : 1, sourceRevision : redisEvidenceSourceRevision, sourceTreeState : redisEvidenceSourceTreeState, releaseQualification : 'NOT_CLAIMED', registryDigest : redisEvidenceDigests().registrySha256, imageRegistryDigest : redisEvidenceDigests().imageRegistrySha256, programBundleDigest : redisEvidenceDigests().programSetSha256, configProjectionDigest: redisEvidenceDigests().configurationSha256, selectedCount : selected.size(), selected : selected, implementedCandidates : candidates, topologyJobs : [ sentinel: candidates.any { it.selectedTopology == 'sentinel' }, cluster : candidates.any { it.selectedTopology == 'cluster' } ] ] File controlDirectory = redisControlDirectory.get().asFile controlDirectory.mkdirs() File output = redisCiMatrixFile.get().asFile output.setText(JsonOutput.prettyPrint(JsonOutput.toJson(matrix)) + '\n', 'UTF-8') File cardsDirectory = new File(controlDirectory, 'capability-cards') cardsDirectory.mkdirs() Map digests = redisEvidenceDigests() redisReadinessCards.keySet().toList().sort().each { String cardId -> Map card = redisReadinessCards[cardId] Map metadata = redisCapabilityMetadata[cardId] Map generatedCard = [ schemaVersion : 1, cardId : cardId, readiness : card.state, selectionResult : card.state == 'selected' ? 'SELECTED' : 'NOT_SELECTED', releaseQualification: 'NOT_CLAIMED', promotionTopology : card.selectedTopology, sourceRevision : redisEvidenceSourceRevision, sourceTreeState : redisEvidenceSourceTreeState, digests : digests, minimumRedisVersion : '7.2', providerIds : metadata.providerIds, roles : metadata.roles, programIds : metadata.programs, keyVersions : metadata.keyVersions, codecVersions : metadata.codecVersions, guarantees : metadata.guarantees, nonGuarantees : metadata.nonGuarantees, requiredSettings : metadata.requiredSettings, evidenceProfile : card.requiredEvidence ] new File(cardsDirectory, "${cardId}.json").setText( JsonOutput.prettyPrint(JsonOutput.toJson(generatedCard)) + '\n', 'UTF-8') } List controlFiles = [output] controlFiles.addAll(cardsDirectory.listFiles().toList()) File checksumFile = new File(controlDirectory, 'checksums.sha256') checksumFile.setText(controlFiles.toSorted { it.name }.collect { File file -> String relative = controlDirectory.toPath().relativize(file.toPath()).toString() "${redisSha256(file).substring('sha256:'.length())} ${relative}" }.join('\n') + '\n', 'UTF-8') logger.lifecycle("writeRedisCiMatrix: ${output}") } } def verifyRedisSelectedEvidenceArtifacts = tasks.register( 'verifyRedisSelectedEvidenceArtifacts') { group = 'redis verification' description = 'Reconciles downloaded sanitized evidence for every selected Redis card.' dependsOn tasks.named('writeRedisCiMatrix') doLast { File expectedControlDirectory = redisControlDirectory.get().asFile String controlDirectoryProperty = providers.gradleProperty('redisControlDirectory') .getOrElse('') File suppliedControlDirectory = controlDirectoryProperty.isBlank() ? expectedControlDirectory : rootProject.file(controlDirectoryProperty) if (!suppliedControlDirectory.isDirectory()) { throw new GradleException( "Redis readiness control directory is missing: ${suppliedControlDirectory}") } Set expectedControlPaths = [ 'redis-readiness-matrix.json', 'checksums.sha256' ] as Set redisReadinessCards.keySet().each { expectedControlPaths.add("capability-cards/${it}.json") } List suppliedControlFiles = fileTree(suppliedControlDirectory).files.toList() Set suppliedControlPaths = suppliedControlFiles.collect { suppliedControlDirectory.toPath().relativize(it.toPath()).toString() } as Set if (suppliedControlPaths != expectedControlPaths) { throw new GradleException( "Redis control artifact file set mismatch; expected=${expectedControlPaths}, actual=${suppliedControlPaths}") } suppliedControlFiles.each { File file -> if (file.length() > 1_048_576L || java.nio.file.Files.isSymbolicLink(file.toPath()) || !file.toPath().toRealPath().startsWith( suppliedControlDirectory.toPath().toRealPath())) { throw new GradleException( "Redis control artifact is oversized, symlinked, or path-escaping: ${file}") } } File suppliedChecksums = new File(suppliedControlDirectory, 'checksums.sha256') Map checksumEntries = new LinkedHashMap<>() suppliedChecksums.eachLine('UTF-8') { String line -> def match = line =~ /^([0-9a-f]{64}) ([a-z0-9.\/-]+)$/ if (!match.matches() || checksumEntries.put(match.group(2), match.group(1)) != null) { throw new GradleException( "Malformed or duplicate Redis control checksum line: ${line}") } } Set checksummedPaths = new LinkedHashSet<>(expectedControlPaths) checksummedPaths.remove('checksums.sha256') if (checksumEntries.keySet() != checksummedPaths) { throw new GradleException( "Redis control checksum set mismatch; expected=${checksummedPaths}, actual=${checksumEntries.keySet()}") } checksumEntries.each { String relative, String expectedSha -> String actualSha = redisSha256( new File(suppliedControlDirectory, relative)) .substring('sha256:'.length()) if (actualSha != expectedSha) { throw new GradleException( "Redis control checksum mismatch for ${relative}") } } Map expectedMatrix = new JsonSlurper().parse( redisCiMatrixFile.get().asFile) as Map Map suppliedMatrix = new JsonSlurper().parse( new File(suppliedControlDirectory, 'redis-readiness-matrix.json')) as Map if (suppliedMatrix != expectedMatrix || suppliedMatrix.sourceRevision != redisEvidenceSourceRevision || suppliedMatrix.sourceTreeState != redisEvidenceSourceTreeState || suppliedMatrix.releaseQualification != 'NOT_CLAIMED') { throw new GradleException( 'Downloaded Redis control matrix does not match this exact source revision and registry') } redisReadinessCards.each { String cardId, Map card -> Map expectedCard = new JsonSlurper().parse( new File(expectedControlDirectory, "capability-cards/${cardId}.json")) as Map Map suppliedCard = new JsonSlurper().parse( new File(suppliedControlDirectory, "capability-cards/${cardId}.json")) as Map if (suppliedCard != expectedCard || suppliedCard.releaseQualification != 'NOT_CLAIMED' || suppliedCard.sourceTreeState != redisEvidenceSourceTreeState || suppliedCard.readiness != card.state) { throw new GradleException( "Downloaded Redis capability card is stale or malformed: ${cardId}") } } Map> selectedCards = redisReadinessCards.findAll { ignored, card -> card.state == 'selected' } if ((suppliedMatrix.selectedCount as Number).intValue() != selectedCards.size()) { throw new GradleException( 'Redis control selectedCount does not match the strict checked-in registry') } String ciResultFileProperty = providers.gradleProperty('redisCiResultFile') .getOrElse('') if (!ciResultFileProperty.isBlank()) { File ciResultFile = rootProject.file(ciResultFileProperty) if (!ciResultFile.isFile() || ciResultFile.length() > 65_536L || java.nio.file.Files.isSymbolicLink(ciResultFile.toPath())) { throw new GradleException( "Redis CI result artifact is missing, oversized, or symlinked: ${ciResultFile}") } Map ciResult = new JsonSlurper().parse(ciResultFile) as Map if (ciResult.keySet() != [ 'schemaVersion', 'runId', 'selectedCount', 'selectedJobResult', 'selectedArtifactNames' ] as Set || ciResult.schemaVersion != 1 || ciResult.selectedCount != selectedCards.size() || !(ciResult.runId ==~ /[1-9][0-9]{0,19}/)) { throw new GradleException( 'Redis CI result artifact has malformed count, run, or schema metadata') } String expectedJobResult = selectedCards.isEmpty() ? 'skipped' : 'success' Set expectedArtifactNames = selectedCards.keySet().collect { "redis-selected-${it}" } as Set Set actualArtifactNames = ciResult.selectedArtifactNames as Set if (ciResult.selectedJobResult != expectedJobResult || actualArtifactNames != expectedArtifactNames || (ciResult.selectedArtifactNames as List).size() != actualArtifactNames.size()) { throw new GradleException( "Redis CI selected job/artifact inventory mismatch; expectedResult=${expectedJobResult}, actualResult=${ciResult.selectedJobResult}, expectedArtifacts=${expectedArtifactNames}, actualArtifacts=${actualArtifactNames}") } } else if (!selectedCards.isEmpty() || providers.environmentVariable('GITHUB_ACTIONS').getOrElse('') == 'true') { throw new GradleException( 'Redis CI/future selected reconciliation requires -PredisCiResultFile=') } String evidenceDirectoryProperty = providers.gradleProperty('redisEvidenceDirectory') .getOrElse('') if (selectedCards.isEmpty()) { if (!evidenceDirectoryProperty.isBlank()) { File unexpectedDirectory = rootProject.file(evidenceDirectoryProperty) if (unexpectedDirectory.isDirectory() && !fileTree(unexpectedDirectory).matching { include '**/manifest.json' }.files.isEmpty()) { throw new GradleException( 'No Redis card is selected but downloaded selected evidence manifests were supplied') } } logger.lifecycle( 'verifyRedisSelectedEvidenceArtifacts: selectedCount=0; explicit no-evidence branch, no R2 claim.') return } if (evidenceDirectoryProperty.isBlank()) { throw new GradleException( 'Selected Redis cards require -PredisEvidenceDirectory=') } File evidenceDirectory = rootProject.file(evidenceDirectoryProperty) if (!evidenceDirectory.isDirectory()) { throw new GradleException( "Redis selected evidence directory is missing: ${evidenceDirectory}") } Set allowedEvidenceFileNames = [ 'manifest.json', 'capability-card.json', 'topology-fault-timeline.json' ] as Set List evidenceFiles = fileTree(evidenceDirectory).files.toList() evidenceFiles.each { File file -> if (!allowedEvidenceFileNames.contains(file.name) || file.length() > 1_048_576L || java.nio.file.Files.isSymbolicLink(file.toPath()) || !file.toPath().toRealPath().startsWith( evidenceDirectory.toPath().toRealPath())) { throw new GradleException( "Redis selected artifact contains an unexpected, oversized, symlinked, or path-escaping file: ${file}") } } Set manifestParents = evidenceFiles.findAll { it.name == 'manifest.json' }.collect { it.parentFile.canonicalFile } as Set if (evidenceFiles.size() != manifestParents.size() * allowedEvidenceFileNames.size() || evidenceFiles.any { !manifestParents.contains(it.parentFile.canonicalFile) } || manifestParents.any { File parent -> parent.listFiles().findAll { it.isFile() }.collect { it.name } as Set != allowedEvidenceFileNames || parent.listFiles().any { it.isDirectory() } }) { throw new GradleException( 'Redis selected evidence must be an exact set of three allowlisted files per manifest parent') } List> manifests = fileTree(evidenceDirectory).matching { include '**/manifest.json' }.files.toSorted().collect { File manifest -> Map parsed = new JsonSlurper().parse(manifest) as Map parsed.__file = manifest parsed } if (manifests.any { it.cardId == null }) { throw new GradleException( 'Downloaded selected evidence must not contain generic or unowned manifests') } Map expectedDigests = redisEvidenceDigests() Set manifestFields = [ 'schemaVersion', 'taskPath', 'tagExpression', 'cardId', 'cardState', 'selectedTopology', 'evidenceCategory', 'outcome', 'tests', 'runtimeImageAttestation', 'actualEventTimeline', 'releaseQualification', 'sourceRevision', 'sourceTreeState', 'digests', 'companionSha256' ] as Set Set testFields = [ 'discovered', 'executed', 'passed', 'failed', 'errors', 'skipped' ] as Set Set capabilityFields = [ 'schemaVersion', 'cardId', 'readiness', 'releaseQualification', 'promotionTopology', 'sourceRevision', 'sourceTreeState', 'digests', 'minimumRedisVersion', 'providerIds', 'roles', 'programIds', 'keyVersions', 'codecVersions', 'guarantees', 'nonGuarantees', 'requiredSettings', 'evidenceProfile' ] as Set Set timelineFields = [ 'schemaVersion', 'taskName', 'cardId', 'topology', 'evidence', 'timelineKind', 'actualEventTimeline', 'sourceRevision', 'sourceTreeState', 'digests', 'events' ] as Set selectedCards.each { String cardId, Map card -> Set expectedEvidence = new LinkedHashSet<>( card.requiredEvidence as List) if (expectedEvidence.remove('selected-topology')) { expectedEvidence.add(card.selectedTopology as String) } List> cardManifests = manifests.findAll { it.cardId == cardId } List> capabilityManifests = cardManifests.findAll { it.evidenceCategory == null } if (capabilityManifests.size() != 1) { throw new GradleException( "${cardId}: expected exactly one capability manifest; got ${capabilityManifests.size()}") } expectedEvidence.each { String evidence -> List> matching = cardManifests.findAll { it.evidenceCategory == evidence } if (matching.size() != 1) { throw new GradleException( "${cardId}/${evidence}: expected exactly one evidence manifest; got ${matching.size()}") } } Set actualEvidence = cardManifests.findAll { it.evidenceCategory != null }.collect { it.evidenceCategory as String } as Set if (actualEvidence != expectedEvidence) { throw new GradleException( "${cardId}: evidence mismatch; expected=${expectedEvidence}, actual=${actualEvidence}") } cardManifests.each { Map manifest -> File manifestFile = manifest.__file as File Set actualManifestFields = new LinkedHashSet<>(manifest.keySet()) actualManifestFields.remove('__file') Map tests = manifest.tests as Map if (actualManifestFields != manifestFields || tests == null || tests.keySet() != testFields || manifest.schemaVersion != 1 || manifest.outcome != 'executed' || (tests.discovered as Number).longValue() <= 0L || (tests.executed as Number).longValue() <= 0L || (tests.passed as Number).longValue() <= 0L || (tests.failed as Number).longValue() != 0L || (tests.errors as Number).longValue() != 0L || (tests.skipped as Number).longValue() != 0L) { throw new GradleException( "${manifestFile}: malformed, non-executed, zero-test, failed, or skipped Redis evidence manifest") } if (manifest.cardState != 'selected' || manifest.selectedTopology != card.selectedTopology || manifest.sourceRevision != redisEvidenceSourceRevision || manifest.sourceTreeState != redisEvidenceSourceTreeState || manifest.releaseQualification != 'NOT_CLAIMED' || manifest.digests != expectedDigests) { throw new GradleException( "${manifestFile}: stale registry/topology/source/digest metadata") } String cardStem = redisReadinessTaskStems[cardId] String expectedTag String expectedTask if (manifest.evidenceCategory == null) { expectedTag = "card-${cardId}" expectedTask = ":adapter:outbound:cache-redis:redis${cardStem}CapabilityTest" } else { String evidence = manifest.evidenceCategory as String String evidenceStem = redisEvidenceTaskStems[evidence] expectedTag = "card-${cardId} & redis-${evidence}" expectedTask = ":adapter:outbound:cache-redis:redis${cardStem}${evidenceStem}EvidenceTest" } if (manifest.tagExpression != expectedTag || manifest.taskPath != expectedTask) { throw new GradleException( "${manifestFile}: wrong task path or exact tag intersection") } File capabilityFile = new File(manifestFile.parentFile, 'capability-card.json') File timelineFile = new File( manifestFile.parentFile, 'topology-fault-timeline.json') if (!capabilityFile.isFile() || !timelineFile.isFile()) { throw new GradleException( "${manifestFile}: missing sanitized evidence companions") } Map companionSha = manifest.companionSha256 as Map if (companionSha?.keySet() != [ 'capabilityCardSha256', 'timelineSha256' ] as Set || companionSha.capabilityCardSha256 != redisSha256(capabilityFile).substring('sha256:'.length()) || companionSha.timelineSha256 != redisSha256(timelineFile).substring('sha256:'.length())) { throw new GradleException( "${manifestFile}: companion checksum mismatch") } Map capability = new JsonSlurper().parse( capabilityFile) as Map Map timeline = new JsonSlurper().parse( timelineFile) as Map Map metadata = redisCapabilityMetadata[cardId] if (capability.keySet() != capabilityFields || capability.schemaVersion != 1 || capability.cardId != cardId || capability.readiness != 'selected' || capability.releaseQualification != 'NOT_CLAIMED' || capability.promotionTopology != card.selectedTopology || capability.sourceRevision != redisEvidenceSourceRevision || capability.sourceTreeState != redisEvidenceSourceTreeState || capability.digests != expectedDigests || capability.minimumRedisVersion != '7.2' || capability.providerIds != metadata.providerIds || capability.roles != metadata.roles || capability.programIds != metadata.programs || capability.keyVersions != metadata.keyVersions || capability.codecVersions != metadata.codecVersions || capability.guarantees != metadata.guarantees || capability.nonGuarantees != metadata.nonGuarantees || capability.requiredSettings != metadata.requiredSettings || capability.evidenceProfile != card.requiredEvidence) { throw new GradleException( "${capabilityFile}: malformed or stale generated capability card") } if (timeline.keySet() != timelineFields || timeline.schemaVersion != 1 || timeline.taskName != manifest.taskPath.tokenize(':').last() || timeline.cardId != cardId || timeline.topology != card.selectedTopology || timeline.evidence != manifest.evidenceCategory || timeline.sourceRevision != redisEvidenceSourceRevision || timeline.sourceTreeState != redisEvidenceSourceTreeState || timeline.digests != expectedDigests || !(timeline.events instanceof List)) { throw new GradleException( "${timelineFile}: malformed or stale sanitized timeline") } if (manifest.evidenceCategory != null && (manifest.runtimeImageAttestation != 'CAPTURED' || manifest.actualEventTimeline != 'CAPTURED' || timeline.actualEventTimeline != 'CAPTURED' || manifest.sourceTreeState != 'CLEAN')) { throw new GradleException( "${manifestFile}: selected promotion is blocked until actual-used image attestation and actual event timeline are captured") } } Set capabilityCardDigests = cardManifests.collect { Map companion = it.companionSha256 as Map companion.capabilityCardSha256 as String } as Set if (capabilityCardDigests.size() != 1) { throw new GradleException( "${cardId}: capability card must be byte-identical across all evidence tasks") } } Set unknownSelectedCards = manifests.findAll { it.cardId != null }.collect { it.cardId as String }.findAll { !selectedCards.containsKey(it) } as Set if (!unknownSelectedCards.isEmpty()) { throw new GradleException( "Downloaded selected evidence contains unselected cards: ${unknownSelectedCards}") } int expectedManifestCount = selectedCards.collect { String ignored, Map card -> Set resolved = new LinkedHashSet<>(card.requiredEvidence as List) if (resolved.remove('selected-topology')) { resolved.add(card.selectedTopology as String) } 1 + resolved.size() }.sum() as int if (manifests.size() != expectedManifestCount) { throw new GradleException( "Downloaded selected evidence manifest count mismatch; expected=${expectedManifestCount}, actual=${manifests.size()}") } logger.lifecycle( "verifyRedisSelectedEvidenceArtifacts: reconciled selected cards ${selectedCards.keySet()}") } } redisPublicReadinessTasks.each { String cardId, String taskName -> Map card = redisReadinessCards[cardId] tasks.register(taskName) { group = 'redis verification' description = "Qualifies checked-in Redis capability card ${cardId}." if (card.state != 'not-implemented') { String cardStem = redisReadinessTaskStems[cardId] dependsOn project(':adapter:outbound:cache-redis').tasks.named( "redis${cardStem}CapabilityTest") Set evidence = new LinkedHashSet<>(card.requiredEvidence as List) if (evidence.remove('selected-topology')) { evidence.add(card.selectedTopology as String) } evidence.each { String category -> String evidenceStem = redisEvidenceTaskStems[category] if (evidenceStem == null) { throw new GradleException( "Redis readiness card ${cardId} has unknown evidence ${category}") } dependsOn project(':adapter:outbound:cache-redis').tasks.named( "redis${cardStem}${evidenceStem}EvidenceTest") } } doLast { if (card.state == 'not-implemented') { logger.lifecycle("${cardId}: not selected (state=not-implemented)") } else { logger.lifecycle( "${cardId}: ${card.state} evidence passed for topology ${card.selectedTopology}") } } } } tasks.register('redisProductionReadiness') { group = 'redis verification' description = 'Runs the checked-in selected Redis card release gate and architecture contracts.' dependsOn project(':application-core').tasks.named('redisPolicyContractTest') dependsOn project(':shared-contract').tasks.named('edgeRateLimitContractTest') dependsOn project(':app-bootstrap').tasks.named('redisCompositionTest') dependsOn tasks.named('verifyCleanArchitectureDependencies') dependsOn tasks.named('verifyEnvKeys') dependsOn tasks.named('verifyPublicPathSnapshot') dependsOn tasks.named('verifyConfigurationPropertiesProcessor') dependsOn verifyRedisSelectedEvidenceArtifacts redisReadinessCards.findAll { ignored, card -> card.state == 'selected' }.each { String cardId, Map ignored -> dependsOn tasks.named(redisPublicReadinessTasks[cardId]) } doLast { List selected = redisReadinessCards.findAll { ignored, card -> card.state == 'selected' }.keySet().toList() if (selected.isEmpty()) { logger.lifecycle( 'redisProductionReadiness: no selected card; verified provider-disabled composition and no release R2 claim.') } else { logger.lifecycle("redisProductionReadiness: selected cards passed ${selected}") } } } tasks.register('redisAllImplementedCandidates') { group = 'redis verification' description = 'Runs selected and implemented-candidate Redis cards without changing release labels.' redisReadinessCards.findAll { ignored, card -> card.state in ['selected', 'implemented-candidate'] }.each { String cardId, Map ignored -> dependsOn tasks.named(redisPublicReadinessTasks[cardId]) } } def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') { group = 'verification' description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.' File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') inputs.file(moduleRegistryFile) doLast { def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile) List violations = [] def processorDeclaration = ~/^\s*annotationProcessor\s+['"]org\.springframework\.boot:spring-boot-configuration-processor['"]\s*$/ moduleRegistry.modules.each { module -> File leafDirectory = rootProject.projectDir.parentFile.toPath() .resolve(module.source_path as String) .normalize() .toFile() File mainSource = new File(leafDirectory, 'src/main') File buildFile = new File(leafDirectory, 'build.gradle') int propertyAnnotationCount = 0 if (mainSource.isDirectory()) { mainSource.eachFileRecurse { File sourceFile -> if (sourceFile.name.endsWith('.java')) { propertyAnnotationCount += sourceFile.text.count('@ConfigurationProperties(') } } } int processorCount = buildFile.readLines().count { String line -> processorDeclaration.matcher(line).matches() } boolean ownsConfigurationProperties = propertyAnnotationCount > 0 if (ownsConfigurationProperties && processorCount != 1) { violations << "${module.id}: ${propertyAnnotationCount} @ConfigurationProperties occurrence(s), " + "but ${processorCount} configuration-processor declaration(s)" } else if (!ownsConfigurationProperties && processorCount != 0) { violations << "${module.id}: no @ConfigurationProperties occurrence, but " + "${processorCount} configuration-processor declaration(s)" } } if (!violations.isEmpty()) { throw new GradleException( "verifyConfigurationPropertiesProcessor: ${violations.size()} parity violation(s):\n " + violations.toSorted().join('\n ')) } logger.lifecycle( "verifyConfigurationPropertiesProcessor: OK — all ${moduleRegistry.modules.size()} registered leaves have exact configuration-processor parity.") } } configure(subprojects.findAll { it.childProjects.isEmpty() }) { tasks.named('check') { dependsOn verifyConfigurationPropertiesProcessor } } // verifyOneTypePerFile — one public top-level type per file, file name == type name // (code-conventions I6). Rationale in README.md. tasks.register('verifyOneTypePerFile') { group = 'verification' description = 'code-conventions I6: one public top-level type per file; file name == type name.' doLast { def typeDecl = ~/^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(?:class|interface|record|enum|@interface)\s+([A-Za-z0-9_]+)/ List violations = [] rootProject.subprojects.each { sub -> File mainJava = sub.file('src/main/java') if (!mainJava.exists()) { return } mainJava.eachFileRecurse { File f -> if (!f.name.endsWith('.java') || f.name == 'package-info.java' || f.name == 'module-info.java') { return } List names = [] f.eachLine { String line -> def m = (line =~ typeDecl) if (m.find()) { names << m.group(1) } } if (names.size() > 1) { violations << "${f.path}: ${names.size()} public top-level types ${names}".toString() } else if (names.size() == 1) { String expected = f.name.replaceFirst(/\.java$/, '') if (names[0] != expected) { violations << "${f.path}: file name != public type name (type is '${names[0]}')".toString() } } } } if (!violations.isEmpty()) { throw new GradleException( "verifyOneTypePerFile: ${violations.size()} violation(s) of code-conventions I6:\n " + violations.join("\n ")) } logger.lifecycle("verifyOneTypePerFile: OK — one public top-level type per file, names match.") } } // verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env in lock-step. // Rationale in README.md. tasks.register('verifyEnvKeys') { group = 'verification' description = 'Verifies application.yml APP_ references, src/.env, and env-keys.yaml stay registered.' File envFile = file("${rootProject.projectDir}/.env") File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml") File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml") inputs.files(envFile, appYml, registryFile) doLast { if (!envFile.exists()) { throw new GradleException("verifyEnvKeys: missing ${envFile}") } if (!appYml.exists()) { throw new GradleException("verifyEnvKeys: missing ${appYml}") } if (!registryFile.exists()) { throw new GradleException("verifyEnvKeys: missing ${registryFile}") } def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/ Set envKeys = envFile.readLines().findResults { String line -> def m = keyPattern.matcher(line) m.matches() ? m.group(1) : null }.toSet() // Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional. Set requiredPlaceholders = new TreeSet<>() Set allPlaceholders = new TreeSet<>() def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/) while (pm.find()) { allPlaceholders << pm.group(1) if (pm.group(2) == null) { requiredPlaceholders << pm.group(1) } } Set environmentSecretReferences = new TreeSet<>() def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/) while (sm.find()) { environmentSecretReferences << sm.group(1) } Set applicationAppReferences = new TreeSet<>( allPlaceholders.findAll { it.startsWith('APP_') }) applicationAppReferences.addAll(environmentSecretReferences) // A. Every required (no inline default) placeholder must exist in .env. Set missingKeys = new TreeSet<>(requiredPlaceholders - envKeys) if (!missingKeys.isEmpty()) { throw new GradleException( "verifyEnvKeys: application.yml references required env absent from src/.env: ${missingKeys}") } // B. Every .env key must be referenced by some application.yml placeholder. Set knownApplicationReferences = new TreeSet<>(allPlaceholders) knownApplicationReferences.addAll(environmentSecretReferences) Set orphanedKeys = new TreeSet<>(envKeys - knownApplicationReferences) if (!orphanedKeys.isEmpty()) { throw new GradleException( "verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses: ${orphanedKeys}") } // C. Every APP_ key in src/.env must be registered in env-keys.yaml (APP_-scoped; // SPRING_* native keys are intentionally not tracked — see README.md). def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/ Set registryAppKeys = registryFile.readLines().findResults { String line -> def m = registryNamePattern.matcher(line) m.find() ? m.group(1) : null }.toSet() Set envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet() Set unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys) if (!unregisteredAppKeys.isEmpty()) { throw new GradleException( "verifyEnvKeys: src/.env declares APP_ keys absent from docs/registries/env-keys.yaml " + "(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}") } // D. Every application-owned reference is registered, including optional placeholders // with inline defaults and literal secret://environment/APP_* references. Set unregisteredApplicationReferences = new TreeSet<>(applicationAppReferences - registryAppKeys) if (!unregisteredApplicationReferences.isEmpty()) { throw new GradleException( "verifyEnvKeys: application.yml references APP_ keys absent from " + "docs/registries/env-keys.yaml (optional defaults and environment " + "secret references are included): ${unregisteredApplicationReferences}") } logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " + "${requiredPlaceholders.size()} required placeholders covered, " + "${applicationAppReferences.size()} application APP_ references registered.") } } // verifyPublicPathSnapshot — fail the build on an unapproved change to the deny-by-default // public path surface (SECURITY_PUBLIC_PATHS). Approve with -PapprovePublicPathChange. // Rationale and the snapshot-vs-reflection decision are in README.md. tasks.register('verifyPublicPathSnapshot') { group = 'verification' description = 'Fails on an unapproved change to the deny-by-default public path surface.' File envFile = file("${rootProject.projectDir}/.env") File snapshotFile = file("${rootProject.projectDir}/../docs/security/public-paths-snapshot.txt") boolean approved = project.hasProperty('approvePublicPathChange') inputs.file(envFile) inputs.property('approved', approved) doLast { if (!envFile.exists()) { throw new GradleException("verifyPublicPathSnapshot: missing ${envFile}") } def valuePattern = ~/^SECURITY_PUBLIC_PATHS=(.*)$/ String raw = envFile.readLines().findResult { String line -> def m = valuePattern.matcher(line) m.matches() ? m.group(1) : null } ?: '' List publicPaths = raw.split(',') .collect { it.trim() } .findAll { !it.isEmpty() } .toSorted() String header = "# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" + "# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.\n" + "# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange\n" String canonical = header + (publicPaths.isEmpty() ? "" : publicPaths.join('\n') + '\n') if (!snapshotFile.exists()) { snapshotFile.parentFile.mkdirs() snapshotFile.text = canonical logger.lifecycle("verifyPublicPathSnapshot: snapshot created at ${snapshotFile} " + "(${publicPaths.size()} public path(s)). Review and commit it.") return } String existing = snapshotFile.text if (existing == canonical) { logger.lifecycle("verifyPublicPathSnapshot: OK — ${publicPaths.size()} public path(s) unchanged.") return } if (approved) { snapshotFile.text = canonical logger.lifecycle("verifyPublicPathSnapshot: snapshot updated (approved). " + "Now ${publicPaths.size()} public path(s).") return } throw new GradleException( "verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" + " expected (snapshot):\n${existing}\n" + " actual (SECURITY_PUBLIC_PATHS):\n${canonical}\n" + "A protected endpoint may now be public. If this change is intended, get it reviewed " + "(security:public-path-change) and regenerate with:\n" + " ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange") } } // verifyTrivyignore — feature-dependency-vulnerability-management-contract D5 (Suppression // governance). Fails the build when a Trivy suppression entry in the repo-root .trivyignore.yaml // lacks an expiry or a reason, is already expired, or exceeds the 90-day max window. Closes the // 2026-05-25 ca-tmpl audit finding: a suppression could be added with no expiry/reason (a silent // permanent bypass). This is the CI-side field check; CODEOWNERS adds the merge-time approval — // the two controls are complementary (see .trivyignore.yaml header and src/README.md). tasks.register('verifyTrivyignore') { group = 'verification' description = 'feature-dependency-vulnerability-management-contract D5: every Trivy suppression has a reason and a non-expired, bounded expiry.' // UNSUPPORTED_IMPL_DECISION (team-policy): 90-day max suppression window. The Trivy docs only // guarantee the `expired_at` field exists (raw/official-docs/trivy-filtering-suppression-policy // C4); they recommend no specific bound. Trade-off: shorter forces frequent re-review; longer // approaches a de-facto permanent ignore. int maxWindowDays = 90 Set governedSections = ['vulnerabilities', 'licenses', 'misconfigurations', 'secrets'] as Set File suppressionFile = file("${rootProject.projectDir}/../.trivyignore.yaml") inputs.file(suppressionFile) doLast { if (!suppressionFile.exists()) { throw new GradleException("verifyTrivyignore: missing ${suppressionFile} (D5 requires the structured suppression file to exist, even if empty).") } java.time.LocalDate today = java.time.LocalDate.now() java.time.LocalDate maxDate = today.plusDays(maxWindowDays) List violations = [] int entryCount = 0 String section = null int entryIndent = -1 Map entry = null int entryLine = -1 Closure validate = { Map e, String sec, int lineNo -> entryCount++ String id = e['id'] String where = "${sec} entry at .trivyignore.yaml:${lineNo}" + (id ? " (id: ${id})" : "") if (!id?.trim()) { violations << "${where}: missing 'id'." } if (!e['statement']?.trim()) { violations << "${where}: missing non-empty 'statement' (every suppression must record a reason)." } String exp = e['expired_at']?.trim() if (!exp) { violations << "${where}: missing 'expired_at' (a missing expiry never expires in Trivy — permanent suppression is forbidden)." } else { try { java.time.LocalDate expDate = java.time.LocalDate.parse(exp) if (!expDate.isAfter(today)) { violations << "${where}: 'expired_at' ${exp} is not in the future (already expired — remove or renew the suppression)." } else if (expDate.isAfter(maxDate)) { violations << "${where}: 'expired_at' ${exp} exceeds the ${maxWindowDays}-day max window (must be on or before ${maxDate})." } } catch (java.time.format.DateTimeParseException ignored) { violations << "${where}: 'expired_at' '${exp}' is not a valid ISO date (expected YYYY-MM-DD)." } } return null } suppressionFile.eachLine { String rawLine, int number -> String noTab = rawLine.replace('\t', ' ') String trimmed = noTab.trim() if (trimmed.isEmpty() || trimmed.startsWith('#')) { return } int indent = noTab.length() - noTab.replaceAll('^ +', '').length() // Top-level section header (column 0), e.g. `vulnerabilities:` or `vulnerabilities: []`. def sectionMatch = (trimmed =~ /^([a-z_]+):(\s*\[\s*\])?\s*$/) if (indent == 0 && sectionMatch.find()) { if (entry != null) { validate(entry, section, entryLine); entry = null } section = sectionMatch.group(1) entryIndent = -1 return } if (section == null || !governedSections.contains(section)) { return } if (trimmed.startsWith('- ') || trimmed == '-') { if (entryIndent == -1) { entryIndent = indent } if (indent == entryIndent) { if (entry != null) { validate(entry, section, entryLine) } entry = [:] entryLine = number // A field may sit inline on the dash line, e.g. `- id: CVE-2024-0001`. String inline = trimmed.replaceFirst(/^-\s*/, '') int colon = inline.indexOf(':') if (colon > 0) { String k = inline.substring(0, colon).trim() String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') if (!k.isEmpty()) { entry[k] = v } } } // indent > entryIndent → nested list item (e.g. under `paths:`) → ignore. return } // A `key: value` field of the current entry (one indent level deeper than the dash). if (entry != null && indent == entryIndent + 2) { int colon = trimmed.indexOf(':') if (colon > 0) { String k = trimmed.substring(0, colon).trim() String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') entry[k] = v } } } if (entry != null) { validate(entry, section, entryLine) } if (!violations.isEmpty()) { throw new GradleException( "verifyTrivyignore: ${violations.size()} Trivy suppression governance violation(s) (D5):\n " + violations.join("\n ") + "\nFix the .trivyignore.yaml entries, or remove them. Every suppression needs an 'id', a " + "'statement' reason, and a future 'expired_at' within ${maxWindowDays} days.") } logger.lifecycle("verifyTrivyignore: OK — ${entryCount} suppression(s) validated (reason + bounded, non-expired expiry).") } } // verifyQuarantineSunset — feature-ci-quality-gates-contract §4 (D7/D9, this branch is the flaky // quarantine SSOT). The flaky-test quarantine bucket (@Tag("quarantine"), excluded from the release // gate) is a TEMPORARY escape, not a parking lot: every quarantined test must be registered in the // repo-root flaky-quarantine.yaml with a reason, a tracking issue, and a quarantined_since date, and // it must leave quarantine within 14 days (the ca-tmpl compromise on the Spotify/Google/MS-vs-Fowler // debate — company-case-study strength only, NOT an official best practice). This is the CI-side // field + sunset check; like verifyTrivyignore it is the field-validation half and CODEOWNERS is the // merge-approval half. Two enforcement directions: // (a) sunset — fail when any registered quarantined_since is older than 14 days; // (b) drift — fail when a test is @Tag("quarantine")-tagged in source but NOT registered // (a flaky test must not escape sunset tracking by skipping the registry). // Ships passing on the empty skeleton (zero tagged tests, `quarantined: []`). tasks.register('verifyQuarantineSunset') { group = 'verification' description = 'feature-ci-quality-gates-contract §4 (D7): every @Tag("quarantine") test is registered and within its 14-day sunset.' // UNSUPPORTED_IMPL_DECISION (company-case-study + team-policy): 14-day sunset. The Spotify/Google/MS // case studies establish that a quarantine bucket is legitimate; the 14-day quantum and its // automatic enforcement are ca-tmpl's own compromise (no external standard). A fork tunes // sunsetDays here. int sunsetDays = 14 File registryFile = file("${rootProject.projectDir}/../flaky-quarantine.yaml") inputs.file(registryFile) rootProject.subprojects.each { sub -> File testJava = sub.file('src/test/java') if (testJava.exists()) { inputs.dir(testJava) } } doLast { if (!registryFile.exists()) { throw new GradleException("verifyQuarantineSunset: missing ${registryFile} " + "(D7 requires the quarantine registry to exist, even if empty: `quarantined: []`).") } // --- Parse the registry (line-based, same shape family as .trivyignore.yaml). ----------- // quarantined: // - test: "fully.qualified.TestClass" | "...TestClass#method" // quarantined_since: "YYYY-MM-DD" // reason: "..." // tracking_issue: "..." List> entries = [] List entryLines = [] boolean inSection = false int entryIndent = -1 Map entry = null int entryLine = -1 Closure closeEntry = { if (entry != null) { entries << entry; entryLines << entryLine; entry = null } return null } registryFile.eachLine { String rawLine, int number -> String noTab = rawLine.replace('\t', ' ') String trimmed = noTab.trim() if (trimmed.isEmpty() || trimmed.startsWith('#')) { return } int indent = noTab.length() - noTab.replaceAll('^ +', '').length() // Top-level `quarantined:` or `quarantined: []`. def sectionMatch = (trimmed =~ /^quarantined:(\s*\[\s*\])?\s*$/) if (indent == 0 && sectionMatch.find()) { closeEntry() inSection = true entryIndent = -1 return } if (indent == 0) { // some other top-level key → leave the section closeEntry() inSection = false return } if (!inSection) { return } if (trimmed.startsWith('- ') || trimmed == '-') { if (entryIndent == -1) { entryIndent = indent } if (indent == entryIndent) { closeEntry() entry = [:] entryLine = number String inline = trimmed.replaceFirst(/^-\s*/, '') int colon = inline.indexOf(':') if (colon > 0) { String k = inline.substring(0, colon).trim() String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') if (!k.isEmpty()) { entry[k] = v } } } return } // A `key: value` field of the current entry (deeper than the dash). if (entry != null && indent == entryIndent + 2) { int colon = trimmed.indexOf(':') if (colon > 0) { String k = trimmed.substring(0, colon).trim() String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') entry[k] = v } } } closeEntry() // --- (a) Schema + sunset validation of every registered entry. ------------------------- java.time.LocalDate today = java.time.LocalDate.now() List violations = [] Set registeredClasses = new HashSet<>() entries.eachWithIndex { Map e, int i -> String test = e['test']?.trim() String where = "flaky-quarantine.yaml:${entryLines[i]}" + (test ? " (test: ${test})" : "") if (!test) { violations << "${where}: missing 'test' (fully-qualified test class, optionally '#method')." } else { registeredClasses << test.replaceAll(/#.*$/, '') } if (!e['reason']?.trim()) { violations << "${where}: missing non-empty 'reason' (why is it flaky / what is the suspected cause)." } if (!e['tracking_issue']?.trim()) { violations << "${where}: missing 'tracking_issue' (the issue tracking the fix — quarantine is not a parking lot)." } String since = e['quarantined_since']?.trim() if (!since) { violations << "${where}: missing 'quarantined_since' (a missing date cannot be sunset — forbidden)." } else { try { java.time.LocalDate sinceDate = java.time.LocalDate.parse(since) if (sinceDate.isAfter(today)) { violations << "${where}: 'quarantined_since' ${since} is in the future." } else if (sinceDate.plusDays(sunsetDays).isBefore(today)) { long age = java.time.temporal.ChronoUnit.DAYS.between(sinceDate, today) violations << "${where}: quarantined ${age} days ago — past the ${sunsetDays}-day sunset. " + "Fix and un-quarantine the test, or escalate; do not extend silently." } } catch (java.time.format.DateTimeParseException ignored) { violations << "${where}: 'quarantined_since' '${since}' is not a valid ISO date (expected YYYY-MM-DD)." } } } // --- (b) Drift: every @Tag("quarantine") test in source must be registered. ------------- def tagPattern = ~/@Tag\(\s*["']quarantine["']\s*\)/ def classPattern = ~/(?:class|interface|enum|record)\s+([A-Za-z0-9_]+)/ int taggedCount = 0 rootProject.subprojects.each { sub -> File testJava = sub.file('src/test/java') if (!testJava.exists()) { return } testJava.eachFileRecurse { File f -> if (!f.name.endsWith('.java')) { return } String text = f.text if (!(text =~ tagPattern)) { return } taggedCount++ def cm = (text =~ classPattern) String simpleName = cm ? cm[0][1] : f.name.replaceFirst(/\.java$/, '') boolean registered = registeredClasses.any { it.endsWith('.' + simpleName) || it == simpleName } if (!registered) { violations << "${f.path}: test '${simpleName}' is @Tag(\"quarantine\") but is not registered " + "in flaky-quarantine.yaml — a quarantined test must be tracked with a sunset date." } } } if (!violations.isEmpty()) { throw new GradleException( "verifyQuarantineSunset: ${violations.size()} quarantine governance violation(s) (§4 / D7):\n " + violations.join("\n ") + "\nEach @Tag(\"quarantine\") test needs a flaky-quarantine.yaml entry with a 'reason', a " + "'tracking_issue', and a 'quarantined_since' within ${sunsetDays} days.") } logger.lifecycle("verifyQuarantineSunset: OK — ${entries.size()} registered, ${taggedCount} tagged " + "(${sunsetDays}-day sunset enforced).") } }