Skip to main content

shipshape_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 shipshape 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 for a `--bump`-less plan, so it
32//!    never *causes* drift within a binary, but binding it authenticates the
33//!    execution shape the approver saw and makes a future phase-model change a
34//!    `SEAL_VERSION` event). A `--bump` plan prepends a `bump` phase, which this
35//!    field binds;
36//! 8. the engine-owned **bump plan** (`release-rust-workspace-multicrate` facet 2/3),
37//!    or absent. `--bump <level>` computes a new version from the current manifest
38//!    version + the level and seals the deterministic edit set (computed version,
39//!    intra-workspace pin rewrites, CHANGELOG-finalize intent, any declared
40//!    `bump_hook`). Omitted from the pre-image when absent (`skip_serializing_if`),
41//!    so a `--bump`-less plan hashes byte-for-byte as it did before this field
42//!    existed — the additive superset that made a `SEAL_VERSION` bump unnecessary.
43//!
44//! ## Coordinator seam (what the sibling consumes)
45//!
46//! The coordinator refuses a `release cut --plan <id>` on drift by re-deriving
47//! current state and calling [`verify`]. It needs to persist only two plain
48//! fields from an approved plan — `plan_id` and `version` — into its journal;
49//! the approved [`ReleasePlan`] is otherwise reconstructed via [`build`] from
50//! the journalled sealed inputs. The plan DTOs are therefore `Serialize`-only,
51//! matching the repo-wide convention that the wire enums (`Ecosystem`/`Registry`
52//! /`Adapter`) do not derive `Deserialize` (they collect-all-errors on parse).
53//! The trust boundary is the *local journal*: an approved plan is one shipshape
54//! itself wrote, not untrusted caller input.
55//!
56//! ## Out of this worker's scope (handed to the coordinator)
57//!
58//! - **Working-tree cleanliness.** The seal binds `HEAD`, not uncommitted
59//!   changes. Enforcing a clean tree / executing from a clean checkout of the
60//!   sealed commit is an *execution* guard the coordinator owns (it needs a new
61//!   read-only `GitRepo` status port). Until then a dirty tree can publish code
62//!   that differs from the sealed commit — an accepted, documented gap.
63//!
64//! **Adapter tool *versions* (accepted gap).** ADR-0002 §3 names "resolved
65//! adapter identities+versions". The adapter registry (a sibling unit) is not
66//! landed, so no adapter *tool version* (e.g. a pinned `cargo-dist` release) is
67//! resolvable yet; today the address binds adapter **identity** (the enum). When
68//! the registry lands, fold the resolved versions into the pre-image — a
69//! deliberate `schema_version`-bumping change to what the address covers, never
70//! a silent one.
71//!
72//! Determinism: no wall-clock, no id-gen, no ordering-unstable map enters the
73//! pre-image — identical `(contract, facts, head, version)` always yield the
74//! same `plan_id` (proven in tests).
75
76use std::collections::BTreeSet;
77
78use serde::Serialize;
79
80use crate::contract::schema::{ChangelogMode, Contract, Ecosystem, Registry};
81use crate::protocol::facts::Facts;
82use crate::protocol::plan::{
83    BumpLevel, BumpPlan, ChangelogFinalizePlan, PinRewrite, PlanPhase, PlanTarget, ReleasePlan,
84};
85
86/// Build and seal a [`ReleasePlan`] from an already-normalized `contract` and
87/// detected `facts`, at git `head_sha`, for the chosen `version`.
88///
89/// The caller (the `shipshape-cli` handler behind `release plan`, or the release
90/// coordinator re-deriving current state) is responsible for having normalized
91/// the contract and gathered the facts through the same code paths behind
92/// `contract show` / `facts` — this function never re-parses `OSS-RELEASE.md`
93/// nor re-derives facts. `version` is treated as an opaque, already-validated
94/// identifier (scheme-specific validation — semver vs a calver pattern — is the
95/// contract's/skill's job, not the plan's).
96#[must_use]
97pub fn build(contract: &Contract, facts: &Facts, head_sha: &str, version: &str) -> ReleasePlan {
98    build_inner(contract, facts, head_sha, version, None)
99}
100
101/// Build and seal a `--bump` [`ReleasePlan`]: an engine-owned version-bump plan
102/// that computes a new version from the current manifest version + a semantic
103/// `level` and owns the deterministic edit set (`release-rust-workspace-multicrate`
104/// facet 2).
105///
106/// `from_version` is the current `[workspace.package] version` (the tree's single
107/// source of truth); the engine **computes** the new version by applying `level` to
108/// it ([`crate::release::bump::bump_version`]) — the caller supplies only the level,
109/// never a literal target, so the plan can never seal a `to_version` that contradicts
110/// its declared `level` (the invariant lives in the core constructor, not the CLI).
111/// The returned plan carries a [`PlanPhase::Bump`] at the front of its phase sequence
112/// and a [`BumpPlan`] describing the edits (pin rewrites, CHANGELOG finalize, any
113/// declared `bump_hook`), all folded into the content address. Its
114/// [`ReleasePlan::version`] is the computed new version — every publish/tag threads it.
115///
116/// A `--bump`-less plan is [`build`]; the two share every non-bump derivation, so
117/// the bump path is a strict additive superset.
118///
119/// # Errors
120/// [`BumpError`](crate::release::bump::BumpError) when `from_version` is not a strict
121/// `MAJOR.MINOR.PATCH` release version or the deterministic edit set contains
122/// non-equivalent exact pins. The engine refuses both before sealing.
123pub fn build_with_bump(
124    contract: &Contract,
125    facts: &Facts,
126    head_sha: &str,
127    from_version: &str,
128    level: BumpLevel,
129) -> Result<ReleasePlan, crate::release::bump::BumpError> {
130    let to_version = crate::release::bump::bump_version(level, from_version)?;
131    let bump = derive_bump_plan(contract, facts, head_sha, level, from_version, &to_version)?;
132    Ok(build_inner(
133        contract,
134        facts,
135        head_sha,
136        &to_version,
137        Some(bump),
138    ))
139}
140
141/// The shared core of [`build`] / [`build_with_bump`]: resolve targets, assemble the
142/// (bump-aware) phase sequence, seal, and construct the [`ReleasePlan`]. `bump` is
143/// `None` for the default path (identical output and `plan_id` to before this field
144/// existed) and `Some` for a `--bump` plan.
145#[must_use]
146fn build_inner(
147    contract: &Contract,
148    facts: &Facts,
149    head_sha: &str,
150    version: &str,
151    bump: Option<BumpPlan>,
152) -> ReleasePlan {
153    let targets = resolve_targets(contract, facts);
154    let phases = bump_aware_phases(bump.is_some());
155    let plan_id = seal(
156        contract,
157        &targets,
158        head_sha,
159        version,
160        &phases,
161        bump.as_ref(),
162    );
163    ReleasePlan {
164        plan_id,
165        contract_schema_version: contract.schema_version,
166        head_sha: head_sha.to_string(),
167        version: version.to_string(),
168        targets,
169        phases,
170        bump,
171        // Carried from the (already-hashed) contract so the coordinator can hand
172        // the Homebrew adapter its tap + license without re-reading the contract.
173        // The first distribution that declares a tap — identical to the old
174        // single-`Distribution` behavior. The release-engine CLI path
175        // (`ensure_single_distribution`) rejects a multi-distribution monorepo
176        // BEFORE reaching here, so `distributions.len() <= 1` and this `find_map`
177        // never silently drops a second distribution's tap; carrying a per-package
178        // tap for a true multi-tap monorepo is a deliberate follow-up.
179        homebrew_tap: contract
180            .distributions
181            .iter()
182            .find_map(|d| d.homebrew_tap.clone()),
183        license: Some(contract.license.clone()),
184        description: facts.description.clone(),
185        homebrew_platforms: contract
186            .distributions
187            .iter()
188            .flat_map(|d| d.platforms.iter().cloned())
189            .collect(),
190    }
191}
192
193/// Compute the content-addressed `plan_id` of a **`--bump`-less** plan for
194/// `(contract, facts, head_sha, version)` **without** allocating a full
195/// [`ReleasePlan`].
196///
197/// The drift-check seam for the coordinator: given the plan a human approved, it
198/// re-derives the *current* repo's contract + facts + `HEAD`, calls this with
199/// the approved plan's sealed `version`, and compares. Prefer [`verify`], which
200/// wraps this and reports *which* inputs drifted; this raw form is exposed for
201/// callers that only need the digest.
202///
203/// **No-bump only.** This seals the invariant phase sequence with **no** bump plan,
204/// so it computes the id of the *no-bump* plan for these inputs — it is **not** the id
205/// of a `--bump` plan (that comes from [`build_with_bump`]). The bump-aware drift check
206/// lives in the CLI (`cut` re-derives via [`build_with_bump`] and compares `plan_id`
207/// directly); this helper is unchanged by the bump feature and stays no-bump.
208#[must_use]
209pub fn compute_plan_id(
210    contract: &Contract,
211    facts: &Facts,
212    head_sha: &str,
213    version: &str,
214) -> String {
215    let targets = resolve_targets(contract, facts);
216    seal(
217        contract,
218        &targets,
219        head_sha,
220        version,
221        &PlanPhase::SEQUENCE,
222        None,
223    )
224}
225
226/// Check whether an `approved` plan still matches the **current** repo state.
227///
228/// The coordinator calls this before crossing into any irreversible phase of
229/// `release cut --plan <plan_id>`. It re-derives the current `plan_id` from the
230/// current `contract`, `facts`, and `head_sha`, holding the *chosen version*
231/// fixed to the approved plan's (a cut may not change the sealed version — that
232/// would require a new plan). `Ok(())` means the approval is still valid; a
233/// [`PlanDrift`] carries the mismatched id pair and human-readable reasons for
234/// the `plan_stale` error envelope. The `plan_id` mismatch is authoritative;
235/// the reasons are **best-effort and may be non-exhaustive** — the approved
236/// plan intentionally does not retain the old normalized contract (trust the
237/// journal, not a re-supplied contract), so an exact field-level contract diff
238/// is not possible here. When more than one input drifts, the reasons name
239/// every one they can pinpoint (`HEAD`, schema version, target set) and fall
240/// back to a generic contract-changed note only when none of those explain it.
241///
242/// # Errors
243/// Returns [`PlanDrift`] when the recomputed `plan_id` differs from
244/// `approved.plan_id` — i.e. the repo moved (a commit, a manifest rename, a
245/// schema bump, a target-set change, or any normalized-contract change) since
246/// approval.
247pub fn verify(
248    approved: &ReleasePlan,
249    contract: &Contract,
250    facts: &Facts,
251    head_sha: &str,
252) -> Result<(), PlanDrift> {
253    let current_targets = resolve_targets(contract, facts);
254    // Hold the sealed *shape* — the approved plan's phase sequence and bump plan —
255    // fixed while re-deriving targets from the current contract/facts: verify checks
256    // for contract/head/target drift, not a re-computation of the bump itself (a cut
257    // recomputes the bump from `--bump` + the current manifest via `build_with_bump`
258    // and compares `plan_id` directly; verify is the read-only reconcile seam).
259    let current_id = seal(
260        contract,
261        &current_targets,
262        head_sha,
263        &approved.version,
264        &approved.phases,
265        approved.bump.as_ref(),
266    );
267    if current_id == approved.plan_id {
268        return Ok(());
269    }
270
271    // The ids differ; pinpoint *why* so the coordinator can surface an
272    // actionable `plan_stale` message rather than a bare hash mismatch.
273    let mut reasons = Vec::new();
274    if approved.head_sha != head_sha {
275        reasons.push(format!(
276            "HEAD moved from {} to {}",
277            short_sha(&approved.head_sha),
278            short_sha(head_sha)
279        ));
280    }
281    if approved.contract_schema_version != contract.schema_version {
282        reasons.push(format!(
283            "contract schema_version changed from {} to {}",
284            approved.contract_schema_version, contract.schema_version
285        ));
286    }
287    if approved.targets != current_targets {
288        reasons.push(
289            "the resolved target set changed (a target, package, registry, or adapter differs)"
290                .to_string(),
291        );
292    }
293    // A change the specific probes above did not catch (any other normalized
294    // contract field: version scheme, changelog, license, health badges, …).
295    if reasons.is_empty() {
296        reasons.push("the normalized contract changed".to_string());
297    }
298
299    Err(PlanDrift {
300        approved_plan_id: approved.plan_id.clone(),
301        current_plan_id: current_id,
302        reasons,
303    })
304}
305
306/// Why a `release cut --plan <plan_id>` was refused: the current repo no longer
307/// hashes to the approved plan (ADR-0002 §3, `plan_stale`).
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct PlanDrift {
310    /// The `plan_id` the human approved.
311    pub approved_plan_id: String,
312    /// The `plan_id` the current repo state produces.
313    pub current_plan_id: String,
314    /// Human-readable specifics of what drifted (`HEAD` moved, the target set
315    /// changed, …) — at least one entry.
316    pub reasons: Vec<String>,
317}
318
319/// Whether a publish target derives its release version from a package manifest
320/// the version guard can read, or has no manifest version by design — the capability
321/// the fail-closed guard keys on (`version-source-fail-closed-nonrust`).
322///
323/// The distinction is a function of the target's **[`Ecosystem`]**, not its publish
324/// registry. A Rust/Node/Python package carries its version in a manifest
325/// (`Cargo.toml`/`package.json`/`pyproject.toml`) regardless of *where* it is
326/// published — a Rust crate repackaged for a Homebrew tap still reads its version
327/// from `Cargo.toml`, so it is [`Manifest`](VersionSource::Manifest). Keying on the
328/// registry instead would wrongly treat that crate (and a binary-distribution-only
329/// Rust repo) as versionless and refuse to derive a version that is plainly in the
330/// tree.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum VersionSource {
333    /// The ecosystem carries the package version in a manifest
334    /// (`rust`←`Cargo.toml`, `node`←`package.json`, `python`←`pyproject.toml`/`setup.py`).
335    /// A resolved target of this class **must** expose a detected manifest version in
336    /// `facts`; a resolved package with none is a *detector failure* that fails the
337    /// guard **closed** ([`VersionResolveError::MissingManifestVersion`]) rather than
338    /// silently skipping the version check (the fail-OPEN gap for manifest-versioned
339    /// non-Rust ecosystems this model closes).
340    Manifest,
341    /// No manifest version **by design**: the ecosystem's version does not live in a
342    /// tree manifest — a raw `binary` distribution (its version binds to the artifact
343    /// it ships), or a VCS-tag-versioned `go` module (`go.mod` declares no version).
344    /// Legitimately **skipped** by the version guard: there is no manifest to read a
345    /// version from and none is expected.
346    Distribution,
347}
348
349impl VersionSource {
350    /// Classify a target by its [`Ecosystem`] (the ecosystem is the authority on
351    /// whether a package's version lives in a tree manifest).
352    ///
353    /// Exhaustive over [`Ecosystem`] on purpose — a new ecosystem must make a
354    /// deliberate manifest-vs-distribution choice here rather than default to a silent
355    /// skip (which would re-open the fail-OPEN gap).
356    #[must_use]
357    pub fn of(ecosystem: Ecosystem) -> Self {
358        match ecosystem {
359            // Ecosystems whose package version lives in a version-carrying manifest.
360            Ecosystem::Rust | Ecosystem::Node | Ecosystem::Python => Self::Manifest,
361            // No tree-manifest version: a raw binary (versioned by the built artifact),
362            // or a Go module (versioned by its VCS tag).
363            Ecosystem::Go | Ecosystem::Binary => Self::Distribution,
364        }
365    }
366}
367
368/// One publishable target's resolved package paired with the version its **tree
369/// manifest** declares — the version the ecosystem's publish command (`cargo
370/// publish` reading `Cargo.toml`, …) would **actually** upload.
371///
372/// The workspace manifest is the single source of truth for the release version
373/// ([`resolve_release_version`]); this is one row of that truth. A tree whose
374/// manifests disagree among themselves carries a set of these
375/// ([`VersionResolveError::InconsistentTree`]).
376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
377pub struct VersionMismatch {
378    /// The resolved package this row describes.
379    pub package: String,
380    /// The package's ecosystem.
381    pub ecosystem: Ecosystem,
382    /// The version declared in the tree manifest — what the ecosystem's publish
383    /// command (`cargo publish` reading `Cargo.toml`, …) would **actually**
384    /// upload for this package.
385    pub manifest_version: String,
386}
387
388/// A manifest-versioned target ([`VersionSource::Manifest`]) whose resolved package
389/// has **no** detected manifest version in `facts` — the fail-closed row for
390/// `version-source-fail-closed-nonrust`.
391///
392/// Unlike a [`VersionSource::Distribution`] target (skipped by design), a manifest
393/// target with no readable version means the detector failed on an ecosystem that
394/// *is* manifest-versioned. The guard refuses rather than publish an unchecked
395/// version.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
397pub struct UnversionedTarget {
398    /// The resolved package whose manifest version could not be read.
399    pub package: String,
400    /// The package's ecosystem.
401    pub ecosystem: Ecosystem,
402    /// The publish destination — the registry whose manifest a version was expected
403    /// from (`npm`←`package.json`, `PyPI`←`pyproject.toml`, …).
404    pub registry: Registry,
405}
406
407/// Why a single release version could not be resolved from the workspace manifest —
408/// the **single source of truth** for the release version. `shipshape release cut`
409/// publishes the version already in the tree; there is no `--version` input to
410/// override it (`release-drop-version-flag`).
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub enum VersionResolveError {
413    /// One or more **manifest-versioned** targets ([`VersionSource::Manifest`]) have a
414    /// resolved package but **no** detected manifest version — the detector returned
415    /// nothing for an ecosystem that *is* manifest-versioned (npm/PyPI/…). Failing
416    /// **closed** here (rather than silently skipping the target) is the fix for
417    /// `version-source-fail-closed-nonrust`: a distribution target is skipped by
418    /// design, but a manifest target with no readable version is a bug that must not
419    /// publish an unchecked version. Carries each such target (sorted, one per
420    /// package).
421    MissingManifestVersion {
422        /// Every manifest-versioned target whose version could not be read.
423        targets: Vec<UnversionedTarget>,
424    },
425    /// The tree's publishable manifests declare **more than one distinct version**,
426    /// so there is no single source of truth to derive the release version from —
427    /// bring the workspace into lockstep first. Carries each checkable target's
428    /// package + version (sorted, one per package).
429    InconsistentTree {
430        /// Every checkable target and the version its manifest declares.
431        versions: Vec<VersionMismatch>,
432    },
433    /// No manifest version could be detected — every target is a distribution target
434    /// with no manifest version by design (or has no resolved package) — so there is
435    /// no manifest to derive the release version from. With the `--version` input
436    /// removed, the release version can **only** come from a manifest; a repo with no
437    /// version-carrying manifest cannot be cut until one declares a version.
438    Undeterminable,
439}
440
441/// Resolve the release version from the workspace manifest — the **single source of
442/// truth**.
443///
444/// `shipshape release cut` does **not** bump the manifest: each ecosystem's publish
445/// command uploads the version already in the tree (`cargo publish` reads
446/// `Cargo.toml`), and the engine threads that version into every registry probe,
447/// index-wait, and receipt. So the version a cut publishes is a **projection of the
448/// tree**, not an independent input — there is no `--version` flag to override it
449/// (`release-drop-version-flag`), which removes the two-masters footgun at the root
450/// (a flag and the manifest could silently drift, the engine publishing the manifest
451/// version while waiting for/recording the flag's, which never lands —
452/// `release-cut-publish-noop`).
453///
454/// The manifest version is the distinct version shared by every **checkable** target
455/// (a [`VersionSource::Manifest`] target with a detected manifest version in
456/// `facts`). A [`VersionSource::Distribution`] target (a homebrew/binary/cargo-dist
457/// target) has no manifest version by design — its release version is bound to the
458/// crate it repackages — so it is skipped. A manifest-versioned target whose version
459/// the detector could not read is **not** skipped: it fails the guard closed
460/// (`version-source-fail-closed-nonrust`).
461///
462/// # Errors
463/// - [`VersionResolveError::MissingManifestVersion`] — a manifest-versioned target
464///   has a resolved package but no readable manifest version (fail closed).
465/// - [`VersionResolveError::InconsistentTree`] — the checkable targets declare more
466///   than one distinct version, so no single source of truth exists.
467/// - [`VersionResolveError::Undeterminable`] — no manifest version anywhere to derive
468///   from.
469pub fn resolve_release_version(
470    contract: &Contract,
471    facts: &Facts,
472) -> Result<String, VersionResolveError> {
473    let classified = classify_target_versions(contract, facts);
474
475    // Fail CLOSED first: a manifest-versioned target whose version the detector could
476    // not read is NOT silently skipped (that would fail OPEN — publishing a version no
477    // guard confirmed). This is the `version-source-fail-closed-nonrust` fix.
478    if !classified.missing.is_empty() {
479        return Err(VersionResolveError::MissingManifestVersion {
480            targets: classified.missing,
481        });
482    }
483
484    let distinct: BTreeSet<&str> = classified
485        .checkable
486        .iter()
487        .map(|m| m.manifest_version.as_str())
488        .collect();
489
490    match distinct.len() {
491        // No manifest version anywhere to derive from (every target is a distribution
492        // target, or has no resolved package). With `--version` removed there is no
493        // fallback — a repo without a version-carrying manifest cannot be cut.
494        0 => Err(VersionResolveError::Undeterminable),
495        // One source of truth: every checkable row shares it, so any row's version is
496        // THE manifest version.
497        1 => Ok(classified.checkable[0].manifest_version.clone()),
498        // The tree disagrees with itself — no single source of truth to project.
499        _ => Err(VersionResolveError::InconsistentTree {
500            versions: classified.checkable,
501        }),
502    }
503}
504
505/// The version-source classification of a repo's resolved targets: the checkable
506/// rows the release version is projected from, and the manifest-versioned targets
507/// whose version could not be read (the fail-closed set).
508struct ClassifiedVersions {
509    /// [`VersionSource::Manifest`] targets **with** a detected manifest version — the
510    /// checkable set the single release version is derived from.
511    checkable: Vec<VersionMismatch>,
512    /// [`VersionSource::Manifest`] targets with a resolved package but **no** detected
513    /// manifest version — the fail-closed set (`version-source-fail-closed-nonrust`).
514    missing: Vec<UnversionedTarget>,
515}
516
517/// Classify every resolved target by its [`VersionSource`], separating the checkable
518/// manifest versions from the manifest-versioned targets whose version could not be
519/// read.
520///
521/// - A [`VersionSource::Distribution`] target (a `binary`/`go` ecosystem) is skipped
522///   regardless of version: it has no tree-manifest version by design.
523/// - A [`VersionSource::Manifest`] target with a detected version becomes a `checkable`
524///   row; one with a resolved package but **no** detected version becomes a `missing`
525///   row (fail closed).
526/// - A manifest target with **no resolved package** cannot be looked up here at all.
527///   Package resolution is a separate concern guarded elsewhere — `release plan` warns
528///   and `release cut` refuses via `coordinator::validate_plan` — so it is not
529///   double-reported here as a version failure. (Deeper: hardening the resolver itself
530///   to fail closed on an unresolved manifest target is tracked as a follow-up.)
531fn classify_target_versions(contract: &Contract, facts: &Facts) -> ClassifiedVersions {
532    let mut checkable: Vec<VersionMismatch> = Vec::new();
533    let mut missing: Vec<UnversionedTarget> = Vec::new();
534    // Publish-none: the contract declares NO publish target (an authored `targets: []`),
535    // so there is no target to project a version through — yet such a repo is still
536    // version-tracked and tagged (that is what its tag-only cut produces). Project the
537    // version from the tree's own manifests instead, for the ecosystems the contract
538    // declares. The rule is unchanged, only its input: one distinct manifest version is
539    // the release version, several are an `InconsistentTree`, none is `Undeterminable`.
540    // A package with no readable version is skipped rather than failing closed — the
541    // fail-closed set exists to stop an *unchecked publish*, and here nothing is ever
542    // published; a repo with no version anywhere still lands on `Undeterminable`.
543    if contract.targets.is_empty() {
544        // SCOPE, in order of authority: a ROOT manifest (`Cargo.toml`,
545        // `package.json` — the repo's own package) outranks the workspace members
546        // below it. Without that preference a normal private workspace — one service
547        // crate at 0.4.0 plus a support crate at 0.1.0 — could never be tagged at all,
548        // and a mixed rust+node repo would compare `Cargo.toml` against `package.json`
549        // and refuse forever. With it, the members only speak when no root package
550        // does (a virtual workspace), where lockstep IS the expectation.
551        let candidates: Vec<&crate::protocol::facts::Package> = facts
552            .packages
553            .iter()
554            .filter(|p| {
555                contract.ecosystems.contains(&p.ecosystem)
556                    && VersionSource::of(p.ecosystem) == VersionSource::Manifest
557                    && p.package.is_some()
558                    && p.version.is_some()
559            })
560            .collect();
561        let roots: Vec<&crate::protocol::facts::Package> = candidates
562            .iter()
563            .copied()
564            .filter(|p| !p.manifest.contains('/'))
565            .collect();
566        let scoped = if roots.is_empty() {
567            &candidates
568        } else {
569            &roots
570        };
571        for package in scoped {
572            if let (Some(name), Some(version)) = (&package.package, &package.version) {
573                checkable.push(VersionMismatch {
574                    package: name.clone(),
575                    ecosystem: package.ecosystem,
576                    manifest_version: version.clone(),
577                });
578            }
579        }
580        checkable.sort_by(|a, b| {
581            (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
582        });
583        checkable.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
584        return ClassifiedVersions { checkable, missing };
585    }
586    for t in resolve_targets(contract, facts) {
587        // Distribution ecosystems have no tree-manifest version by design — skip them
588        // whether or not `facts` happens to carry a version for their package.
589        if VersionSource::of(t.ecosystem) == VersionSource::Distribution {
590            continue;
591        }
592        // A manifest target with no resolved package cannot be version-checked here
593        // (see the null-package guards named above).
594        let Some(package) = t.package else { continue };
595        match facts
596            .packages
597            .iter()
598            .find(|p| p.ecosystem == t.ecosystem && p.package.as_deref() == Some(package.as_str()))
599            .and_then(|p| p.version.clone())
600        {
601            Some(manifest_version) => checkable.push(VersionMismatch {
602                package,
603                ecosystem: t.ecosystem,
604                manifest_version,
605            }),
606            // Manifest-versioned, resolved package, but the detector read no version:
607            // fail closed rather than skip (the non-Rust fail-OPEN gap).
608            None => missing.push(UnversionedTarget {
609                package,
610                ecosystem: t.ecosystem,
611                registry: t.registry,
612            }),
613        }
614    }
615    // Deterministic order, and one row per package even if a package backs several
616    // targets (a crate published to crates.io AND repackaged for homebrew). Sort and
617    // dedup on the SAME (ecosystem, package) key so equal keys are guaranteed adjacent
618    // before the consecutive-only `dedup_by` runs.
619    checkable.sort_by(|a, b| {
620        (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
621    });
622    checkable.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
623    missing.sort_by(|a, b| {
624        (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
625    });
626    missing.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
627    ClassifiedVersions { checkable, missing }
628}
629
630/// Overlay facts-derived package names onto the contract's target set, yielding
631/// the concrete targets a cut would execute, then **expand a multi-crate Rust
632/// workspace** into its full dependency-ordered publish set.
633///
634/// Base resolution is 1:1 with the contract's (normalizer-canonical) `targets`,
635/// resolving a `null` package from facts. Then [`expand_rust_workspace_members`]
636/// derives the complete crates.io publish set for a Cargo workspace from
637/// [`Facts::rust_workspace`]: a downstream repo that declares only its bin crate
638/// still gets its lib crate planned, lib-before-bin, so a cut never `cargo publish`es
639/// a crate whose `=`-pinned workspace sibling is not yet on the index
640/// (`release-rust-workspace-multicrate`). A repo that already declares every member
641/// (shipshape itself) is unchanged: the derived set equals what it declared.
642/// The coordinator phase sequence for a plan, prepending [`PlanPhase::Bump`] when
643/// the plan owns a version bump. A `--bump`-less plan yields exactly
644/// [`PlanPhase::SEQUENCE`], so its sealed `phases` (and `plan_id`) are unchanged.
645fn bump_aware_phases(has_bump: bool) -> Vec<PlanPhase> {
646    if has_bump {
647        let mut phases = Vec::with_capacity(PlanPhase::SEQUENCE.len() + 1);
648        phases.push(PlanPhase::Bump);
649        phases.extend_from_slice(&PlanPhase::SEQUENCE);
650        phases
651    } else {
652        PlanPhase::SEQUENCE.to_vec()
653    }
654}
655
656/// Assemble the [`BumpPlan`] — the deterministic edit set the bump phase applies —
657/// from the contract + workspace facts and the caller-computed `from`/`to` versions.
658fn derive_bump_plan(
659    contract: &Contract,
660    facts: &Facts,
661    head_sha: &str,
662    level: BumpLevel,
663    from_version: &str,
664    to_version: &str,
665) -> Result<BumpPlan, crate::release::bump::BumpError> {
666    Ok(BumpPlan {
667        level,
668        from_version: from_version.to_string(),
669        to_version: to_version.to_string(),
670        pin_rewrites: derive_pin_rewrites(facts, from_version, to_version)?,
671        changelog_finalize: changelog_is_finalizable(contract),
672        changelog: changelog_is_finalizable(contract).then(|| ChangelogFinalizePlan {
673            mode: contract.changelog.mode,
674            source: contract.changelog.source,
675            fragment_dir: contract.changelog.fragment_dir.clone(),
676            issuectl_range: issuectl_range(contract, facts, head_sha, from_version),
677        }),
678        // Copied from the (already-hashed) contract so the executor need not re-read it;
679        // being a copy of a hashed value it adds no new content to the address beyond
680        // its presence on the bump plan.
681        bump_hook: contract.release.bump_hook.clone(),
682    })
683}
684
685/// Whether the bump phase finalizes the CHANGELOG (`[Unreleased]` → a dated
686/// `[to_version]` section).
687///
688/// True for the human/fragment-authored modes (`curated`, `fragment`) whose
689/// `[Unreleased]` section the engine promotes on release. False for `automated`,
690/// where a release bot (release-please/changesets) owns the CHANGELOG and the engine
691/// must not also rewrite it (a double-writer would clash). The concrete date is a
692/// cut-time value and is deliberately not part of the plan (see [`BumpPlan::changelog_finalize`]).
693///
694/// An **exhaustive** match (not `!= Automated`) so a future `ChangelogMode` variant —
695/// e.g. a "none"/"off" that means *no* changelog to finalize — must make a deliberate
696/// choice here rather than silently defaulting to engine-finalized (which would seal a
697/// bump plan that promotes a changelog that does not exist).
698fn issuectl_range(
699    contract: &Contract,
700    facts: &Facts,
701    head_sha: &str,
702    from_version: &str,
703) -> Option<String> {
704    use crate::contract::schema::ChangelogSource;
705
706    if contract.changelog.source != ChangelogSource::IssuectlTrailers {
707        return None;
708    }
709    let expected = format!("v{from_version}");
710    Some(if facts.tags.iter().any(|tag| tag == &expected) {
711        format!("{expected}..{head_sha}")
712    } else {
713        // The coordinator always creates `v<version>` tags. Its absence identifies
714        // the first engine release for this manifest line, where the bundled skill
715        // deliberately compiles the reachable history.
716        head_sha.to_string()
717    })
718}
719
720fn changelog_is_finalizable(contract: &Contract) -> bool {
721    match contract.changelog.mode {
722        ChangelogMode::Curated | ChangelogMode::Fragment => true,
723        ChangelogMode::Automated => false,
724    }
725}
726
727/// Derive the intra-workspace `=`-version pin rewrites the bump applies in lockstep
728/// with the workspace version.
729///
730/// For each publishable workspace member, exact internal pins may live either in its
731/// own dependency tables or once in root `[workspace.dependencies]` and be inherited
732/// with `workspace = true`. Both locations are sealed and rewritten from
733/// `=<from_version>` to `=<to_version>`. Entries are emitted deterministically; the
734/// set is empty for a single-crate workspace or a repo with no detected workspace graph.
735///
736/// **Precise, not over-broad** (`release-rust-workspace-multicrate` facet 3, llm-review):
737/// a rewrite is emitted **only** when the member's manifest declares that edge's
738/// requirement literally as `=<from_version>` — the exact lockstep pin — across every
739/// dependency table, read from
740/// [`WorkspaceMember::pin_reqs`](crate::protocol::facts::WorkspaceMember). Equivalent
741/// repeated declarations form one deterministic rewrite set; a mix of exact and
742/// different/path-only requirements is refused here before sealing. A caret/range/
743/// `workspace = true`/independently-versioned edge with no exact lockstep declaration
744/// is skipped, while a different exact requirement is refused before sealing. The
745/// executor applies the same
746/// equivalence rule to the sealed manifest text before replacing every match.
747fn exact_pin_count(
748    owner: &str,
749    dependency: &str,
750    requirements: &[Option<String>],
751    from_pin: &str,
752    from_version: &str,
753) -> Result<Option<usize>, crate::release::bump::BumpError> {
754    let explicit = requirements.iter().filter(|req| req.is_some()).count();
755    let matching = requirements
756        .iter()
757        .filter(|req| req.as_deref() == Some(from_pin))
758        .count();
759    if matching == 0 {
760        if requirements
761            .iter()
762            .flatten()
763            .any(|requirement| requirement.trim_start().starts_with('='))
764        {
765            return Err(crate::release::bump::BumpError {
766                version: from_version.to_string(),
767                reason: format!(
768                    "{owner} declares exact internal pin `{dependency}` at a version other than `{from_pin}` — refusing to leave it outside the sealed edit set"
769                ),
770            });
771        }
772        return Ok(None);
773    }
774    if matching != explicit {
775        return Err(crate::release::bump::BumpError {
776            version: from_version.to_string(),
777            reason: format!(
778                "{owner} declares `{dependency}` with explicit requirements that differ from `{from_pin}` — refusing to seal an ambiguous pin rewrite"
779            ),
780        });
781    }
782    Ok(Some(matching))
783}
784
785fn derive_pin_rewrites(
786    facts: &Facts,
787    from_version: &str,
788    to_version: &str,
789) -> Result<Vec<PinRewrite>, crate::release::bump::BumpError> {
790    let Some(workspace) = facts.rust_workspace.as_ref() else {
791        return Ok(Vec::new());
792    };
793    if let Some(reason) = &workspace.pin_parse_error {
794        return Err(crate::release::bump::BumpError {
795            version: from_version.to_string(),
796            reason: format!(
797                "cannot seal exact Cargo pin edits because a workspace manifest could not be parsed: {reason}"
798            ),
799        });
800    }
801    let is_member: BTreeSet<&str> = workspace
802        .members
803        .iter()
804        .map(|m| m.package.as_str())
805        .collect();
806    let from_pin = format!("={from_version}");
807    let mut rewrites: Vec<PinRewrite> = Vec::new();
808    for member in &workspace.members {
809        for (dep, requirements) in &member.pin_reqs {
810            // Only edges to another publishable member carry an intra-workspace pin.
811            if !is_member.contains(dep.as_str()) {
812                continue;
813            }
814            // Pin discovery preserves every declaration across normal, dev, build,
815            // and target-specific tables. Rewrite one sealed dependency set only when
816            // every declaration is provably the same exact lockstep pin. This is the
817            // same equivalence rule the cut-time rewriter enforces.
818            let owner = format!("crate `{}`", member.package);
819            if exact_pin_count(&owner, dep, requirements, &from_pin, from_version)?.is_none() {
820                continue;
821            }
822            rewrites.push(PinRewrite {
823                in_package: member.package.clone(),
824                workspace_root: false,
825                dependency: dep.clone(),
826                from: from_pin.clone(),
827                to: format!("={to_version}"),
828            });
829        }
830    }
831    for (dep, requirements) in &workspace.workspace_pin_reqs {
832        if !is_member.contains(dep.as_str()) {
833            continue;
834        }
835        if exact_pin_count(
836            "root `[workspace.dependencies]`",
837            dep,
838            requirements,
839            &from_pin,
840            from_version,
841        )?
842        .is_none()
843        {
844            continue;
845        }
846        rewrites.push(PinRewrite {
847            in_package: "workspace".to_string(),
848            workspace_root: true,
849            dependency: dep.clone(),
850            from: from_pin.clone(),
851            to: format!("={to_version}"),
852        });
853    }
854    rewrites.sort_unstable_by(|a, b| {
855        (a.workspace_root, &a.in_package, &a.dependency).cmp(&(
856            b.workspace_root,
857            &b.in_package,
858            &b.dependency,
859        ))
860    });
861    // Defensive dedup: a well-formed facts graph lists each (member, dep) edge once, so
862    // this is a no-op in practice; it guards against a facts parser that emitted a
863    // duplicate edge producing a duplicated rewrite.
864    rewrites.dedup_by(|a, b| {
865        a.workspace_root == b.workspace_root
866            && a.in_package == b.in_package
867            && a.dependency == b.dependency
868    });
869    Ok(rewrites)
870}
871
872/// One engine-published crate whose release is blocked by a CI-delegated workspace
873/// dependency — the phase-ordering conflict [`delegated_dependency_conflicts`] finds.
874#[derive(Debug, Clone, PartialEq, Eq)]
875pub struct DelegatedDependencyConflict {
876    /// The crate the ENGINE would publish in publish-all.
877    pub engine_package: String,
878    /// The workspace crate it depends on, whose publish is CI-delegated and therefore
879    /// cannot happen until the tag — which is pushed after publish-all.
880    pub delegated_package: String,
881}
882
883/// Find engine-published crates.io targets that depend on a **CI-delegated** crate in
884/// the same workspace — a plan that can never complete, detected before it is cut.
885///
886/// The barrier order is `publish-all → tag`, and a `cargo-publish-ci` crate is
887/// published by the workflow the **tag push** triggers. So if an engine-published
888/// crate depends on a delegated one, publish-all reaches the dependent, the cargo
889/// adapter waits for the dependency to become index-visible (it cannot be — its tag
890/// has not been pushed), and the cut fails on a timeout with no ordering that could
891/// ever satisfy it. Retrying does not help; only editing the contract does.
892///
893/// The reverse edge is fine and deliberately allowed: the engine publishes the
894/// dependency in publish-all, then the tag triggers CI to publish the dependent.
895///
896/// Read-only and derived — it never mutates the plan and is not part of the sealed
897/// pre-image. Returns an empty vec when the repo is not a multi-crate workspace, when
898/// the closure touches no delegated crate, or when a target's package is unresolved
899/// (an ambiguous plan is refused by its own guard, and guessing here could invent a
900/// conflict that does not exist).
901#[must_use]
902pub fn delegated_dependency_conflicts(
903    plan: &ReleasePlan,
904    facts: &Facts,
905) -> Vec<DelegatedDependencyConflict> {
906    let Some(workspace) = facts.rust_workspace.as_ref() else {
907        return Vec::new();
908    };
909    let delegated: BTreeSet<&str> = plan
910        .targets
911        .iter()
912        .filter(|t| t.adapter == crate::contract::schema::Adapter::CargoPublishCi)
913        .filter_map(|t| t.package.as_deref())
914        .collect();
915    if delegated.is_empty() {
916        return Vec::new();
917    }
918    let deps: std::collections::BTreeMap<&str, &[String]> = workspace
919        .members
920        .iter()
921        .map(|m| (m.package.as_str(), m.workspace_deps.as_slice()))
922        .collect();
923
924    let mut conflicts = Vec::new();
925    for engine in plan.targets.iter().filter(|t| is_rust_crates_io_publish(t)) {
926        let Some(root) = engine.package.as_deref() else {
927            continue;
928        };
929        // Transitive closure over intra-workspace edges. The graph is small (workspace
930        // members) and `seen` makes a cyclic/diamond graph terminate.
931        let mut seen: BTreeSet<&str> = BTreeSet::new();
932        let mut stack: Vec<&str> = deps.get(root).map(|d| collect(d)).unwrap_or_default();
933        while let Some(pkg) = stack.pop() {
934            if !seen.insert(pkg) {
935                continue;
936            }
937            if delegated.contains(pkg) {
938                conflicts.push(DelegatedDependencyConflict {
939                    engine_package: root.to_string(),
940                    delegated_package: pkg.to_string(),
941                });
942            }
943            if let Some(next) = deps.get(pkg) {
944                stack.extend(collect(next));
945            }
946        }
947    }
948    conflicts.sort_by(|a, b| {
949        (&a.engine_package, &a.delegated_package).cmp(&(&b.engine_package, &b.delegated_package))
950    });
951    conflicts.dedup();
952    conflicts
953}
954
955/// Borrow a member's dependency names as `&str`s for the closure walk.
956fn collect(deps: &[String]) -> Vec<&str> {
957    deps.iter().map(String::as_str).collect()
958}
959
960/// Render [`delegated_dependency_conflicts`] as operator-facing messages.
961#[must_use]
962pub fn delegated_dependency_messages(conflicts: &[DelegatedDependencyConflict]) -> Vec<String> {
963    conflicts
964        .iter()
965        .map(|c| {
966            format!(
967                "target '{}' is published by the engine but depends on workspace crate '{}', whose                  publish is CI-delegated (adapter 'cargo-publish-ci'). The engine publishes in                  publish-all, BEFORE the tag push that triggers CI — so '{}' could never be on the                  index in time and the cut would fail waiting for it. Declare '{}' as                  'cargo-publish-ci' too (let CI publish both, in its own order), or publish '{}'                  with the engine ('cargo-publish')",
968                c.engine_package,
969                c.delegated_package,
970                c.delegated_package,
971                c.engine_package,
972                c.delegated_package
973            )
974        })
975        .collect()
976}
977
978fn resolve_targets(contract: &Contract, facts: &Facts) -> Vec<PlanTarget> {
979    let base: Vec<PlanTarget> = contract
980        .targets
981        .iter()
982        .map(|t| {
983            let package = t
984                .package
985                .clone()
986                .or_else(|| resolve_package(facts, t.ecosystem));
987            PlanTarget {
988                ecosystem: t.ecosystem,
989                package,
990                registry: t.registry,
991                adapter: t.adapter,
992            }
993        })
994        .collect();
995    expand_rust_workspace_members(base, facts)
996}
997
998/// Whether a resolved target is a Rust crate published to crates.io via
999/// `cargo-publish` — the target class the workspace-member derivation expands (a
1000/// `cargo-dist` binary distribution or a non-crates.io registry is left untouched).
1001fn is_rust_crates_io_publish(t: &PlanTarget) -> bool {
1002    t.ecosystem == Ecosystem::Rust
1003        && t.registry == Registry::CratesIo
1004        && t.adapter == crate::contract::schema::Adapter::CargoPublish
1005}
1006
1007/// Expand the crates.io `cargo-publish` Rust targets of `base` into the
1008/// **dependency-ordered closure** of the declared crates (lib before bin), leaving
1009/// every other target in place.
1010///
1011/// The gap this closes (`release-rust-workspace-multicrate`): a two-crate workspace
1012/// (a lib + a bin pinning `lib = "=X"`) whose contract declares **only** the bin as a
1013/// target would plan a single `cargo publish <bin>` — which fails, because `lib@X` is
1014/// not yet on crates.io. From [`Facts::rust_workspace`] this derives the bin's
1015/// intra-workspace dependency closure and adds each dep as its own ordered target so
1016/// the coordinator publishes lib → bin (ADR-0004, one target = one publish unit; the
1017/// coordinator walks plan order and the adapter index-waits on each crate's own deps).
1018///
1019/// **Closure, not "every member".** The publish set is the declared Rust crates.io
1020/// targets plus their transitive intra-workspace dependencies — **never** an unrelated
1021/// publishable member the contract deliberately omitted (a not-yet-release-ready
1022/// crate). Publishing is irreversible, so "all publishable members" would be the wrong,
1023/// dangerous safety property. It is still a **strict superset of what the contract
1024/// declared**: every declared Rust crates.io package is a closure root (a package not
1025/// present as a workspace member is planned as-is, never dropped). For a repo that
1026/// already declares every member (shipshape itself) the closure equals the declared set,
1027/// so its plan is unchanged.
1028///
1029/// **Ambiguity is preserved, never expanded.** If any Rust crates.io target is
1030/// unresolved (`package: None` — a monorepo the facts could not disambiguate), `base`
1031/// is returned untouched so the downstream null-package guard/warning fires; an
1032/// unnamed target must never be silently turned into a workspace-wide publish.
1033///
1034/// The derived targets are spliced in at the position of the **first** Rust crates.io
1035/// target; the contract's other targets (cargo-dist, homebrew, a non-crates.io
1036/// registry) keep their relative order. Cross-ecosystem/registry order is immaterial
1037/// to correctness (publishes are independent per registry and the single tag is taken
1038/// after *all* publishes), so hoisting the crates.io block changes no behavior. When
1039/// there is no Rust crates.io target, or the repo is not a multi-crate workspace
1040/// ([`Facts::rust_workspace`] is `None`), `base` is returned unchanged — so a
1041/// single-crate repo and every non-Rust plan are untouched.
1042fn expand_rust_workspace_members(base: Vec<PlanTarget>, facts: &Facts) -> Vec<PlanTarget> {
1043    let Some(workspace) = facts.rust_workspace.as_ref() else {
1044        return base;
1045    };
1046    let first_rust = base.iter().position(is_rust_crates_io_publish);
1047    let Some(first_rust_idx) = first_rust else {
1048        return base;
1049    };
1050    // Never expand an ambiguous (unresolved-package) Rust crates.io target into a
1051    // workspace-wide publish: leave the plan untouched so the downstream null-package
1052    // guard refuses it. (`is_rust_crates_io_publish` targets only.)
1053    if base
1054        .iter()
1055        .filter(|t| is_rust_crates_io_publish(t))
1056        .any(|t| t.package.is_none())
1057    {
1058        return base;
1059    }
1060    // The declared crates.io Rust packages — the closure roots (all resolved by the
1061    // guard above).
1062    let roots: Vec<String> = base
1063        .iter()
1064        .filter(|t| is_rust_crates_io_publish(t))
1065        .filter_map(|t| t.package.clone())
1066        .collect();
1067    // The (uniform) registry+adapter every derived member target carries — taken from
1068    // the representative target so the derived crates match how the contract publishes
1069    // Rust (crates.io / cargo-publish, by construction of `is_rust_crates_io_publish`).
1070    let representative = base[first_rust_idx].clone();
1071
1072    let ordered_packages = dependency_closure_order(&roots, &workspace.members);
1073    let derived: Vec<PlanTarget> = ordered_packages
1074        .into_iter()
1075        .map(|package| PlanTarget {
1076            ecosystem: Ecosystem::Rust,
1077            package: Some(package),
1078            registry: representative.registry,
1079            adapter: representative.adapter,
1080        })
1081        .collect();
1082
1083    // Splice: derived member set at the first Rust crates.io position; all other
1084    // (non-Rust-crates.io) targets keep their relative order around it.
1085    let mut out: Vec<PlanTarget> = Vec::with_capacity(base.len() + derived.len());
1086    let mut spliced = false;
1087    for t in base {
1088        if is_rust_crates_io_publish(&t) {
1089            if !spliced {
1090                out.extend(derived.iter().cloned());
1091                spliced = true;
1092            }
1093            // Drop the original Rust crates.io target — it is represented in `derived`.
1094            continue;
1095        }
1096        out.push(t);
1097    }
1098    out
1099}
1100
1101/// The dependency-ordered publish set for `roots`: the transitive intra-workspace
1102/// dependency closure of the declared crates, topologically ordered (a dependency
1103/// before its dependents).
1104///
1105/// The closure follows [`WorkspaceMember::workspace_deps`](crate::protocol::facts::WorkspaceMember)
1106/// edges from each root. A root that is **not** a workspace member (an explicitly
1107/// declared package the graph did not capture) contributes no edges but is still
1108/// included — the superset guarantee. Only members in the closure are ordered; an
1109/// unrelated publishable member the contract omitted never enters the set.
1110fn dependency_closure_order(
1111    roots: &[String],
1112    members: &[crate::protocol::facts::WorkspaceMember],
1113) -> Vec<String> {
1114    use std::collections::BTreeMap;
1115    let by_name: BTreeMap<&str, &crate::protocol::facts::WorkspaceMember> =
1116        members.iter().map(|m| (m.package.as_str(), m)).collect();
1117
1118    // Transitive closure of `roots` over workspace_deps edges.
1119    let mut required: BTreeSet<String> = BTreeSet::new();
1120    let mut stack: Vec<String> = roots.to_vec();
1121    while let Some(pkg) = stack.pop() {
1122        if !required.insert(pkg.clone()) {
1123            continue;
1124        }
1125        if let Some(member) = by_name.get(pkg.as_str()) {
1126            for dep in &member.workspace_deps {
1127                if !required.contains(dep) {
1128                    stack.push(dep.clone());
1129                }
1130            }
1131        }
1132    }
1133
1134    // Topologically order only the members inside the closure (declaration order
1135    // preserved as the deterministic tie-break); append any root that is not a graph
1136    // member (no edges to order, superset guarantee) in declared order.
1137    let subgraph: Vec<crate::protocol::facts::WorkspaceMember> = members
1138        .iter()
1139        .filter(|m| required.contains(&m.package))
1140        .cloned()
1141        .collect();
1142    let mut ordered = topo_order_members(&subgraph);
1143    for root in roots {
1144        if !ordered.iter().any(|p| p == root) {
1145            ordered.push(root.clone());
1146        }
1147    }
1148    ordered
1149}
1150
1151/// Topologically order a workspace's publishable members so a dependency precedes
1152/// its dependents (lib before bin) — the publish order the coordinator walks.
1153///
1154/// Kahn's algorithm with a **deterministic** tie-break: among members whose
1155/// intra-workspace dependencies are all already emitted, the one earliest in
1156/// declaration order is chosen next, so the output is stable and reproducible (a
1157/// requirement of the content-addressed plan). Only edges to *other listed members*
1158/// gate order (an edge to a filtered-out member cannot, and does not, block).
1159///
1160/// Emission is tracked **by index**, not by package name, so two members that happen
1161/// to share a name (Cargo forbids this, but the graph is parsed from raw manifests)
1162/// are both emitted rather than one masking the other. A dependency **cycle** (which
1163/// Cargo itself rejects among normal/build deps, so unreachable for a valid
1164/// workspace) cannot be ordered; the remaining members are appended in declaration
1165/// order rather than dropped or looped on — the plan stays a faithful superset and the
1166/// cut fails later with a concrete registry error, never a planner-omitted crate.
1167fn topo_order_members(members: &[crate::protocol::facts::WorkspaceMember]) -> Vec<String> {
1168    let names: BTreeSet<&str> = members.iter().map(|m| m.package.as_str()).collect();
1169    // Remaining dependency count per member, counting only edges to other members.
1170    let mut pending: Vec<usize> = members
1171        .iter()
1172        .map(|m| {
1173            m.workspace_deps
1174                .iter()
1175                .filter(|d| names.contains(d.as_str()) && d.as_str() != m.package)
1176                .count()
1177        })
1178        .collect();
1179    // Emitted state per member INDEX (never by name — see the doc comment).
1180    let mut emitted: Vec<bool> = vec![false; members.len()];
1181    let mut order: Vec<String> = Vec::with_capacity(members.len());
1182    // Each round emits the earliest-declared member whose deps are all emitted.
1183    while order.len() < members.len() {
1184        let next = (0..members.len()).find(|&i| !emitted[i] && pending[i] == 0);
1185        let Some(idx) = next else {
1186            // A cycle blocks every remaining member: append them in declaration order
1187            // (deterministic) rather than loop forever or drop them.
1188            for i in 0..members.len() {
1189                if !emitted[i] {
1190                    emitted[i] = true;
1191                    order.push(members[i].package.clone());
1192                }
1193            }
1194            break;
1195        };
1196        emitted[idx] = true;
1197        order.push(members[idx].package.clone());
1198        // Decrement dependents that depended on the just-emitted member.
1199        for i in 0..members.len() {
1200            if !emitted[i]
1201                && pending[i] > 0
1202                && members[i]
1203                    .workspace_deps
1204                    .iter()
1205                    .any(|d| *d == members[idx].package)
1206            {
1207                pending[i] -= 1;
1208            }
1209        }
1210    }
1211    order
1212}
1213
1214/// The detected package name for `ecosystem`, resolved **only when
1215/// unambiguous** — exactly one named manifest for that ecosystem.
1216///
1217/// `None` when no manifest named one (a virtual workspace, a binary-only repo)
1218/// **or** when several do (a monorepo with multiple crates of one ecosystem):
1219/// with no per-target manifest key in the contract, picking the first would
1220/// silently mis-assign the same package to every `null` target, so we leave it
1221/// `null` for cut-time inference instead. A monorepo should declare explicit
1222/// per-target `package`s in the contract; the CLI warns when this fires.
1223fn resolve_package(facts: &Facts, ecosystem: crate::contract::schema::Ecosystem) -> Option<String> {
1224    let mut named = facts
1225        .packages
1226        .iter()
1227        .filter(|p| p.ecosystem == ecosystem && p.package.is_some());
1228    let first = named.next()?;
1229    // More than one named candidate ⇒ ambiguous ⇒ do not guess.
1230    if named.next().is_some() {
1231        return None;
1232    }
1233    first.package.clone()
1234}
1235
1236/// Domain separator baked into every pre-image so a `plan_id` can never be
1237/// confused with any other SHA-256 a Shipshape subsystem might compute over
1238/// similar bytes. Ends in the seal-format version for readability; the numeric
1239/// [`SEAL_VERSION`] is also hashed as its own field.
1240// COMPATIBILITY (ADR-0005 §3): changing this invalidates every stored approval.
1241const SEAL_DOMAIN: &str = "ossctl.release-plan";
1242
1243/// Version of the sealed approval interpretation: the hashing pre-image's field set,
1244/// order, canonicalization, **and execution semantics**. Independent of contract or
1245/// wire-envelope versions. Bump this (never silently) whenever the shape changes or an
1246/// unchanged sealed field gains a different effect, so approvals made under distinct
1247/// interpretations always occupy disjoint plan-id spaces.
1248// v10 seals the final advance-branch barrier. Older plan documents remain readable
1249// for resume; a fresh plan binds the guarantee that a verified release commit is
1250// fast-forwarded onto the remote default branch before the run completes.
1251const SEAL_VERSION: u32 = 10;
1252
1253/// The canonical hashed pre-image (see the module docs for the exact contents).
1254/// A dedicated struct rather than an ad-hoc byte concatenation so the field set
1255/// is explicit and serde's deterministic struct-field ordering fixes the byte
1256/// layout.
1257///
1258/// **DO NOT REORDER these fields** — field order is part of the content address,
1259/// so a reorder silently changes every `plan_id`. Evolve the format via
1260/// [`SEAL_VERSION`] instead.
1261#[derive(Serialize)]
1262struct SealInput<'a> {
1263    domain: &'static str,
1264    seal_version: u32,
1265    contract_schema_version: u32,
1266    contract: &'a Contract,
1267    head_sha: &'a str,
1268    version: &'a str,
1269    targets: &'a [PlanTarget],
1270    phases: &'a [PlanPhase],
1271    /// The engine-owned bump plan, or absent. Omitted from the pre-image when `None`
1272    /// (`skip_serializing_if`), so a `--bump`-less plan hashes byte-for-byte as it did
1273    /// before this field existed — the additive superset guarantee, and why the field
1274    /// did not require a [`SEAL_VERSION`] bump (an absent field changes no existing
1275    /// pre-image). A `--bump` plan's `phases` also differ (a leading `bump`), which the
1276    /// already-hashed `phases` field independently binds.
1277    #[serde(skip_serializing_if = "Option::is_none")]
1278    bump: Option<&'a BumpPlan>,
1279}
1280
1281/// Serialize the pre-image to canonical JSON and return its SHA-256 hex digest.
1282fn seal(
1283    contract: &Contract,
1284    targets: &[PlanTarget],
1285    head_sha: &str,
1286    version: &str,
1287    phases: &[PlanPhase],
1288    bump: Option<&BumpPlan>,
1289) -> String {
1290    sha256::hex(&seal_bytes(
1291        contract, targets, head_sha, version, phases, bump,
1292    ))
1293}
1294
1295/// Produce the canonical seal pre-image bytes used by the internal sealing routine. The durable plan
1296/// store persists these exact bytes and verifies them through this one seam.
1297pub fn seal_bytes(
1298    contract: &Contract,
1299    targets: &[PlanTarget],
1300    head_sha: &str,
1301    version: &str,
1302    phases: &[PlanPhase],
1303    bump: Option<&BumpPlan>,
1304) -> Vec<u8> {
1305    let input = SealInput {
1306        domain: SEAL_DOMAIN,
1307        seal_version: SEAL_VERSION,
1308        contract_schema_version: contract.schema_version,
1309        contract,
1310        head_sha,
1311        version,
1312        targets,
1313        phases,
1314        bump,
1315    };
1316    // `to_vec` on a struct of only structs/Vecs/BTreeMaps (contract's
1317    // `extra_fields` is a `serde_json::Map` = `BTreeMap` without the
1318    // `preserve_order` feature) is deterministic — no wall-clock, no HashMap,
1319    // no float. It is also infallible for these concrete types; `expect` (never
1320    // `unwrap_or_default`, which would fail *open* by hashing an empty pre-image
1321    // and collide every failing plan on the empty-string digest).
1322    serde_json::to_vec(&input).expect("release-plan pre-image is infallible to serialize")
1323}
1324
1325/// Hash stored canonical seal bytes through the same hashing implementation that
1326/// seals newly-derived plans. Kept here so storage never grows its own hash.
1327#[must_use]
1328pub fn seal_id_from_bytes(bytes: &[u8]) -> String {
1329    sha256::hex(bytes)
1330}
1331
1332/// Short (first 12 hex chars) `HEAD` sha for drift messages; whole string if
1333/// shorter.
1334fn short_sha(sha: &str) -> &str {
1335    sha.get(..12).unwrap_or(sha)
1336}
1337
1338/// A self-contained SHA-256 (FIPS 180-4) so `plan_id` needs no third-party hash
1339/// dependency and no edit to the workspace `Cargo.toml` (a hot file). Content
1340/// addressing is an integrity check over local, non-adversarial inputs, so a
1341/// vendored reference implementation is appropriate; correctness is pinned by
1342/// the RFC known-answer vectors in the module tests.
1343mod sha256 {
1344    // The canonical reference form is dense in bit-twiddling and single-letter
1345    // working variables; the lints below fight that idiom for no clarity gain.
1346    #![allow(
1347        clippy::unreadable_literal,
1348        clippy::many_single_char_names,
1349        clippy::needless_range_loop
1350    )]
1351
1352    use std::fmt::Write as _;
1353
1354    /// SHA-256 round constants (first 32 bits of the fractional parts of the
1355    /// cube roots of the first 64 primes).
1356    const K: [u32; 64] = [
1357        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
1358        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
1359        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
1360        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
1361        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
1362        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
1363        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
1364        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
1365        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
1366        0xc67178f2,
1367    ];
1368
1369    /// Initial hash values (first 32 bits of the fractional parts of the square
1370    /// roots of the first 8 primes).
1371    const H0: [u32; 8] = [
1372        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
1373        0x5be0cd19,
1374    ];
1375
1376    /// The lowercase 64-character SHA-256 hex digest of `data`.
1377    pub fn hex(data: &[u8]) -> String {
1378        let mut h = H0;
1379
1380        // Pad: 0x80, then zeros to a 56-mod-64 boundary, then the 64-bit
1381        // big-endian bit length.
1382        let mut msg = data.to_vec();
1383        // FIPS 180-4 caps the message at 2^64 - 1 bits; a checked multiply turns
1384        // the (practically unreachable) overflow into a loud panic rather than a
1385        // silently wrong digest.
1386        let bit_len = (data.len() as u64)
1387            .checked_mul(8)
1388            .expect("SHA-256 input exceeds 2^64 bits");
1389        msg.push(0x80);
1390        while msg.len() % 64 != 56 {
1391            msg.push(0);
1392        }
1393        msg.extend_from_slice(&bit_len.to_be_bytes());
1394
1395        for chunk in msg.chunks_exact(64) {
1396            let mut w = [0u32; 64];
1397            for i in 0..16 {
1398                w[i] = u32::from_be_bytes([
1399                    chunk[4 * i],
1400                    chunk[4 * i + 1],
1401                    chunk[4 * i + 2],
1402                    chunk[4 * i + 3],
1403                ]);
1404            }
1405            for i in 16..64 {
1406                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
1407                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
1408                w[i] = w[i - 16]
1409                    .wrapping_add(s0)
1410                    .wrapping_add(w[i - 7])
1411                    .wrapping_add(s1);
1412            }
1413
1414            let mut a = h[0];
1415            let mut b = h[1];
1416            let mut c = h[2];
1417            let mut d = h[3];
1418            let mut e = h[4];
1419            let mut f = h[5];
1420            let mut g = h[6];
1421            let mut hh = h[7];
1422
1423            for i in 0..64 {
1424                let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
1425                let ch = (e & f) ^ ((!e) & g);
1426                let t1 = hh
1427                    .wrapping_add(s1)
1428                    .wrapping_add(ch)
1429                    .wrapping_add(K[i])
1430                    .wrapping_add(w[i]);
1431                let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
1432                let maj = (a & b) ^ (a & c) ^ (b & c);
1433                let t2 = s0.wrapping_add(maj);
1434                hh = g;
1435                g = f;
1436                f = e;
1437                e = d.wrapping_add(t1);
1438                d = c;
1439                c = b;
1440                b = a;
1441                a = t1.wrapping_add(t2);
1442            }
1443
1444            h[0] = h[0].wrapping_add(a);
1445            h[1] = h[1].wrapping_add(b);
1446            h[2] = h[2].wrapping_add(c);
1447            h[3] = h[3].wrapping_add(d);
1448            h[4] = h[4].wrapping_add(e);
1449            h[5] = h[5].wrapping_add(f);
1450            h[6] = h[6].wrapping_add(g);
1451            h[7] = h[7].wrapping_add(hh);
1452        }
1453
1454        let mut out = String::with_capacity(64);
1455        for v in h {
1456            let _ = write!(out, "{v:08x}");
1457        }
1458        out
1459    }
1460}
1461
1462#[cfg(test)]
1463mod tests;