Skip to main content

ossctl_core/release/
plan.rs

1//! The sealed, content-addressed release plan — the read-only pre-image the
2//! human approves (ADR-0002 §3).
3//!
4//! `release plan` computes and seals a `plan_id`; `release cut --plan <plan_id>`
5//! executes it and refuses on repo drift. The binary never prompts: it plans
6//! and exits at the approval boundary.
7//!
8//! ## What `plan_id` hashes (the content address)
9//!
10//! [`build`] derives a [`ReleasePlan`] from the already-normalized contract and
11//! detected repo facts, then content-addresses it. The `plan_id` is the
12//! lowercase SHA-256 hex digest of a canonical JSON pre-image (`serde_json`,
13//! whose struct-field and `BTreeMap` ordering is deterministic) covering
14//! **exactly**, in this fixed order:
15//!
16//! 1. a domain separator + `SEAL_VERSION` — so a `plan_id` can never collide
17//!    with any other ossctl digest and the canonicalization format can be
18//!    evolved by a deliberate `SEAL_VERSION` bump instead of silently;
19//! 2. the contract-document `schema_version` (ADR-0002 lists it explicitly);
20//! 3. the **full normalized contract JSON** (`contract show`'s canonical output
21//!    — every defaulted field, so any config change is drift; hashing the whole
22//!    contract is deliberately *fail-closed*: a cosmetic change re-requires
23//!    approval rather than risk missing a substantive one);
24//! 4. the git `HEAD` sha the plan was sealed against;
25//! 5. the chosen release version (the human's bump — design §3.4);
26//! 6. the **resolved concrete target set** — each target's ecosystem, resolved
27//!    package name, registry, and adapter *identity*. Resolution overlays
28//!    facts-derived package names onto the contract's (which may be `null`), so
29//!    a manifest rename is detectable drift even though the contract text is
30//!    unchanged;
31//! 7. the phase sequence (constant per ADR-0002 §2, so it never *causes* drift
32//!    within a binary, but binding it authenticates the execution shape the
33//!    approver saw and makes a future phase-model change a `SEAL_VERSION` event).
34//!
35//! ## Coordinator seam (what the sibling consumes)
36//!
37//! The coordinator refuses a `release cut --plan <id>` on drift by re-deriving
38//! current state and calling [`verify`]. It needs to persist only two plain
39//! fields from an approved plan — `plan_id` and `version` — into its journal;
40//! the approved [`ReleasePlan`] is otherwise reconstructed via [`build`] from
41//! the journalled sealed inputs. The plan DTOs are therefore `Serialize`-only,
42//! matching the repo-wide convention that the wire enums (`Ecosystem`/`Registry`
43//! /`Adapter`) do not derive `Deserialize` (they collect-all-errors on parse).
44//! The trust boundary is the *local journal*: an approved plan is one ossctl
45//! itself wrote, not untrusted caller input.
46//!
47//! ## Out of this worker's scope (handed to the coordinator)
48//!
49//! - **Working-tree cleanliness.** The seal binds `HEAD`, not uncommitted
50//!   changes. Enforcing a clean tree / executing from a clean checkout of the
51//!   sealed commit is an *execution* guard the coordinator owns (it needs a new
52//!   read-only `GitRepo` status port). Until then a dirty tree can publish code
53//!   that differs from the sealed commit — an accepted, documented gap.
54//!
55//! **Adapter tool *versions* (accepted gap).** ADR-0002 §3 names "resolved
56//! adapter identities+versions". The adapter registry (a sibling unit) is not
57//! landed, so no adapter *tool version* (e.g. a pinned `cargo-dist` release) is
58//! resolvable yet; today the address binds adapter **identity** (the enum). When
59//! the registry lands, fold the resolved versions into the pre-image — a
60//! deliberate `schema_version`-bumping change to what the address covers, never
61//! a silent one.
62//!
63//! Determinism: no wall-clock, no id-gen, no ordering-unstable map enters the
64//! pre-image — identical `(contract, facts, head, version)` always yield the
65//! same `plan_id` (proven in tests).
66
67use std::collections::BTreeSet;
68
69use serde::Serialize;
70
71use crate::contract::schema::{Contract, Ecosystem, Registry};
72use crate::protocol::facts::Facts;
73use crate::protocol::plan::{PlanPhase, PlanTarget, ReleasePlan};
74
75/// Build and seal a [`ReleasePlan`] from an already-normalized `contract` and
76/// detected `facts`, at git `head_sha`, for the chosen `version`.
77///
78/// The caller (the `ossctl-cli` handler behind `release plan`, or the release
79/// coordinator re-deriving current state) is responsible for having normalized
80/// the contract and gathered the facts through the same code paths behind
81/// `contract show` / `facts` — this function never re-parses `OSS-RELEASE.md`
82/// nor re-derives facts. `version` is treated as an opaque, already-validated
83/// identifier (scheme-specific validation — semver vs a calver pattern — is the
84/// contract's/skill's job, not the plan's).
85#[must_use]
86pub fn build(contract: &Contract, facts: &Facts, head_sha: &str, version: &str) -> ReleasePlan {
87    let targets = resolve_targets(contract, facts);
88    let plan_id = seal(contract, &targets, head_sha, version);
89    ReleasePlan {
90        plan_id,
91        contract_schema_version: contract.schema_version,
92        head_sha: head_sha.to_string(),
93        version: version.to_string(),
94        targets,
95        phases: PlanPhase::SEQUENCE.to_vec(),
96        // Carried from the (already-hashed) contract so the coordinator can hand
97        // the Homebrew adapter its tap + license without re-reading the contract.
98        // The first distribution that declares a tap — identical to the old
99        // single-`Distribution` behavior. The release-engine CLI path
100        // (`ensure_single_distribution`) rejects a multi-distribution monorepo
101        // BEFORE reaching here, so `distributions.len() <= 1` and this `find_map`
102        // never silently drops a second distribution's tap; carrying a per-package
103        // tap for a true multi-tap monorepo is a deliberate follow-up.
104        homebrew_tap: contract
105            .distributions
106            .iter()
107            .find_map(|d| d.homebrew_tap.clone()),
108        license: Some(contract.license.clone()),
109    }
110}
111
112/// Compute the content-addressed `plan_id` for `(contract, facts, head_sha,
113/// version)` **without** allocating a full [`ReleasePlan`].
114///
115/// The drift-check seam for the coordinator: given the plan a human approved, it
116/// re-derives the *current* repo's contract + facts + `HEAD`, calls this with
117/// the approved plan's sealed `version`, and compares. Prefer [`verify`], which
118/// wraps this and reports *which* inputs drifted; this raw form is exposed for
119/// callers that only need the digest.
120#[must_use]
121pub fn compute_plan_id(
122    contract: &Contract,
123    facts: &Facts,
124    head_sha: &str,
125    version: &str,
126) -> String {
127    let targets = resolve_targets(contract, facts);
128    seal(contract, &targets, head_sha, version)
129}
130
131/// Check whether an `approved` plan still matches the **current** repo state.
132///
133/// The coordinator calls this before crossing into any irreversible phase of
134/// `release cut --plan <plan_id>`. It re-derives the current `plan_id` from the
135/// current `contract`, `facts`, and `head_sha`, holding the *chosen version*
136/// fixed to the approved plan's (a cut may not change the sealed version — that
137/// would require a new plan). `Ok(())` means the approval is still valid; a
138/// [`PlanDrift`] carries the mismatched id pair and human-readable reasons for
139/// the `plan_stale` error envelope. The `plan_id` mismatch is authoritative;
140/// the reasons are **best-effort and may be non-exhaustive** — the approved
141/// plan intentionally does not retain the old normalized contract (trust the
142/// journal, not a re-supplied contract), so an exact field-level contract diff
143/// is not possible here. When more than one input drifts, the reasons name
144/// every one they can pinpoint (`HEAD`, schema version, target set) and fall
145/// back to a generic contract-changed note only when none of those explain it.
146///
147/// # Errors
148/// Returns [`PlanDrift`] when the recomputed `plan_id` differs from
149/// `approved.plan_id` — i.e. the repo moved (a commit, a manifest rename, a
150/// schema bump, a target-set change, or any normalized-contract change) since
151/// approval.
152pub fn verify(
153    approved: &ReleasePlan,
154    contract: &Contract,
155    facts: &Facts,
156    head_sha: &str,
157) -> Result<(), PlanDrift> {
158    let current_targets = resolve_targets(contract, facts);
159    let current_id = seal(contract, &current_targets, head_sha, &approved.version);
160    if current_id == approved.plan_id {
161        return Ok(());
162    }
163
164    // The ids differ; pinpoint *why* so the coordinator can surface an
165    // actionable `plan_stale` message rather than a bare hash mismatch.
166    let mut reasons = Vec::new();
167    if approved.head_sha != head_sha {
168        reasons.push(format!(
169            "HEAD moved from {} to {}",
170            short_sha(&approved.head_sha),
171            short_sha(head_sha)
172        ));
173    }
174    if approved.contract_schema_version != contract.schema_version {
175        reasons.push(format!(
176            "contract schema_version changed from {} to {}",
177            approved.contract_schema_version, contract.schema_version
178        ));
179    }
180    if approved.targets != current_targets {
181        reasons.push(
182            "the resolved target set changed (a target, package, registry, or adapter differs)"
183                .to_string(),
184        );
185    }
186    // A change the specific probes above did not catch (any other normalized
187    // contract field: version scheme, changelog, license, health badges, …).
188    if reasons.is_empty() {
189        reasons.push("the normalized contract changed".to_string());
190    }
191
192    Err(PlanDrift {
193        approved_plan_id: approved.plan_id.clone(),
194        current_plan_id: current_id,
195        reasons,
196    })
197}
198
199/// Why a `release cut --plan <plan_id>` was refused: the current repo no longer
200/// hashes to the approved plan (ADR-0002 §3, `plan_stale`).
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
202pub struct PlanDrift {
203    /// The `plan_id` the human approved.
204    pub approved_plan_id: String,
205    /// The `plan_id` the current repo state produces.
206    pub current_plan_id: String,
207    /// Human-readable specifics of what drifted (`HEAD` moved, the target set
208    /// changed, …) — at least one entry.
209    pub reasons: Vec<String>,
210}
211
212/// Whether a publish target derives its release version from a package manifest
213/// the version guard can read, or has no manifest version by design — the capability
214/// the fail-closed guard keys on (`version-source-fail-closed-nonrust`).
215///
216/// The distinction is a function of the target's **[`Ecosystem`]**, not its publish
217/// registry. A Rust/Node/Python package carries its version in a manifest
218/// (`Cargo.toml`/`package.json`/`pyproject.toml`) regardless of *where* it is
219/// published — a Rust crate repackaged for a Homebrew tap still reads its version
220/// from `Cargo.toml`, so it is [`Manifest`](VersionSource::Manifest). Keying on the
221/// registry instead would wrongly treat that crate (and a binary-distribution-only
222/// Rust repo) as versionless and refuse to derive a version that is plainly in the
223/// tree.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum VersionSource {
226    /// The ecosystem carries the package version in a manifest
227    /// (`rust`←`Cargo.toml`, `node`←`package.json`, `python`←`pyproject.toml`/`setup.py`).
228    /// A resolved target of this class **must** expose a detected manifest version in
229    /// `facts`; a resolved package with none is a *detector failure* that fails the
230    /// guard **closed** ([`VersionResolveError::MissingManifestVersion`]) rather than
231    /// silently skipping the version check (the fail-OPEN gap for manifest-versioned
232    /// non-Rust ecosystems this model closes).
233    Manifest,
234    /// No manifest version **by design**: the ecosystem's version does not live in a
235    /// tree manifest — a raw `binary` distribution (its version binds to the artifact
236    /// it ships), or a VCS-tag-versioned `go` module (`go.mod` declares no version).
237    /// Legitimately **skipped** by the version guard: there is no manifest to read a
238    /// version from and none is expected.
239    Distribution,
240}
241
242impl VersionSource {
243    /// Classify a target by its [`Ecosystem`] (the ecosystem is the authority on
244    /// whether a package's version lives in a tree manifest).
245    ///
246    /// Exhaustive over [`Ecosystem`] on purpose — a new ecosystem must make a
247    /// deliberate manifest-vs-distribution choice here rather than default to a silent
248    /// skip (which would re-open the fail-OPEN gap).
249    #[must_use]
250    pub fn of(ecosystem: Ecosystem) -> Self {
251        match ecosystem {
252            // Ecosystems whose package version lives in a version-carrying manifest.
253            Ecosystem::Rust | Ecosystem::Node | Ecosystem::Python => Self::Manifest,
254            // No tree-manifest version: a raw binary (versioned by the built artifact),
255            // or a Go module (versioned by its VCS tag).
256            Ecosystem::Go | Ecosystem::Binary => Self::Distribution,
257        }
258    }
259}
260
261/// One publishable target's resolved package paired with the version its **tree
262/// manifest** declares — the version the ecosystem's publish command (`cargo
263/// publish` reading `Cargo.toml`, …) would **actually** upload.
264///
265/// The workspace manifest is the single source of truth for the release version
266/// ([`resolve_release_version`]); this is one row of that truth. A tree whose
267/// manifests disagree among themselves carries a set of these
268/// ([`VersionResolveError::InconsistentTree`]).
269#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
270pub struct VersionMismatch {
271    /// The resolved package this row describes.
272    pub package: String,
273    /// The package's ecosystem.
274    pub ecosystem: Ecosystem,
275    /// The version declared in the tree manifest — what the ecosystem's publish
276    /// command (`cargo publish` reading `Cargo.toml`, …) would **actually**
277    /// upload for this package.
278    pub manifest_version: String,
279}
280
281/// A manifest-versioned target ([`VersionSource::Manifest`]) whose resolved package
282/// has **no** detected manifest version in `facts` — the fail-closed row for
283/// `version-source-fail-closed-nonrust`.
284///
285/// Unlike a [`VersionSource::Distribution`] target (skipped by design), a manifest
286/// target with no readable version means the detector failed on an ecosystem that
287/// *is* manifest-versioned. The guard refuses rather than publish an unchecked
288/// version.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
290pub struct UnversionedTarget {
291    /// The resolved package whose manifest version could not be read.
292    pub package: String,
293    /// The package's ecosystem.
294    pub ecosystem: Ecosystem,
295    /// The publish destination — the registry whose manifest a version was expected
296    /// from (`npm`←`package.json`, `PyPI`←`pyproject.toml`, …).
297    pub registry: Registry,
298}
299
300/// Why a single release version could not be resolved from the workspace manifest —
301/// the **single source of truth** for the release version. `ossctl release cut`
302/// publishes the version already in the tree; there is no `--version` input to
303/// override it (`release-drop-version-flag`).
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub enum VersionResolveError {
306    /// One or more **manifest-versioned** targets ([`VersionSource::Manifest`]) have a
307    /// resolved package but **no** detected manifest version — the detector returned
308    /// nothing for an ecosystem that *is* manifest-versioned (npm/PyPI/…). Failing
309    /// **closed** here (rather than silently skipping the target) is the fix for
310    /// `version-source-fail-closed-nonrust`: a distribution target is skipped by
311    /// design, but a manifest target with no readable version is a bug that must not
312    /// publish an unchecked version. Carries each such target (sorted, one per
313    /// package).
314    MissingManifestVersion {
315        /// Every manifest-versioned target whose version could not be read.
316        targets: Vec<UnversionedTarget>,
317    },
318    /// The tree's publishable manifests declare **more than one distinct version**,
319    /// so there is no single source of truth to derive the release version from —
320    /// bring the workspace into lockstep first. Carries each checkable target's
321    /// package + version (sorted, one per package).
322    InconsistentTree {
323        /// Every checkable target and the version its manifest declares.
324        versions: Vec<VersionMismatch>,
325    },
326    /// No manifest version could be detected — every target is a distribution target
327    /// with no manifest version by design (or has no resolved package) — so there is
328    /// no manifest to derive the release version from. With the `--version` input
329    /// removed, the release version can **only** come from a manifest; a repo with no
330    /// version-carrying manifest cannot be cut until one declares a version.
331    Undeterminable,
332}
333
334/// Resolve the release version from the workspace manifest — the **single source of
335/// truth**.
336///
337/// `ossctl release cut` does **not** bump the manifest: each ecosystem's publish
338/// command uploads the version already in the tree (`cargo publish` reads
339/// `Cargo.toml`), and the engine threads that version into every registry probe,
340/// index-wait, and receipt. So the version a cut publishes is a **projection of the
341/// tree**, not an independent input — there is no `--version` flag to override it
342/// (`release-drop-version-flag`), which removes the two-masters footgun at the root
343/// (a flag and the manifest could silently drift, the engine publishing the manifest
344/// version while waiting for/recording the flag's, which never lands —
345/// `release-cut-publish-noop`).
346///
347/// The manifest version is the distinct version shared by every **checkable** target
348/// (a [`VersionSource::Manifest`] target with a detected manifest version in
349/// `facts`). A [`VersionSource::Distribution`] target (a homebrew/binary/cargo-dist
350/// target) has no manifest version by design — its release version is bound to the
351/// crate it repackages — so it is skipped. A manifest-versioned target whose version
352/// the detector could not read is **not** skipped: it fails the guard closed
353/// (`version-source-fail-closed-nonrust`).
354///
355/// # Errors
356/// - [`VersionResolveError::MissingManifestVersion`] — a manifest-versioned target
357///   has a resolved package but no readable manifest version (fail closed).
358/// - [`VersionResolveError::InconsistentTree`] — the checkable targets declare more
359///   than one distinct version, so no single source of truth exists.
360/// - [`VersionResolveError::Undeterminable`] — no manifest version anywhere to derive
361///   from.
362pub fn resolve_release_version(
363    contract: &Contract,
364    facts: &Facts,
365) -> Result<String, VersionResolveError> {
366    let classified = classify_target_versions(contract, facts);
367
368    // Fail CLOSED first: a manifest-versioned target whose version the detector could
369    // not read is NOT silently skipped (that would fail OPEN — publishing a version no
370    // guard confirmed). This is the `version-source-fail-closed-nonrust` fix.
371    if !classified.missing.is_empty() {
372        return Err(VersionResolveError::MissingManifestVersion {
373            targets: classified.missing,
374        });
375    }
376
377    let distinct: BTreeSet<&str> = classified
378        .checkable
379        .iter()
380        .map(|m| m.manifest_version.as_str())
381        .collect();
382
383    match distinct.len() {
384        // No manifest version anywhere to derive from (every target is a distribution
385        // target, or has no resolved package). With `--version` removed there is no
386        // fallback — a repo without a version-carrying manifest cannot be cut.
387        0 => Err(VersionResolveError::Undeterminable),
388        // One source of truth: every checkable row shares it, so any row's version is
389        // THE manifest version.
390        1 => Ok(classified.checkable[0].manifest_version.clone()),
391        // The tree disagrees with itself — no single source of truth to project.
392        _ => Err(VersionResolveError::InconsistentTree {
393            versions: classified.checkable,
394        }),
395    }
396}
397
398/// The version-source classification of a repo's resolved targets: the checkable
399/// rows the release version is projected from, and the manifest-versioned targets
400/// whose version could not be read (the fail-closed set).
401struct ClassifiedVersions {
402    /// [`VersionSource::Manifest`] targets **with** a detected manifest version — the
403    /// checkable set the single release version is derived from.
404    checkable: Vec<VersionMismatch>,
405    /// [`VersionSource::Manifest`] targets with a resolved package but **no** detected
406    /// manifest version — the fail-closed set (`version-source-fail-closed-nonrust`).
407    missing: Vec<UnversionedTarget>,
408}
409
410/// Classify every resolved target by its [`VersionSource`], separating the checkable
411/// manifest versions from the manifest-versioned targets whose version could not be
412/// read.
413///
414/// - A [`VersionSource::Distribution`] target (a `binary`/`go` ecosystem) is skipped
415///   regardless of version: it has no tree-manifest version by design.
416/// - A [`VersionSource::Manifest`] target with a detected version becomes a `checkable`
417///   row; one with a resolved package but **no** detected version becomes a `missing`
418///   row (fail closed).
419/// - A manifest target with **no resolved package** cannot be looked up here at all.
420///   Package resolution is a separate concern guarded elsewhere — `release plan` warns
421///   and `release cut` refuses via `coordinator::validate_plan` — so it is not
422///   double-reported here as a version failure. (Deeper: hardening the resolver itself
423///   to fail closed on an unresolved manifest target is tracked as a follow-up.)
424fn classify_target_versions(contract: &Contract, facts: &Facts) -> ClassifiedVersions {
425    let mut checkable: Vec<VersionMismatch> = Vec::new();
426    let mut missing: Vec<UnversionedTarget> = Vec::new();
427    for t in resolve_targets(contract, facts) {
428        // Distribution ecosystems have no tree-manifest version by design — skip them
429        // whether or not `facts` happens to carry a version for their package.
430        if VersionSource::of(t.ecosystem) == VersionSource::Distribution {
431            continue;
432        }
433        // A manifest target with no resolved package cannot be version-checked here
434        // (see the null-package guards named above).
435        let Some(package) = t.package else { continue };
436        match facts
437            .packages
438            .iter()
439            .find(|p| p.ecosystem == t.ecosystem && p.package.as_deref() == Some(package.as_str()))
440            .and_then(|p| p.version.clone())
441        {
442            Some(manifest_version) => checkable.push(VersionMismatch {
443                package,
444                ecosystem: t.ecosystem,
445                manifest_version,
446            }),
447            // Manifest-versioned, resolved package, but the detector read no version:
448            // fail closed rather than skip (the non-Rust fail-OPEN gap).
449            None => missing.push(UnversionedTarget {
450                package,
451                ecosystem: t.ecosystem,
452                registry: t.registry,
453            }),
454        }
455    }
456    // Deterministic order, and one row per package even if a package backs several
457    // targets (a crate published to crates.io AND repackaged for homebrew). Sort and
458    // dedup on the SAME (ecosystem, package) key so equal keys are guaranteed adjacent
459    // before the consecutive-only `dedup_by` runs.
460    checkable.sort_by(|a, b| {
461        (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
462    });
463    checkable.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
464    missing.sort_by(|a, b| {
465        (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
466    });
467    missing.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
468    ClassifiedVersions { checkable, missing }
469}
470
471/// Overlay facts-derived package names onto the contract's target set, yielding
472/// the concrete targets a cut would execute. Order follows the contract's
473/// `targets` (already canonicalized by the normalizer).
474fn resolve_targets(contract: &Contract, facts: &Facts) -> Vec<PlanTarget> {
475    contract
476        .targets
477        .iter()
478        .map(|t| {
479            let package = t
480                .package
481                .clone()
482                .or_else(|| resolve_package(facts, t.ecosystem));
483            PlanTarget {
484                ecosystem: t.ecosystem,
485                package,
486                registry: t.registry,
487                adapter: t.adapter,
488            }
489        })
490        .collect()
491}
492
493/// The detected package name for `ecosystem`, resolved **only when
494/// unambiguous** — exactly one named manifest for that ecosystem.
495///
496/// `None` when no manifest named one (a virtual workspace, a binary-only repo)
497/// **or** when several do (a monorepo with multiple crates of one ecosystem):
498/// with no per-target manifest key in the contract, picking the first would
499/// silently mis-assign the same package to every `null` target, so we leave it
500/// `null` for cut-time inference instead. A monorepo should declare explicit
501/// per-target `package`s in the contract; the CLI warns when this fires.
502fn resolve_package(facts: &Facts, ecosystem: crate::contract::schema::Ecosystem) -> Option<String> {
503    let mut named = facts
504        .packages
505        .iter()
506        .filter(|p| p.ecosystem == ecosystem && p.package.is_some());
507    let first = named.next()?;
508    // More than one named candidate ⇒ ambiguous ⇒ do not guess.
509    if named.next().is_some() {
510        return None;
511    }
512    first.package.clone()
513}
514
515/// Domain separator baked into every pre-image so a `plan_id` can never be
516/// confused with any other SHA-256 an ossctl subsystem might compute over
517/// similar bytes. Ends in the seal-format version for readability; the numeric
518/// [`SEAL_VERSION`] is also hashed as its own field.
519const SEAL_DOMAIN: &str = "ossctl.release-plan";
520
521/// Version of the *hashing pre-image format* — the field set, their order, and
522/// the canonicalization. Independent of the contract-document or wire-envelope
523/// versions. Bump this (never silently) whenever the pre-image shape changes
524/// (e.g. once resolved adapter versions are folded in), so old and new plan ids
525/// are intentionally disjoint rather than accidentally colliding.
526const SEAL_VERSION: u32 = 5;
527
528/// The canonical hashed pre-image (see the module docs for the exact contents).
529/// A dedicated struct rather than an ad-hoc byte concatenation so the field set
530/// is explicit and serde's deterministic struct-field ordering fixes the byte
531/// layout.
532///
533/// **DO NOT REORDER these fields** — field order is part of the content address,
534/// so a reorder silently changes every `plan_id`. Evolve the format via
535/// [`SEAL_VERSION`] instead.
536#[derive(Serialize)]
537struct SealInput<'a> {
538    domain: &'static str,
539    seal_version: u32,
540    contract_schema_version: u32,
541    contract: &'a Contract,
542    head_sha: &'a str,
543    version: &'a str,
544    targets: &'a [PlanTarget],
545    phases: &'a [PlanPhase],
546}
547
548/// Serialize the pre-image to canonical JSON and return its SHA-256 hex digest.
549fn seal(contract: &Contract, targets: &[PlanTarget], head_sha: &str, version: &str) -> String {
550    let input = SealInput {
551        domain: SEAL_DOMAIN,
552        seal_version: SEAL_VERSION,
553        contract_schema_version: contract.schema_version,
554        contract,
555        head_sha,
556        version,
557        targets,
558        phases: &PlanPhase::SEQUENCE,
559    };
560    // `to_vec` on a struct of only structs/Vecs/BTreeMaps (contract's
561    // `extra_fields` is a `serde_json::Map` = `BTreeMap` without the
562    // `preserve_order` feature) is deterministic — no wall-clock, no HashMap,
563    // no float. It is also infallible for these concrete types; `expect` (never
564    // `unwrap_or_default`, which would fail *open* by hashing an empty pre-image
565    // and collide every failing plan on the empty-string digest).
566    let bytes =
567        serde_json::to_vec(&input).expect("release-plan pre-image is infallible to serialize");
568    sha256::hex(&bytes)
569}
570
571/// Short (first 12 hex chars) `HEAD` sha for drift messages; whole string if
572/// shorter.
573fn short_sha(sha: &str) -> &str {
574    sha.get(..12).unwrap_or(sha)
575}
576
577/// A self-contained SHA-256 (FIPS 180-4) so `plan_id` needs no third-party hash
578/// dependency and no edit to the workspace `Cargo.toml` (a hot file). Content
579/// addressing is an integrity check over local, non-adversarial inputs, so a
580/// vendored reference implementation is appropriate; correctness is pinned by
581/// the RFC known-answer vectors in the module tests.
582mod sha256 {
583    // The canonical reference form is dense in bit-twiddling and single-letter
584    // working variables; the lints below fight that idiom for no clarity gain.
585    #![allow(
586        clippy::unreadable_literal,
587        clippy::many_single_char_names,
588        clippy::needless_range_loop
589    )]
590
591    use std::fmt::Write as _;
592
593    /// SHA-256 round constants (first 32 bits of the fractional parts of the
594    /// cube roots of the first 64 primes).
595    const K: [u32; 64] = [
596        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
597        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
598        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
599        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
600        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
601        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
602        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
603        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
604        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
605        0xc67178f2,
606    ];
607
608    /// Initial hash values (first 32 bits of the fractional parts of the square
609    /// roots of the first 8 primes).
610    const H0: [u32; 8] = [
611        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
612        0x5be0cd19,
613    ];
614
615    /// The lowercase 64-character SHA-256 hex digest of `data`.
616    pub fn hex(data: &[u8]) -> String {
617        let mut h = H0;
618
619        // Pad: 0x80, then zeros to a 56-mod-64 boundary, then the 64-bit
620        // big-endian bit length.
621        let mut msg = data.to_vec();
622        // FIPS 180-4 caps the message at 2^64 - 1 bits; a checked multiply turns
623        // the (practically unreachable) overflow into a loud panic rather than a
624        // silently wrong digest.
625        let bit_len = (data.len() as u64)
626            .checked_mul(8)
627            .expect("SHA-256 input exceeds 2^64 bits");
628        msg.push(0x80);
629        while msg.len() % 64 != 56 {
630            msg.push(0);
631        }
632        msg.extend_from_slice(&bit_len.to_be_bytes());
633
634        for chunk in msg.chunks_exact(64) {
635            let mut w = [0u32; 64];
636            for i in 0..16 {
637                w[i] = u32::from_be_bytes([
638                    chunk[4 * i],
639                    chunk[4 * i + 1],
640                    chunk[4 * i + 2],
641                    chunk[4 * i + 3],
642                ]);
643            }
644            for i in 16..64 {
645                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
646                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
647                w[i] = w[i - 16]
648                    .wrapping_add(s0)
649                    .wrapping_add(w[i - 7])
650                    .wrapping_add(s1);
651            }
652
653            let mut a = h[0];
654            let mut b = h[1];
655            let mut c = h[2];
656            let mut d = h[3];
657            let mut e = h[4];
658            let mut f = h[5];
659            let mut g = h[6];
660            let mut hh = h[7];
661
662            for i in 0..64 {
663                let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
664                let ch = (e & f) ^ ((!e) & g);
665                let t1 = hh
666                    .wrapping_add(s1)
667                    .wrapping_add(ch)
668                    .wrapping_add(K[i])
669                    .wrapping_add(w[i]);
670                let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
671                let maj = (a & b) ^ (a & c) ^ (b & c);
672                let t2 = s0.wrapping_add(maj);
673                hh = g;
674                g = f;
675                f = e;
676                e = d.wrapping_add(t1);
677                d = c;
678                c = b;
679                b = a;
680                a = t1.wrapping_add(t2);
681            }
682
683            h[0] = h[0].wrapping_add(a);
684            h[1] = h[1].wrapping_add(b);
685            h[2] = h[2].wrapping_add(c);
686            h[3] = h[3].wrapping_add(d);
687            h[4] = h[4].wrapping_add(e);
688            h[5] = h[5].wrapping_add(f);
689            h[6] = h[6].wrapping_add(g);
690            h[7] = h[7].wrapping_add(hh);
691        }
692
693        let mut out = String::with_capacity(64);
694        for v in h {
695            let _ = write!(out, "{v:08x}");
696        }
697        out
698    }
699}
700
701#[cfg(test)]
702mod tests;