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