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 a900e7e58..80765a273 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 @@ -868,6 +868,12 @@ abstract class MaintainabilityBaselinePlugin : Plugin { // 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), + // The promotion declaration is loaded from the transition's OWN tree by design: + // it is candidate-side, because it must name the base SHA it promotes onto and + // the base branch cannot name the commit that will contain it. Absent means "no + // promotion", which is the fail-closed state - M35/M45 then bind every + // appearing row to the transition base exactly as before. + promotion = MutationPopulationPromotionLoader.load(project.rootDir), ) val diagnostics = MutationRatchetVerifier().verify( diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt index 10c4612ae..5eee3d4a8 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt @@ -37,8 +37,9 @@ object MutationAuthorityDigestCertificateCeremony { candidate: MutationAuthorityDigestCertificates, baseSha: String, validConsumptions: Set = emptySet(), + promotion: MutationPopulationPromotion? = null, ): List = - mintChecks(base, candidate, baseSha) + + mintChecks(base, candidate, baseSha, promotion) + retentionChecks(base, candidate) + singleUseChecks(base, candidate, validConsumptions) + removalChecks(base, candidate, validConsumptions) @@ -48,17 +49,23 @@ object MutationAuthorityDigestCertificateCeremony { * Anti-replay at mint time: a certificate payload cannot be replayed onto a different authority * base. Deliberately not re-checked later - a certificate legitimately survives intermediate * merges, so requiring it to track the immediate base would make delayed consumption impossible. + * + * One narrow exception (0.7.1i): a certificate carried across a release promotion boundary by an + * exact [MutationPopulationPromotion] was not minted here, so it is not bound to this base. The + * exception applies only to the source digest the declaration carries. */ private fun mintChecks( base: MutationAuthorityDigestCertificates, candidate: MutationAuthorityDigestCertificates, baseSha: String, + promotion: MutationPopulationPromotion?, ): List { val diagnostics = mutableListOf() val baseByFromDigest = base.byFromDigest() for ((fromDigest, certificate) in candidate.byFromDigest()) { if (fromDigest in baseByFromDigest) continue - if (certificate.fromBaseSha != baseSha) { + val carriedForward = promotion != null && certificate.fromDigest == promotion.populationDigest + if (certificate.fromBaseSha != baseSha && !carriedForward) { diagnostics += certificateFailure( fromDigest, 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 f3d7feafe..4591dfd47 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 @@ -74,7 +74,8 @@ object MutationPopulationAdmissionCeremony { base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, authority: AdmissionAuthority, - ): List = lifecycleChecks(base, candidate, authority) + promotion: MutationPopulationPromotion? = null, + ): List = lifecycleChecks(base, candidate, authority, promotion) /** * The verdict for one identity that is present in the candidate population and absent from the @@ -201,8 +202,9 @@ object MutationPopulationAdmissionCeremony { base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, authority: AdmissionAuthority, + promotion: MutationPopulationPromotion?, ): List { - val diagnostics = mintChecks(base, candidate) + val diagnostics = mintChecks(base, candidate, promotion) return diagnostics + consumptionChecks(base, candidate, authority) } @@ -212,15 +214,22 @@ object MutationPopulationAdmissionCeremony { * onto a different authority base. It is deliberately NOT re-checked at consumption: a pending * authorization legitimately survives intermediate merges, and requiring it to track the * immediate base would make delayed consumption impossible. + * + * One narrow exception (0.7.1i): a row carried across a release promotion boundary by an exact + * [MutationPopulationPromotion] was not minted here, so it is not bound to this base. The + * exception applies only to the digest the declaration carries - a newly minted row has a + * different digest and is rejected exactly as before. */ private fun mintChecks( base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, + promotion: MutationPopulationPromotion?, ): List { val diagnostics = mutableListOf() val baseAdmissions = base.admissions.byIdentity() for ((id, admission) in candidate.admissions.byIdentity().filterKeys { it !in baseAdmissions }) { - if (admission.fromBaseSha != base.baseSha) { + val carriedForward = promotion != null && admission.populationDigest == promotion.populationDigest + if (admission.fromBaseSha != base.baseSha && !carriedForward) { diagnostics += VerificationDiagnostic.failure( DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationPromotion.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationPromotion.kt new file mode 100644 index 000000000..a27037dfa --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationPromotion.kt @@ -0,0 +1,193 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File + +/** + * A promotion declaration: the statement that one exact, already-certified evidence population is + * CARRIED ACROSS a release promotion boundary rather than minted by the promotion transition. + * + * It grants no authority. M35 and M45 bind a newly introduced admission/certificate to the authority + * base it was minted against, and a promotion cannot satisfy that binding by any legitimate means: + * re-minting admissions, rewriting `fromBaseSha`, manufacturing replacement certificates and a + * per-entry migration chain are all forbidden. This declaration states the narrow fact the promotion + * actually needs - the appearing evidence is the SAME certified population, unchanged - so the + * mint-binding rules do not apply to it. + * + * SHA-exact and candidate-side: it names the exact base SHA the promotion is proposed against, so it + * can never authorize a different transition, and it travels in the promotion's own candidate because + * the base branch cannot name a commit that does not exist until the declaration is committed. + * + * Fail-closed: an absent or malformed declaration, or a declaration that does not describe the + * candidate exactly, means "no promotion" - every appearing row then fails M35/M45 as before. + */ +data class MutationPopulationPromotion( + /** The exact authority base SHA this promotion is proposed against. */ + val promotionBase: String, + /** The digest of the carried population; the only digest this declaration carries forward. */ + val populationDigest: String, + /** The exact number of admissions in the carried population. */ + val admissions: Int, + /** The exact number of digest-migration certificates in the carried population. */ + val certificates: Int, +) { + companion object { + /** + * The declaration that legitimately carries the candidate's appearing evidence across + * [base], or null when there is none or it does not describe that evidence exactly. + * + * Every field is enforced against the ledgers themselves, so a declaration cannot authorize a + * population other than the one it names: the base SHA must be this transition's, both counts + * must equal the number of rows appearing relative to the base, and every appearing row must + * carry the declared digest. A single mismatch anywhere yields no promotion at all. + */ + fun carried( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): MutationPopulationPromotion? = + candidate.promotion?.takeIf { promotion -> + promotion.promotionBase == base.baseSha && + carriesAdmissions(base, candidate, promotion) && + carriesCertificates(base, candidate, promotion) + } + + /** The exact admission count, and every appearing row's digest. */ + private fun carriesAdmissions( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + promotion: MutationPopulationPromotion, + ): Boolean { + val baseAdmissions = base.admissions.byIdentity().keys + val appearing = + candidate.admissions + .byIdentity() + .filterKeys { it !in baseAdmissions } + .values + return appearing.size == promotion.admissions && + appearing.all { it.populationDigest == promotion.populationDigest } + } + + /** The exact certificate count, and every appearing certificate's source digest. */ + private fun carriesCertificates( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + promotion: MutationPopulationPromotion, + ): Boolean { + val baseCertificates = base.certificates.byFromDigest().keys + val appearing = + candidate.certificates + .byFromDigest() + .filterKeys { it !in baseCertificates } + .values + return appearing.size == promotion.certificates && + appearing.all { it.fromDigest == promotion.populationDigest } + } + } +} + +/** + * Parses config/quality/mutation-population-promotions.yml. + * + * An absent file means "no promotion", which is fail-closed: every appearing admission and + * certificate is then bound to the transition base by M35/M45 exactly as before. A present but + * malformed file is a hard failure - a broken declaration must never silently degrade into "no + * promotion happened to be needed". + * + * Notes carry declaration history, so a later promotion appends rather than replaces: an older + * declaration simply never matches the transition being verified, since the base SHA is exact. + */ +object MutationPopulationPromotionLoader { + const val FILE_NAME = "config/quality/mutation-population-promotions.yml" + + private const val SCHEMA_VERSION = "1" + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + private val COMMIT_SHA = Regex("[0-9a-f]{40}") + private val DIGEST = Regex("[0-9a-f]{64}") + + fun load(repositoryRoot: File): MutationPopulationPromotion? { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return null + } + val declarations = parseDeclarations(readLedger(file)) + // One transition has exactly one base, so at most one declaration can apply. More than one + // would make the promotion depend on file order, which is not an authority. + require(declarations.size <= 1) { + "$FILE_NAME: at most one promotion declaration may be present; found ${declarations.size}" + } + return declarations.singleOrNull() + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(file.readText(Charsets.UTF_8)) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid $FILE_NAME: ${e.message}", e) + } + } + + private fun parseDeclarations(raw: Map): List { + val schemaVersion = raw["schemaVersion"]?.toString() + require(schemaVersion == SCHEMA_VERSION) { + "$FILE_NAME: schemaVersion must be '$SCHEMA_VERSION', was '$schemaVersion'" + } + val promotions = raw["promotions"] + require(promotions is List<*>) { "$FILE_NAME: promotions must be a list" } + return promotions.mapIndexed { index, item -> + require(item is Map<*, *>) { "$FILE_NAME: promotions[$index] must be a mapping" } + val promotionBase = + requireCanonical(item, "promotionBase", index, COMMIT_SHA, "a 40-hex commit SHA") + val populationDigest = + requireCanonical(item, "populationDigest", index, DIGEST, "a 64-hex digest") + MutationPopulationPromotion( + promotionBase = promotionBase, + populationDigest = populationDigest, + admissions = requiredCount(item, "admissions", index), + certificates = requiredCount(item, "certificates", index), + ) + } + } + + /** + * Counts are part of the declaration's meaning, so an absent one is invalid rather than zero: a + * declaration that forgot `certificates` must not read as "carries no certificates". + */ + private fun requiredCount( + entry: Map<*, *>, + field: String, + index: Int, + ): Int { + val value = + (entry[field] as? Number)?.toInt() + ?: throw GradleException("$FILE_NAME: promotions[$index].$field is required and must be an integer") + require(value >= 0) { + "$FILE_NAME: promotions[$index].$field must not be negative" + } + return value + } + + private fun requireCanonical( + entry: Map<*, *>, + field: String, + index: Int, + pattern: Regex, + expected: String, + ): String { + val value = + entry[field]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException("$FILE_NAME: promotions[$index].$field is required and must be non-blank") + require(pattern.matches(value)) { + "$FILE_NAME: promotions[$index].$field '$value' is not $expected" + } + return value + } +} 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 9e6f90d84..f01230f34 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 @@ -91,6 +91,16 @@ data class MutationRatchetCandidate( * unconsumed and M47 fails loudly, which is the conservative direction. */ val certificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, + /** + * Promotion declaration proposed by this transition (0.7.1i). Not authority: it grants nothing + * and authorizes no admission, it states that an already-certified evidence population is + * carried across a release promotion boundary and therefore was not minted here, so M35/M45 do + * not bind it to the promotion base. + * + * Defaults to absent, which is the conservative state: with no promotion every appearing + * admission and certificate must still bind the transition base, exactly as before. + */ + val promotion: MutationPopulationPromotion? = null, ) 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 40064107d..285c325fc 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 @@ -111,7 +111,11 @@ class MutationRatchetVerifier { ) diagnostics += classificationRatchet(base, candidate) diagnostics += MutationEnrollmentCeremony.checks(base, candidate) - diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, admissionAuthority) + // A promotion declaration carries already-certified evidence across the release promotion + // boundary. Computed once from the same ledgers both ceremonies judge, so the admission and + // certificate rules cannot disagree about what was carried. Null means "no promotion". + val promotion = MutationPopulationPromotion.carried(base, candidate) + diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, admissionAuthority, promotion) // 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 += @@ -120,6 +124,7 @@ class MutationRatchetVerifier { candidate = candidate.certificates, baseSha = base.baseSha, validConsumptions = admissionAuthority.certifiedConsumptions, + promotion = promotion, ) diagnostics += familyAndTargetChecks( diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationPromotionTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationPromotionTest.kt new file mode 100644 index 000000000..c12772ad4 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationPromotionTest.kt @@ -0,0 +1,272 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.File +import java.nio.file.Files + +/** + * TASK-0.7.1i: a promotion declaration carries an ALREADY-CERTIFIED evidence population across a + * release promotion boundary. + * + * M35 and M45 bind a newly introduced admission/certificate to the authority base it was minted + * against. A promotion cannot satisfy that binding legitimately - re-minting admissions, rewriting + * `fromBaseSha` and manufacturing replacement certificates are all forbidden - so it needs exactly + * one statement: this evidence was not minted here, it is the same certified population, carried + * across unchanged. + * + * These tests are the negative proofs. Each one changes a single field of an otherwise exact + * declaration, or removes it, and every one of them must still fail. + */ +class MutationPopulationPromotionTest : MutationRatchetTestSupport() { + private companion object { + const val CARRIED_DIGEST = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val OTHER_DIGEST = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const val MINTING_SHA = "1111111111111111111111111111111111111111" + const val CERTIFICATE_MINTING_SHA = "2222222222222222222222222222222222222222" + const val OTHER_BASE_SHA = "ffffffffffffffffffffffffffffffffffffffff" + } + + /** The exact declaration for the fixture below: one carried admission, no certificates. */ + private fun declaration( + promotionBase: String = BASE_SHA, + populationDigest: String = CARRIED_DIGEST, + admissions: Int = 1, + certificates: Int = 0, + ) = MutationPopulationPromotion( + promotionBase = promotionBase, + populationDigest = populationDigest, + admissions = admissions, + certificates = certificates, + ) + + /** An admission minted on an earlier line: it does not bind the base this transition sees. */ + private fun carriedAdmission(digest: String = CARRIED_DIGEST) = + admission("appearing", populationDigest = digest, fromBaseSha = MINTING_SHA) + + private fun certificate(fromDigest: String = CARRIED_DIGEST) = + MutationAuthorityDigestCertificate( + fromAlgorithm = "raw-v1", + fromDigest = fromDigest, + toAlgorithm = "authority-v2", + toDigest = OTHER_DIGEST, + admissionSetDigest = OTHER_DIGEST, + fromBaseSha = CERTIFICATE_MINTING_SHA, + reason = "fixture", + ) + + private fun certificates(vararg records: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = records.toList()) + + /** + * The transition under test. Populations are identical across base and candidate so M06 is not + * in play: the only question is whether the ledger's mint binding still fires. + */ + private fun verify( + candidateAdmissions: MutationPopulationAdmissions = noAdmissions, + candidateCertificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, + promotion: MutationPopulationPromotion? = null, + ): List { + val shared = population(listOf(row("anchor"))) + return MutationRatchetVerifier().verify( + MutationRatchetAuthority( + BASE_SHA, + shared, + classifications(), + baseFamilies, + MutationClassificationEnrollments.NONE, + noAdmissions, + ), + MutationRatchetCandidate( + shared, + classifications(), + baseFamilies, + MutationClassificationEnrollments.NONE, + candidateAdmissions, + candidateCertificates, + promotion, + ), + MutationPopulationAggregator.canonicalSemantics(), + MutationPopulationEvolution.RECORDED_EVOLUTION, + evidence(shared), + ) + } + + // ── the exact declaration is the only thing that carries ── + + @Test + fun `an exact declaration carries an admission minted on the promotion line`() { + passes(verify(candidateAdmissions = admissions(carriedAdmission()), promotion = declaration())) + } + + @Test + fun `an exact declaration carries the digest-migration certificate`() { + passes( + verify( + candidateCertificates = certificates(certificate()), + promotion = declaration(admissions = 0, certificates = 1), + ), + ) + } + + // ── every other declaration carries nothing ── + + @Test + fun `no declaration leaves the mint binding in force`() { + val diagnostics = verify(candidateAdmissions = admissions(carriedAdmission())) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration for another promotion base carries nothing`() { + val diagnostics = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(promotionBase = OTHER_BASE_SHA), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration naming another population digest carries nothing`() { + val diagnostics = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(populationDigest = OTHER_DIGEST), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration whose admission count does not describe the carried set carries nothing`() { + // The real promotion names 67; 68 is the off-by-one this rule exists to reject, and the same + // mismatch one row up or down is rejected here. + val tooMany = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(admissions = 2), + ) + assertFailsWith(tooMany, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + val tooFew = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(admissions = 0), + ) + assertFailsWith(tooFew, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration whose certificate count does not describe the carried set carries nothing`() { + // One certificate present, declaration says zero: not carried, so M45 still fires. + val zero = + verify( + candidateCertificates = certificates(certificate()), + promotion = declaration(admissions = 0, certificates = 0), + ) + assertFailsWith(zero, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, "M45") + // No certificate present, declaration says two: the whole declaration is void, so the + // admission it names is not carried either. + val two = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(admissions = 1, certificates = 2), + ) + assertFailsWith(two, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a newly minted admission carrying another digest is not carried`() { + // The declaration names the certified population; a row whose digest is not that population + // was minted here and is bound to this base like any other new authorization. + val diagnostics = + verify( + candidateAdmissions = admissions(carriedAdmission(digest = OTHER_DIGEST)), + promotion = declaration(), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a newly introduced certificate carrying another digest is not carried`() { + val diagnostics = + verify( + candidateCertificates = certificates(certificate(fromDigest = OTHER_DIGEST)), + promotion = declaration(admissions = 0, certificates = 1), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, "M45") + } + + // ── the declaration itself: fail-closed parsing ── + + @Test + fun `an absent declaration file is no promotion`() { + val root = Files.createTempDirectory("promotion-absent").toFile() + assertEquals(null, MutationPopulationPromotionLoader.load(root)) + } + + @Test + fun `a declaration of the wrong shape is a hard failure, not an absent promotion`() { + // A malformed ledger must not silently degrade into "no promotion was needed": the counts are + // part of the declaration's meaning, so a missing one is invalid rather than zero. Structural + // faults surface as GradleException and canonicality faults as IllegalArgumentException, the + // same split the population-admissions loader uses - both are hard build failures. + val missingCount = + ledger( + """ + schemaVersion: "1" + promotions: + - promotionBase: "$MINTING_SHA" + populationDigest: "$CARRIED_DIGEST" + admissions: 67 + """.trimIndent(), + ) + assertThrows { MutationPopulationPromotionLoader.load(missingCount) } + + val badDigest = + ledger( + """ + schemaVersion: "1" + promotions: + - promotionBase: "$MINTING_SHA" + populationDigest: "not-a-digest" + admissions: 67 + certificates: 1 + """.trimIndent(), + ) + assertThrows { MutationPopulationPromotionLoader.load(badDigest) } + } + + @Test + fun `a well-formed declaration loads exactly what it states`() { + val root = + ledger( + """ + schemaVersion: "1" + promotions: + - promotionBase: "$MINTING_SHA" + populationDigest: "$CARRIED_DIGEST" + admissions: 67 + certificates: 1 + """.trimIndent(), + ) + assertEquals( + MutationPopulationPromotion( + promotionBase = MINTING_SHA, + populationDigest = CARRIED_DIGEST, + admissions = 67, + certificates = 1, + ), + MutationPopulationPromotionLoader.load(root), + ) + } + + private fun ledger(contents: String): File { + val root = Files.createTempDirectory("promotion-ledger").toFile() + val file = File(root, MutationPopulationPromotionLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText(contents) + return root + } +} diff --git a/config/quality/mutation-population-promotions.yml b/config/quality/mutation-population-promotions.yml new file mode 100644 index 000000000..7d4a1d91f --- /dev/null +++ b/config/quality/mutation-population-promotions.yml @@ -0,0 +1,30 @@ +# Promotion declarations: carry an already-certified evidence population across a release promotion +# boundary. +# +# Authority: TASK-0.7.1i. Minting and promotion are different operations. M35 and M45 bind a NEWLY +# INTRODUCED admission/certificate to the exact authority base it was minted against, so a promotion +# - where the entire promoted line is new relative to its base - can satisfy them by no legitimate +# means: re-minting admissions, rewriting fromBaseSha, manufacturing replacement certificates and a +# per-entry migration chain are all forbidden. +# +# A declaration here states the narrow fact the promotion actually needs: this exact evidence +# population already exists, is already certified, and is CARRIED ACROSS unchanged. Because it was not +# minted by this transition, the mint-binding rules do not apply to it. +# +# This grants nothing. It is exact: the base SHA must be the transition's own, and both counts and +# every row's digest must match the ledgers as they appear against that base. Anything else is "no +# promotion", and every appearing row fails M35/M45 exactly as before. +# +# SHA-exact and candidate-side, deliberately: the declaration names the base SHA it promotes onto, so +# it can never authorize a different transition, and it must ride in the promotion's own candidate +# because a file committed to the base branch cannot name the commit that contains it. +schemaVersion: "1" + +promotions: + - # 0.7.1 -> release/0.7.0 promotion. The 67 admissions and the single raw-v1 -> authority-v2 + # digest-migration certificate were minted and certified on the 0.7.1 line; the promotion carries + # them across unchanged, so M35/M45 do not bind them to the promotion base. + promotionBase: "9ccbd0d3c6d2b0c0f30277724ef9a33ef240a22a" + populationDigest: "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + admissions: 67 + certificates: 1