From 70dd3672ccae619d9ee3daf94a37f054acab83f9 Mon Sep 17 00:00:00 2001 From: gionag Date: Thu, 1 Oct 2026 21:00:41 +0200 Subject: [PATCH 1/2] feat(0.7.1g1P2-2): digest-migration certificate as a first-class authority object Step 2 of TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md 6.2: the mechanism only. No certificate-aware consumption, no M40-M44, no P2, and the 67 admissions untouched. The certificate carries a *semantic* upgrade of the whole-population authority digest without rewriting historical authority. The 67 P1 authorizations stay bound to their raw-v1 digests and byte-identical; the certificate stands beside them in its own ledger and states, in base authority, that one exact fromDigest is equivalent to one exact toDigest for one exact bounded set of authorizations. M36's payload comparison never sees it, so M36 and M37 stay literally unchanged - no exception branch, no permitted-field list. Three files, mirroring the admissions trio: - MutationAuthorityDigestCertificate.kt - the type, the ledger, and the bounded admission-set digest. Fields exactly as designed: fromAlgorithm, fromDigest, toAlgorithm, toDigest, admissionSetDigest, fromBaseSha, reason. enforcedPayload() is that field set with authorizedBy/authorizedAt excluded as audit-only, so M46 judges byte-identity over bound fields while touching who/when can never manufacture authority. The admission-set recipe (sorted identities joined with a newline plus a trailing newline, SHA-256) is documented as contract, not implementation detail. - MutationAuthorityDigestCertificateLoader.kt - parses config/quality/mutation-authority-digest-certificates.yml. An absent file means no certificates, which is fail-closed: no migration is possible and pre-authority-v2 authorizations stay unconsumable. A present but malformed file is a hard failure. Validates shape only - required fields, 64-hex digests, 40-hex fromBaseSha, known digest semantics, unique source digest, and no certificate that migrates a digest to itself. - MutationAuthorityDigestCertificateCeremony.kt - the lifecycle: M45 mint-time base binding, M46 retained immutability, M47 removal custody. Standalone over the two ledgers plus the authority baseSha, because a lifecycle rule is a transition property that cannot be expressed by parsing one file. M47 takes its strictly fail-closed half. With no certificate-aware consumption path there is no way to *prove* a valid consumption, so any base certificate absent from the candidate fails. That is deliberate and documented in the rule and the test, not an unfinished branch: a disappearance must never become its own evidence. When M40-M44 land it becomes "unless validly consumed by the same transition", judged against an independently established consumption. This is the T19 boundary flagged during Step 1. No ledger instance is created: minting the P1M certificate is a later base transition, so this change touches no config/quality file and no authority record. Tests (22 new): loader shape contract including every fail-closed case; the admission-set recipe pinned against the real committed 67-admission set; M45 in both directions plus the discriminator that binding is enforced at mint only, so a retained certificate survives intermediate merges; M46 driven as a loop over each bound field, which fails if a field is ever added to the type without being classified into the enforced payload; audit-only metadata changing without failing; and M47's removal custody in both directions. Verification: - :build-logic:test --tests 'dev.tramai.build.quality.MutationAuthorityDigestCertificate*' --rerun-tasks - BUILD SUCCESSFUL, 8 tasks executed - spotlessKotlinCheck, verifyStaticAnalysis, verifyChangePolicy - see the pull request --- .../MutationAuthorityDigestCertificate.kt | 117 ++++++++++ ...ationAuthorityDigestCertificateCeremony.kt | 142 ++++++++++++ ...utationAuthorityDigestCertificateLoader.kt | 201 +++++++++++++++++ ...nAuthorityDigestCertificateCeremonyTest.kt | 161 +++++++++++++ ...ionAuthorityDigestCertificateLoaderTest.kt | 213 ++++++++++++++++++ 5 files changed, 834 insertions(+) create mode 100644 build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt create mode 100644 build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt create mode 100644 build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt create mode 100644 build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt create mode 100644 build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt new file mode 100644 index 000000000..860d6c737 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt @@ -0,0 +1,117 @@ +package dev.tramai.build.quality + +import java.security.MessageDigest + +/** + * Base-side digest-migration certificate (0.7.1g1P2, M40-M47). + * + * This is the artifact that carries a *semantic* upgrade of the whole-population authority digest + * without rewriting historical authority. The P1 authorizations are bound to `raw-v1` raw-status + * digests and must stay byte-identical; the certificate stands beside them and states, in base + * authority, that one exact `fromDigest` is equivalent to one exact `toDigest` for one exact, + * bounded set of authorizations. M36's payload comparison never sees it, because it lives in its + * own ledger - no exception branch, no permitted-field list, is added to the admissions rules. + * + * ``` + * mint against the exact base (M45) + * -> retain byte-identically (M46) + * -> consume only from base (M43 + M42 + M41 + M40) + * -> remove in the valid consuming transition (M44 + M47) + * ``` + * + * ## Field semantics + * + * - [fromAlgorithm] / [fromDigest] - the digest semantics the covered authorizations were minted + * under, and that exact digest. M41 requires this to match the cited admission's + * `populationDigest`; a certificate can never be applied to a different authorization than the + * one whose digest it names. + * - [toAlgorithm] / [toDigest] - the digest semantics the covered authorizations are to be consumed + * under, and that exact digest. M40 requires [toDigest] to equal the verifier's own fresh + * authority projection, never anything the candidate supplies. + * - [admissionSetDigest] - SHA-256 over the exact set of covered identities, sorted and joined with + * `"\n"` with a trailing newline (the same recipe this repository uses for manifest digests). M42 + * requires it to equal the exact set of base authorizations, which is what makes the certificate + * *bounded*: it cannot be widened to cover a different or later set. + * - [fromBaseSha] - the authority base the certificate is minted against. **Enforced at + * introduction only (M45); thereafter immutable provenance.** Same semantics as + * [MutationPopulationAdmission.fromBaseSha]: a certificate legitimately survives intermediate + * merges, so requiring it to track the immediate base would make delayed consumption impossible; + * its enforcement value is anti-replay at mint time. + * - [reason] - the recorded provenance of the supersession, part of the enforced payload. + * - [authorizedBy], [authorizedAt] - **audit only, no enforcement value.** Recording them is fine; + * letting identity or actor carry trust would make them a bypass. + * + * ## Rule ownership + * + * M45 (mint binding), M46 (retained immutability) and M47 (removal custody) are lifecycle rules and + * live in [MutationAuthorityDigestCertificateCeremony]. M40-M44 are *consumption* rules and arrive + * with the certificate-aware consumption path; until then a base certificate may not disappear at + * all, which is fail-closed and is the stricter half of M47. + */ +data class MutationAuthorityDigestCertificate( + val fromAlgorithm: String, + val fromDigest: String, + val toAlgorithm: String, + val toDigest: String, + val admissionSetDigest: String, + val fromBaseSha: String, + val reason: String, + val authorizedBy: String? = null, + val authorizedAt: String? = null, +) { + /** + * The enforceable payload: every bound field except the audit-only ones. Base/candidate + * byte-identity is judged over this (M46), so rewriting any bound field fails while touching + * audit metadata alone can never manufacture authority. + */ + fun enforcedPayload(): List = + listOf( + fromAlgorithm, + fromDigest, + toAlgorithm, + toDigest, + admissionSetDigest, + fromBaseSha, + reason, + ) +} + +data class MutationAuthorityDigestCertificates( + val schemaVersion: String, + val certificates: List, +) { + /** + * Keyed by [MutationAuthorityDigestCertificate.fromDigest]: the source of a migration is + * unique. Two certificates claiming the same source digest would contradict each other, and the + * loader rejects that rather than leaving the winner to map iteration order. + */ + fun byFromDigest(): Map = certificates.associateBy { it.fromDigest } + + companion object { + /** The digest semantics a certificate may migrate from and to. */ + const val ALGORITHM_RAW_V1 = "raw-v1" + const val ALGORITHM_AUTHORITY_V2 = "authority-v2" + + /** Byte mask for the hex rendering of a digest. */ + private const val HEX_BYTE_MASK = 0xFF + + /** No certificates: strictly the most restrictive state, so no migration is possible. */ + val NONE = MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = emptyList()) + + /** + * The bounded admission-set digest (M42): SHA-256 over the covered identities, sorted and + * joined with `"\n"` with a trailing newline. + * + * The recipe is part of the contract, not an implementation detail: whoever mints a + * certificate must be able to reproduce this value exactly from the authorization set, and a + * test pins it against the real ledger. + */ + fun admissionSetDigest(identities: Collection): String { + val payload = identities.sorted().joinToString("\n", postfix = if (identities.isEmpty()) "" else "\n") + return MessageDigest + .getInstance("SHA-256") + .digest(payload.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and HEX_BYTE_MASK) } + } + } +} 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 new file mode 100644 index 000000000..09cd01aea --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt @@ -0,0 +1,142 @@ +package dev.tramai.build.quality + +/** + * The digest-migration certificate's authority lifecycle (0.7.1g1P2, M45-M47). + * + * The certificate is itself base authority, so its whole lifecycle is fail-closed, not only its + * consumption. These three rules are the lifecycle half; M40-M44 are consumption rules and arrive + * with the certificate-aware consumption path. + * + * ``` + * mint against the exact base (M45) <- this file + * -> retain byte-identically (M46) <- this file + * -> consume only from base (M43 + M42 + M41 + M40) + * -> remove in the valid consuming transition (M44 + M47) <- M47's consuming half is here + * ``` + * + * Written standalone over the two ledgers plus the authority [baseSha], because a lifecycle rule is + * a transition property: it cannot be expressed by parsing one file, and it needs no coupling to the + * ratchet's own types. + */ +object MutationAuthorityDigestCertificateCeremony { + /** + * M45, M46 and M47 over the base/candidate certificate ledgers. + * + * @param base the certificate ledger as it exists in the PR's authority base + * @param candidate the certificate ledger as the PR proposes it + * @param baseSha the exact authority base SHA this transition is proposed against + */ + fun checks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + baseSha: String, + ): List = + mintChecks(base, candidate, baseSha) + + retentionChecks(base, candidate) + + removalChecks(base, candidate) + + /** + * M45: a newly introduced certificate binds the exact authority base it is proposed against. + * 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. + */ + private fun mintChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + baseSha: String, + ): List { + val diagnostics = mutableListOf() + val baseByFromDigest = base.byFromDigest() + for ((fromDigest, certificate) in candidate.byFromDigest()) { + if (fromDigest in baseByFromDigest) continue + if (certificate.fromBaseSha != baseSha) { + diagnostics += + certificateFailure( + fromDigest, + "M45: the digest-migration certificate for source digest ${short(fromDigest)} " + + "records fromBaseSha '${certificate.fromBaseSha}', not the authority base " + + "'$baseSha' this transition is proposed against. fromBaseSha is the base of " + + "the minting transition.", + ) + } + } + return diagnostics + } + + /** + * M46: a certificate present in the base is immutable from the moment it is introduced. A + * retained copy differing in any field of its enforced payload is a rewrite, whether or not the + * migration it authorizes is being consumed in this transition. Correcting a certificate means + * minting a new one in a later transition, never editing a retained one. + * + * Judged over [MutationAuthorityDigestCertificate.enforcedPayload], so touching audit metadata + * alone never fails - and never manufactures authority either. + */ + private fun retentionChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List { + val diagnostics = mutableListOf() + val candidateByFromDigest = candidate.byFromDigest() + for ((fromDigest, baseCertificate) in base.byFromDigest()) { + val candidateCertificate = candidateByFromDigest[fromDigest] ?: continue + if (baseCertificate.enforcedPayload() != candidateCertificate.enforcedPayload()) { + diagnostics += + certificateFailure( + fromDigest, + "M46: the retained digest-migration certificate for source digest " + + "${short(fromDigest)} does not match the base certificate. A certificate, " + + "once introduced, is immutable.", + ) + } + } + return diagnostics + } + + /** + * M47: removal custody. A certificate may only disappear by being consumed; it may not be + * cancelled silently. + * + * Until the certificate-aware consumption path exists there is no way to *prove* a valid + * consumption, so this rule takes its strictly fail-closed half: **any** base certificate absent + * from the candidate fails. That is deliberate, not an unfinished branch. A disappearance is + * never its own evidence - when M40-M44 land, this becomes "unless validly consumed by the same + * transition", judged against an independently established consumption, and never against the + * mere absence of the certificate. + */ + private fun removalChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List { + val diagnostics = mutableListOf() + val candidateByFromDigest = candidate.byFromDigest() + for ((fromDigest, _) in base.byFromDigest()) { + if (fromDigest !in candidateByFromDigest) { + diagnostics += + certificateFailure( + fromDigest, + "M47: the digest-migration certificate for source digest ${short(fromDigest)} " + + "was removed without being consumed. A certificate may not be cancelled " + + "silently.", + ) + } + } + return diagnostics + } + + private fun certificateFailure( + fromDigest: String, + message: String, + ): VerificationDiagnostic = + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, + message, + findingId = fromDigest, + ) + + private fun short(digest: String): String = digest.take(SHORT_HASH_LENGTH) + + /** Prefix length used to identify a digest in diagnostics; the full value stays in findingId. */ + private const val SHORT_HASH_LENGTH = 8 +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt new file mode 100644 index 000000000..d08524e2c --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt @@ -0,0 +1,201 @@ +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 + +/** + * Parses config/quality/mutation-authority-digest-certificates.yml. + * + * An absent file means "no certificates", which is fail-closed: no semantic migration of a + * population-authority digest is then possible, and every authorization whose digest predates + * authority-v2 stays unconsumable. A present but malformed file is a hard failure - a broken + * certificate ledger must never silently degrade into "no certificates that happen to be unused". + * + * Every enforcement-critical field is required and validated here, because a partially specified + * certificate is indistinguishable from one that authorizes a different migration. + * + * **This loader validates shape only.** Mint-time base binding (M45), retained immutability (M46) + * and removal custody (M47) are transition properties and cannot be enforced by parsing a single + * file; they live in [MutationAuthorityDigestCertificateCeremony]. Leaving this ledger's lifecycle + * to a generic loader would give it none of those properties. + */ +object MutationAuthorityDigestCertificateLoader { + const val FILE_NAME = "config/quality/mutation-authority-digest-certificates.yml" + + private const val SCHEMA_VERSION = "1" + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + private val DIGEST = Regex("[0-9a-f]{64}") + private val COMMIT_SHA = Regex("[0-9a-f]{40}") + private val ALGORITHMS = + setOf( + MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + ) + + fun load(repositoryRoot: File): MutationAuthorityDigestCertificates { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return MutationAuthorityDigestCertificates.NONE + } + val raw = readLedger(file) + val schemaVersion = requireSchemaVersion(raw) + val certificates = parseCertificates(raw) + requireUniqueFromDigests(certificates) + requireDistinctAlgorithms(certificates) + return MutationAuthorityDigestCertificates( + schemaVersion = schemaVersion, + certificates = certificates, + ) + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + val text = file.readText(Charsets.UTF_8) + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(text) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid mutation-authority-digest-certificates.yml: ${e.message}", e) + } + } + + private fun requireSchemaVersion(raw: Map): String { + val schemaVersion = + raw["schemaVersion"]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: schemaVersion is required", + ) + if (schemaVersion != SCHEMA_VERSION) { + throw GradleException( + "Unsupported mutation-authority-digest-certificates.yml schemaVersion '$schemaVersion'", + ) + } + return schemaVersion + } + + private fun parseCertificates(raw: Map): List { + val raw = + raw["certificates"] as? List<*> + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates must be a list", + ) + return raw.mapIndexed { index, item -> + val entry = + item as? Map<*, *> + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index] must be a mapping", + ) + MutationAuthorityDigestCertificate( + fromAlgorithm = requireAlgorithm(entry, "fromAlgorithm", index), + fromDigest = requireCanonical(entry, "fromDigest", index, DIGEST, "a 64-hex digest"), + toAlgorithm = requireAlgorithm(entry, "toAlgorithm", index), + toDigest = requireCanonical(entry, "toDigest", index, DIGEST, "a 64-hex digest"), + admissionSetDigest = + requireCanonical( + entry, + "admissionSetDigest", + index, + DIGEST, + "a 64-hex admission-set digest", + ), + fromBaseSha = + requireCanonical(entry, "fromBaseSha", index, COMMIT_SHA, "a 40-hex commit SHA"), + reason = requireString(entry, "reason", index), + authorizedBy = optionalString(entry, "authorizedBy"), + authorizedAt = optionalString(entry, "authorizedAt"), + ) + } + } + + private fun requireAlgorithm( + entry: Map<*, *>, + field: String, + index: Int, + ): String { + val value = + entry[field]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required", + ) + if (value !in ALGORITHMS) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field '$value' is " + + "not a known digest semantics (${ALGORITHMS.sorted().joinToString(", ")})", + ) + } + return value + } + + private fun requireCanonical( + entry: Map<*, *>, + field: String, + index: Int, + pattern: Regex, + description: String, + ): String { + val value = + entry[field]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required", + ) + if (!pattern.matches(value)) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field '$value' is not " + + "$description", + ) + } + return value + } + + private fun requireString( + entry: Map<*, *>, + field: String, + index: Int, + ): String { + val value = entry[field]?.toString()?.takeIf { it.isNotBlank() } + return value + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required and " + + "must be non-blank", + ) + } + + private fun optionalString( + entry: Map<*, *>, + field: String, + ): String? = entry[field]?.toString()?.takeIf { it.isNotBlank() } + + private fun requireUniqueFromDigests(certificates: List) { + val duplicates = + certificates + .groupingBy { it.fromDigest } + .eachCount() + .filterValues { it > 1 } + .keys + if (duplicates.isNotEmpty()) { + throw GradleException( + "mutation-authority-digest-certificates.yml: duplicate fromDigest ${duplicates.sorted()} - " + + "the source of a migration is unique, so two certificates claiming it would contradict " + + "each other", + ) + } + } + + private fun requireDistinctAlgorithms(certificates: List) { + for (certificate in certificates) { + if (certificate.fromAlgorithm == certificate.toAlgorithm) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificate ${certificate.fromDigest} " + + "migrates ${certificate.fromAlgorithm} to itself, which certifies nothing", + ) + } + } + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt new file mode 100644 index 000000000..65fa08177 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt @@ -0,0 +1,161 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The digest-migration certificate's authority lifecycle (M45/M46/M47). + * + * A certificate is itself base authority, so a strong consumption check would still be worthless if + * its creation, retention or disappearance were weak. Each rule gets a discriminator in both + * directions: the property holds for the legitimate transition, and fails for the attack that + * property exists to defeat. + * + * M40-M44 are consumption rules and are not implemented here. The consequence is visible in + * [M47 removal custody is never satisfied by the mere absence of the certificate]: with no + * certificate-aware consumption path there is no way to *prove* a valid consumption, so a + * disappearance always fails. That is the fail-closed half of M47, not an unfinished branch - a + * disappearance must never become its own evidence. + */ +class MutationAuthorityDigestCertificateCeremonyTest { + private val baseSha = "ec8b4da59bc9e22711b0cd802417d297f803b4e0" + private val otherSha = "e6e0d69ed2adfa47c6cdfa4485ef30bc184bb881" + private val rawV1Digest = "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + private val authorityV2Digest = "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + private val admissionSetDigest = "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + + /** The legitimate certificate: raw-v1 to authority-v2, minted against [baseSha]. */ + private val certificate = + MutationAuthorityDigestCertificate( + fromAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + fromDigest = rawV1Digest, + toAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + toDigest = authorityV2Digest, + admissionSetDigest = admissionSetDigest, + fromBaseSha = baseSha, + reason = "digest migration", + ) + + /** A copy of [certificate] with exactly one bound field replaced. */ + private fun rewritten( + field: String, + value: String, + ): MutationAuthorityDigestCertificate = + when (field) { + "fromAlgorithm" -> certificate.copy(fromAlgorithm = value) + "toAlgorithm" -> certificate.copy(toAlgorithm = value) + "toDigest" -> certificate.copy(toDigest = value) + "admissionSetDigest" -> certificate.copy(admissionSetDigest = value) + "fromBaseSha" -> certificate.copy(fromBaseSha = value) + "reason" -> certificate.copy(reason = value) + else -> error("$field is not a field of the enforced payload") + } + + // ── M45 mint-time base binding (T17) ── + + @Test + fun `M45 a certificate minted against the authority base is accepted`() { + val diagnostics = checks(base = ledger(), candidate = ledger(certificate)) + + assertEquals(emptyList(), diagnostics, "the legitimate mint must pass") + } + + @Test + fun `M45 a certificate minted against a different base is rejected`() { + val diagnostics = checks(base = ledger(), candidate = ledger(certificate.copy(fromBaseSha = otherSha))) + + val failure = diagnostics.single() + assertTrue(failure.message.contains("M45"), failure.message) + assertEquals(rawV1Digest, failure.findingId, "the certificate's source digest identifies it") + } + + @Test + fun `M45 binding is enforced at mint only, so a retained certificate survives later merges`() { + // A pending certificate legitimately outlives intermediate merges: requiring fromBaseSha to + // track the immediate base would make delayed consumption impossible and would itself violate + // certificate immutability. If this fails, mint binding has been turned into a retention rule. + val retained = certificate.copy(fromBaseSha = otherSha) + + val diagnostics = checks(base = ledger(retained), candidate = ledger(retained)) + + assertEquals(emptyList(), diagnostics, "anti-replay applies at introduction, not forever") + } + + // ── M46 retained immutability (T18) ── + + @Test + fun `M46 a retained certificate rewritten in any enforced field is rejected`() { + // Driven as a loop so the enforced payload is proven to cover exactly these fields: adding a + // bound field without classifying it would leave it unprotected and fail this discriminator. + val rewrites = + listOf( + "fromAlgorithm" to "raw-v2", + "toAlgorithm" to "authority-v3", + "toDigest" to "f".repeat(64), + "admissionSetDigest" to "f".repeat(64), + "fromBaseSha" to otherSha, + "reason" to "a later, more convenient story", + ) + + for ((field, value) in rewrites) { + val diagnostics = checks(base = ledger(certificate), candidate = ledger(rewritten(field, value))) + assertTrue(diagnostics.isNotEmpty(), "rewriting $field must fail M46") + assertTrue( + diagnostics.any { it.message.contains("M46") }, + "rewriting $field: ${diagnostics.map { it.message }}", + ) + } + } + + @Test + fun `M46 audit-only metadata may change, and cannot manufacture authority`() { + val diagnostics = + checks( + base = ledger(certificate.copy(authorizedBy = "giona", authorizedAt = "2026-10-01T00:00:00Z")), + candidate = ledger(certificate.copy(authorizedBy = "someone", authorizedAt = "2027-01-01T00:00:00Z")), + ) + + assertEquals(emptyList(), diagnostics, "who/when carry no enforcement value in either direction") + } + + // ── M47 removal custody (T19, lifecycle half) ── + + @Test + fun `M47 a retained certificate is not a removal`() { + val diagnostics = checks(base = ledger(certificate), candidate = ledger(certificate)) + + assertEquals(emptyList(), diagnostics) + } + + @Test + fun `M47 removal custody is never satisfied by the mere absence of the certificate`() { + // The exact attack T19 exists to defeat: a transition that simply deletes the certificate and + // calls the deletion evidence of consumption. With no consumption path implemented, no + // disappearance can be proven valid, so every disappearance must fail closed. + val diagnostics = checks(base = ledger(certificate), candidate = ledger()) + + val failure = diagnostics.single() + assertTrue(failure.message.contains("M47"), failure.message) + assertTrue(failure.message.contains("removed without being consumed"), failure.message) + assertEquals(rawV1Digest, failure.findingId) + } + + @Test + fun `a certificate introduced and removed by the same transition is not a way to consume it`() { + // Net-zero: nothing is gained, and M45 still judges the introduction against the base. What + // must never happen is this being read as a consumption, which is M43's job in Step 3. + val diagnostics = checks(base = ledger(), candidate = ledger(certificate.copy(fromBaseSha = otherSha))) + + assertTrue(diagnostics.single().message.contains("M45"), "the introduction is still judged") + } + + private fun checks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List = + MutationAuthorityDigestCertificateCeremony.checks(base = base, candidate = candidate, baseSha = baseSha) + + private fun ledger(vararg certificates: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = certificates.toList()) +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt new file mode 100644 index 000000000..6efcfba99 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt @@ -0,0 +1,213 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The digest-migration certificate ledger's shape contract, and the one value a future mint must be + * able to reproduce exactly. + * + * The loader validates *shape* only: mint-time base binding (M45), retained immutability (M46) and + * removal custody (M47) are transition properties and are tested in + * [MutationAuthorityDigestCertificateCeremonyTest]. What is tested here is that a partially + * specified certificate - indistinguishable from one describing a different migration - is a hard + * failure rather than a silently weaker authority. + */ +class MutationAuthorityDigestCertificateLoaderTest { + @TempDir + lateinit var tempDir: File + + private val fromDigest = "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + private val toDigest = "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + private val admissionSetDigest = "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + private val fromBaseSha = "ec8b4da59bc9e22711b0cd802417d297f803b4e0" + + @Test + fun `an absent certificate ledger is no migration authority rather than a failure`() { + val loaded = MutationAuthorityDigestCertificateLoader.load(tempDir) + + assertEquals(MutationAuthorityDigestCertificates.NONE.certificates, loaded.certificates) + assertEquals("1", loaded.schemaVersion) + } + + @Test + fun `a fully specified certificate round-trips, audit metadata included`() { + writeLedger(auditedEntry("giona", "2026-10-01T00:00:00Z")) + + val certificate = + MutationAuthorityDigestCertificateLoader + .load(tempDir) + .certificates + .single() + + assertEquals("raw-v1", certificate.fromAlgorithm) + assertEquals(fromDigest, certificate.fromDigest) + assertEquals("authority-v2", certificate.toAlgorithm) + assertEquals(toDigest, certificate.toDigest) + assertEquals(admissionSetDigest, certificate.admissionSetDigest) + assertEquals(fromBaseSha, certificate.fromBaseSha) + assertEquals("test reason", certificate.reason) + assertEquals("giona", certificate.authorizedBy) + } + + @Test + fun `the admission-set digest recipe reproduces the committed 67-admission set`() { + // The recipe is part of the contract, not an implementation detail: whoever mints the P1M + // certificate must be able to reproduce this value exactly from the authorization set the + // certificate is meant to bound, and M42 later compares against it. + val admissions = MutationPopulationAdmissionLoader.load(repositoryRoot()).admissions + + assertEquals(67, admissions.size, "the committed P1 authorization set") + assertEquals( + admissionSetDigest, + MutationAuthorityDigestCertificates.admissionSetDigest(admissions.map { it.identity }), + "sorted identities joined with a newline and a trailing newline, SHA-256", + ) + } + + @Test + fun `the admission-set digest is order independent and set dependent`() { + val a = "a".repeat(64) + val b = "b".repeat(64) + + assertEquals( + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a, b)), + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(b, a)), + ) + assertTrue( + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a, b)) != + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a)), + "bounding a different set must change the digest, otherwise the certificate is not bounded", + ) + } + + @Test + fun `a malformed from digest is a hard failure, not a weaker authority`() { + failingLoad(entry(mapOf("fromDigest" to "not-a-digest"))) + } + + @Test + fun `a short fromBaseSha is a hard failure`() { + val failure = failingLoad(entry(mapOf("fromBaseSha" to "ec8b4da5"))) + + assertTrue(failure.message!!.contains("fromBaseSha"), failure.message) + } + + @Test + fun `an unknown digest semantics is a hard failure`() { + val failure = failingLoad(entry(mapOf("toAlgorithm" to "authority-v3"))) + + assertTrue(failure.message!!.contains("known digest semantics"), failure.message) + } + + @Test + fun `a missing reason is a hard failure`() { + val failure = failingLoad(entry(mapOf("reason" to null))) + + assertTrue(failure.message!!.contains("reason"), failure.message) + } + + @Test + fun `two certificates claiming the same source digest contradict each other and fail`() { + val failure = failingLoad(entry() + entry(mapOf("toDigest" to "f".repeat(64)))) + + assertTrue(failure.message!!.contains("duplicate fromDigest"), failure.message) + } + + @Test + fun `a certificate that migrates a digest to itself fails, because it certifies nothing`() { + val failure = failingLoad(entry(mapOf("toAlgorithm" to "raw-v1"))) + + assertTrue(failure.message!!.contains("certifies nothing"), failure.message) + } + + @Test + fun `an absent schemaVersion is a hard failure`() { + val file = File(tempDir, MutationAuthorityDigestCertificateLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText("certificates:\n" + entry(), Charsets.UTF_8) + + val failure = assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } + + assertTrue(failure.message!!.contains("schemaVersion"), failure.message) + } + + @Test + fun `the enforced payload excludes audit metadata and includes every bound field`() { + val certificate = + MutationAuthorityDigestCertificate( + fromAlgorithm = "raw-v1", + fromDigest = fromDigest, + toAlgorithm = "authority-v2", + toDigest = toDigest, + admissionSetDigest = admissionSetDigest, + fromBaseSha = fromBaseSha, + reason = "test reason", + authorizedBy = "someone", + authorizedAt = "sometime", + ) + + assertEquals( + listOf("raw-v1", fromDigest, "authority-v2", toDigest, admissionSetDigest, fromBaseSha, "test reason"), + certificate.enforcedPayload(), + ) + assertEquals( + certificate.enforcedPayload(), + certificate.copy(authorizedBy = "another", authorizedAt = "elsewhere").enforcedPayload(), + "audit metadata alone can never manufacture or break authority", + ) + } + + /** Writes [entries] as the ledger, then asserts that loading it fails, returning the failure. */ + private fun failingLoad(entries: String): GradleException { + writeLedger(entries) + return assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } + } + + private fun repositoryRoot(): File = File(System.getProperty("user.dir")).let { File(it, "..") } + + private fun auditedEntry( + authorizedBy: String, + authorizedAt: String, + ): String = + entry().replace( + " reason: \"test reason\"", + " authorizedBy: \"$authorizedBy\"\n authorizedAt: \"$authorizedAt\"\n reason: \"test reason\"", + ) + + /** + * The canonical certificate YAML, with selected fields replaced. + * + * Malformed-input cases need raw values that a typed certificate cannot represent, so overrides + * are raw strings (or null to omit a field) rather than certificate fields. + */ + private fun entry(overrides: Map = emptyMap()): String { + val fields = + linkedMapOf( + "fromAlgorithm" to "raw-v1", + "fromDigest" to fromDigest, + "toAlgorithm" to "authority-v2", + "toDigest" to toDigest, + "admissionSetDigest" to admissionSetDigest, + "fromBaseSha" to fromBaseSha, + "reason" to "test reason", + ) + fields.putAll(overrides) + val lines = + fields.entries + .filter { it.value != null } + .map { " ${it.key}: \"${it.value}\"" } + return " - " + lines.first().trimStart() + "\n" + lines.drop(1).joinToString("") { "$it\n" } + } + + private fun writeLedger(entries: String) { + val file = File(tempDir, MutationAuthorityDigestCertificateLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText("schemaVersion: \"1\"\ncertificates:\n$entries", Charsets.UTF_8) + } +} From d571e7329a46d76712bd5047260b52f922a422cd Mon Sep 17 00:00:00 2001 From: gionag Date: Fri, 2 Oct 2026 00:07:10 +0200 Subject: [PATCH 2/2] fix(0.7.1g1P2-2): address both Copilot findings on #471 1. Medium, valid: MutationAuthorityDigestCertificateLoaderTest.repositoryRoot() assumed the test starts exactly one level below the repository by using a single parent hop. This test is deliberately pinned against the real committed 67-admission ledger, so a brittle path guess would make it fail for a reason unrelated to the recipe. It now walks upward to the `gradlew` marker, the same idiom CanonicalProbeFunctionalTest uses, and fails with an explicit message naming the starting directory if no root is found. 2. Low, readability: parseCertificates declared `val raw = raw["certificates"]`, shadowing its own parameter. The parameter is now `ledger` and the binding `certificatesRaw`, so the two are distinguishable at the point of use. Both fixes are narrow: no rule, field, threshold or test expectation is weakened, and no suppression or baseline entry was added. Verification: - :build-logic:test --tests 'dev.tramai.build.quality.MutationAuthorityDigestCertificate*' --rerun-tasks - BUILD SUCCESSFUL - spotlessKotlinCheck -PtramaiFormattingBaseRef=e6e0d69... - BUILD SUCCESSFUL - verifyStaticAnalysis - Detekt baseline unchanged (4792 -> 4792, 0 added, 0 removed), no new findings - verifyChangePolicy -PchangePolicyBase=e6e0d69... - PASSED, 5 changed files, class build-logic --- .../MutationAuthorityDigestCertificateLoader.kt | 8 ++++---- ...ationAuthorityDigestCertificateLoaderTest.kt | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt index d08524e2c..6b716b366 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt @@ -80,13 +80,13 @@ object MutationAuthorityDigestCertificateLoader { return schemaVersion } - private fun parseCertificates(raw: Map): List { - val raw = - raw["certificates"] as? List<*> + private fun parseCertificates(ledger: Map): List { + val certificatesRaw = + ledger["certificates"] as? List<*> ?: throw GradleException( "mutation-authority-digest-certificates.yml: certificates must be a list", ) - return raw.mapIndexed { index, item -> + return certificatesRaw.mapIndexed { index, item -> val entry = item as? Map<*, *> ?: throw GradleException( diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt index 6efcfba99..8ec44a9cc 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt @@ -169,7 +169,22 @@ class MutationAuthorityDigestCertificateLoaderTest { return assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } } - private fun repositoryRoot(): File = File(System.getProperty("user.dir")).let { File(it, "..") } + /** + * The repository root, found by walking up to the `gradlew` marker (the same idiom + * `CanonicalProbeFunctionalTest` uses) rather than assuming this test starts a fixed number of + * levels below it. This test is deliberately pinned against the real committed admission ledger, + * so a brittle path guess would fail for a reason that has nothing to do with the recipe. + */ + 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 (directory containing gradlew) above ${System.getProperty("user.dir")}" + } + return candidate + } private fun auditedEntry( authorizedBy: String,