diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt index d14a7912..a1f5d71c 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt @@ -99,6 +99,72 @@ sealed interface CertificateConsumption { ) : CertificateConsumption } +/** + * The transition's population-authority context for one admission verdict (M34). + * + * Carries the two things a verdict needs about authority - the canonical fresh projection and the + * consumptions that projection certifies - as one value, so the two judgment paths (the lifecycle + * scan and the appearing-identity path) are handed the same object and cannot disagree about + * consumption. Bundling them also keeps [MutationPopulationAdmissionCeremony.appearanceVerdict] + * inside the repository's parameter budget without suppressing anything. + * + * Produced exactly once per transition and passed unchanged to every consumer - M34, the admission + * lifecycle and the certificate lifecycle (M44/M47). Consumers must never re-derive it: a + * value-equal recomputation is not the same property as shared evidence, and only one production + * site makes it structurally impossible for the rules to disagree about which consumptions were + * proven. `CertificateCustodyTransportTest` pins that with a source-level assertion. + */ +data class AdmissionAuthority( + val freshAuthorityProjectionHash: String?, + val certifiedConsumptions: Set = emptySet(), +) + +/** + * The certified consumptions a base can prove for this fresh projection (Step 3b). + * + * The single production site for the facts M34, M44 and M47 act on: they all consume the same + * `Set`, so no consumer re-derives its own and the call sites cannot drift apart. Both inputs + * come from the coherent transition context - the base snapshot supplies the certificate ledger, the + * historical admission digests and the exact authorized identity set, while the authority-v2 + * projection comes only from the fresh measurement. + * + * One fact is produced per distinct historical digest the base's admissions were minted under, and + * each citation is verified against that admission's own digest (M41) and the exact base + * authorization set (M42). A null projection yields no facts: without a fresh measurement there is + * nothing to certify, which is the fail-closed state. + */ +fun certifiedConsumptions( + certificates: MutationAuthorityDigestCertificates, + baseAdmissions: MutationPopulationAdmissions, + freshAuthorityProjectionHash: String?, +): Set { + val projection = freshAuthorityProjectionHash ?: return emptySet() + val identities = baseAdmissions.admissions.map { it.identity } + val historicalDigests = baseAdmissions.admissions.map { it.populationDigest }.distinct() + return certificates.certificates + .flatMap { certificate -> + historicalDigests.mapNotNull { digest -> + verifyCertificateConsumption( + certificate = certificate, + baseCertificates = certificates, + citedAdmissionPopulationDigest = digest, + baseAdmissionIdentities = identities, + freshAuthorityProjectionHash = projection, + ) as? CertificateConsumption.Valid + } + }.toSet() +} + +/** + * The authority context for a verdict over one base snapshot and this transition's fresh projection. + */ +fun MutationRatchetAuthority.admissionAuthority(freshAuthorityProjectionHash: String?): AdmissionAuthority = + AdmissionAuthority( + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + certifiedConsumptions = + certifiedConsumptions(certificates, admissions, freshAuthorityProjectionHash), + ) + /** * The consumption entry point for call sites: delegates to [CertificateConsumption.Valid.verify], so * there is exactly one implementation of the proof and no second route to a fact. diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt index d15d505b..a900e7e5 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt @@ -864,6 +864,10 @@ abstract class MaintainabilityBaselinePlugin : Plugin { // defaulted away: without it M35 cannot bind a minted authorization to its // base, and M36-M38 cannot see retention or rewriting of a pending row. admissions = MutationPopulationAdmissionLoader.load(project.rootDir), + // The transition's own certificate ledger, loaded the same way: M45 binds a + // newly minted certificate to this base, M46 rejects a rewritten retained one, + // and M47 must be able to see a base certificate disappear. + certificates = MutationAuthorityDigestCertificateLoader.load(project.rootDir), ) val diagnostics = MutationRatchetVerifier().verify( diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt index a80fffbc..f3d7feaf 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt @@ -73,8 +73,8 @@ object MutationPopulationAdmissionCeremony { fun checks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, - freshAuthorityProjectionHash: String?, - ): List = lifecycleChecks(base, candidate, freshAuthorityProjectionHash) + authority: AdmissionAuthority, + ): List = lifecycleChecks(base, candidate, authority) /** * The verdict for one identity that is present in the candidate population and absent from the @@ -89,8 +89,10 @@ object MutationPopulationAdmissionCeremony { candidateAdmission: MutationPopulationAdmission?, mutant: MutationOutcome, candidateAnalyzer: MutationAnalyzerSemantics, - freshAuthorityProjectionHash: String?, + authority: AdmissionAuthority, ): AdmissionVerdict { + val freshAuthorityProjectionHash = authority.freshAuthorityProjectionHash + val certifiedConsumptions = authority.certifiedConsumptions val short = short(mutant.identity) return when { baseAdmission == null -> { @@ -132,7 +134,14 @@ object MutationPopulationAdmissionCeremony { ) } - baseAdmission.populationDigest != freshAuthorityProjectionHash -> { + // M34 with certified migration (Step 3b): a raw-v1 authorization remains consumable when a + // base certificate translates exactly its historical digest into this transition's fresh + // authority projection, bounded to the exact base authorization set. The facts come from + // one production site ([certifiedConsumptions]) and a Valid exists only if M43-M42 passed + // against the base ledger, so this branch cannot be reached by asserting anything. Without + // a certificate the condition is unchanged and M34 fails exactly as it did before. + baseAdmission.populationDigest != freshAuthorityProjectionHash && + certifiedConsumptions.none { it.fromDigest == baseAdmission.populationDigest } -> { reject( DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "M34: $short was authorized against population digest " + @@ -191,10 +200,10 @@ object MutationPopulationAdmissionCeremony { private fun lifecycleChecks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, - freshAuthorityProjectionHash: String?, + authority: AdmissionAuthority, ): List { val diagnostics = mintChecks(base, candidate) - return diagnostics + consumptionChecks(base, candidate, freshAuthorityProjectionHash) + return diagnostics + consumptionChecks(base, candidate, authority) } /** @@ -233,7 +242,7 @@ object MutationPopulationAdmissionCeremony { private fun consumptionChecks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, - freshAuthorityProjectionHash: String?, + authority: AdmissionAuthority, ): List { val diagnostics = mutableListOf() val baseAdmissions = base.admissions.byIdentity() @@ -254,7 +263,7 @@ object MutationPopulationAdmissionCeremony { candidateAdmission = candidateAdmission, mutant = mutant, candidateAnalyzer = candidate.population.analyzer, - freshAuthorityProjectionHash = freshAuthorityProjectionHash, + authority = authority, ) is AdmissionVerdict.Authorized if (candidateAdmission == null) { // M37: a retained authorization may only disappear by being consumed. Otherwise a diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt index 57bc65ac..9e6f90d8 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt @@ -40,6 +40,17 @@ data class MutationRatchetAuthority( * must fail to compile rather than silently degrade the transition to "no authorizations". */ val admissions: MutationPopulationAdmissions, + /** + * Base-side digest-migration certificates (0.7.1g1P2, M40-M47). OPTIONAL authority, like + * [enrollments]: a base that predates the certificate ledger simply has none, which is the most + * restrictive state - no certified migration exists, so every raw-v1 admission still fails M34. + * A present ledger is validated by its own loader, so a malformed one fails hard rather than + * degrading into "no certificates". + * + * The default is convenient for fixtures and is always the conservative answer; the loader passes + * it explicitly, read from the base revision. + */ + val certificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, ) /** @@ -68,6 +79,18 @@ data class MutationRatchetCandidate( * not compile. Missing it silently turned every candidate proposal into "none". */ val admissions: MutationPopulationAdmissions, + /** + * Certificates this transition proposes. They are validated here (M45 binds a newly introduced + * certificate to the base it is proposed against, M46 rejects a rewritten retained one) but they + * can never authorize a consumption in the same transition: only + * [MutationRatchetAuthority.certificates] is consulted, so a candidate that both mints and + * consumes fails M43. + * + * Defaults to [MutationAuthorityDigestCertificates.NONE] for fixtures. The loader always passes it + * explicitly. A call site that forgot would weaken nothing: every removal then looks + * unconsumed and M47 fails loudly, which is the conservative direction. + */ + val certificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, ) object MutationRatchetAuthorityLoader { @@ -164,6 +187,21 @@ object MutationRatchetAuthorityLoader { admissionsFile.writeText(admissionsAtBase.output, Charsets.UTF_8) } val admissions = MutationPopulationAdmissionLoader.load(tempDir) + // Digest-migration certificates (0.7.1g1P2) are OPTIONAL authority for the same reason: a + // base that predates the ledger has none, which is the most restrictive state, since no + // certified migration exists and every raw-v1 admission still fails M34. A present ledger + // is validated by its own loader, so a malformed one fails hard instead of degrading into + // "no certificates". + val certificatesFile = File(qualityDir, "mutation-authority-digest-certificates.yml") + val certificatesAtBase = + runGit( + rootDir, + listOf("show", "$baseSha:${MutationAuthorityDigestCertificateLoader.FILE_NAME}"), + ) + if (certificatesAtBase.exitCode == 0) { + certificatesFile.writeText(certificatesAtBase.output, Charsets.UTF_8) + } + val certificates = MutationAuthorityDigestCertificateLoader.load(tempDir) return MutationRatchetAuthority( baseSha = baseSha, population = population, @@ -171,6 +209,7 @@ object MutationRatchetAuthorityLoader { targetFamilies = configuration.mutation.targetFamilies, enrollments = enrollments, admissions = admissions, + certificates = certificates, ) } finally { tempDir.deleteRecursively() diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt index 7b496d6e..40064107 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt @@ -90,6 +90,10 @@ class MutationRatchetVerifier { diagnostics += validateClassificationList("candidate", candidate.classifications) diagnostics += baseClassificationIntegrity(base) val freshAuthorityProjectionHash = evolutionEvidence.proof?.authorityProjectionHash + // Produced ONCE for the whole transition. M34, the admission lifecycle and the certificate + // lifecycle (M44/M47) all receive this exact instance, so they cannot quietly come to + // different conclusions about which consumptions were proven. + val admissionAuthority = base.admissionAuthority(freshAuthorityProjectionHash) diagnostics += outcomeRatchet( base.population, @@ -102,11 +106,21 @@ class MutationRatchetVerifier { base.admissions, candidate.admissions, freshAuthorityProjectionHash, + admissionAuthority, ), ) diagnostics += classificationRatchet(base, candidate) diagnostics += MutationEnrollmentCeremony.checks(base, candidate) - diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, freshAuthorityProjectionHash) + diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, admissionAuthority) + // Certificate custody (M44-M47), decided on the same facts M34 uses: one production site, so the + // lifecycle rules cannot disagree with the admission verdicts about what was consumed. + diagnostics += + MutationAuthorityDigestCertificateCeremony.checks( + base = base.certificates, + candidate = candidate.certificates, + baseSha = base.baseSha, + validConsumptions = admissionAuthority.certifiedConsumptions, + ) diagnostics += familyAndTargetChecks( base.population, @@ -320,7 +334,7 @@ class MutationRatchetVerifier { candidatePopulation = candidatePopulation, baseAdmissions = evolution.baseAdmissions.byIdentity(), candidateAdmissions = evolution.candidateAdmissions.byIdentity(), - freshAuthorityProjectionHash = evolution.freshAuthorityProjectionHash, + authority = evolution.authority, ) // M21: a base identity that simply stopped being measured. Absence is not evidence of @@ -351,7 +365,7 @@ class MutationRatchetVerifier { candidatePopulation: MutationPopulationBaseline, baseAdmissions: Map, candidateAdmissions: Map, - freshAuthorityProjectionHash: String?, + authority: AdmissionAuthority, ): List { val diagnostics = mutableListOf() val candidateById = candidatePopulation.mutants.associateBy { it.identity } @@ -368,7 +382,7 @@ class MutationRatchetVerifier { candidateAdmission = candidateAdmissions[id], mutant = candidate, candidateAnalyzer = candidatePopulation.analyzer, - freshAuthorityProjectionHash = freshAuthorityProjectionHash, + authority = authority, ) ) { is MutationPopulationAdmissionCeremony.AdmissionVerdict.Authorized -> { @@ -781,6 +795,12 @@ private data class MutationEvolutionContext( * Null means no trusted measurement proof exists, and admission then fails closed. */ val freshAuthorityProjectionHash: String? = null, + /** + * Authority context for appearing-identity verdicts (M34, Step 3b): the fresh projection plus the + * consumptions it certifies. Defaults to no projection and no consumptions, which is the + * fail-closed state: an appearing identity with no trusted measurement proof still fails M34. + */ + val authority: AdmissionAuthority = AdmissionAuthority(null), ) // No population hash is stored in mutation-evolution.yml: exact measurement diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt new file mode 100644 index 00000000..95723be1 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt @@ -0,0 +1,72 @@ +package dev.tramai.build.quality + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Base-revision transport for the digest-migration certificate ledger (Step 3b). + * + * M43/M47 are judged against the certificate ledger **as it exists in the base**, so the base side + * must be readable at a revision - not only from the working tree. This proves both directions + * against real repository history, using the same `git show`-into-a-temp-tree path the admissions, + * enrollments and baseline already use, so one authority snapshot carries the whole base context: + * population, classifications, enrollments, admissions and certificates. + * + * Both SHAs are real and immutable: `9ebe7b44` is the P1M merge that minted the certificate, and + * `64d05450` is the base P1M was proposed against, which predates the ledger entirely. + */ +class CertificateBaseRevisionTransportTest { + private val repositoryRoot = repositoryRoot() + + @Test + fun `the revision that minted the certificate exposes it through the authority snapshot`() { + val authority = MutationRatchetAuthorityLoader.load(repositoryRoot, P1M_MERGE) + + val certificate = authority.certificates.certificates.single() + assertEquals( + authority.admissions.admissions + .map { it.populationDigest } + .toSet(), + setOf(certificate.fromDigest), + ) + } + + @Test + fun `a base that predates the certificate ledger exposes no certificates`() { + val authority = MutationRatchetAuthorityLoader.load(repositoryRoot, PRE_P1M_BASE) + + assertTrue( + authority.certificates.certificates.isEmpty(), + "a base predating the ledger must expose no certificates, got " + + "${authority.certificates.certificates.map { it.fromDigest }}", + ) + // The same base still carries its authorizations: this isolates the certificate ledger + // rather than proving that an unrelated empty snapshot is empty. + assertTrue(authority.admissions.admissions.isNotEmpty()) + } + + /** + * The repository root, found by walking up to the `gradlew` marker (the idiom the other + * real-task tests use) rather than assuming a fixed depth below it. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (gradlew marker) found above ${System.getProperty("user.dir")}" + } + return candidate + } + + private companion object { + /** The P1M merge commit: the first revision whose tree contains the certificate ledger. */ + const val P1M_MERGE = "9ebe7b4430760ac313874750e7c5ac9bbe56ef1e" + + /** The base the P1M transition was proposed against: predates the certificate ledger. */ + const val PRE_P1M_BASE = "64d05450c285ecd9cf3635ca2935da816f649856" + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt new file mode 100644 index 00000000..b8c7ed7e --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt @@ -0,0 +1,174 @@ +package dev.tramai.build.quality + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Real-task custody transport for the certificate lifecycle (Step 3b, M44-M47). + * + * The committed certificate ledger is loaded through the real loader and driven through the + * certificate ceremony with the facts the verifier itself produces, so these are the real rules over + * real base authority rather than fixtures. The last case is the one the whole ordering exists for: + * a certificate may disappear only because a consumption was **independently proven** in the same + * transition - never because it was absent. + */ +class CertificateCustodyTransportTest { + private val repositoryRoot = repositoryRoot() + private val base = MutationAuthorityDigestCertificateLoader.load(repositoryRoot) + private val admissions = MutationPopulationAdmissionLoader.load(repositoryRoot) + private val real = base.certificates.single() + + private fun checks( + candidate: MutationAuthorityDigestCertificates, + validConsumptions: Set = emptySet(), + ) = MutationAuthorityDigestCertificateCeremony.checks( + base = base, + candidate = candidate, + baseSha = base.certificates.single().fromBaseSha, + validConsumptions = validConsumptions, + ) + + /** The facts the verifier produces for this base: same production site, same inputs. */ + private fun facts() = certifiedConsumptions(base, admissions, base.certificates.single().toDigest) + + @Test + fun `the committed certificate survives an unchanged transition`() { + val diagnostics = checks(base) + + assertTrue(diagnostics.isEmpty(), diagnostics.joinToString { it.message }) + } + + @Test + fun `the committed certificate cannot be dropped without a proven consumption`() { + val diagnostics = checks(MutationAuthorityDigestCertificates.NONE) + + assertTrue(diagnostics.single().message.contains("M47"), diagnostics.single().message) + } + + @Test + fun `dropping it is permitted once the consumption is proven from the real ledgers`() { + val diagnostics = checks(MutationAuthorityDigestCertificates.NONE, facts()) + + assertTrue(diagnostics.isEmpty(), diagnostics.joinToString { it.message }) + } + + @Test + fun `the committed ledger really does prove a consumption for its own target digest`() { + assertEquals(1, facts().size) + } + + // ── The remaining lifecycle discriminators, over the real committed ledger ── + + @Test + fun `T18 a retained certificate whose enforced payload was rewritten fails M46`() { + val rewritten = + MutationAuthorityDigestCertificate( + fromAlgorithm = real.fromAlgorithm, + fromDigest = real.fromDigest, + toAlgorithm = real.toAlgorithm, + toDigest = real.toDigest, + admissionSetDigest = real.admissionSetDigest, + fromBaseSha = real.fromBaseSha, + reason = "rewritten after it was minted", + ) + + val diagnostics = checks(MutationAuthorityDigestCertificates("1", listOf(rewritten))) + + assertTrue(diagnostics.single().message.contains("M46"), diagnostics.single().message) + } + + @Test + fun `T17 a certificate introduced against another base fails M45`() { + val foreign = foreignCertificate(fromDigest = "2".repeat(64), fromBaseSha = "b".repeat(40)) + + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = base, + candidate = MutationAuthorityDigestCertificates("1", listOf(real, foreign)), + baseSha = real.fromBaseSha, + ) + + assertTrue(diagnostics.single().message.contains("M45"), diagnostics.single().message) + } + + @Test + fun `T14 a certificate absent from the base cannot be consumed, however it is cited`() { + val foreign = foreignCertificate(fromDigest = "3".repeat(64), fromBaseSha = real.fromBaseSha) + + val verdict = + verifyCertificateConsumption( + certificate = foreign, + baseCertificates = base, + citedAdmissionPopulationDigest = foreign.fromDigest, + baseAdmissionIdentities = admissions.admissions.map { it.identity }, + freshAuthorityProjectionHash = foreign.toDigest, + ) + + assertTrue(verdict is CertificateConsumption.Invalid, "expected a refusal, got $verdict") + assertTrue( + (verdict as CertificateConsumption.Invalid).diagnostic.message.contains("M43"), + verdict.diagnostic.message, + ) + } + + /** A certificate with the real one's shape but a different provenance or source digest. */ + private fun foreignCertificate( + fromDigest: String, + fromBaseSha: String, + ) = MutationAuthorityDigestCertificate( + fromAlgorithm = real.fromAlgorithm, + fromDigest = fromDigest, + toAlgorithm = real.toAlgorithm, + toDigest = real.toDigest, + admissionSetDigest = real.admissionSetDigest, + fromBaseSha = fromBaseSha, + reason = "foreign certificate", + ) + + @Test + fun `the authority facts are produced exactly once and shared with every consumer`() { + val verifier = source("MutationRatchetVerifier.kt") + val ceremony = source("MutationPopulationAdmissionCeremony.kt") + + // One production event for the whole transition. Value-equal recomputation is not the same + // property as shared evidence: only one production site makes it impossible for M34, the + // admission lifecycle and M44/M47 to disagree about which consumptions were proven. + assertEquals( + 1, + Regex("admissionAuthority\\(").findAll(verifier).count(), + "the transition must produce its authority facts exactly once", + ) + // The ceremony receives the authority; it must never rebuild it. + assertEquals( + 0, + Regex("admissionAuthority\\(").findAll(ceremony).count(), + "the admission ceremony must not re-derive authority facts", + ) + // And the certificate lifecycle consumes that same local rather than its own copy. + assertTrue( + verifier.contains("validConsumptions = admissionAuthority.certifiedConsumptions"), + "M44/M47 must consume the same local authority M34 was judged on", + ) + } + + private val qualitySource = "build-logic/src/main/kotlin/dev/tramai/build/quality" + + private fun source(name: String) = File(repositoryRoot, "$qualitySource/$name").readText() + + /** + * The repository root, found by walking up to the `gradlew` marker (the idiom the other + * real-task tests use) rather than assuming a fixed depth below it. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (gradlew marker) found above ${System.getProperty("user.dir")}" + } + return candidate + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt index 3c94ad8e..9dd2f3a1 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt @@ -435,7 +435,7 @@ class MutationPopulationAdmissionCeremonyTest : MutationRatchetTestSupport() { candidateAdmission = null, mutant = target, candidateAnalyzer = semantics, - freshAuthorityProjectionHash = "0".repeat(64), + authority = AdmissionAuthority("0".repeat(64)), ) as? AdmissionVerdict.Rejected assertNotNull(rejected) assertEquals(DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, rejected.code) @@ -487,4 +487,90 @@ class MutationPopulationAdmissionCeremonyTest : MutationRatchetTestSupport() { ) passes(diagnostics) } + + // ── M34 with certified migration (Step 3b) ── + // + // A raw-v1 authorization is consumable under authority-v2 only when a base certificate translates + // exactly its historical digest into this transition's fresh authority projection. Without that + // fact M34 must still fail, so the escape cannot be reached by asserting anything. + + private fun certificate( + rawDigest: String, + toDigest: String, + identity: String, + ) = MutationAuthorityDigestCertificate( + fromAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + fromDigest = rawDigest, + toAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + toDigest = toDigest, + admissionSetDigest = + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(identity)), + fromBaseSha = "a".repeat(40), + reason = "test certificate", + ) + + @Test + fun `certifiedConsumptions produces the fact from a base certificate and refuses it otherwise`() { + val target = row("target") + val fresh = population(listOf(target)) + val rawDigest = "1".repeat(64) + val certificates = + MutationAuthorityDigestCertificates("1", listOf(certificate(rawDigest, digestOf(fresh), target.identity))) + val base = admissions(admission("target", populationDigest = rawDigest)) + + assertEquals(1, certifiedConsumptions(certificates, base, digestOf(fresh)).size) + // A projection this certificate does not certify yields no fact at all (M40). + assertTrue(certifiedConsumptions(certificates, base, "0".repeat(64)).isEmpty()) + // No certificate at all yields no fact: the fail-closed state. + assertTrue( + certifiedConsumptions(MutationAuthorityDigestCertificates.NONE, base, digestOf(fresh)).isEmpty(), + ) + } + + @Test + fun `M34 accepts a raw-v1 admission when a base certificate covers exactly its historical digest`() { + val target = row("target") + val fresh = population(listOf(target)) + val rawDigest = "1".repeat(64) + val certificate = certificate(rawDigest, digestOf(fresh), target.identity) + val fact = + verifyCertificateConsumption( + certificate = certificate, + baseCertificates = MutationAuthorityDigestCertificates("1", listOf(certificate)), + citedAdmissionPopulationDigest = rawDigest, + baseAdmissionIdentities = listOf(target.identity), + freshAuthorityProjectionHash = digestOf(fresh), + ) + assertTrue(fact is CertificateConsumption.Valid, "expected a valid consumption, got $fact") + + val verdict = + MutationPopulationAdmissionCeremony.appearanceVerdict( + baseAdmission = admission("target", populationDigest = rawDigest), + candidateAdmission = null, + mutant = target, + candidateAnalyzer = fresh.analyzer, + authority = AdmissionAuthority(digestOf(fresh), setOf(fact as CertificateConsumption.Valid)), + ) + + assertTrue(verdict is AdmissionVerdict.Authorized, "expected an authorized verdict, got $verdict") + } + + @Test + fun `M34 still fails a raw-v1 admission with no certified migration`() { + val target = row("target") + val fresh = population(listOf(target)) + val rawDigest = "1".repeat(64) + + val rejected = + MutationPopulationAdmissionCeremony.appearanceVerdict( + baseAdmission = admission("target", populationDigest = rawDigest), + candidateAdmission = null, + mutant = target, + candidateAnalyzer = fresh.analyzer, + authority = AdmissionAuthority(digestOf(fresh)), + ) as? AdmissionVerdict.Rejected + + assertNotNull(rejected) + assertTrue(rejected.message.startsWith("M34:"), rejected.message) + } } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt index 2952cd33..f0f4c1e6 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt @@ -92,6 +92,11 @@ class MutationRatchetAuthorityTest { // population are M39 warnings, not failures. This test failed at the // pristine base ec8b4da5 for exactly that reason. admissions = authority.admissions, + // An identity transition must RETAIN the base's certificate too. Passing NONE + // here would assert a transition that removes the certificate without a proven + // consumption, which M47 correctly refuses - M47's consuming half requires the + // fact that a consumption happened, not the mere absence of the certificate. + certificates = authority.certificates, ), executable = MutationPopulationAggregator.canonicalSemantics(), ).filter { it.severity == DiagnosticSeverity.FAILURE }