ossctl-core 0.1.0

Core library for ossctl: contract normalizer, repo-fact detection, audit scoring, release engine, and the versioned protocol DTOs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! Remote-is-ground-truth resume/reconcile (ADR-0003 §4).
//!
//! `release resume` continues an interrupted run — but it does **not** trust the
//! local journal as authoritative for what actually published. The journal is an
//! optimization; the **remote registry state is the ground truth** (a run whose
//! `.git`-local journal was lost can still be reconciled from what the registries
//! hold, via each adapter's [`verify`](ReleaseAdapter::verify)). This module is
//! the read-only *reconcile* half: it classifies every planned target against the
//! ADR-0003 §4 state table and returns the per-target action a resume must take.
//! Actually continuing the phase barrier is the [coordinator](super::coordinator)'s
//! job — this module never mutates the journal or the registry; it only decides.
//!
//! # The state table (ADR-0003 §4)
//!
//! For each target the run planned, its journal state (does a durable
//! [`PublishReceipt`](crate::protocol::journal::PublishReceipt) exist?) is crossed
//! with what [`verify`](ReleaseAdapter::verify) observes remotely:
//!
//! | Journal | `verify()` | Action ([`ResumeAction`]) |
//! |---|---|---|
//! | published | `Matches` | [`Skip`](ResumeAction::Skip) — done, idempotent success |
//! | published | `Conflicts` | [`Conflict`](ResumeAction::Conflict) — hard stop, never overwrite |
//! | published | `Missing` | [`Conflict`](ResumeAction::Conflict) — ambiguous, hard stop + surface |
//! | published | `Unknown` | [`Unverifiable`](ResumeAction::Unverifiable) — needs explicit go-ahead |
//! | not recorded | `Matches` | [`AdoptForward`](ResumeAction::AdoptForward) — publish landed pre-receipt; adopt it |
//! | not recorded | `Missing` | [`ResumePublish`](ResumeAction::ResumePublish) — resume the publish |
//! | not recorded | `Unknown` | [`Unverifiable`](ResumeAction::Unverifiable) — needs explicit go-ahead |
//!
//! The `Unknown` rows are the tri-state discipline (also ADR-0002 §1): a lookup
//! that **could not be performed** — a registry outage, a package with no name, an
//! ecosystem this binary cannot query, or a structurally-unobservable distribution
//! target (homebrew taps / GitHub Releases) — is **never** read as `Missing` (which
//! would drive a dangerous blind re-publish of an already-published version). It is
//! surfaced as unverifiable; a resume proceeds past it only with an explicit human
//! go-ahead (`allow_unverified`), which collapses `Unknown` to trust-the-journal
//! (`Skip` when published, `ResumePublish` when not) rather than a hard stop.
//!
//! The **tag** rows of the ADR table (`created_local` only → retry push;
//! `pushed_remote`, no Release → create Release) are *not* reconciled here: the
//! coordinator's tag-once phase is already an idempotent, step-by-step re-entry
//! (each of `tag_created_local` / `tag_pushed_remote` / `github_release_created`
//! is skipped if journalled and the [`Tagger`](crate::ports::Tagger) treats
//! "already exists" as success), so continuing the barrier *is* the tag reconcile.
//! Forking a second copy of that logic here is exactly what ADR-0003 forbids.
//!
//! A target the original run **cancelled** (a `target_cancelled` fact) is off the
//! table entirely: it is a deliberate skip, and the coordinator's publish-all skips
//! only *published* targets, so continuing would re-publish it. Resume classifies it
//! as [`ResumeAction::Cancelled`] — a hard stop — rather than silently un-cancelling
//! it (there is no ADR-0003 cell for cancelled × remote).

use std::collections::HashMap;

use crate::contract::schema::Ecosystem;
use crate::protocol::journal::{PublishReceipt as JournalReceipt, RunState};
use crate::protocol::plan::{PlanTarget, ReleasePlan};
use crate::protocol::release::{PublishReceipt as AdapterReceipt, VerifyOutcome};

use super::adapters::{resolve, EffectCtx, ReleaseAdapter};

/// Whether a target carried a durable publish receipt in the journal at reconcile
/// time — the left axis of the ADR-0003 §4 state table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalState {
    /// The journal holds a `target_published` receipt for this target.
    Published,
    /// The journal holds no receipt for this target (never published, or the
    /// publish landed before its receipt was fsynced).
    NotRecorded,
    /// The journal recorded a `target_cancelled` for this target — a deliberate
    /// skip. Resume must never silently un-cancel it into a publish.
    Cancelled,
}

/// The reconciled action for one target — the resolved cell of the ADR-0003 §4
/// state table (journal-state × remote-state), with the `Unknown` rows already
/// collapsed by the caller's `allow_unverified` go-ahead.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeAction {
    /// published × `Matches` — already landed; the coordinator's publish-all skips
    /// it (it is in `state.published`). Nothing to do.
    Skip,
    /// not-recorded × `Matches` — a publish landed before its receipt fsynced;
    /// **adopt the receipt forward** (journal a `target_published`) so the
    /// coordinator skips it rather than re-publishing an already-published version.
    AdoptForward,
    /// not-recorded × `Missing` — the publish genuinely did not land; let the
    /// coordinator resume it in publish-all.
    ResumePublish,
    /// published × {`Conflicts`, `Missing`} — a **hard stop**: something other than
    /// this run's artifact is at that version, or a recorded publish has vanished.
    /// Never overwritten, never blind-re-published; surfaced for a human.
    Conflict,
    /// `Unknown` with no explicit go-ahead — the reconcile could not be performed,
    /// so the target is **unverifiable**. A hard stop until a human passes the
    /// go-ahead (`allow_unverified`), because an outage must never be assumed to
    /// mean "not published".
    Unverifiable,
    /// The target was cancelled in the original run — a **hard stop**. The
    /// coordinator's publish-all skips only *published* targets, so continuing
    /// would re-publish a target the operator deliberately cancelled; resume never
    /// silently un-cancels it (there is no ADR-0003 cell for cancelled × remote).
    Cancelled,
}

impl ResumeAction {
    /// Whether this action **blocks** a resume (a hard stop that must be surfaced,
    /// not continued past).
    #[must_use]
    pub fn is_blocker(self) -> bool {
        matches!(self, Self::Conflict | Self::Unverifiable | Self::Cancelled)
    }

    /// The stable wire/diagnostic string for this action.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Skip => "skip",
            Self::AdoptForward => "adopt_forward",
            Self::ResumePublish => "resume_publish",
            Self::Conflict => "conflict",
            Self::Unverifiable => "unverifiable",
            Self::Cancelled => "cancelled",
        }
    }
}

/// One target's reconcile decision — the classified cell plus the material a
/// resume needs to act on it.
#[derive(Debug, Clone)]
pub struct TargetDecision {
    /// The journal/coordinator target id (its ecosystem wire string).
    pub target: String,
    /// The ecosystem this target publishes to.
    pub ecosystem: Ecosystem,
    /// Whether the journal recorded a receipt for it.
    pub journal_state: JournalState,
    /// What the adapter's `verify` observed remotely.
    pub outcome: VerifyOutcome,
    /// The resolved action from the state table.
    pub action: ResumeAction,
    /// An operator-facing reason for a non-`Skip` decision (why it conflicts, is
    /// unverifiable, will be adopted, or will be resumed).
    pub detail: Option<String>,
    /// For [`ResumeAction::AdoptForward`], the synthetic receipt to journal so the
    /// coordinator treats the target as already published. `None` otherwise.
    pub adopted_receipt: Option<JournalReceipt>,
}

/// The full reconcile of a run against remote registry state — one
/// [`TargetDecision`] per planned target, in the plan's target order.
#[derive(Debug, Clone)]
pub struct ResumeReconcile {
    /// The run reconciled.
    pub run_id: String,
    /// The sealed plan id the run executes.
    pub plan_id: String,
    /// One decision per planned target.
    pub decisions: Vec<TargetDecision>,
}

impl ResumeReconcile {
    /// The decisions that **block** the resume (hard stops the caller must surface
    /// via the §10 envelope rather than continue past).
    #[must_use]
    pub fn blockers(&self) -> Vec<&TargetDecision> {
        self.decisions
            .iter()
            .filter(|d| d.action.is_blocker())
            .collect()
    }

    /// Whether any decision blocks the resume.
    #[must_use]
    pub fn is_blocked(&self) -> bool {
        self.decisions.iter().any(|d| d.action.is_blocker())
    }

    /// The `(target id, receipt)` pairs to journal as `target_published` **before**
    /// continuing the barrier, so an adopted-forward publish is never re-run. Empty
    /// unless a publish landed without a durable receipt (not-recorded × `Matches`).
    #[must_use]
    pub fn adoptions(&self) -> Vec<(&str, &JournalReceipt)> {
        self.decisions
            .iter()
            .filter_map(|d| d.adopted_receipt.as_ref().map(|r| (d.target.as_str(), r)))
            .collect()
    }
}

/// Reconcile a journaled run against current remote registry state, per the
/// ADR-0003 §4 state table.
///
/// Read-only with respect to the world **except** for the registry lookups it
/// performs through `ctx` (the same read-only `verify` path `release verify` uses)
/// — it writes nothing to the journal or the registry. Iterates `plan.targets` (the
/// authority for the run's target set; the caller has already confirmed the plan
/// still hashes to the run's `plan_id`), classifies each cell, and returns the
/// per-target [`TargetDecision`]s.
///
/// `allow_unverified` is the human's explicit go-ahead for the `Unknown` rows: with
/// it, an unverifiable target is trusted to the journal (`Skip` when a receipt
/// exists, `ResumePublish` when not) instead of blocking. It never downgrades a
/// genuine `Conflicts`/`Missing`-after-publish hard stop.
#[must_use]
pub fn reconcile_for_resume(
    state: &RunState,
    plan: &ReleasePlan,
    ctx: &EffectCtx<'_>,
    allow_unverified: bool,
) -> ResumeReconcile {
    // The remote outcome for *published* targets comes from the same read-only
    // reconcile engine `release verify` uses (remote is ground truth). Note this
    // supplies only the outcome — the journal-state axis is decided directly from
    // `state.published` below, never from report membership, so a receipt can never
    // be misclassified as not-recorded (which would risk a double publish).
    let published_report = super::reconcile::reconcile(state, ctx);
    let published: HashMap<&str, (VerifyOutcome, Option<String>)> = published_report
        .targets
        .iter()
        .map(|t| (t.target.as_str(), (t.outcome, t.detail.clone())))
        .collect();

    let mut decisions = Vec::with_capacity(plan.targets.len());
    for pt in &plan.targets {
        let target = pt.ecosystem.as_str().to_string();

        // A cancelled target is a deliberate skip, not a publish candidate. The
        // coordinator's publish-all skips only *published* targets, so continuing
        // would re-publish it — block instead of silently un-cancelling.
        if let Some(reason) = state.cancelled.get(&target) {
            decisions.push(TargetDecision {
                target,
                ecosystem: pt.ecosystem,
                journal_state: JournalState::Cancelled,
                outcome: VerifyOutcome::Unknown,
                action: ResumeAction::Cancelled,
                detail: Some(format!(
                    "this target was cancelled in the original run ({reason}); resuming would \
                     re-publish it. ossctl will not silently un-cancel a target — abandon and \
                     re-plan, or reconcile it by hand"
                )),
                adopted_receipt: None,
            });
            continue;
        }

        // The journal-state axis: authoritative from `state.published`.
        let (journal_state, outcome, verify_detail) = if state.published.contains_key(&target) {
            // A receipt exists; take its remote outcome from the reconcile report
            // (defensively Unknown if — impossibly — the engine omitted the row).
            let (outcome, detail) = published.get(target.as_str()).cloned().unwrap_or((
                VerifyOutcome::Unknown,
                Some("the published receipt could not be reconciled against the registry".into()),
            ));
            (JournalState::Published, outcome, detail)
        } else {
            let (outcome, detail) = verify_not_recorded(ctx, pt, &plan.version);
            (JournalState::NotRecorded, outcome, detail)
        };

        let action = classify(journal_state, outcome, allow_unverified);
        let adopted_receipt = (action == ResumeAction::AdoptForward).then(|| JournalReceipt {
            ecosystem: pt.ecosystem.as_str().to_string(),
            package: pt.package.clone(),
            version: plan.version.clone(),
            // The current RegistryQuery port lists versions only (no remote digest
            // or URL to capture); an adopted receipt therefore records presence,
            // matching what a live publish receipt carries through this port. A
            // richer digest-observing port is a documented follow-up.
            registry_url: None,
            digest: None,
        });
        decisions.push(TargetDecision {
            detail: action_detail(action, outcome, journal_state, verify_detail),
            target,
            ecosystem: pt.ecosystem,
            journal_state,
            outcome,
            action,
            adopted_receipt,
        });
    }

    ResumeReconcile {
        run_id: state.run_id.clone(),
        plan_id: state.plan_id.clone(),
        decisions,
    }
}

/// Map one (journal-state × remote-outcome) cell to its [`ResumeAction`], folding
/// the `allow_unverified` go-ahead into the two `Unknown` rows.
// Each arm is one cell of the ADR-0003 §4 state table, kept separate (even where
// two resolve to the same action) so the mapping reads as the documented table and
// a future divergence is a one-line edit, not a pattern split.
#[allow(clippy::match_same_arms)]
fn classify(
    journal_state: JournalState,
    outcome: VerifyOutcome,
    allow_unverified: bool,
) -> ResumeAction {
    use JournalState::{Cancelled, NotRecorded, Published};
    use VerifyOutcome::{Conflicts, Matches, Missing, Unknown};
    match (journal_state, outcome) {
        // Cancelled targets are decided before classify (never queried); this arm
        // only satisfies exhaustiveness and mirrors that hard-stop disposition.
        (Cancelled, _) => ResumeAction::Cancelled,
        (Published, Matches) => ResumeAction::Skip,
        // A recorded publish that now conflicts, or has vanished, is a hard stop:
        // never overwrite someone else's artifact, never blind-re-publish.
        (Published, Conflicts | Missing) => ResumeAction::Conflict,
        (Published, Unknown) => {
            if allow_unverified {
                // The go-ahead trusts the journal's own receipt.
                ResumeAction::Skip
            } else {
                ResumeAction::Unverifiable
            }
        }
        // A publish landed before its receipt fsynced — adopt it forward.
        (NotRecorded, Matches) => ResumeAction::AdoptForward,
        // Genuinely absent remotely — resume the publish.
        (NotRecorded, Missing) => ResumeAction::ResumePublish,
        // Cannot arise from a receipt-less query (no local digest to disagree), but
        // classify it as a hard stop rather than guess if a future port surfaces it.
        (NotRecorded, Conflicts) => ResumeAction::Conflict,
        (NotRecorded, Unknown) => {
            if allow_unverified {
                // The go-ahead accepts the double-publish risk on an unverifiable,
                // not-recorded target (adapters treat "already published" as an
                // error the coordinator then surfaces — never a silent overwrite).
                ResumeAction::ResumePublish
            } else {
                ResumeAction::Unverifiable
            }
        }
    }
}

/// Verify a target the journal never recorded a receipt for, by synthesizing a
/// receipt from the plan's coordinates and dispatching the ecosystem adapter's
/// read-only `verify` — the "did a publish land without a receipt?" question.
///
/// A target whose package the plan could not resolve cannot be queried (the caller
/// validates the plan first, so this is defensive): honest `Unknown`, never a
/// fabricated query that a registry would read as absent.
fn verify_not_recorded(
    ctx: &EffectCtx<'_>,
    pt: &PlanTarget,
    version: &str,
) -> (VerifyOutcome, Option<String>) {
    let Some(package) = pt.package.clone() else {
        return (
            VerifyOutcome::Unknown,
            Some(
                "the plan target has no resolved package name; the registry cannot be queried"
                    .to_string(),
            ),
        );
    };
    let receipt = AdapterReceipt {
        adapter: pt.adapter,
        ecosystem: pt.ecosystem,
        package,
        version: version.to_string(),
        // `verify` classifies on version + digest only; a receipt-less target has
        // no digest to compare, so presence resolves to Matches/Missing.
        canonical_ref: String::new(),
        digest: None,
        remote_url: None,
        timestamp: 0,
    };
    let outcome = resolve(pt.adapter)
        .verify(ctx, &receipt)
        .unwrap_or(VerifyOutcome::Unknown);
    (outcome, verify_reason(outcome, pt.ecosystem))
}

/// The operator-facing reason a receipt-less target verified to a non-`Matches`
/// outcome (mirrors the reconcile engine's wording so `verify` and `resume` read
/// alike).
fn verify_reason(outcome: VerifyOutcome, ecosystem: Ecosystem) -> Option<String> {
    match outcome {
        VerifyOutcome::Matches => None,
        VerifyOutcome::Missing => {
            Some("the registry does not report this version as published".to_string())
        }
        VerifyOutcome::Conflicts => {
            Some("the registry holds this version but its digest differs from the plan".to_string())
        }
        VerifyOutcome::Unknown if ecosystem == Ecosystem::Binary => Some(
            "this distribution target (GitHub Releases or a homebrew formula) is not \
             observable through the registry query"
                .to_string(),
        ),
        VerifyOutcome::Unknown => Some(
            "the registry lookup could not be performed (registry outage or unresolvable package)"
                .to_string(),
        ),
    }
}

/// The decision-level detail: what the resume will *do* about this cell, layering
/// the reconcile reason (`verify_detail`) under an action-specific explanation.
fn action_detail(
    action: ResumeAction,
    outcome: VerifyOutcome,
    journal_state: JournalState,
    verify_detail: Option<String>,
) -> Option<String> {
    match action {
        // Skip carries no note; a Cancelled decision is built with its own detail
        // at the call site (it needs the cancellation reason), so neither reaches here.
        ResumeAction::Skip | ResumeAction::Cancelled => None,
        ResumeAction::AdoptForward => Some(
            "a publish landed before its receipt was recorded; adopting it forward so it is \
             not re-published"
                .to_string(),
        ),
        ResumeAction::ResumePublish => Some(match journal_state {
            JournalState::NotRecorded if outcome == VerifyOutcome::Unknown => {
                "unverifiable and not recorded as published; resuming the publish under the \
                 explicit go-ahead"
                    .to_string()
            }
            _ => "not published; resuming the publish for this target".to_string(),
        }),
        ResumeAction::Conflict => Some(match outcome {
            VerifyOutcome::Conflicts => {
                "a different artifact is published at this version — a human must reconcile \
                 before resuming; ossctl will not overwrite it"
                    .to_string()
            }
            VerifyOutcome::Missing => {
                "this run recorded a publish the registry no longer reports (deleted or \
                 transient) — a human must decide; ossctl will not blindly re-publish"
                    .to_string()
            }
            _ => verify_detail.unwrap_or_else(|| "conflicting registry state".to_string()),
        }),
        ResumeAction::Unverifiable => Some(verify_detail.map_or_else(
            || {
                "the reconcile could not be performed; pass --allow-unverified to proceed on trust"
                    .to_string()
            },
            |d| format!("{d}; pass --allow-unverified to proceed on trust"),
        )),
    }
}

#[cfg(test)]
mod tests;