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 serde::Serialize;
68
69use crate::contract::schema::Contract;
70use crate::protocol::facts::Facts;
71use crate::protocol::plan::{PlanPhase, PlanTarget, ReleasePlan};
72
73/// Build and seal a [`ReleasePlan`] from an already-normalized `contract` and
74/// detected `facts`, at git `head_sha`, for the chosen `version`.
75///
76/// The caller (the `ossctl-cli` handler behind `release plan`, or the release
77/// coordinator re-deriving current state) is responsible for having normalized
78/// the contract and gathered the facts through the same code paths behind
79/// `contract show` / `facts` — this function never re-parses `OSS-RELEASE.md`
80/// nor re-derives facts. `version` is treated as an opaque, already-validated
81/// identifier (scheme-specific validation — semver vs a calver pattern — is the
82/// contract's/skill's job, not the plan's).
83#[must_use]
84pub fn build(contract: &Contract, facts: &Facts, head_sha: &str, version: &str) -> ReleasePlan {
85    let targets = resolve_targets(contract, facts);
86    let plan_id = seal(contract, &targets, head_sha, version);
87    ReleasePlan {
88        plan_id,
89        contract_schema_version: contract.schema_version,
90        head_sha: head_sha.to_string(),
91        version: version.to_string(),
92        targets,
93        phases: PlanPhase::SEQUENCE.to_vec(),
94        // Carried from the (already-hashed) contract so the coordinator can hand
95        // the Homebrew adapter its tap + license without re-reading the contract.
96        // The first distribution that declares a tap — identical to the old
97        // single-`Distribution` behavior. The release-engine CLI path
98        // (`ensure_single_distribution`) rejects a multi-distribution monorepo
99        // BEFORE reaching here, so `distributions.len() <= 1` and this `find_map`
100        // never silently drops a second distribution's tap; carrying a per-package
101        // tap for a true multi-tap monorepo is a deliberate follow-up.
102        homebrew_tap: contract
103            .distributions
104            .iter()
105            .find_map(|d| d.homebrew_tap.clone()),
106        license: Some(contract.license.clone()),
107    }
108}
109
110/// Compute the content-addressed `plan_id` for `(contract, facts, head_sha,
111/// version)` **without** allocating a full [`ReleasePlan`].
112///
113/// The drift-check seam for the coordinator: given the plan a human approved, it
114/// re-derives the *current* repo's contract + facts + `HEAD`, calls this with
115/// the approved plan's sealed `version`, and compares. Prefer [`verify`], which
116/// wraps this and reports *which* inputs drifted; this raw form is exposed for
117/// callers that only need the digest.
118#[must_use]
119pub fn compute_plan_id(
120    contract: &Contract,
121    facts: &Facts,
122    head_sha: &str,
123    version: &str,
124) -> String {
125    let targets = resolve_targets(contract, facts);
126    seal(contract, &targets, head_sha, version)
127}
128
129/// Check whether an `approved` plan still matches the **current** repo state.
130///
131/// The coordinator calls this before crossing into any irreversible phase of
132/// `release cut --plan <plan_id>`. It re-derives the current `plan_id` from the
133/// current `contract`, `facts`, and `head_sha`, holding the *chosen version*
134/// fixed to the approved plan's (a cut may not change the sealed version — that
135/// would require a new plan). `Ok(())` means the approval is still valid; a
136/// [`PlanDrift`] carries the mismatched id pair and human-readable reasons for
137/// the `plan_stale` error envelope. The `plan_id` mismatch is authoritative;
138/// the reasons are **best-effort and may be non-exhaustive** — the approved
139/// plan intentionally does not retain the old normalized contract (trust the
140/// journal, not a re-supplied contract), so an exact field-level contract diff
141/// is not possible here. When more than one input drifts, the reasons name
142/// every one they can pinpoint (`HEAD`, schema version, target set) and fall
143/// back to a generic contract-changed note only when none of those explain it.
144///
145/// # Errors
146/// Returns [`PlanDrift`] when the recomputed `plan_id` differs from
147/// `approved.plan_id` — i.e. the repo moved (a commit, a manifest rename, a
148/// schema bump, a target-set change, or any normalized-contract change) since
149/// approval.
150pub fn verify(
151    approved: &ReleasePlan,
152    contract: &Contract,
153    facts: &Facts,
154    head_sha: &str,
155) -> Result<(), PlanDrift> {
156    let current_targets = resolve_targets(contract, facts);
157    let current_id = seal(contract, &current_targets, head_sha, &approved.version);
158    if current_id == approved.plan_id {
159        return Ok(());
160    }
161
162    // The ids differ; pinpoint *why* so the coordinator can surface an
163    // actionable `plan_stale` message rather than a bare hash mismatch.
164    let mut reasons = Vec::new();
165    if approved.head_sha != head_sha {
166        reasons.push(format!(
167            "HEAD moved from {} to {}",
168            short_sha(&approved.head_sha),
169            short_sha(head_sha)
170        ));
171    }
172    if approved.contract_schema_version != contract.schema_version {
173        reasons.push(format!(
174            "contract schema_version changed from {} to {}",
175            approved.contract_schema_version, contract.schema_version
176        ));
177    }
178    if approved.targets != current_targets {
179        reasons.push(
180            "the resolved target set changed (a target, package, registry, or adapter differs)"
181                .to_string(),
182        );
183    }
184    // A change the specific probes above did not catch (any other normalized
185    // contract field: version scheme, changelog, license, health badges, …).
186    if reasons.is_empty() {
187        reasons.push("the normalized contract changed".to_string());
188    }
189
190    Err(PlanDrift {
191        approved_plan_id: approved.plan_id.clone(),
192        current_plan_id: current_id,
193        reasons,
194    })
195}
196
197/// Why a `release cut --plan <plan_id>` was refused: the current repo no longer
198/// hashes to the approved plan (ADR-0002 §3, `plan_stale`).
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct PlanDrift {
201    /// The `plan_id` the human approved.
202    pub approved_plan_id: String,
203    /// The `plan_id` the current repo state produces.
204    pub current_plan_id: String,
205    /// Human-readable specifics of what drifted (`HEAD` moved, the target set
206    /// changed, …) — at least one entry.
207    pub reasons: Vec<String>,
208}
209
210/// Overlay facts-derived package names onto the contract's target set, yielding
211/// the concrete targets a cut would execute. Order follows the contract's
212/// `targets` (already canonicalized by the normalizer).
213fn resolve_targets(contract: &Contract, facts: &Facts) -> Vec<PlanTarget> {
214    contract
215        .targets
216        .iter()
217        .map(|t| {
218            let package = t
219                .package
220                .clone()
221                .or_else(|| resolve_package(facts, t.ecosystem));
222            PlanTarget {
223                ecosystem: t.ecosystem,
224                package,
225                registry: t.registry,
226                adapter: t.adapter,
227            }
228        })
229        .collect()
230}
231
232/// The detected package name for `ecosystem`, resolved **only when
233/// unambiguous** — exactly one named manifest for that ecosystem.
234///
235/// `None` when no manifest named one (a virtual workspace, a binary-only repo)
236/// **or** when several do (a monorepo with multiple crates of one ecosystem):
237/// with no per-target manifest key in the contract, picking the first would
238/// silently mis-assign the same package to every `null` target, so we leave it
239/// `null` for cut-time inference instead. A monorepo should declare explicit
240/// per-target `package`s in the contract; the CLI warns when this fires.
241fn resolve_package(facts: &Facts, ecosystem: crate::contract::schema::Ecosystem) -> Option<String> {
242    let mut named = facts
243        .packages
244        .iter()
245        .filter(|p| p.ecosystem == ecosystem && p.package.is_some());
246    let first = named.next()?;
247    // More than one named candidate ⇒ ambiguous ⇒ do not guess.
248    if named.next().is_some() {
249        return None;
250    }
251    first.package.clone()
252}
253
254/// Domain separator baked into every pre-image so a `plan_id` can never be
255/// confused with any other SHA-256 an ossctl subsystem might compute over
256/// similar bytes. Ends in the seal-format version for readability; the numeric
257/// [`SEAL_VERSION`] is also hashed as its own field.
258const SEAL_DOMAIN: &str = "ossctl.release-plan";
259
260/// Version of the *hashing pre-image format* — the field set, their order, and
261/// the canonicalization. Independent of the contract-document or wire-envelope
262/// versions. Bump this (never silently) whenever the pre-image shape changes
263/// (e.g. once resolved adapter versions are folded in), so old and new plan ids
264/// are intentionally disjoint rather than accidentally colliding.
265const SEAL_VERSION: u32 = 5;
266
267/// The canonical hashed pre-image (see the module docs for the exact contents).
268/// A dedicated struct rather than an ad-hoc byte concatenation so the field set
269/// is explicit and serde's deterministic struct-field ordering fixes the byte
270/// layout.
271///
272/// **DO NOT REORDER these fields** — field order is part of the content address,
273/// so a reorder silently changes every `plan_id`. Evolve the format via
274/// [`SEAL_VERSION`] instead.
275#[derive(Serialize)]
276struct SealInput<'a> {
277    domain: &'static str,
278    seal_version: u32,
279    contract_schema_version: u32,
280    contract: &'a Contract,
281    head_sha: &'a str,
282    version: &'a str,
283    targets: &'a [PlanTarget],
284    phases: &'a [PlanPhase],
285}
286
287/// Serialize the pre-image to canonical JSON and return its SHA-256 hex digest.
288fn seal(contract: &Contract, targets: &[PlanTarget], head_sha: &str, version: &str) -> String {
289    let input = SealInput {
290        domain: SEAL_DOMAIN,
291        seal_version: SEAL_VERSION,
292        contract_schema_version: contract.schema_version,
293        contract,
294        head_sha,
295        version,
296        targets,
297        phases: &PlanPhase::SEQUENCE,
298    };
299    // `to_vec` on a struct of only structs/Vecs/BTreeMaps (contract's
300    // `extra_fields` is a `serde_json::Map` = `BTreeMap` without the
301    // `preserve_order` feature) is deterministic — no wall-clock, no HashMap,
302    // no float. It is also infallible for these concrete types; `expect` (never
303    // `unwrap_or_default`, which would fail *open* by hashing an empty pre-image
304    // and collide every failing plan on the empty-string digest).
305    let bytes =
306        serde_json::to_vec(&input).expect("release-plan pre-image is infallible to serialize");
307    sha256::hex(&bytes)
308}
309
310/// Short (first 12 hex chars) `HEAD` sha for drift messages; whole string if
311/// shorter.
312fn short_sha(sha: &str) -> &str {
313    sha.get(..12).unwrap_or(sha)
314}
315
316/// A self-contained SHA-256 (FIPS 180-4) so `plan_id` needs no third-party hash
317/// dependency and no edit to the workspace `Cargo.toml` (a hot file). Content
318/// addressing is an integrity check over local, non-adversarial inputs, so a
319/// vendored reference implementation is appropriate; correctness is pinned by
320/// the RFC known-answer vectors in the module tests.
321mod sha256 {
322    // The canonical reference form is dense in bit-twiddling and single-letter
323    // working variables; the lints below fight that idiom for no clarity gain.
324    #![allow(
325        clippy::unreadable_literal,
326        clippy::many_single_char_names,
327        clippy::needless_range_loop
328    )]
329
330    use std::fmt::Write as _;
331
332    /// SHA-256 round constants (first 32 bits of the fractional parts of the
333    /// cube roots of the first 64 primes).
334    const K: [u32; 64] = [
335        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
336        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
337        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
338        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
339        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
340        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
341        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
342        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
343        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
344        0xc67178f2,
345    ];
346
347    /// Initial hash values (first 32 bits of the fractional parts of the square
348    /// roots of the first 8 primes).
349    const H0: [u32; 8] = [
350        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
351        0x5be0cd19,
352    ];
353
354    /// The lowercase 64-character SHA-256 hex digest of `data`.
355    pub fn hex(data: &[u8]) -> String {
356        let mut h = H0;
357
358        // Pad: 0x80, then zeros to a 56-mod-64 boundary, then the 64-bit
359        // big-endian bit length.
360        let mut msg = data.to_vec();
361        // FIPS 180-4 caps the message at 2^64 - 1 bits; a checked multiply turns
362        // the (practically unreachable) overflow into a loud panic rather than a
363        // silently wrong digest.
364        let bit_len = (data.len() as u64)
365            .checked_mul(8)
366            .expect("SHA-256 input exceeds 2^64 bits");
367        msg.push(0x80);
368        while msg.len() % 64 != 56 {
369            msg.push(0);
370        }
371        msg.extend_from_slice(&bit_len.to_be_bytes());
372
373        for chunk in msg.chunks_exact(64) {
374            let mut w = [0u32; 64];
375            for i in 0..16 {
376                w[i] = u32::from_be_bytes([
377                    chunk[4 * i],
378                    chunk[4 * i + 1],
379                    chunk[4 * i + 2],
380                    chunk[4 * i + 3],
381                ]);
382            }
383            for i in 16..64 {
384                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
385                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
386                w[i] = w[i - 16]
387                    .wrapping_add(s0)
388                    .wrapping_add(w[i - 7])
389                    .wrapping_add(s1);
390            }
391
392            let mut a = h[0];
393            let mut b = h[1];
394            let mut c = h[2];
395            let mut d = h[3];
396            let mut e = h[4];
397            let mut f = h[5];
398            let mut g = h[6];
399            let mut hh = h[7];
400
401            for i in 0..64 {
402                let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
403                let ch = (e & f) ^ ((!e) & g);
404                let t1 = hh
405                    .wrapping_add(s1)
406                    .wrapping_add(ch)
407                    .wrapping_add(K[i])
408                    .wrapping_add(w[i]);
409                let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
410                let maj = (a & b) ^ (a & c) ^ (b & c);
411                let t2 = s0.wrapping_add(maj);
412                hh = g;
413                g = f;
414                f = e;
415                e = d.wrapping_add(t1);
416                d = c;
417                c = b;
418                b = a;
419                a = t1.wrapping_add(t2);
420            }
421
422            h[0] = h[0].wrapping_add(a);
423            h[1] = h[1].wrapping_add(b);
424            h[2] = h[2].wrapping_add(c);
425            h[3] = h[3].wrapping_add(d);
426            h[4] = h[4].wrapping_add(e);
427            h[5] = h[5].wrapping_add(f);
428            h[6] = h[6].wrapping_add(g);
429            h[7] = h[7].wrapping_add(hh);
430        }
431
432        let mut out = String::with_capacity(64);
433        for v in h {
434            let _ = write!(out, "{v:08x}");
435        }
436        out
437    }
438}
439
440#[cfg(test)]
441mod tests;