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 40d7652d6..a80fffbce 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 @@ -32,13 +32,21 @@ package dev.tramai.build.quality * general M06 bypass is therefore preserved by construction: authorized X plus unauthorized Y in * the same measurement fails for Y, through unchanged M06. * - * The digest binds the *whole* transition, not just the identity's row: the authorization records - * the projection hash of the complete canonical fresh measurement it was minted against, and - * consumption compares it with the verifier's own fresh measurement + * The digest binds the *whole* transition's AUTHORITY content, not just the identity's row: the + * authorization records the **authority projection** — identity, canonical outcome, family, module, + * family/module topology and analyzer semantics — of the canonical fresh measurement it was minted + * against, and consumption compares it with the verifier's own fresh authority projection * ([MutationEvolutionEvidence.proof]). "Binds the neighbours" is what makes this all-or-nothing: if - * any other identity in the population changes between minting and consumption, the digest no - * longer matches and the authorization must be re-minted against the new measurement. A candidate - * can never construct that measurement, because the hash compared is never read from the ledger. + * another identity's canonical outcome, family or module changes between minting and consumption, + * the digest no longer matches and the authorization must be re-minted. A candidate can never + * construct that projection, because the hash compared is never read from the ledger. + * + * Raw PIT status is deliberately outside this digest. C7 ([MutationOutcome]) declares raw status + * diagnostic evidence rather than authority, and four complete unrestricted campaigns at identical + * effective PIT inputs produced four distinct raw-status digests (SURVIVED↔TIMED_OUT on three + * identities of 2544, all NON_KILLED in every campaign) while this projection was byte-identical. + * Raw status remains fully authoritative where it belongs: the fresh↔committed exact comparison + * (M21) and each individual authorization's exact row (M32). * * ## Division of responsibility (one implementation, two call sites) * @@ -65,8 +73,8 @@ object MutationPopulationAdmissionCeremony { fun checks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, - freshProjectionHash: String?, - ): List = lifecycleChecks(base, candidate, freshProjectionHash) + freshAuthorityProjectionHash: String?, + ): List = lifecycleChecks(base, candidate, freshAuthorityProjectionHash) /** * The verdict for one identity that is present in the candidate population and absent from the @@ -81,7 +89,7 @@ object MutationPopulationAdmissionCeremony { candidateAdmission: MutationPopulationAdmission?, mutant: MutationOutcome, candidateAnalyzer: MutationAnalyzerSemantics, - freshProjectionHash: String?, + freshAuthorityProjectionHash: String?, ): AdmissionVerdict { val short = short(mutant.identity) return when { @@ -116,7 +124,7 @@ object MutationPopulationAdmissionCeremony { ) } - freshProjectionHash == null -> { + freshAuthorityProjectionHash == null -> { reject( DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "M34: $short cannot be admitted: no proof of the canonical fresh measurement exists, " + @@ -124,13 +132,16 @@ object MutationPopulationAdmissionCeremony { ) } - baseAdmission.populationDigest != freshProjectionHash -> { + baseAdmission.populationDigest != freshAuthorityProjectionHash -> { reject( DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "M34: $short was authorized against population digest " + "${short(baseAdmission.populationDigest)}, but this transition's canonical " + - "fresh measurement hashes to ${short(freshProjectionHash)}. An authorization " + - "binds the complete measured population.", + "authority projection hashes to ${short(freshAuthorityProjectionHash)}. An " + + "authorization binds the measured population's AUTHORITY content — identity, " + + "canonical outcome, family, module, topology and analyzer semantics — and " + + "deliberately not raw PIT status, which C7 declares diagnostic evidence rather " + + "than authority.", ) } @@ -180,10 +191,10 @@ object MutationPopulationAdmissionCeremony { private fun lifecycleChecks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, - freshProjectionHash: String?, + freshAuthorityProjectionHash: String?, ): List { val diagnostics = mintChecks(base, candidate) - return diagnostics + consumptionChecks(base, candidate, freshProjectionHash) + return diagnostics + consumptionChecks(base, candidate, freshAuthorityProjectionHash) } /** @@ -222,7 +233,7 @@ object MutationPopulationAdmissionCeremony { private fun consumptionChecks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, - freshProjectionHash: String?, + freshAuthorityProjectionHash: String?, ): List { val diagnostics = mutableListOf() val baseAdmissions = base.admissions.byIdentity() @@ -243,7 +254,7 @@ object MutationPopulationAdmissionCeremony { candidateAdmission = candidateAdmission, mutant = mutant, candidateAnalyzer = candidate.population.analyzer, - freshProjectionHash = freshProjectionHash, + freshAuthorityProjectionHash = 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 a25826bae..7b496d6e9 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 @@ -89,7 +89,7 @@ class MutationRatchetVerifier { diagnostics += validateClassificationList("base authority", base.classifications) diagnostics += validateClassificationList("candidate", candidate.classifications) diagnostics += baseClassificationIntegrity(base) - val freshProjectionHash = evolutionEvidence.proof?.projectionHash + val freshAuthorityProjectionHash = evolutionEvidence.proof?.authorityProjectionHash diagnostics += outcomeRatchet( base.population, @@ -101,12 +101,12 @@ class MutationRatchetVerifier { AUTHORITY_EXCLUDED_IDENTITIES, base.admissions, candidate.admissions, - freshProjectionHash, + freshAuthorityProjectionHash, ), ) diagnostics += classificationRatchet(base, candidate) diagnostics += MutationEnrollmentCeremony.checks(base, candidate) - diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, freshProjectionHash) + diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, freshAuthorityProjectionHash) diagnostics += familyAndTargetChecks( base.population, @@ -320,7 +320,7 @@ class MutationRatchetVerifier { candidatePopulation = candidatePopulation, baseAdmissions = evolution.baseAdmissions.byIdentity(), candidateAdmissions = evolution.candidateAdmissions.byIdentity(), - freshProjectionHash = evolution.freshProjectionHash, + freshAuthorityProjectionHash = evolution.freshAuthorityProjectionHash, ) // M21: a base identity that simply stopped being measured. Absence is not evidence of @@ -351,7 +351,7 @@ class MutationRatchetVerifier { candidatePopulation: MutationPopulationBaseline, baseAdmissions: Map, candidateAdmissions: Map, - freshProjectionHash: String?, + freshAuthorityProjectionHash: String?, ): List { val diagnostics = mutableListOf() val candidateById = candidatePopulation.mutants.associateBy { it.identity } @@ -368,7 +368,7 @@ class MutationRatchetVerifier { candidateAdmission = candidateAdmissions[id], mutant = candidate, candidateAnalyzer = candidatePopulation.analyzer, - freshProjectionHash = freshProjectionHash, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, ) ) { is MutationPopulationAdmissionCeremony.AdmissionVerdict.Authorized -> { @@ -775,21 +775,27 @@ private data class MutationEvolutionContext( /** Admissions this transition proposes: validated, but never authority for its own admission. */ val candidateAdmissions: MutationPopulationAdmissions = MutationPopulationAdmissions.NONE, /** - * Projection hash of the canonical fresh measurement ([MutationEvolutionEvidence.proof]) - the - * digest a population authorization must match to be consumable. Null means no trusted - * measurement proof exists, and admission then fails closed. + * Authority projection hash of the canonical fresh measurement + * ([MutationPopulationEvolutionProof.authorityProjectionHash]) - the digest a population + * authorization must match to be consumable (M34). Raw PIT status is excluded by design (C7). + * Null means no trusted measurement proof exists, and admission then fails closed. */ - val freshProjectionHash: String? = null, + val freshAuthorityProjectionHash: String? = null, ) // No population hash is stored in mutation-evolution.yml: exact measurement // equality already binds the candidate, while this proof binds the verifier call. -// The proof hashes the FULL canonical comparison projection — identity, raw status, -// canonical outcome, family, module, family/module topology and analyzer semantics — -// not just the identity set, so it cannot be reused for a population that shares the -// identities but differs in outcomes. +// [projectionHash] hashes the FULL canonical comparison projection — identity, raw status, +// canonical outcome, family, module, family/module topology and analyzer semantics — not just the +// identity set, so it cannot be reused for a population that shares the identities but differs in +// outcomes. It stays raw-exact: that is the measurement-truth question. +// [authorityProjectionHash] hashes the authority projection — identity, canonical outcome, family, +// module, topology and analyzer semantics, with raw status deliberately excluded. That is the +// population-context question M34 asks, and it is computed from the same fresh measurement by this +// verifier, so it can never be supplied by a candidate. class MutationPopulationEvolutionProof private constructor( val projectionHash: String, + val authorityProjectionHash: String, ) { fun matches(population: MutationPopulationBaseline): Boolean = projectionHash == population.projectionHash() @@ -828,6 +834,7 @@ class MutationPopulationEvolutionProof private constructor( if (diagnostics.isEmpty()) { MutationPopulationEvolutionProof( projectionHash = fresh.projectionHash(), + authorityProjectionHash = fresh.authorityProjectionHash(), ) } else { null @@ -908,6 +915,41 @@ class MutationPopulationEvolutionProof private constructor( .map { "${it.identity}|${it.status}|${it.outcome}|${it.family}|${it.module}" } .sorted() .forEach { appendLine(it) } + append(contextLines()) + } + + /** + * The canonical authority projection: the population context a population authorization is + * bound to (M34). Raw PIT status is deliberately EXCLUDED. + * + * C7 ([MutationOutcome]) declares raw `status` diagnostic evidence and not authority, and + * anticipates TIMED_OUT↔SURVIVED scheduler races. Four complete unrestricted campaigns at + * identical effective PIT inputs produced four distinct raw-status digests while this + * projection was byte-identical every time, so binding neighbours' raw status here let a + * scheduler race invalidate adjudicated authority that was itself stable. + * + * What remains is exactly the authority-relevant content: identity, canonical outcome, + * family, module, family/module topology and analyzer semantics. Use this for M34; the + * raw-exact [canonicalProjection] stays the measurement proof (M21, [exactComparison]). + */ + private fun MutationPopulationBaseline.authorityProjection(): String = + buildString { + mutants + .map { "${it.identity}|${it.outcome}|${it.family}|${it.module}" } + .sorted() + .forEach { appendLine(it) } + append(contextLines()) + } + + private fun MutationPopulationBaseline.authorityProjectionHash(): String = authorityProjection().sha256() + + /** + * The serialization suffix every projection shares: family/module topology, then analyzer + * semantics. It lives in one place because the authority projection IS the raw projection + * minus raw status - duplicating the suffix would let the two drift apart silently. + */ + private fun MutationPopulationBaseline.contextLines(): String = + buildString { appendLine( "topology=" + byFamily.toSortedMap().entries.joinToString(",") { 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 515d5b59f..3c94ad8e0 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 @@ -4,6 +4,7 @@ import dev.tramai.build.quality.MutationPopulationAdmissionCeremony.AdmissionVer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -147,7 +148,7 @@ class MutationPopulationAdmissionCeremonyTest : MutationRatchetTestSupport() { baseAdmissions = admissions(admission("target", populationDigest = digestOf(base))), candidatePopulation = candidate, ) - assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "complete measured population") + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "authority projection") } @Test @@ -434,7 +435,7 @@ class MutationPopulationAdmissionCeremonyTest : MutationRatchetTestSupport() { candidateAdmission = null, mutant = target, candidateAnalyzer = semantics, - freshProjectionHash = "0".repeat(64), + freshAuthorityProjectionHash = "0".repeat(64), ) as? AdmissionVerdict.Rejected assertNotNull(rejected) assertEquals(DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, rejected.code) @@ -449,4 +450,41 @@ class MutationPopulationAdmissionCeremonyTest : MutationRatchetTestSupport() { "cannot certify its own survivors." assertEquals(expected, rejected.message) } + + // ── T1 (ceremony level): the case-3 discriminator ── + // + // Four campaigns produced four distinct raw-status digests: three identities of 2544 oscillating + // only SURVIVED<->TIMED_OUT, all NON_KILLED in every campaign, while identity, outcome, family, + // module and analyzer semantics were identical. An authorization minted against one such + // measurement must remain consumable in another. Binding neighbouring raw status (the previous + // behaviour) rejected it, which is the defect this slice removes. + + @Test + fun `T1 a neighbour's raw status movement does not invalidate an authorized consumption`() { + val target = row("target") + val neighbourMinted = row("neighbour", status = "SURVIVED") + val neighbourConsumed = row("neighbour", status = "TIMED_OUT") + val mintedAgainst = population(listOf(target, neighbourMinted)) + val fresh = population(listOf(target, neighbourConsumed)) + + // Raw status moved between the two measurements; authority content did not. + assertEquals(digestOf(mintedAgainst), digestOf(fresh)) + // The raw-exact measurement proof still distinguishes them, so nothing was relaxed where raw + // status genuinely is authority (M21 exact comparison / M32 exact row). + assertNotEquals(rawDigestOf(mintedAgainst), rawDigestOf(fresh)) + + // The authorization was minted against the measurement where the neighbour was SURVIVED and + // is consumed in the measurement where it is TIMED_OUT. It must still be authorized: the + // target is candidate-only NON_KILLED, the row matches exactly, and the population context + // is unchanged in authority terms. + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(neighbourMinted)), + baseAdmissions = admissions(admission("target", populationDigest = digestOf(mintedAgainst))), + candidatePopulation = fresh, + candidateAdmissions = MutationPopulationAdmissions.NONE, + evidence = evidence(fresh), + ) + passes(diagnostics) + } } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt index ba20cc206..4b9bc4321 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt @@ -100,7 +100,7 @@ class MutationPopulationAdmissionIntegrationTest { // C: the authority base. It carries the base state plus the authorization a FUTURE transition // consumes: an authorization is merged into the base, never created by the transition that uses // it (M31). `fromBaseSha` records the base of the minting transition. - writeAdmissionLedger(candidate, mintingBaseSha, projectionDigest(candidate)) + writeAdmissionLedger(candidate, mintingBaseSha, authorityProjectionDigest(candidate)) commit("authority base with minted population authorizations") baseSha = headSha() git("branch", "authority-base") @@ -427,14 +427,18 @@ class MutationPopulationAdmissionIntegrationTest { } /** - * The verifier's own trusted digest computation, never a value this test invents: the same call - * the task makes to derive the proof it compares an authorization against. + * The verifier's own trusted **authority projection** digest computation, never a value this + * test invents: the same call the task makes to derive the proof M34 compares an authorization + * against. It must be the authority projection (identity, canonical outcome, family, module, + * topology, analyzer) and NOT the raw-exact measurement proof: production M34 compares + * `authorityProjectionHash`, so a fixture minting `projectionHash` would exercise the wrong + * digest semantics and pass while the real transport is broken. */ - private fun projectionDigest(population: MutationPopulationBaseline): String = + private fun authorityProjectionDigest(population: MutationPopulationBaseline): String = MutationPopulationEvolutionProof .exactComparison(population, population) .proof - ?.projectionHash + ?.authorityProjectionHash ?: error("the fixture population is not self-comparable, so no digest can be minted") private fun assertAuthorityTransportPreconditions( @@ -459,7 +463,10 @@ class MutationPopulationAdmissionIntegrationTest { "the candidate source must produce at least one appearing NON_KILLED identity, otherwise " + "the admission ceremony is never reached: base=$baseIds candidate=$candidateIds", ) - assertTrue(projectionDigest(base) != projectionDigest(candidate), "the two populations must differ") + assertTrue( + authorityProjectionDigest(base) != authorityProjectionDigest(candidate), + "the two populations must differ", + ) } // ── task driving ── diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt index bed0730b4..c45729640 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt @@ -1,6 +1,8 @@ package dev.tramai.build.quality import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class MutationPopulationEvolutionProofTest : MutationRatchetTestSupport() { @@ -46,4 +48,41 @@ class MutationPopulationEvolutionProofTest : MutationRatchetTestSupport() { assertTrue(comparison.diagnostics.isEmpty()) assertTrue(comparison.proof != null) } + + // ── T1 / T2 / T8: the authority projection is authority content only ── + // + // The empirical defect: four complete unrestricted campaigns at identical effective PIT inputs + // produced four distinct raw-status digests — three identities of 2544 oscillating only between + // SURVIVED and TIMED_OUT, all NON_KILLED in every campaign — while the authority content was + // byte-identical. M34 bound the raw-status digest, so a scheduler race invalidated adjudicated + // authority. Raw status stays authoritative for the measurement proof (M21) and for each + // individual authorization's exact row (M32); it must not be population authority (C7). + + @Test + fun `T1 a neighbour's raw status movement leaves the authority projection unchanged`() { + val before = population(listOf(row("target"), row("neighbour", status = "SURVIVED"))) + val after = population(listOf(row("target"), row("neighbour", status = "TIMED_OUT"))) + assertEquals(digestOf(before), digestOf(after)) + // ...while the raw-exact measurement proof still distinguishes them, so nothing was relaxed + // where raw status genuinely is authority. + assertNotEquals(rawDigestOf(before), rawDigestOf(after)) + } + + @Test + fun `T2 an outcome change changes the authority projection`() { + val survivor = population(listOf(row("target"))) + val killer = population(listOf(row("target", status = "KILLED", outcome = "KILLED"))) + assertNotEquals(digestOf(survivor), digestOf(killer)) + } + + @Test + fun `T8 analyzer semantics change the authority projection`() { + val base = population(listOf(row("target"))) + val drifted = + population( + listOf(row("target")), + analyzer = semantics.copy(timeoutConst = semantics.timeoutConst + 1), + ) + assertNotEquals(digestOf(base), digestOf(drifted)) + } } 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 f391d0fc6..2952cd337 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 @@ -85,7 +85,13 @@ class MutationRatchetAuthorityTest { population = candidatePopulation, classifications = candidateClassifications, targetFamilies = candidateConfiguration.mutation.targetFamilies, - admissions = MutationPopulationAdmissions.NONE, + // An identity transition must RETAIN the base's pending authorizations: M37 + // forbids an authorization disappearing without a valid consumption, so + // passing NONE here asserted a transition this authority cannot validly make. + // Retained authorizations whose target is absent from the committed + // population are M39 warnings, not failures. This test failed at the + // pristine base ec8b4da5 for exactly that reason. + admissions = authority.admissions, ), executable = MutationPopulationAggregator.canonicalSemantics(), ).filter { it.severity == DiagnosticSeverity.FAILURE } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt index eccbff2e0..462797e46 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt @@ -220,11 +220,22 @@ abstract class MutationRatchetTestSupport { diagnostics.filter { it.severity == DiagnosticSeverity.FAILURE } /** - * The canonical projection digest the verifier itself computes for a population (the M21 - * exact-comparison proof). Tests must never invent a digest: the admission ceremony compares - * against this value, so using it is what proves the trust direction. + * The canonical projection digest the verifier itself computes for a population. + * + * This is the **authority projection** digest (identity, canonical outcome, family, module, + * topology and analyzer semantics; raw PIT status deliberately excluded), i.e. the M34 + * population context. Tests must never invent a digest: the admission ceremony compares against + * this value, so using it is what proves the trust direction. For the raw-exact measurement + * proof (M21) use `rawDigestOf`. */ protected fun digestOf(population: MutationPopulationBaseline): String = + MutationPopulationEvolutionProof + .exactComparison(population, population) + .proof!! + .authorityProjectionHash + + /** The raw-exact measurement projection digest (M21 exact comparison), raw status included. */ + protected fun rawDigestOf(population: MutationPopulationBaseline): String = MutationPopulationEvolutionProof.exactComparison(population, population).proof!!.projectionHash /** The verifier's own trusted measurement evidence for a population (the M21 exact comparison). */