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