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