Skip to main content

ossctl_core/release/
resume.rs

1//! Remote-is-ground-truth resume/reconcile (ADR-0003 §4).
2//!
3//! `release resume` continues an interrupted run — but it does **not** trust the
4//! local journal as authoritative for what actually published. The journal is an
5//! optimization; the **remote registry state is the ground truth** (a run whose
6//! `.git`-local journal was lost can still be reconciled from what the registries
7//! hold, via each adapter's [`verify`](ReleaseAdapter::verify)). This module is
8//! the read-only *reconcile* half: it classifies every planned target against the
9//! ADR-0003 §4 state table and returns the per-target action a resume must take.
10//! Actually continuing the phase barrier is the [coordinator](super::coordinator)'s
11//! job — this module never mutates the journal or the registry; it only decides.
12//!
13//! # The state table (ADR-0003 §4)
14//!
15//! For each target the run planned, its journal state (does a durable
16//! [`PublishReceipt`](crate::protocol::journal::PublishReceipt) exist?) is crossed
17//! with what [`verify`](ReleaseAdapter::verify) observes remotely:
18//!
19//! | Journal | `verify()` | Action ([`ResumeAction`]) |
20//! |---|---|---|
21//! | published | `Matches` | [`Skip`](ResumeAction::Skip) — done, idempotent success |
22//! | published | `Conflicts` | [`Conflict`](ResumeAction::Conflict) — hard stop, never overwrite |
23//! | published | `Missing` | [`Conflict`](ResumeAction::Conflict) — ambiguous, hard stop + surface |
24//! | published | `Unknown` | [`Unverifiable`](ResumeAction::Unverifiable) — needs explicit go-ahead |
25//! | not recorded | `Matches` | [`AdoptForward`](ResumeAction::AdoptForward) — publish landed pre-receipt; adopt it |
26//! | not recorded | `Missing` | [`ResumePublish`](ResumeAction::ResumePublish) — resume the publish |
27//! | not recorded | `Unknown` | [`Unverifiable`](ResumeAction::Unverifiable) — needs explicit go-ahead |
28//!
29//! The `Unknown` rows are the tri-state discipline (also ADR-0002 §1): a lookup
30//! that **could not be performed** — a registry outage, a package with no name, an
31//! ecosystem this binary cannot query, or a structurally-unobservable distribution
32//! target (homebrew taps / GitHub Releases) — is **never** read as `Missing` (which
33//! would drive a dangerous blind re-publish of an already-published version). It is
34//! surfaced as unverifiable; a resume proceeds past it only with an explicit human
35//! go-ahead (`allow_unverified`), which collapses `Unknown` to trust-the-journal
36//! (`Skip` when published, `ResumePublish` when not) rather than a hard stop.
37//!
38//! The **tag** rows of the ADR table (`created_local` only → retry push;
39//! `pushed_remote`, no Release → create Release) are *not* reconciled here: the
40//! coordinator's tag-once phase is already an idempotent, step-by-step re-entry
41//! (each of `tag_created_local` / `tag_pushed_remote` / `github_release_created`
42//! is skipped if journalled and the [`Tagger`](crate::ports::Tagger) treats
43//! "already exists" as success), so continuing the barrier *is* the tag reconcile.
44//! Forking a second copy of that logic here is exactly what ADR-0003 forbids.
45//!
46//! A target the original run **cancelled** (a `target_cancelled` fact) is off the
47//! table entirely: it is a deliberate skip, and the coordinator's publish-all skips
48//! only *published* targets, so continuing would re-publish it. Resume classifies it
49//! as [`ResumeAction::Cancelled`] — a hard stop — rather than silently un-cancelling
50//! it (there is no ADR-0003 cell for cancelled × remote).
51
52use std::collections::HashMap;
53
54use crate::contract::schema::Ecosystem;
55use crate::protocol::journal::{PublishReceipt as JournalReceipt, RunState};
56use crate::protocol::plan::{PlanTarget, ReleasePlan};
57use crate::protocol::release::{PublishReceipt as AdapterReceipt, VerifyOutcome};
58
59use super::adapters::{resolve, EffectCtx, ReleaseAdapter};
60
61/// Whether a target carried a durable publish receipt in the journal at reconcile
62/// time — the left axis of the ADR-0003 §4 state table.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum JournalState {
65    /// The journal holds a `target_published` receipt for this target.
66    Published,
67    /// The journal holds no receipt for this target (never published, or the
68    /// publish landed before its receipt was fsynced).
69    NotRecorded,
70    /// The journal recorded a `target_cancelled` for this target — a deliberate
71    /// skip. Resume must never silently un-cancel it into a publish.
72    Cancelled,
73    /// The journal recorded a `target_delegated` for this target — its artifact is
74    /// produced by the tag-triggered CI, not the engine (e.g. `cargo-dist`). Resume
75    /// must not try to publish it; there is nothing for the engine to resume.
76    Delegated,
77}
78
79/// The reconciled action for one target — the resolved cell of the ADR-0003 §4
80/// state table (journal-state × remote-state), with the `Unknown` rows already
81/// collapsed by the caller's `allow_unverified` go-ahead.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ResumeAction {
84    /// published × `Matches` — already landed; the coordinator's publish-all skips
85    /// it (it is in `state.published`). Nothing to do.
86    Skip,
87    /// not-recorded × `Matches` — a publish landed before its receipt fsynced;
88    /// **adopt the receipt forward** (journal a `target_published`) so the
89    /// coordinator skips it rather than re-publishing an already-published version.
90    AdoptForward,
91    /// not-recorded × `Missing` — the publish genuinely did not land; let the
92    /// coordinator resume it in publish-all.
93    ResumePublish,
94    /// published × {`Conflicts`, `Missing`} — a **hard stop**: something other than
95    /// this run's artifact is at that version, or a recorded publish has vanished.
96    /// Never overwritten, never blind-re-published; surfaced for a human.
97    Conflict,
98    /// `Unknown` with no explicit go-ahead — the reconcile could not be performed,
99    /// so the target is **unverifiable**. A hard stop until a human passes the
100    /// go-ahead (`allow_unverified`), because an outage must never be assumed to
101    /// mean "not published".
102    Unverifiable,
103    /// The target was cancelled in the original run — a **hard stop**. The
104    /// coordinator's publish-all skips only *published* targets, so continuing
105    /// would re-publish a target the operator deliberately cancelled; resume never
106    /// silently un-cancels it (there is no ADR-0003 cell for cancelled × remote).
107    Cancelled,
108    /// The target is **CI-delegated** — its artifact is produced by the
109    /// tag-triggered CI, not the engine, so there is nothing for a resume to
110    /// publish. **Not** a blocker: the coordinator re-journals/skips it on re-entry,
111    /// exactly as on a fresh cut.
112    Delegated,
113}
114
115impl ResumeAction {
116    /// Whether this action **blocks** a resume (a hard stop that must be surfaced,
117    /// not continued past).
118    #[must_use]
119    pub fn is_blocker(self) -> bool {
120        matches!(self, Self::Conflict | Self::Unverifiable | Self::Cancelled)
121    }
122
123    /// The stable wire/diagnostic string for this action.
124    #[must_use]
125    pub fn as_str(self) -> &'static str {
126        match self {
127            Self::Skip => "skip",
128            Self::AdoptForward => "adopt_forward",
129            Self::ResumePublish => "resume_publish",
130            Self::Conflict => "conflict",
131            Self::Unverifiable => "unverifiable",
132            Self::Cancelled => "cancelled",
133            Self::Delegated => "delegated",
134        }
135    }
136}
137
138/// One target's reconcile decision — the classified cell plus the material a
139/// resume needs to act on it.
140#[derive(Debug, Clone)]
141pub struct TargetDecision {
142    /// The journal/coordinator target id — the ecosystem wire string for a
143    /// lone-in-its-ecosystem target, else a per-target key
144    /// ([`journal_target_ids`](crate::release::journal_target_ids)).
145    pub target: String,
146    /// The ecosystem this target publishes to.
147    pub ecosystem: Ecosystem,
148    /// Whether the journal recorded a receipt for it.
149    pub journal_state: JournalState,
150    /// What the adapter's `verify` observed remotely.
151    pub outcome: VerifyOutcome,
152    /// The resolved action from the state table.
153    pub action: ResumeAction,
154    /// An operator-facing reason for a non-`Skip` decision (why it conflicts, is
155    /// unverifiable, will be adopted, or will be resumed).
156    pub detail: Option<String>,
157    /// For [`ResumeAction::AdoptForward`], the synthetic receipt to journal so the
158    /// coordinator treats the target as already published. `None` otherwise.
159    pub adopted_receipt: Option<JournalReceipt>,
160}
161
162/// The full reconcile of a run against remote registry state — one
163/// [`TargetDecision`] per planned target, in the plan's target order.
164#[derive(Debug, Clone)]
165pub struct ResumeReconcile {
166    /// The run reconciled.
167    pub run_id: String,
168    /// The sealed plan id the run executes.
169    pub plan_id: String,
170    /// One decision per planned target.
171    pub decisions: Vec<TargetDecision>,
172}
173
174impl ResumeReconcile {
175    /// The decisions that **block** the resume (hard stops the caller must surface
176    /// via the §10 envelope rather than continue past).
177    #[must_use]
178    pub fn blockers(&self) -> Vec<&TargetDecision> {
179        self.decisions
180            .iter()
181            .filter(|d| d.action.is_blocker())
182            .collect()
183    }
184
185    /// Whether any decision blocks the resume.
186    #[must_use]
187    pub fn is_blocked(&self) -> bool {
188        self.decisions.iter().any(|d| d.action.is_blocker())
189    }
190
191    /// The `(target id, receipt)` pairs to journal as `target_published` **before**
192    /// continuing the barrier, so an adopted-forward publish is never re-run. Empty
193    /// unless a publish landed without a durable receipt (not-recorded × `Matches`).
194    #[must_use]
195    pub fn adoptions(&self) -> Vec<(&str, &JournalReceipt)> {
196        self.decisions
197            .iter()
198            .filter_map(|d| d.adopted_receipt.as_ref().map(|r| (d.target.as_str(), r)))
199            .collect()
200    }
201}
202
203/// Reconcile a journaled run against current remote registry state, per the
204/// ADR-0003 §4 state table.
205///
206/// Read-only with respect to the world **except** for the registry lookups it
207/// performs through `ctx` (the same read-only `verify` path `release verify` uses)
208/// — it writes nothing to the journal or the registry. Iterates `plan.targets` (the
209/// authority for the run's target set; the caller has already confirmed the plan
210/// still hashes to the run's `plan_id`), classifies each cell, and returns the
211/// per-target [`TargetDecision`]s.
212///
213/// `allow_unverified` is the human's explicit go-ahead for the `Unknown` rows: with
214/// it, an unverifiable target is trusted to the journal (`Skip` when a receipt
215/// exists, `ResumePublish` when not) instead of blocking. It never downgrades a
216/// genuine `Conflicts`/`Missing`-after-publish hard stop.
217#[must_use]
218pub fn reconcile_for_resume(
219    state: &RunState,
220    plan: &ReleasePlan,
221    ctx: &EffectCtx<'_>,
222    allow_unverified: bool,
223) -> ResumeReconcile {
224    // The remote outcome for *published* targets comes from the same read-only
225    // reconcile engine `release verify` uses (remote is ground truth). Note this
226    // supplies only the outcome — the journal-state axis is decided directly from
227    // `state.published` below, never from report membership, so a receipt can never
228    // be misclassified as not-recorded (which would risk a double publish).
229    let published_report = super::reconcile::reconcile(state, ctx);
230    let published: HashMap<&str, (VerifyOutcome, Option<String>)> = published_report
231        .targets
232        .iter()
233        .map(|t| (t.target.as_str(), (t.outcome, t.detail.clone())))
234        .collect();
235
236    // The same per-target journal ids the coordinator keyed `state.published` by
237    // (and the CLI journalled as `RunCreated.targets`) — derived from the same
238    // plan, so a resume looks up the right receipt for every target even when an
239    // ecosystem carries several (never the bare ecosystem, which would collide and
240    // risk re-publishing an already-landed crate).
241    let target_ids = super::journal_target_ids(&plan.targets);
242    let mut decisions = Vec::with_capacity(plan.targets.len());
243    for (pt, target) in plan.targets.iter().zip(target_ids) {
244        // A cancelled target is a deliberate skip, not a publish candidate. The
245        // coordinator's publish-all skips only *published* targets, so continuing
246        // would re-publish it — block instead of silently un-cancelling.
247        if let Some(reason) = state.cancelled.get(&target) {
248            decisions.push(TargetDecision {
249                target,
250                ecosystem: pt.ecosystem,
251                journal_state: JournalState::Cancelled,
252                outcome: VerifyOutcome::Unknown,
253                action: ResumeAction::Cancelled,
254                detail: Some(format!(
255                    "this target was cancelled in the original run ({reason}); resuming would \
256                     re-publish it. ossctl will not silently un-cancel a target — abandon and \
257                     re-plan, or reconcile it by hand"
258                )),
259                adopted_receipt: None,
260            });
261            continue;
262        }
263
264        // A CI-delegated target is off the publish table: the tag-triggered CI owns
265        // its artifact, so there is nothing for the engine to resume. Classify it as
266        // a non-blocking Delegated skip rather than querying a registry that cannot
267        // observe it (which would misread as `Missing` → a spurious re-publish).
268        //
269        // Delegation is decided by EITHER the journal (`target_delegated` was
270        // recorded) OR the adapter's live capability. The latter is load-bearing for
271        // two cases the journal alone misses: a **v1** run that failed on this
272        // adapter's `Unsupported` before the event existed, and a crash after the
273        // publish phase entered but before `target_delegated` was appended. In both,
274        // the resolved adapter still declares itself delegated, so resume never tries
275        // to publish it.
276        if state.delegated.contains(&target) || resolve(pt.adapter).is_ci_delegated() {
277            decisions.push(TargetDecision {
278                target,
279                ecosystem: pt.ecosystem,
280                journal_state: JournalState::Delegated,
281                outcome: VerifyOutcome::Unknown,
282                action: ResumeAction::Delegated,
283                detail: Some(
284                    "this target is produced by the tag-triggered CI (delegated), not the \
285                     engine; there is nothing to resume"
286                        .to_string(),
287                ),
288                adopted_receipt: None,
289            });
290            continue;
291        }
292
293        // The journal-state axis: authoritative from `state.published`.
294        let (journal_state, outcome, verify_detail) = if state.published.contains_key(&target) {
295            // A receipt exists; take its remote outcome from the reconcile report
296            // (defensively Unknown if — impossibly — the engine omitted the row).
297            let (outcome, detail) = published.get(target.as_str()).cloned().unwrap_or((
298                VerifyOutcome::Unknown,
299                Some("the published receipt could not be reconciled against the registry".into()),
300            ));
301            (JournalState::Published, outcome, detail)
302        } else {
303            let (outcome, detail) = verify_not_recorded(ctx, pt, &plan.version);
304            (JournalState::NotRecorded, outcome, detail)
305        };
306
307        let action = classify(journal_state, outcome, allow_unverified);
308        let adopted_receipt = (action == ResumeAction::AdoptForward).then(|| JournalReceipt {
309            ecosystem: pt.ecosystem.as_str().to_string(),
310            package: pt.package.clone(),
311            version: plan.version.clone(),
312            // The current RegistryQuery port lists versions only (no remote digest
313            // or URL to capture); an adopted receipt therefore records presence,
314            // matching what a live publish receipt carries through this port. A
315            // richer digest-observing port is a documented follow-up.
316            registry_url: None,
317            digest: None,
318        });
319        decisions.push(TargetDecision {
320            detail: action_detail(action, outcome, journal_state, verify_detail),
321            target,
322            ecosystem: pt.ecosystem,
323            journal_state,
324            outcome,
325            action,
326            adopted_receipt,
327        });
328    }
329
330    ResumeReconcile {
331        run_id: state.run_id.clone(),
332        plan_id: state.plan_id.clone(),
333        decisions,
334    }
335}
336
337/// Map one (journal-state × remote-outcome) cell to its [`ResumeAction`], folding
338/// the `allow_unverified` go-ahead into the two `Unknown` rows.
339// Each arm is one cell of the ADR-0003 §4 state table, kept separate (even where
340// two resolve to the same action) so the mapping reads as the documented table and
341// a future divergence is a one-line edit, not a pattern split.
342#[allow(clippy::match_same_arms)]
343fn classify(
344    journal_state: JournalState,
345    outcome: VerifyOutcome,
346    allow_unverified: bool,
347) -> ResumeAction {
348    use JournalState::{Cancelled, Delegated, NotRecorded, Published};
349    use VerifyOutcome::{Conflicts, Matches, Missing, Unknown};
350    match (journal_state, outcome) {
351        // Cancelled / delegated targets are decided before classify (never queried);
352        // these arms only satisfy exhaustiveness and mirror that disposition.
353        (Cancelled, _) => ResumeAction::Cancelled,
354        (Delegated, _) => ResumeAction::Delegated,
355        (Published, Matches) => ResumeAction::Skip,
356        // A recorded publish that now conflicts, or has vanished, is a hard stop:
357        // never overwrite someone else's artifact, never blind-re-publish.
358        (Published, Conflicts | Missing) => ResumeAction::Conflict,
359        (Published, Unknown) => {
360            if allow_unverified {
361                // The go-ahead trusts the journal's own receipt.
362                ResumeAction::Skip
363            } else {
364                ResumeAction::Unverifiable
365            }
366        }
367        // A publish landed before its receipt fsynced — adopt it forward.
368        (NotRecorded, Matches) => ResumeAction::AdoptForward,
369        // Genuinely absent remotely — resume the publish.
370        (NotRecorded, Missing) => ResumeAction::ResumePublish,
371        // Cannot arise from a receipt-less query (no local digest to disagree), but
372        // classify it as a hard stop rather than guess if a future port surfaces it.
373        (NotRecorded, Conflicts) => ResumeAction::Conflict,
374        (NotRecorded, Unknown) => {
375            if allow_unverified {
376                // The go-ahead accepts the double-publish risk on an unverifiable,
377                // not-recorded target (adapters treat "already published" as an
378                // error the coordinator then surfaces — never a silent overwrite).
379                ResumeAction::ResumePublish
380            } else {
381                ResumeAction::Unverifiable
382            }
383        }
384    }
385}
386
387/// Verify a target the journal never recorded a receipt for, by synthesizing a
388/// receipt from the plan's coordinates and dispatching the ecosystem adapter's
389/// read-only `verify` — the "did a publish land without a receipt?" question.
390///
391/// A target whose package the plan could not resolve cannot be queried (the caller
392/// validates the plan first, so this is defensive): honest `Unknown`, never a
393/// fabricated query that a registry would read as absent.
394fn verify_not_recorded(
395    ctx: &EffectCtx<'_>,
396    pt: &PlanTarget,
397    version: &str,
398) -> (VerifyOutcome, Option<String>) {
399    let Some(package) = pt.package.clone() else {
400        return (
401            VerifyOutcome::Unknown,
402            Some(
403                "the plan target has no resolved package name; the registry cannot be queried"
404                    .to_string(),
405            ),
406        );
407    };
408    let receipt = AdapterReceipt {
409        adapter: pt.adapter,
410        ecosystem: pt.ecosystem,
411        package,
412        version: version.to_string(),
413        // `verify` classifies on version + digest only; a receipt-less target has
414        // no digest to compare, so presence resolves to Matches/Missing.
415        canonical_ref: String::new(),
416        digest: None,
417        remote_url: None,
418        timestamp: 0,
419    };
420    let outcome = resolve(pt.adapter)
421        .verify(ctx, &receipt)
422        .unwrap_or(VerifyOutcome::Unknown);
423    (outcome, verify_reason(outcome, pt.ecosystem))
424}
425
426/// The operator-facing reason a receipt-less target verified to a non-`Matches`
427/// outcome (mirrors the reconcile engine's wording so `verify` and `resume` read
428/// alike).
429fn verify_reason(outcome: VerifyOutcome, ecosystem: Ecosystem) -> Option<String> {
430    match outcome {
431        VerifyOutcome::Matches => None,
432        VerifyOutcome::Missing => {
433            Some("the registry does not report this version as published".to_string())
434        }
435        VerifyOutcome::Conflicts => {
436            Some("the registry holds this version but its digest differs from the plan".to_string())
437        }
438        VerifyOutcome::Unknown if ecosystem == Ecosystem::Binary => Some(
439            "this distribution target (GitHub Releases or a homebrew formula) is not \
440             observable through the registry query"
441                .to_string(),
442        ),
443        VerifyOutcome::Unknown => Some(
444            "the registry lookup could not be performed (registry outage or unresolvable package)"
445                .to_string(),
446        ),
447    }
448}
449
450/// The decision-level detail: what the resume will *do* about this cell, layering
451/// the reconcile reason (`verify_detail`) under an action-specific explanation.
452fn action_detail(
453    action: ResumeAction,
454    outcome: VerifyOutcome,
455    journal_state: JournalState,
456    verify_detail: Option<String>,
457) -> Option<String> {
458    match action {
459        // Skip carries no note; Cancelled and Delegated decisions are built with
460        // their own detail at the call site, so none of these reach here.
461        ResumeAction::Skip | ResumeAction::Cancelled | ResumeAction::Delegated => None,
462        ResumeAction::AdoptForward => Some(
463            "a publish landed before its receipt was recorded; adopting it forward so it is \
464             not re-published"
465                .to_string(),
466        ),
467        ResumeAction::ResumePublish => Some(match journal_state {
468            JournalState::NotRecorded if outcome == VerifyOutcome::Unknown => {
469                "unverifiable and not recorded as published; resuming the publish under the \
470                 explicit go-ahead"
471                    .to_string()
472            }
473            _ => "not published; resuming the publish for this target".to_string(),
474        }),
475        ResumeAction::Conflict => Some(match outcome {
476            VerifyOutcome::Conflicts => {
477                "a different artifact is published at this version — a human must reconcile \
478                 before resuming; ossctl will not overwrite it"
479                    .to_string()
480            }
481            VerifyOutcome::Missing => {
482                "this run recorded a publish the registry no longer reports (deleted or \
483                 transient) — a human must decide; ossctl will not blindly re-publish"
484                    .to_string()
485            }
486            _ => verify_detail.unwrap_or_else(|| "conflicting registry state".to_string()),
487        }),
488        ResumeAction::Unverifiable => Some(verify_detail.map_or_else(
489            || {
490                "the reconcile could not be performed; pass --allow-unverified to proceed on trust"
491                    .to_string()
492            },
493            |d| format!("{d}; pass --allow-unverified to proceed on trust"),
494        )),
495    }
496}
497
498#[cfg(test)]
499mod tests;