Skip to main content

ossctl_core/protocol/
journal.rs

1//! Public wire DTOs for the event-sourced release journal (ADR-0003).
2//!
3//! Two durable representations ride on these types:
4//!
5//! - **`journal.jsonl`** — the append-only event log: one [`JournalEvent`] per
6//!   line, each self-contained (it carries its own [`JOURNAL_SCHEMA_VERSION`], a
7//!   monotonic [`JournalEvent::seq`], a [`JournalEvent::ts`], an
8//!   [`JournalEvent::idempotency_key`], and its [`EventKind`] payload). The log
9//!   is the **single source of truth**.
10//! - **`manifest.json`** — the materialized [`RunState`] projection reduced from
11//!   the log. It is **disposable and reconstructable** from the events (ADR-0003
12//!   §2): if the manifest cannot be rebuilt from the journal there would be two
13//!   sources of truth, which is forbidden. It exists only as an O(1) cache for
14//!   `release show`.
15//!
16//! ## Versioning + forward tolerance
17//!
18//! The journal is durable across `ossctl` upgrades — a run started under one
19//! version must be resumable under the next — so each event carries its **own**
20//! [`JOURNAL_SCHEMA_VERSION`], independent of the envelope [`crate::SCHEMA_VERSION`]
21//! that versions the `--json` wire surface. Additive fields are tolerated (serde
22//! ignores unknown fields on read); a *newer required* event schema is refused
23//! with an actionable error rather than silently mutating state (the refusal
24//! lives in [`crate::release::journal`], which reads these back).
25//!
26//! These DTOs are **owned by the journal**: siblings (the plan model, the
27//! adapters) may hold richer in-memory receipt types, but what the journal
28//! *persists* is exactly the shape here.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use serde::{Deserialize, Serialize};
33
34/// Schema version stamped on every [`JournalEvent`] and [`RunState`].
35///
36/// Monotonic integer, **independent** of [`crate::SCHEMA_VERSION`]: that one
37/// versions the transient `--json`/`--output=jsonl` wire surface; this one
38/// versions the *durable* on-disk journal, which must survive `ossctl` upgrades.
39/// Bump on a breaking event/state change (removing/renaming fields, changing a
40/// variant's semantics); additive optional fields do not bump it.
41///
42/// **v2** (2026-08-05): added the [`EventKind::TargetDelegated`] event class and
43/// the post-tag [`Phase::Dist`] barrier (a new event *kind* / phase value an
44/// older reader cannot interpret, so the version is bumped per the migration
45/// rule — a v1 `ossctl` refuses a v2 line rather than misreading it). A v2 run
46/// carries no v1-incompatible receipt shape; the reduce path stays
47/// backward-tolerant of v1 logs (which simply lack these events).
48///
49/// **v3** (2026-08-05): added the [`EventKind::GithubReleaseDelegated`] event class
50/// (the coordinator delegating GitHub Release creation to a target's CI, e.g.
51/// `cargo-dist`). This is a **new event kind a v2 reader cannot interpret**, so the
52/// migration rule requires its own bump — folding it into v2 would defeat the
53/// version gate ([`read_events`](crate::release::journal::read_events)), letting a
54/// v2 binary silently choke on a `github_release_delegated` line instead of refusing
55/// it with an upgrade error. (This matters even though the engine has never cut a
56/// release itself: a build from `main` between the v2 and v3 commits can emit a v2
57/// journal, and that journal must stay readable while a v3 line is refused by the
58/// older binary.) The reduce path stays backward-tolerant of v1/v2 logs (which lack
59/// this event); `TagState::github_release_delegated` is `#[serde(default)]`.
60pub const JOURNAL_SCHEMA_VERSION: u32 = 3;
61
62/// The five coordinator phases, in barrier order (ADR-0002): the derived
63/// `PartialOrd`/`Ord` follows declaration order, so `DryRun < Build < Publish <
64/// Tag < Dist` — the order the projection sorts phase records in.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum Phase {
68    /// Dry-run-all: every adapter proves it *can* publish, nothing lands.
69    DryRun,
70    /// Build-all: every adapter produces its release artifact.
71    Build,
72    /// Publish-all: artifacts are pushed to their registries (point of no return
73    /// per target — receipts are written per target before the next is tried).
74    Publish,
75    /// Tag-once: the coordinator (never an adapter) creates and pushes the tag
76    /// and the GitHub Release.
77    Tag,
78    /// Dist (post-tag finalize): distribution targets whose artifact only exists
79    /// *after* the tag is pushed are finalized here — the Homebrew formula, whose
80    /// `url` is the just-created tag archive, is fetched, hashed, and its `.rb`
81    /// written with the real `sha256`. Runs after [`Self::Tag`]; its `Ok`
82    /// completion is what flips the run to [`RunStatus::Completed`]. A cut with no
83    /// post-tag target still runs this barrier as a clean no-op so completion is
84    /// uniform (ADR-0002 §2, extended by `release-engine-cut-cargo-dist-flow`).
85    Dist,
86}
87
88impl Phase {
89    /// The wire string for this phase (matches the `Serialize` derive), so text
90    /// diagnostics and JSON never drift.
91    #[must_use]
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::DryRun => "dry_run",
95            Self::Build => "build",
96            Self::Publish => "publish",
97            Self::Tag => "tag",
98            Self::Dist => "dist",
99        }
100    }
101
102    /// Whether this phase is [`Self::Publish`] or a later barrier — i.e. a phase in
103    /// which a target could have had a publish side-effect on a registry. Written as
104    /// an explicit match (not `self >= Self::Publish`) so that inserting or
105    /// reordering a phase forces a deliberate review of this safety predicate rather
106    /// than silently changing it through the derived `Ord`. Used by the resume
107    /// reconcile's "publish phase reached" signal (ADR-0003 §4).
108    #[must_use]
109    pub fn is_publish_or_later(self) -> bool {
110        match self {
111            Self::DryRun | Self::Build => false,
112            Self::Publish | Self::Tag | Self::Dist => true,
113        }
114    }
115}
116
117/// How a phase barrier finished.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum PhaseOutcome {
121    /// Every target cleared the barrier.
122    Ok,
123    /// The barrier failed (at least one target did not clear it); the run does
124    /// not advance past a failed barrier.
125    Failed,
126}
127
128/// Terminal-or-not status of a run, derived from the event stream.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum RunStatus {
132    /// The run is live (created, not yet completed or abandoned).
133    InProgress,
134    /// The final [`Phase::Dist`] barrier completed [`PhaseOutcome::Ok`].
135    Completed,
136    /// A `run_abandoned` event was recorded (see [`RunState::abandon_reason`]).
137    Abandoned,
138}
139
140impl RunStatus {
141    /// The wire string for this status (matches the `Serialize` derive), so text
142    /// diagnostics and JSON never drift.
143    #[must_use]
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::InProgress => "in_progress",
147            Self::Completed => "completed",
148            Self::Abandoned => "abandoned",
149        }
150    }
151}
152
153/// The per-target publish receipt the journal persists — the fact "this exact
154/// artifact landed" that resume/reconcile checks against the registry (the
155/// remote is ground truth, ADR-0003 §4).
156///
157/// Written **per target before the next target is attempted**, never batched, so
158/// an interrupted publish-all leaves an accurate record of exactly what landed.
159/// Every descriptive field is optional so an adapter that cannot supply (say) a
160/// content digest still records a usable receipt.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct PublishReceipt {
163    /// The ecosystem the artifact was published to (`cargo`, `npm`, …).
164    pub ecosystem: String,
165    /// The published package/crate name, when the ecosystem has one.
166    pub package: Option<String>,
167    /// The version string that was published.
168    pub version: String,
169    /// The registry URL of the published artifact, when the adapter reports one.
170    pub registry_url: Option<String>,
171    /// A content digest of the published artifact, when available — the strongest
172    /// signal for `verify()`'s `Matches`/`Conflicts` decision on resume.
173    pub digest: Option<String>,
174}
175
176/// The progress of one release tag through its landing steps. Every field is a
177/// monotonic (`false → true`) fact set by its own journal event, so re-applying a
178/// tag event is a no-op. `created_local` and `pushed_remote` are orthogonal landing
179/// facts; `github_release` vs `github_release_delegated` are the two
180/// **mutually-exclusive** dispositions of the Release step (created-by-engine vs
181/// delegated-to-CI) — the coordinator writes exactly one, and refuses to record a
182/// second contradictory one (`crate::release::coordinator`'s tag phase), so the
183/// illegal both-true state is unreachable for a valid run. They stay flat flags
184/// (with a `clippy::struct_excessive_bools` allow) rather than a
185/// `ReleaseDisposition` enum for consistency with the surrounding flat-flag style
186/// and a `#[serde(default)]`-friendly additive wire shape; folding them into an
187/// enum is a tracked cleanup, not a correctness fix given the write-time guard.
188#[allow(clippy::struct_excessive_bools)]
189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
190pub struct TagState {
191    /// The annotated tag exists in the local repository.
192    pub created_local: bool,
193    /// The tag has been pushed to the remote.
194    pub pushed_remote: bool,
195    /// The GitHub Release for the tag has been created.
196    pub github_release: bool,
197    /// The GitHub Release URL, once created.
198    pub github_release_url: Option<String>,
199    /// The GitHub Release was **delegated to CI** rather than created by the
200    /// coordinator: the plan carried a CI-delegated target (e.g. `cargo-dist`'s
201    /// `release.yml`) whose tag-triggered workflow owns Release creation and the
202    /// cross-platform binary upload. Mutually exclusive in practice with
203    /// [`Self::github_release`] — the coordinator either creates the Release or
204    /// delegates it, never both — and, like the others, monotonic (`false → true`).
205    /// Resume/verify treat a delegated Release as an intentional non-step, never a
206    /// missing one to re-attempt (`coordinator-release-vs-cargo-dist-ownership`).
207    /// `#[serde(default)]` so a pre-field manifest still deserializes (the manifest
208    /// is disposable and rebuilt from the log anyway).
209    #[serde(default)]
210    pub github_release_delegated: bool,
211}
212
213/// One completed-phase record in the [`RunState`] projection.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct PhaseRecord {
216    /// Which barrier completed.
217    pub phase: Phase,
218    /// How it completed.
219    pub outcome: PhaseOutcome,
220}
221
222/// The payload of a journal event — the ADR-0002 event classes. Serialized
223/// **internally tagged** on a `kind` discriminator, flattened into the
224/// [`JournalEvent`] envelope so each JSONL line is one flat object.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(tag = "kind", rename_all = "snake_case")]
227pub enum EventKind {
228    /// The run was created: fixes its identity (`run_id`), the approved plan it
229    /// executes (`plan_id`), and the ordered target set. Always the first event.
230    RunCreated {
231        /// The run's unique id (a ULID from the injected `IdGen`).
232        run_id: String,
233        /// The sealed, content-addressed plan id this run executes (ADR-0002).
234        plan_id: String,
235        /// The chosen release version this run publishes — the human's approved
236        /// bump, sealed into `plan_id` and journalled as an input so `resume`
237        /// (wave-3) can reconstruct the plan from the durable record alone
238        /// (ADR-0002 §3; the plan module persists exactly `plan_id` + `version`).
239        ///
240        /// A **required** field of the v1 event, deliberately *not* `#[serde(default)]`:
241        /// a `RunCreated` without it is corrupt (a resume must never fabricate an
242        /// empty version and hash it into a wrong `plan_id`), so [`crate::release::journal::read_events`]
243        /// refuses such a line with an actionable error rather than defaulting to `""`.
244        version: String,
245        /// The ordered target set (e.g. `["rust", "node"]`).
246        targets: Vec<String>,
247    },
248    /// A phase barrier was entered.
249    PhaseEntered {
250        /// The barrier now in progress.
251        phase: Phase,
252    },
253    /// A phase barrier completed.
254    PhaseCompleted {
255        /// The barrier that completed.
256        phase: Phase,
257        /// Its outcome.
258        outcome: PhaseOutcome,
259    },
260    /// A target cleared its dry-run.
261    TargetDryRun {
262        /// The target id.
263        target: String,
264    },
265    /// A target's release artifact was built.
266    TargetBuilt {
267        /// The target id.
268        target: String,
269    },
270    /// A target was published — the point-of-no-return fact, with its receipt.
271    TargetPublished {
272        /// The target id.
273        target: String,
274        /// The receipt proving exactly what landed.
275        receipt: PublishReceipt,
276    },
277    /// A target was cancelled (skipped) with a reason.
278    TargetCancelled {
279        /// The target id.
280        target: String,
281        /// Why it was cancelled.
282        reason: String,
283    },
284    /// A **CI-delegated** target was skipped in the publish phase: its artifact is
285    /// produced out-of-band by the tag-triggered CI (e.g. `cargo-dist`'s
286    /// `release.yml`, a `release-please` merge job, or `PyPI`'s trusted-publisher
287    /// workflow), not by the engine's `publish` step. Distinct from
288    /// [`Self::TargetCancelled`] (a deliberate operator skip): a delegated target
289    /// is *expected* to land via CI, so resume/verify treat it as neither
290    /// engine-published nor missing/failed — the engine simply does not own it
291    /// (`release-engine-cut-cargo-dist-flow`).
292    TargetDelegated {
293        /// The target id.
294        target: String,
295        /// The adapter identity that declared itself CI-delegated (its wire
296        /// string, e.g. `"cargo-dist"`), for the operator-facing record.
297        adapter: String,
298    },
299    /// The release tag was created locally.
300    TagCreatedLocal {
301        /// The tag name.
302        tag: String,
303    },
304    /// The release tag was pushed to the remote.
305    TagPushedRemote {
306        /// The tag name.
307        tag: String,
308    },
309    /// The GitHub Release for the tag was created.
310    GithubReleaseCreated {
311        /// The tag name.
312        tag: String,
313        /// The Release URL, when GitHub reports one.
314        url: Option<String>,
315    },
316    /// The GitHub Release for the tag was **delegated to CI** — recorded in place
317    /// of [`Self::GithubReleaseCreated`]. The plan carries a CI-delegated target
318    /// (e.g. `cargo-dist`'s tag-triggered `release.yml`) whose workflow creates and
319    /// finalizes the Release and uploads the cross-platform binaries, so the
320    /// coordinator still creates and pushes the tag (that tag is what triggers CI)
321    /// but deliberately does **not** create the Release itself — doing so would
322    /// clash with CI over ownership of the same Release (creating it first, then CI
323    /// either fails on "release already exists" or uploads into an engine-created
324    /// stub). This fact is what lets resume/verify treat the missing engine-created
325    /// Release as intentional rather than a step to re-attempt
326    /// (`coordinator-release-vs-cargo-dist-ownership`).
327    GithubReleaseDelegated {
328        /// The tag name whose Release creation was delegated to CI.
329        tag: String,
330        /// The adapter identity whose CI owns the Release (its wire string, e.g.
331        /// `"cargo-dist"`) — the operator-facing record of *what* the Release was
332        /// delegated to, mirroring [`Self::TargetDelegated`]'s `adapter`.
333        delegated_to: String,
334    },
335    /// The run was abandoned. Terminal; there is **no** auto-rollback (ADR-0002).
336    RunAbandoned {
337        /// Why the run was abandoned.
338        reason: String,
339    },
340}
341
342/// One line of `journal.jsonl`: a schema-versioned, sequenced, timestamped
343/// envelope around an [`EventKind`].
344///
345/// The `kind` payload is `#[serde(flatten)]`ed so the on-disk line is a single
346/// flat JSON object (`{"schema_version":1,"seq":3,"ts":…,"idempotency_key":…,
347/// "kind":"target_published","target":"cargo","receipt":{…}}`), keeping it
348/// `jq`/`tail`-friendly (AGENTS-AI-FIRST-CLI §2).
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub struct JournalEvent {
351    /// The durable schema version of this event (see [`JOURNAL_SCHEMA_VERSION`]).
352    pub schema_version: u32,
353    /// Monotonic sequence number within the run, starting at 1. The reducer's
354    /// high-water mark: an event at or below the applied `seq` is a no-op on
355    /// replay (append-then-apply crash recovery, ADR-0003 §2).
356    pub seq: u64,
357    /// Event time as whole seconds since the Unix epoch, from the injected
358    /// `Clock` (never wall-clock directly, so runs are deterministic under test).
359    pub ts: u64,
360    /// Stable semantic key identifying *what subject* this event records (e.g.
361    /// `published:cargo`, `phase_completed:build`). It is **metadata**, not the
362    /// append gate: it deliberately ignores the payload (a phase's `outcome`, a
363    /// receipt's version), so it is safe for diagnostics and for a coordinator's
364    /// own "have I already acted on this subject?" lookups, but it must **not**
365    /// be used to suppress an append — a phase that completed `Failed` and then,
366    /// after a resume, completes `Ok` shares this key yet is a distinct fact that
367    /// must be recorded. Replay idempotency is provided by [`Self::seq`] (the
368    /// high-water mark), not by this key.
369    pub idempotency_key: String,
370    /// The event payload, flattened into this envelope.
371    #[serde(flatten)]
372    pub kind: EventKind,
373}
374
375impl EventKind {
376    /// The stable idempotency key for this event — its natural semantic identity,
377    /// so a retried step (re-publishing an already-published target, re-entering a
378    /// phase) resolves to the same key.
379    ///
380    /// This is **metadata only**: it is deliberately *not* used to suppress an
381    /// append (see [`JournalEvent::idempotency_key`]). Two events can share a key
382    /// yet be distinct facts that must both be recorded — a `phase_completed`
383    /// `Failed` and, after a resume, a `phase_completed` `Ok` for the same phase.
384    /// Replay idempotency comes from [`JournalEvent::seq`] (the watermark), never
385    /// from this key.
386    #[must_use]
387    pub fn idempotency_key(&self) -> String {
388        match self {
389            Self::RunCreated { .. } => "run_created".to_string(),
390            Self::PhaseEntered { phase } => format!("phase_entered:{}", phase.as_str()),
391            Self::PhaseCompleted { phase, .. } => {
392                format!("phase_completed:{}", phase.as_str())
393            }
394            Self::TargetDryRun { target } => format!("dry_run:{target}"),
395            Self::TargetBuilt { target } => format!("built:{target}"),
396            Self::TargetPublished { target, .. } => format!("published:{target}"),
397            Self::TargetCancelled { target, .. } => format!("cancelled:{target}"),
398            Self::TargetDelegated { target, .. } => format!("delegated:{target}"),
399            Self::TagCreatedLocal { tag } => format!("tag_created_local:{tag}"),
400            Self::TagPushedRemote { tag } => format!("tag_pushed_remote:{tag}"),
401            Self::GithubReleaseCreated { tag, .. } => format!("github_release_created:{tag}"),
402            Self::GithubReleaseDelegated { tag, .. } => format!("github_release_delegated:{tag}"),
403            Self::RunAbandoned { .. } => "run_abandoned".to_string(),
404        }
405    }
406}
407
408/// The materialized run state — the projection reduced from the event log and
409/// cached in `manifest.json`.
410///
411/// Every collection is a `BTree*`/sorted `Vec`, so the serialized manifest is
412/// **byte-deterministic** for a given event stream: the same events always
413/// produce the same JSON, which is what makes the manifest a trustworthy cache
414/// of the log. It is disposable — rebuild it any time with
415/// [`crate::release::journal::reduce`].
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct RunState {
418    /// Durable schema version of this state document.
419    pub schema_version: u32,
420    /// The run's unique id (empty only before the `RunCreated` event).
421    pub run_id: String,
422    /// The sealed plan id this run executes.
423    pub plan_id: String,
424    /// The chosen release version this run publishes (from the `RunCreated`
425    /// event) — the input `resume` reconstructs the plan against. Populated from
426    /// the required event field; the manifest is disposable and always rebuilt
427    /// from the log, so no cross-version default is needed here.
428    pub version: String,
429    /// The ordered target set, as declared at creation.
430    pub targets: Vec<String>,
431    /// The high-water sequence number folded into this state — the append-then-
432    /// apply watermark (ADR-0003 §2).
433    pub applied_seq: u64,
434    /// Derived run status.
435    pub status: RunStatus,
436    /// The phase currently in progress, if any.
437    pub current_phase: Option<Phase>,
438    /// Completed phases with their outcomes, sorted by phase order.
439    pub phases: Vec<PhaseRecord>,
440    /// Targets that cleared dry-run.
441    pub dry_run: BTreeSet<String>,
442    /// Targets whose artifact was built.
443    pub built: BTreeSet<String>,
444    /// Published targets → their receipts.
445    pub published: BTreeMap<String, PublishReceipt>,
446    /// Cancelled targets → their reasons.
447    pub cancelled: BTreeMap<String, String>,
448    /// CI-delegated targets (their ids): skipped in publish because a
449    /// tag-triggered CI job produces their artifact, not the engine. Neither
450    /// engine-published nor missing/failed (`release-engine-cut-cargo-dist-flow`).
451    /// `#[serde(default)]` so a v1 manifest that predates the field still
452    /// deserializes (the manifest is disposable and rebuilt from the log anyway).
453    #[serde(default)]
454    pub delegated: BTreeSet<String>,
455    /// Release tags → their landing progress.
456    pub tags: BTreeMap<String, TagState>,
457    /// The reason recorded by a `run_abandoned` event, if any.
458    pub abandon_reason: Option<String>,
459    /// Timestamp of the `RunCreated` event.
460    pub created_ts: u64,
461    /// Timestamp of the most recently applied event.
462    pub updated_ts: u64,
463}
464
465impl RunState {
466    /// The empty pre-`RunCreated` state the reducer folds events into. Carries
467    /// the current [`JOURNAL_SCHEMA_VERSION`] and [`RunStatus::InProgress`];
468    /// identity fields are filled by the first (`RunCreated`) event.
469    #[must_use]
470    pub fn empty() -> Self {
471        Self {
472            schema_version: JOURNAL_SCHEMA_VERSION,
473            run_id: String::new(),
474            plan_id: String::new(),
475            version: String::new(),
476            targets: Vec::new(),
477            applied_seq: 0,
478            status: RunStatus::InProgress,
479            current_phase: None,
480            phases: Vec::new(),
481            dry_run: BTreeSet::new(),
482            built: BTreeSet::new(),
483            published: BTreeMap::new(),
484            cancelled: BTreeMap::new(),
485            delegated: BTreeSet::new(),
486            tags: BTreeMap::new(),
487            abandon_reason: None,
488            created_ts: 0,
489            updated_ts: 0,
490        }
491    }
492}
493
494impl Default for RunState {
495    fn default() -> Self {
496        Self::empty()
497    }
498}