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}
74
75/// The reconciled action for one target — the resolved cell of the ADR-0003 §4
76/// state table (journal-state × remote-state), with the `Unknown` rows already
77/// collapsed by the caller's `allow_unverified` go-ahead.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ResumeAction {
80 /// published × `Matches` — already landed; the coordinator's publish-all skips
81 /// it (it is in `state.published`). Nothing to do.
82 Skip,
83 /// not-recorded × `Matches` — a publish landed before its receipt fsynced;
84 /// **adopt the receipt forward** (journal a `target_published`) so the
85 /// coordinator skips it rather than re-publishing an already-published version.
86 AdoptForward,
87 /// not-recorded × `Missing` — the publish genuinely did not land; let the
88 /// coordinator resume it in publish-all.
89 ResumePublish,
90 /// published × {`Conflicts`, `Missing`} — a **hard stop**: something other than
91 /// this run's artifact is at that version, or a recorded publish has vanished.
92 /// Never overwritten, never blind-re-published; surfaced for a human.
93 Conflict,
94 /// `Unknown` with no explicit go-ahead — the reconcile could not be performed,
95 /// so the target is **unverifiable**. A hard stop until a human passes the
96 /// go-ahead (`allow_unverified`), because an outage must never be assumed to
97 /// mean "not published".
98 Unverifiable,
99 /// The target was cancelled in the original run — a **hard stop**. The
100 /// coordinator's publish-all skips only *published* targets, so continuing
101 /// would re-publish a target the operator deliberately cancelled; resume never
102 /// silently un-cancels it (there is no ADR-0003 cell for cancelled × remote).
103 Cancelled,
104}
105
106impl ResumeAction {
107 /// Whether this action **blocks** a resume (a hard stop that must be surfaced,
108 /// not continued past).
109 #[must_use]
110 pub fn is_blocker(self) -> bool {
111 matches!(self, Self::Conflict | Self::Unverifiable | Self::Cancelled)
112 }
113
114 /// The stable wire/diagnostic string for this action.
115 #[must_use]
116 pub fn as_str(self) -> &'static str {
117 match self {
118 Self::Skip => "skip",
119 Self::AdoptForward => "adopt_forward",
120 Self::ResumePublish => "resume_publish",
121 Self::Conflict => "conflict",
122 Self::Unverifiable => "unverifiable",
123 Self::Cancelled => "cancelled",
124 }
125 }
126}
127
128/// One target's reconcile decision — the classified cell plus the material a
129/// resume needs to act on it.
130#[derive(Debug, Clone)]
131pub struct TargetDecision {
132 /// The journal/coordinator target id (its ecosystem wire string).
133 pub target: String,
134 /// The ecosystem this target publishes to.
135 pub ecosystem: Ecosystem,
136 /// Whether the journal recorded a receipt for it.
137 pub journal_state: JournalState,
138 /// What the adapter's `verify` observed remotely.
139 pub outcome: VerifyOutcome,
140 /// The resolved action from the state table.
141 pub action: ResumeAction,
142 /// An operator-facing reason for a non-`Skip` decision (why it conflicts, is
143 /// unverifiable, will be adopted, or will be resumed).
144 pub detail: Option<String>,
145 /// For [`ResumeAction::AdoptForward`], the synthetic receipt to journal so the
146 /// coordinator treats the target as already published. `None` otherwise.
147 pub adopted_receipt: Option<JournalReceipt>,
148}
149
150/// The full reconcile of a run against remote registry state — one
151/// [`TargetDecision`] per planned target, in the plan's target order.
152#[derive(Debug, Clone)]
153pub struct ResumeReconcile {
154 /// The run reconciled.
155 pub run_id: String,
156 /// The sealed plan id the run executes.
157 pub plan_id: String,
158 /// One decision per planned target.
159 pub decisions: Vec<TargetDecision>,
160}
161
162impl ResumeReconcile {
163 /// The decisions that **block** the resume (hard stops the caller must surface
164 /// via the §10 envelope rather than continue past).
165 #[must_use]
166 pub fn blockers(&self) -> Vec<&TargetDecision> {
167 self.decisions
168 .iter()
169 .filter(|d| d.action.is_blocker())
170 .collect()
171 }
172
173 /// Whether any decision blocks the resume.
174 #[must_use]
175 pub fn is_blocked(&self) -> bool {
176 self.decisions.iter().any(|d| d.action.is_blocker())
177 }
178
179 /// The `(target id, receipt)` pairs to journal as `target_published` **before**
180 /// continuing the barrier, so an adopted-forward publish is never re-run. Empty
181 /// unless a publish landed without a durable receipt (not-recorded × `Matches`).
182 #[must_use]
183 pub fn adoptions(&self) -> Vec<(&str, &JournalReceipt)> {
184 self.decisions
185 .iter()
186 .filter_map(|d| d.adopted_receipt.as_ref().map(|r| (d.target.as_str(), r)))
187 .collect()
188 }
189}
190
191/// Reconcile a journaled run against current remote registry state, per the
192/// ADR-0003 §4 state table.
193///
194/// Read-only with respect to the world **except** for the registry lookups it
195/// performs through `ctx` (the same read-only `verify` path `release verify` uses)
196/// — it writes nothing to the journal or the registry. Iterates `plan.targets` (the
197/// authority for the run's target set; the caller has already confirmed the plan
198/// still hashes to the run's `plan_id`), classifies each cell, and returns the
199/// per-target [`TargetDecision`]s.
200///
201/// `allow_unverified` is the human's explicit go-ahead for the `Unknown` rows: with
202/// it, an unverifiable target is trusted to the journal (`Skip` when a receipt
203/// exists, `ResumePublish` when not) instead of blocking. It never downgrades a
204/// genuine `Conflicts`/`Missing`-after-publish hard stop.
205#[must_use]
206pub fn reconcile_for_resume(
207 state: &RunState,
208 plan: &ReleasePlan,
209 ctx: &EffectCtx<'_>,
210 allow_unverified: bool,
211) -> ResumeReconcile {
212 // The remote outcome for *published* targets comes from the same read-only
213 // reconcile engine `release verify` uses (remote is ground truth). Note this
214 // supplies only the outcome — the journal-state axis is decided directly from
215 // `state.published` below, never from report membership, so a receipt can never
216 // be misclassified as not-recorded (which would risk a double publish).
217 let published_report = super::reconcile::reconcile(state, ctx);
218 let published: HashMap<&str, (VerifyOutcome, Option<String>)> = published_report
219 .targets
220 .iter()
221 .map(|t| (t.target.as_str(), (t.outcome, t.detail.clone())))
222 .collect();
223
224 let mut decisions = Vec::with_capacity(plan.targets.len());
225 for pt in &plan.targets {
226 let target = pt.ecosystem.as_str().to_string();
227
228 // A cancelled target is a deliberate skip, not a publish candidate. The
229 // coordinator's publish-all skips only *published* targets, so continuing
230 // would re-publish it — block instead of silently un-cancelling.
231 if let Some(reason) = state.cancelled.get(&target) {
232 decisions.push(TargetDecision {
233 target,
234 ecosystem: pt.ecosystem,
235 journal_state: JournalState::Cancelled,
236 outcome: VerifyOutcome::Unknown,
237 action: ResumeAction::Cancelled,
238 detail: Some(format!(
239 "this target was cancelled in the original run ({reason}); resuming would \
240 re-publish it. ossctl will not silently un-cancel a target — abandon and \
241 re-plan, or reconcile it by hand"
242 )),
243 adopted_receipt: None,
244 });
245 continue;
246 }
247
248 // The journal-state axis: authoritative from `state.published`.
249 let (journal_state, outcome, verify_detail) = if state.published.contains_key(&target) {
250 // A receipt exists; take its remote outcome from the reconcile report
251 // (defensively Unknown if — impossibly — the engine omitted the row).
252 let (outcome, detail) = published.get(target.as_str()).cloned().unwrap_or((
253 VerifyOutcome::Unknown,
254 Some("the published receipt could not be reconciled against the registry".into()),
255 ));
256 (JournalState::Published, outcome, detail)
257 } else {
258 let (outcome, detail) = verify_not_recorded(ctx, pt, &plan.version);
259 (JournalState::NotRecorded, outcome, detail)
260 };
261
262 let action = classify(journal_state, outcome, allow_unverified);
263 let adopted_receipt = (action == ResumeAction::AdoptForward).then(|| JournalReceipt {
264 ecosystem: pt.ecosystem.as_str().to_string(),
265 package: pt.package.clone(),
266 version: plan.version.clone(),
267 // The current RegistryQuery port lists versions only (no remote digest
268 // or URL to capture); an adopted receipt therefore records presence,
269 // matching what a live publish receipt carries through this port. A
270 // richer digest-observing port is a documented follow-up.
271 registry_url: None,
272 digest: None,
273 });
274 decisions.push(TargetDecision {
275 detail: action_detail(action, outcome, journal_state, verify_detail),
276 target,
277 ecosystem: pt.ecosystem,
278 journal_state,
279 outcome,
280 action,
281 adopted_receipt,
282 });
283 }
284
285 ResumeReconcile {
286 run_id: state.run_id.clone(),
287 plan_id: state.plan_id.clone(),
288 decisions,
289 }
290}
291
292/// Map one (journal-state × remote-outcome) cell to its [`ResumeAction`], folding
293/// the `allow_unverified` go-ahead into the two `Unknown` rows.
294// Each arm is one cell of the ADR-0003 §4 state table, kept separate (even where
295// two resolve to the same action) so the mapping reads as the documented table and
296// a future divergence is a one-line edit, not a pattern split.
297#[allow(clippy::match_same_arms)]
298fn classify(
299 journal_state: JournalState,
300 outcome: VerifyOutcome,
301 allow_unverified: bool,
302) -> ResumeAction {
303 use JournalState::{Cancelled, NotRecorded, Published};
304 use VerifyOutcome::{Conflicts, Matches, Missing, Unknown};
305 match (journal_state, outcome) {
306 // Cancelled targets are decided before classify (never queried); this arm
307 // only satisfies exhaustiveness and mirrors that hard-stop disposition.
308 (Cancelled, _) => ResumeAction::Cancelled,
309 (Published, Matches) => ResumeAction::Skip,
310 // A recorded publish that now conflicts, or has vanished, is a hard stop:
311 // never overwrite someone else's artifact, never blind-re-publish.
312 (Published, Conflicts | Missing) => ResumeAction::Conflict,
313 (Published, Unknown) => {
314 if allow_unverified {
315 // The go-ahead trusts the journal's own receipt.
316 ResumeAction::Skip
317 } else {
318 ResumeAction::Unverifiable
319 }
320 }
321 // A publish landed before its receipt fsynced — adopt it forward.
322 (NotRecorded, Matches) => ResumeAction::AdoptForward,
323 // Genuinely absent remotely — resume the publish.
324 (NotRecorded, Missing) => ResumeAction::ResumePublish,
325 // Cannot arise from a receipt-less query (no local digest to disagree), but
326 // classify it as a hard stop rather than guess if a future port surfaces it.
327 (NotRecorded, Conflicts) => ResumeAction::Conflict,
328 (NotRecorded, Unknown) => {
329 if allow_unverified {
330 // The go-ahead accepts the double-publish risk on an unverifiable,
331 // not-recorded target (adapters treat "already published" as an
332 // error the coordinator then surfaces — never a silent overwrite).
333 ResumeAction::ResumePublish
334 } else {
335 ResumeAction::Unverifiable
336 }
337 }
338 }
339}
340
341/// Verify a target the journal never recorded a receipt for, by synthesizing a
342/// receipt from the plan's coordinates and dispatching the ecosystem adapter's
343/// read-only `verify` — the "did a publish land without a receipt?" question.
344///
345/// A target whose package the plan could not resolve cannot be queried (the caller
346/// validates the plan first, so this is defensive): honest `Unknown`, never a
347/// fabricated query that a registry would read as absent.
348fn verify_not_recorded(
349 ctx: &EffectCtx<'_>,
350 pt: &PlanTarget,
351 version: &str,
352) -> (VerifyOutcome, Option<String>) {
353 let Some(package) = pt.package.clone() else {
354 return (
355 VerifyOutcome::Unknown,
356 Some(
357 "the plan target has no resolved package name; the registry cannot be queried"
358 .to_string(),
359 ),
360 );
361 };
362 let receipt = AdapterReceipt {
363 adapter: pt.adapter,
364 ecosystem: pt.ecosystem,
365 package,
366 version: version.to_string(),
367 // `verify` classifies on version + digest only; a receipt-less target has
368 // no digest to compare, so presence resolves to Matches/Missing.
369 canonical_ref: String::new(),
370 digest: None,
371 remote_url: None,
372 timestamp: 0,
373 };
374 let outcome = resolve(pt.adapter)
375 .verify(ctx, &receipt)
376 .unwrap_or(VerifyOutcome::Unknown);
377 (outcome, verify_reason(outcome, pt.ecosystem))
378}
379
380/// The operator-facing reason a receipt-less target verified to a non-`Matches`
381/// outcome (mirrors the reconcile engine's wording so `verify` and `resume` read
382/// alike).
383fn verify_reason(outcome: VerifyOutcome, ecosystem: Ecosystem) -> Option<String> {
384 match outcome {
385 VerifyOutcome::Matches => None,
386 VerifyOutcome::Missing => {
387 Some("the registry does not report this version as published".to_string())
388 }
389 VerifyOutcome::Conflicts => {
390 Some("the registry holds this version but its digest differs from the plan".to_string())
391 }
392 VerifyOutcome::Unknown if ecosystem == Ecosystem::Binary => Some(
393 "this distribution target (GitHub Releases or a homebrew formula) is not \
394 observable through the registry query"
395 .to_string(),
396 ),
397 VerifyOutcome::Unknown => Some(
398 "the registry lookup could not be performed (registry outage or unresolvable package)"
399 .to_string(),
400 ),
401 }
402}
403
404/// The decision-level detail: what the resume will *do* about this cell, layering
405/// the reconcile reason (`verify_detail`) under an action-specific explanation.
406fn action_detail(
407 action: ResumeAction,
408 outcome: VerifyOutcome,
409 journal_state: JournalState,
410 verify_detail: Option<String>,
411) -> Option<String> {
412 match action {
413 // Skip carries no note; a Cancelled decision is built with its own detail
414 // at the call site (it needs the cancellation reason), so neither reaches here.
415 ResumeAction::Skip | ResumeAction::Cancelled => None,
416 ResumeAction::AdoptForward => Some(
417 "a publish landed before its receipt was recorded; adopting it forward so it is \
418 not re-published"
419 .to_string(),
420 ),
421 ResumeAction::ResumePublish => Some(match journal_state {
422 JournalState::NotRecorded if outcome == VerifyOutcome::Unknown => {
423 "unverifiable and not recorded as published; resuming the publish under the \
424 explicit go-ahead"
425 .to_string()
426 }
427 _ => "not published; resuming the publish for this target".to_string(),
428 }),
429 ResumeAction::Conflict => Some(match outcome {
430 VerifyOutcome::Conflicts => {
431 "a different artifact is published at this version — a human must reconcile \
432 before resuming; ossctl will not overwrite it"
433 .to_string()
434 }
435 VerifyOutcome::Missing => {
436 "this run recorded a publish the registry no longer reports (deleted or \
437 transient) — a human must decide; ossctl will not blindly re-publish"
438 .to_string()
439 }
440 _ => verify_detail.unwrap_or_else(|| "conflicting registry state".to_string()),
441 }),
442 ResumeAction::Unverifiable => Some(verify_detail.map_or_else(
443 || {
444 "the reconcile could not be performed; pass --allow-unverified to proceed on trust"
445 .to_string()
446 },
447 |d| format!("{d}; pass --allow-unverified to proceed on trust"),
448 )),
449 }
450}
451
452#[cfg(test)]
453mod tests;