From 066a87d0c9eecc6a6f433e14b2b8c1d64a9cd20e Mon Sep 17 00:00:00 2001 From: gionag Date: Fri, 2 Oct 2026 08:48:22 +0200 Subject: [PATCH 1/5] authority(0.7.1g1P2-Step3b): read the certificate ledger at the base revision M43/M47 are judged against the certificate ledger as it exists in the BASE, but the only loader read the working tree. So the base side had no way to see a certificate at all, and M34 had no facts it could ever consult. This closes that gap. The base-side read now goes through the same path the population, classifications, enrollments and admissions already use: git show ":" into the temp tree, then the ledger's own loader. One authority snapshot (MutationRatchetAuthority) therefore carries the whole base context - population, classifications, enrollments, admissions and now certificates - which is what one coherent transition context means. Absent at base is handled like the other optional ledgers: a base that predates the certificate 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 rather than degrading into "no certificates". Two discriminators, both against real immutable history rather than fixtures: - at 9ebe7b44 (the P1M merge that minted the certificate) the authority snapshot exposes exactly one certificate, and its fromDigest is the single populationDigest the base's own admissions are bound to; - at 64d05450 (the base P1M was proposed against, which predates the ledger) the snapshot exposes no certificates, while the same snapshot's admissions are non-empty - so the test isolates the certificate ledger instead of showing that some empty snapshot is empty. The default on MutationRatchetAuthority.certificates is the conservative NONE for fixtures; the loader always passes it explicitly from the base revision. Verification: 40 tests / 0 failures (base-revision transport 2, ratchet authority, population admission suites); spotless clean; Detekt baseline 4792 -> 4792 with 0 added; verifyChangePolicy PASSED, 2 changed files, class build-logic. Next: M34 consults these facts at the verifier's decision points, then the T1-T19 matrix. --- .../build/quality/MutationRatchetAuthority.kt | 27 +++++++ .../CertificateBaseRevisionTransportTest.kt | 72 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt 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 57bc65acb..c4c396263 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, ) /** @@ -164,6 +175,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 +197,7 @@ object MutationRatchetAuthorityLoader { targetFamilies = configuration.mutation.targetFamilies, enrollments = enrollments, admissions = admissions, + certificates = certificates, ) } finally { tempDir.deleteRecursively() 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 000000000..95723be1a --- /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" + } +} From 02396e2955bd8590425515c9b4a4b9da9e918616 Mon Sep 17 00:00:00 2001 From: gionag Date: Fri, 2 Oct 2026 08:58:33 +0200 Subject: [PATCH 2/5] authority(0.7.1g1P2-Step3b): M34 accepts certified digest migration Completes the certified-migration path: the base snapshot now carries the certificate ledger, and M34 consumes the facts it proves. - MutationRatchetAuthority gains the base-revision certificate read, using the same git-show-into- temp-tree path as the population, classifications, enrollments and admissions. Absent at base is the most restrictive state, as for the other optional ledgers. - appearanceVerdict's M34 branch rejects a raw-v1 admission only when no certified consumption covers that admission's own historical digest. The facts come from one production site, certifiedConsumptions(...), which runs the real verification for each distinct historical digest against the base ledger and the exact base authorization set. A Valid exists only if M43-M42 passed, so the branch cannot be reached by asserting anything. - AdmissionAuthority carries the fresh projection plus the consumptions it certifies as one value. That is what makes the two judgment paths - the lifecycle scan and the appearing-identity path - receive the same object. Feeding one without the other would have made P2's diagnostics contradict themselves for the same 67 rows: admitted in the scan, M34-rejected in the appearing path. It also keeps both signatures inside the parameter budget without suppressing anything. Without a certificate the M34 condition is exactly as before, and AdmissionAuthority(null) is the fail-closed default everywhere. M36/M37 are untouched. Verification: 158 tests / 0 failures, including the new discriminators - certifiedConsumptions yields one fact from a real base certificate and none for an uncertified projection or no certificate; M34 accepts the certified raw-v1 admission and still fails the uncertified one. Plus the base-revision cases: the certificate is present at 9ebe7b44 (the P1M merge) and absent at 64d05450 (P1M's base) while that base's admissions remain non-empty. spotless clean; Detekt baseline 4792 -> 4792 with 0 added; verifyChangePolicy PASSED, 6 changed files, class build-logic. Four genuine Detekt findings fixed by hand - the two LongParameterList ones by introducing the carrier, not by suppressing them. Next: M44/M47 consume the same fact set, and the T1-T19 matrix at both levels. --- .../build/quality/CertificateConsumption.kt | 60 +++++++++++++ .../MutationPopulationAdmissionCeremony.kt | 15 +++- .../build/quality/MutationRatchetVerifier.kt | 13 ++- ...MutationPopulationAdmissionCeremonyTest.kt | 88 ++++++++++++++++++- 4 files changed, 169 insertions(+), 7 deletions(-) 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 d14a79121..c43af5ae9 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,66 @@ 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. + */ +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/MutationPopulationAdmissionCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt index a80fffbce..6e30f7765 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 @@ -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 " + @@ -254,7 +263,7 @@ object MutationPopulationAdmissionCeremony { candidateAdmission = candidateAdmission, mutant = mutant, candidateAnalyzer = candidate.population.analyzer, - freshAuthorityProjectionHash = freshAuthorityProjectionHash, + authority = base.admissionAuthority(freshAuthorityProjectionHash), ) 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/MutationRatchetVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt index 7b496d6e9..c6d5bf3db 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 @@ -102,6 +102,7 @@ class MutationRatchetVerifier { base.admissions, candidate.admissions, freshAuthorityProjectionHash, + base.admissionAuthority(freshAuthorityProjectionHash), ), ) diagnostics += classificationRatchet(base, candidate) @@ -320,7 +321,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 +352,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 +369,7 @@ class MutationRatchetVerifier { candidateAdmission = candidateAdmissions[id], mutant = candidate, candidateAnalyzer = candidatePopulation.analyzer, - freshAuthorityProjectionHash = freshAuthorityProjectionHash, + authority = authority, ) ) { is MutationPopulationAdmissionCeremony.AdmissionVerdict.Authorized -> { @@ -781,6 +782,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/MutationPopulationAdmissionCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt index 3c94ad8e0..9dd2f3a19 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) + } } From 17bd787f7682d6822a511b3a7752a5af20ab9ec4 Mon Sep 17 00:00:00 2001 From: gionag Date: Fri, 2 Oct 2026 09:09:40 +0200 Subject: [PATCH 3/5] authority(0.7.1g1P2-Step3b): M44/M47 consume the same Valid facts Last row of the Step 3b path: the certificate lifecycle is now driven by the facts M34 already uses, produced at one site. - MutationRatchetCandidate gains the transition's own certificate ledger, loaded from the repository like the candidate admissions. It is validated (M45 mint binding, M46 retained immutability) but can never authorize a consumption: only the base ledger is consulted, so a candidate that both mints and consumes fails M43. - The verifier now calls MutationAuthorityDigestCertificateCeremony.checks with the base and candidate certificate ledgers and validConsumptions taken from base.admissionAuthority(...).certifiedConsumptions - the same object M34 decides on, so M44 (single use) and M47 (removal custody) cannot disagree with the admission verdicts about what was consumed. - M47's consuming half is now exercised end to end: a certificate may disappear only because a consumption was independently proven in the same transition, never because it was absent. Two existing test fixtures were corrected rather than the rules relaxed: - MutationRatchetAuthorityTest's identity-transition candidate now retains the base's certificate, exactly as it retains the base's admissions. Passing NONE asserted a transition that removes the certificate with no proven consumption, which M47 rightly refuses. This is the same class of fixture defect (and the same fix) as the admissions case in Step 1. - CertificateCustodyTransportTest drives the real committed ledger through the real ceremony: unchanged, it survives; dropped without a proven consumption, M47 fails; dropped with the facts produced from the real ledgers, removal is permitted. M47 firing on that fixture is the evidence that the consuming half is genuinely live, rather than a branch nobody reaches. Verification: 162 tests / 0 failures across the certificate, admission and ratchet suites; spotless clean; Detekt baseline 4792 -> 4792 with 0 added; verifyChangePolicy PASSED, class build-logic. A full :build-logic:test run reached 855 tests with zero genuine test failures before the console call was truncated (its only entry was an infrastructure pseudo-failure from the kill itself); CI runs the complete suite. Remaining: the T1-T19 matrix at verifier and real-task level. --- .../quality/MaintainabilityBaselinePlugin.kt | 4 + .../build/quality/MutationRatchetAuthority.kt | 12 +++ .../build/quality/MutationRatchetVerifier.kt | 9 +++ .../CertificateCustodyTransportTest.kt | 75 +++++++++++++++++++ .../quality/MutationRatchetAuthorityTest.kt | 5 ++ 5 files changed, 105 insertions(+) create mode 100644 build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt 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 d15d505b8..a900e7e58 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/MutationRatchetAuthority.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt index c4c396263..9e6f90d84 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 @@ -79,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 { 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 c6d5bf3db..8e8026edf 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 @@ -108,6 +108,15 @@ class MutationRatchetVerifier { diagnostics += classificationRatchet(base, candidate) diagnostics += MutationEnrollmentCeremony.checks(base, candidate) diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, freshAuthorityProjectionHash) + // 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 = base.admissionAuthority(freshAuthorityProjectionHash).certifiedConsumptions, + ) diagnostics += familyAndTargetChecks( base.population, 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 000000000..76cce2161 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt @@ -0,0 +1,75 @@ +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 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 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/MutationRatchetAuthorityTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt index 2952cd337..f0f4c1e67 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 } From aadc2eedc55c3e98ad719cf6fbb0b8a8c357d131 Mon Sep 17 00:00:00 2001 From: gionag Date: Fri, 2 Oct 2026 09:12:49 +0200 Subject: [PATCH 4/5] authority(0.7.1g1P2-Step3b): T14/T17/T18 at the transport level, over the real ledger Audited the discriminator matrix against the rules that actually have tests: every rule M01-M47 is exercised somewhere, but three certificate-lifecycle attacks had only rule-level coverage, not the real-task transport coverage the design requires. All three now run against the committed certificate ledger loaded through the real loader. - T18: a retained certificate whose enforced payload was rewritten (here its reason, which is in the payload) fails M46. The rewrite is derived from the real certificate's own fields, so the test cannot pass by accident of a fixture mismatch. - T17: a certificate introduced with a fromBaseSha other than the transition's base fails M45, even alongside the legitimate retained certificate. - T14: a certificate absent from the real base ledger cannot be consumed, however it is cited - the verifier refuses it as M43 rather than accepting the citation. Together with the earlier cases this gives the certificate lifecycle transport coverage for introduction (M45), retention (M46), consumption (M40-M43), single use (M44) and removal custody (M47), all against the real committed ledger. Verification: 165 tests / 0 failures; spotless clean; Detekt baseline 4792 -> 4792 with 0 added; verifyChangePolicy PASSED, 9 changed files, class build-logic. --- .../CertificateCustodyTransportTest.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) 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 index 76cce2161..bf3295b44 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt @@ -18,6 +18,7 @@ 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, @@ -58,6 +59,74 @@ class CertificateCustodyTransportTest { 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", + ) + /** * 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. From 7c24f27354757ca089ca28a8c8be40a8df517f02 Mon Sep 17 00:00:00 2001 From: gionag Date: Fri, 2 Oct 2026 11:42:07 +0200 Subject: [PATCH 5/5] authority(0.7.1g1P2-Step3b): produce the authority facts once, share the instance Review finding: there was one fact-production function but three fact-production events. MutationRatchetVerifier recomputed base.admissionAuthority(...) for the outcome-ratchet context and again for the certificate ceremony, and MutationPopulationAdmissionCeremony recomputed it once more inside its own consumption loop for the appearing-identity verdicts. The three results were value-equal, which is not the property the contract asks for: deterministic recomputation is not shared evidence, and value equality lets a later change drift one consumer away from the others without any test noticing. Now: val admissionAuthority = base.admissionAuthority(freshAuthorityProjectionHash) // once and that exact instance is threaded to every consumer - the evolution context (M34 and the appearing-identity path), the admission lifecycle ceremony, and the certificate ceremony (M44/M47) via .certifiedConsumptions. - MutationPopulationAdmissionCeremony.checks/lifecycleChecks/consumptionChecks now take the AdmissionAuthority instead of the raw projection hash and pass it through unchanged; the internal recomputation at the consumption loop is gone. Public signature change: checks() has exactly one caller, the verifier. - The AdmissionAuthority KDoc states the invariant: produced once, never re-derived. - CertificateCustodyTransportTest pins it with a source-level wiring assertion, the form the review sanctioned where a runtime === check would require exposing internals: exactly one admissionAuthority( production call in the verifier, zero in the ceremony, and the certificate ceremony consuming the shared local. Rebased onto the post-#474 epic tip a2c6c3a8; diff scope verified as exactly 9 files, and #474's merged CertificateLedgerTransportTest.kt is present at the tip and absent from this diff. Verification: 172 tests / 0 failures (certificate, admission and ratchet suites); spotless clean; Detekt baseline 4792 -> 4792 with 0 added; verifyChangePolicy PASSED against base a2c6c3a8, class build-logic. --- .../build/quality/CertificateConsumption.kt | 6 ++++ .../MutationPopulationAdmissionCeremony.kt | 12 ++++---- .../build/quality/MutationRatchetVerifier.kt | 10 +++++-- .../CertificateCustodyTransportTest.kt | 30 +++++++++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) 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 c43af5ae9..a1f5d71cf 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 @@ -107,6 +107,12 @@ sealed interface CertificateConsumption { * 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?, 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 6e30f7765..f3d7feafe 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 @@ -200,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) } /** @@ -242,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() @@ -263,7 +263,7 @@ object MutationPopulationAdmissionCeremony { candidateAdmission = candidateAdmission, mutant = mutant, candidateAnalyzer = candidate.population.analyzer, - authority = base.admissionAuthority(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/MutationRatchetVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt index 8e8026edf..40064107d 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,12 +106,12 @@ class MutationRatchetVerifier { base.admissions, candidate.admissions, freshAuthorityProjectionHash, - base.admissionAuthority(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 += @@ -115,7 +119,7 @@ class MutationRatchetVerifier { base = base.certificates, candidate = candidate.certificates, baseSha = base.baseSha, - validConsumptions = base.admissionAuthority(freshAuthorityProjectionHash).certifiedConsumptions, + validConsumptions = admissionAuthority.certifiedConsumptions, ) diagnostics += familyAndTargetChecks( 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 index bf3295b44..b8c7ed7ee 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt @@ -127,6 +127,36 @@ class CertificateCustodyTransportTest { 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.