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)]`.
60///
61/// **v4** (2026-08-13): added the engine-owned version-bump phase
62/// (`release-rust-workspace-multicrate` facet 2/3) — a new [`Phase::Bump`] barrier
63/// value, the [`EventKind::BumpApplied`] event class, and the additive
64/// `RunCreated { head_sha, bump }` reconstruction fields. Each is a **value a v3
65/// reader cannot interpret** (a `bump` phase / a `bump_applied` line), so the
66/// migration rule requires its own bump: a v3 binary refuses a v4 line rather than
67/// misreading it. The reduce path stays backward-tolerant of v1–v3 logs (which lack
68/// the bump phase/event); the new `RunState` fields are `#[serde(default)]`.
69pub const JOURNAL_SCHEMA_VERSION: u32 = 4;
70
71/// The coordinator phases, in barrier order (ADR-0002): the derived
72/// `PartialOrd`/`Ord` follows declaration order, so `Bump < DryRun < Build <
73/// Publish < Tag < Dist` — the order the projection sorts phase records in.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum Phase {
77 /// Engine-owned version bump (present only for a `--bump` plan): edit the
78 /// workspace version + `=`-pins + lockfile + CHANGELOG in the clean checkout,
79 /// run any declared `bump_hook`, and commit — **before** dry-run-all, so every
80 /// later phase builds/publishes the bumped tree. Recorded once; idempotent on
81 /// resume via the [`EventKind::BumpApplied`] fact (never double-bumps).
82 Bump,
83 /// Dry-run-all: every adapter proves it *can* publish, nothing lands.
84 DryRun,
85 /// Build-all: every adapter produces its release artifact.
86 Build,
87 /// Publish-all: artifacts are pushed to their registries (point of no return
88 /// per target — receipts are written per target before the next is tried).
89 Publish,
90 /// Tag-once: the coordinator (never an adapter) creates and pushes the tag
91 /// and the GitHub Release.
92 Tag,
93 /// Dist (post-tag finalize): distribution targets whose artifact only exists
94 /// *after* the tag is pushed are finalized here — the Homebrew formula, whose
95 /// `url` is the just-created tag archive, is fetched, hashed, and its `.rb`
96 /// written with the real `sha256`. Runs after [`Self::Tag`]; its `Ok`
97 /// completion is what flips the run to [`RunStatus::Completed`]. A cut with no
98 /// post-tag target still runs this barrier as a clean no-op so completion is
99 /// uniform (ADR-0002 §2, extended by `release-engine-cut-cargo-dist-flow`).
100 Dist,
101}
102
103impl Phase {
104 /// The wire string for this phase (matches the `Serialize` derive), so text
105 /// diagnostics and JSON never drift.
106 #[must_use]
107 pub fn as_str(self) -> &'static str {
108 match self {
109 Self::Bump => "bump",
110 Self::DryRun => "dry_run",
111 Self::Build => "build",
112 Self::Publish => "publish",
113 Self::Tag => "tag",
114 Self::Dist => "dist",
115 }
116 }
117
118 /// Whether this phase is [`Self::Publish`] or a later barrier — i.e. a phase in
119 /// which a target could have had a publish side-effect on a registry. Written as
120 /// an explicit match (not `self >= Self::Publish`) so that inserting or
121 /// reordering a phase forces a deliberate review of this safety predicate rather
122 /// than silently changing it through the derived `Ord`. Used by the resume
123 /// reconcile's "publish phase reached" signal (ADR-0003 §4).
124 #[must_use]
125 pub fn is_publish_or_later(self) -> bool {
126 match self {
127 Self::Bump | Self::DryRun | Self::Build => false,
128 Self::Publish | Self::Tag | Self::Dist => true,
129 }
130 }
131}
132
133/// How a phase barrier finished.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum PhaseOutcome {
137 /// Every target cleared the barrier.
138 Ok,
139 /// The barrier failed (at least one target did not clear it); the run does
140 /// not advance past a failed barrier.
141 Failed,
142}
143
144/// Terminal-or-not status of a run, derived from the event stream.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum RunStatus {
148 /// The run is live (created, not yet completed or abandoned).
149 InProgress,
150 /// The final [`Phase::Dist`] barrier completed [`PhaseOutcome::Ok`].
151 Completed,
152 /// A `run_abandoned` event was recorded (see [`RunState::abandon_reason`]).
153 Abandoned,
154}
155
156impl RunStatus {
157 /// The wire string for this status (matches the `Serialize` derive), so text
158 /// diagnostics and JSON never drift.
159 #[must_use]
160 pub fn as_str(self) -> &'static str {
161 match self {
162 Self::InProgress => "in_progress",
163 Self::Completed => "completed",
164 Self::Abandoned => "abandoned",
165 }
166 }
167}
168
169/// The per-target publish receipt the journal persists — the fact "this exact
170/// artifact landed" that resume/reconcile checks against the registry (the
171/// remote is ground truth, ADR-0003 §4).
172///
173/// Written **per target before the next target is attempted**, never batched, so
174/// an interrupted publish-all leaves an accurate record of exactly what landed.
175/// Every descriptive field is optional so an adapter that cannot supply (say) a
176/// content digest still records a usable receipt.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct PublishReceipt {
179 /// The ecosystem the artifact was published to (`cargo`, `npm`, …).
180 pub ecosystem: String,
181 /// The published package/crate name, when the ecosystem has one.
182 pub package: Option<String>,
183 /// The version string that was published.
184 pub version: String,
185 /// The registry URL of the published artifact, when the adapter reports one.
186 pub registry_url: Option<String>,
187 /// A content digest of the published artifact, when available — the strongest
188 /// signal for `verify()`'s `Matches`/`Conflicts` decision on resume.
189 pub digest: Option<String>,
190}
191
192/// The progress of one release tag through its landing steps. Every field is a
193/// monotonic (`false → true`) fact set by its own journal event, so re-applying a
194/// tag event is a no-op. `created_local` and `pushed_remote` are orthogonal landing
195/// facts; `github_release` vs `github_release_delegated` are the two
196/// **mutually-exclusive** dispositions of the Release step (created-by-engine vs
197/// delegated-to-CI) — the coordinator writes exactly one, and refuses to record a
198/// second contradictory one (`crate::release::coordinator`'s tag phase), so the
199/// illegal both-true state is unreachable for a valid run. They stay flat flags
200/// (with a `clippy::struct_excessive_bools` allow) rather than a
201/// `ReleaseDisposition` enum for consistency with the surrounding flat-flag style
202/// and a `#[serde(default)]`-friendly additive wire shape; folding them into an
203/// enum is a tracked cleanup, not a correctness fix given the write-time guard.
204#[allow(clippy::struct_excessive_bools)]
205#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
206pub struct TagState {
207 /// The annotated tag exists in the local repository.
208 pub created_local: bool,
209 /// The tag has been pushed to the remote.
210 pub pushed_remote: bool,
211 /// The GitHub Release for the tag has been created.
212 pub github_release: bool,
213 /// The GitHub Release URL, once created.
214 pub github_release_url: Option<String>,
215 /// The GitHub Release was **delegated to CI** rather than created by the
216 /// coordinator: the plan carried a CI-delegated target (e.g. `cargo-dist`'s
217 /// `release.yml`) whose tag-triggered workflow owns Release creation and the
218 /// cross-platform binary upload. Mutually exclusive in practice with
219 /// [`Self::github_release`] — the coordinator either creates the Release or
220 /// delegates it, never both — and, like the others, monotonic (`false → true`).
221 /// Resume/verify treat a delegated Release as an intentional non-step, never a
222 /// missing one to re-attempt (`coordinator-release-vs-cargo-dist-ownership`).
223 /// `#[serde(default)]` so a pre-field manifest still deserializes (the manifest
224 /// is disposable and rebuilt from the log anyway).
225 #[serde(default)]
226 pub github_release_delegated: bool,
227}
228
229/// The engine-owned bump inputs a `--bump` run persists so `release resume` can
230/// reconstruct the **exact** sealed plan after an interruption.
231///
232/// A `--bump` cut applies the version bump as its first phase, which advances the
233/// working tree's HEAD past the plan's sealed (pre-bump) commit. Resume must
234/// re-derive the plan against the *sealed* commit + the recorded bump level, not the
235/// moved live HEAD, or its recomputed `plan_id` would never match. Persisted on the
236/// `RunCreated` event (before the bump runs), so it is available whether the
237/// interruption happened before or after the bump landed. `None`/absent for a
238/// no-bump run (the default path).
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct BumpInputs {
241 /// The semantic bump level (`major` / `minor` / `patch`) the plan was sealed with.
242 pub level: String,
243 /// The manifest version the bump was computed **from** (the pre-bump workspace
244 /// version) — the input `bump_version(level, from_version)` reproduces `to_version`
245 /// from.
246 pub from_version: String,
247}
248
249/// The record of an **applied** engine bump — the durable fact that makes resume
250/// idempotent (never double-bumps).
251///
252/// Written by the [`Phase::Bump`] barrier once the version/pin/lockfile/CHANGELOG
253/// edits are committed and any `bump_hook` has run. Its presence flips the run's bump
254/// state from *not-started* to *applied*: a resumed cut that sees it skips the bump
255/// entirely and continues from the recorded [`Self::commit`] (which the tag phase then
256/// points at). The [`Self::effective_date`] is journalled once here and reused on
257/// resume so a run spanning midnight cannot re-date the CHANGELOG.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct BumpRecord {
260 /// The commit sha the bump edits landed in — the commit the release tag points at
261 /// (not the pre-bump sealed HEAD).
262 pub commit: String,
263 /// The `YYYY-MM-DD` date the CHANGELOG's `[Unreleased]` section was finalized
264 /// under — journalled once so resume reuses it verbatim.
265 pub effective_date: String,
266}
267
268/// One completed-phase record in the [`RunState`] projection.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct PhaseRecord {
271 /// Which barrier completed.
272 pub phase: Phase,
273 /// How it completed.
274 pub outcome: PhaseOutcome,
275}
276
277/// The payload of a journal event — the ADR-0002 event classes. Serialized
278/// **internally tagged** on a `kind` discriminator, flattened into the
279/// [`JournalEvent`] envelope so each JSONL line is one flat object.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(tag = "kind", rename_all = "snake_case")]
282pub enum EventKind {
283 /// The run was created: fixes its identity (`run_id`), the approved plan it
284 /// executes (`plan_id`), and the ordered target set. Always the first event.
285 RunCreated {
286 /// The run's unique id (a ULID from the injected `IdGen`).
287 run_id: String,
288 /// The sealed, content-addressed plan id this run executes (ADR-0002).
289 plan_id: String,
290 /// The chosen release version this run publishes — the human's approved
291 /// bump, sealed into `plan_id` and journalled as an input so `resume`
292 /// (wave-3) can reconstruct the plan from the durable record alone
293 /// (ADR-0002 §3; the plan module persists exactly `plan_id` + `version`).
294 ///
295 /// A **required** field of the v1 event, deliberately *not* `#[serde(default)]`:
296 /// a `RunCreated` without it is corrupt (a resume must never fabricate an
297 /// empty version and hash it into a wrong `plan_id`), so [`crate::release::journal::read_events`]
298 /// refuses such a line with an actionable error rather than defaulting to `""`.
299 version: String,
300 /// The ordered target set (e.g. `["rust", "node"]`).
301 targets: Vec<String>,
302 /// The git HEAD the plan was sealed against, when persisted (v4+). Resume
303 /// reconstructs a `--bump` plan against this **sealed** commit rather than the
304 /// live HEAD the bump commit moved past. Additive/optional: a pre-v4 log omits
305 /// it (`#[serde(default)]`), and a no-bump run does not depend on it.
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 head_sha: Option<String>,
308 /// The engine-owned bump inputs when this run executes a `--bump` plan; `None`
309 /// for the default no-bump path. Persisted so resume can recompute the exact
310 /// sealed plan (`build_with_bump`) from the level + pre-bump version.
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 bump: Option<BumpInputs>,
313 },
314 /// The engine version bump was applied and committed (`--bump` runs only) — the
315 /// idempotency fact that stops a resume from double-bumping. Recorded by the
316 /// [`Phase::Bump`] barrier after the version/pin/lockfile/CHANGELOG edits are
317 /// committed and any `bump_hook` has run.
318 BumpApplied {
319 /// The bump commit sha (the commit the release tag points at).
320 commit: String,
321 /// The `YYYY-MM-DD` CHANGELOG effective date, journalled once and reused on
322 /// resume.
323 effective_date: String,
324 },
325 /// A phase barrier was entered.
326 PhaseEntered {
327 /// The barrier now in progress.
328 phase: Phase,
329 },
330 /// A phase barrier completed.
331 PhaseCompleted {
332 /// The barrier that completed.
333 phase: Phase,
334 /// Its outcome.
335 outcome: PhaseOutcome,
336 },
337 /// A target cleared its dry-run.
338 TargetDryRun {
339 /// The target id.
340 target: String,
341 },
342 /// A target's release artifact was built.
343 TargetBuilt {
344 /// The target id.
345 target: String,
346 },
347 /// A target was published — the point-of-no-return fact, with its receipt.
348 TargetPublished {
349 /// The target id.
350 target: String,
351 /// The receipt proving exactly what landed.
352 receipt: PublishReceipt,
353 },
354 /// A target was cancelled (skipped) with a reason.
355 TargetCancelled {
356 /// The target id.
357 target: String,
358 /// Why it was cancelled.
359 reason: String,
360 },
361 /// A **CI-delegated** target was skipped in the publish phase: its artifact is
362 /// produced out-of-band by the tag-triggered CI (e.g. `cargo-dist`'s
363 /// `release.yml`, a `release-please` merge job, or `PyPI`'s trusted-publisher
364 /// workflow), not by the engine's `publish` step. Distinct from
365 /// [`Self::TargetCancelled`] (a deliberate operator skip): a delegated target
366 /// is *expected* to land via CI, so resume/verify treat it as neither
367 /// engine-published nor missing/failed — the engine simply does not own it
368 /// (`release-engine-cut-cargo-dist-flow`).
369 TargetDelegated {
370 /// The target id.
371 target: String,
372 /// The adapter identity that declared itself CI-delegated (its wire
373 /// string, e.g. `"cargo-dist"`), for the operator-facing record.
374 adapter: String,
375 },
376 /// The release tag was created locally.
377 TagCreatedLocal {
378 /// The tag name.
379 tag: String,
380 },
381 /// The release tag was pushed to the remote.
382 TagPushedRemote {
383 /// The tag name.
384 tag: String,
385 },
386 /// The GitHub Release for the tag was created.
387 GithubReleaseCreated {
388 /// The tag name.
389 tag: String,
390 /// The Release URL, when GitHub reports one.
391 url: Option<String>,
392 },
393 /// The GitHub Release for the tag was **delegated to CI** — recorded in place
394 /// of [`Self::GithubReleaseCreated`]. The plan carries a CI-delegated target
395 /// (e.g. `cargo-dist`'s tag-triggered `release.yml`) whose workflow creates and
396 /// finalizes the Release and uploads the cross-platform binaries, so the
397 /// coordinator still creates and pushes the tag (that tag is what triggers CI)
398 /// but deliberately does **not** create the Release itself — doing so would
399 /// clash with CI over ownership of the same Release (creating it first, then CI
400 /// either fails on "release already exists" or uploads into an engine-created
401 /// stub). This fact is what lets resume/verify treat the missing engine-created
402 /// Release as intentional rather than a step to re-attempt
403 /// (`coordinator-release-vs-cargo-dist-ownership`).
404 GithubReleaseDelegated {
405 /// The tag name whose Release creation was delegated to CI.
406 tag: String,
407 /// The adapter identity whose CI owns the Release (its wire string, e.g.
408 /// `"cargo-dist"`) — the operator-facing record of *what* the Release was
409 /// delegated to, mirroring [`Self::TargetDelegated`]'s `adapter`.
410 delegated_to: String,
411 },
412 /// The run was abandoned. Terminal; there is **no** auto-rollback (ADR-0002).
413 RunAbandoned {
414 /// Why the run was abandoned.
415 reason: String,
416 },
417}
418
419/// One line of `journal.jsonl`: a schema-versioned, sequenced, timestamped
420/// envelope around an [`EventKind`].
421///
422/// The `kind` payload is `#[serde(flatten)]`ed so the on-disk line is a single
423/// flat JSON object (`{"schema_version":1,"seq":3,"ts":…,"idempotency_key":…,
424/// "kind":"target_published","target":"cargo","receipt":{…}}`), keeping it
425/// `jq`/`tail`-friendly (AGENTS-AI-FIRST-CLI §2).
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct JournalEvent {
428 /// The durable schema version of this event (see [`JOURNAL_SCHEMA_VERSION`]).
429 pub schema_version: u32,
430 /// Monotonic sequence number within the run, starting at 1. The reducer's
431 /// high-water mark: an event at or below the applied `seq` is a no-op on
432 /// replay (append-then-apply crash recovery, ADR-0003 §2).
433 pub seq: u64,
434 /// Event time as whole seconds since the Unix epoch, from the injected
435 /// `Clock` (never wall-clock directly, so runs are deterministic under test).
436 pub ts: u64,
437 /// Stable semantic key identifying *what subject* this event records (e.g.
438 /// `published:cargo`, `phase_completed:build`). It is **metadata**, not the
439 /// append gate: it deliberately ignores the payload (a phase's `outcome`, a
440 /// receipt's version), so it is safe for diagnostics and for a coordinator's
441 /// own "have I already acted on this subject?" lookups, but it must **not**
442 /// be used to suppress an append — a phase that completed `Failed` and then,
443 /// after a resume, completes `Ok` shares this key yet is a distinct fact that
444 /// must be recorded. Replay idempotency is provided by [`Self::seq`] (the
445 /// high-water mark), not by this key.
446 pub idempotency_key: String,
447 /// The event payload, flattened into this envelope.
448 #[serde(flatten)]
449 pub kind: EventKind,
450}
451
452impl EventKind {
453 /// The stable idempotency key for this event — its natural semantic identity,
454 /// so a retried step (re-publishing an already-published target, re-entering a
455 /// phase) resolves to the same key.
456 ///
457 /// This is **metadata only**: it is deliberately *not* used to suppress an
458 /// append (see [`JournalEvent::idempotency_key`]). Two events can share a key
459 /// yet be distinct facts that must both be recorded — a `phase_completed`
460 /// `Failed` and, after a resume, a `phase_completed` `Ok` for the same phase.
461 /// Replay idempotency comes from [`JournalEvent::seq`] (the watermark), never
462 /// from this key.
463 #[must_use]
464 pub fn idempotency_key(&self) -> String {
465 match self {
466 Self::RunCreated { .. } => "run_created".to_string(),
467 Self::BumpApplied { .. } => "bump_applied".to_string(),
468 Self::PhaseEntered { phase } => format!("phase_entered:{}", phase.as_str()),
469 Self::PhaseCompleted { phase, .. } => {
470 format!("phase_completed:{}", phase.as_str())
471 }
472 Self::TargetDryRun { target } => format!("dry_run:{target}"),
473 Self::TargetBuilt { target } => format!("built:{target}"),
474 Self::TargetPublished { target, .. } => format!("published:{target}"),
475 Self::TargetCancelled { target, .. } => format!("cancelled:{target}"),
476 Self::TargetDelegated { target, .. } => format!("delegated:{target}"),
477 Self::TagCreatedLocal { tag } => format!("tag_created_local:{tag}"),
478 Self::TagPushedRemote { tag } => format!("tag_pushed_remote:{tag}"),
479 Self::GithubReleaseCreated { tag, .. } => format!("github_release_created:{tag}"),
480 Self::GithubReleaseDelegated { tag, .. } => format!("github_release_delegated:{tag}"),
481 Self::RunAbandoned { .. } => "run_abandoned".to_string(),
482 }
483 }
484}
485
486/// The materialized run state — the projection reduced from the event log and
487/// cached in `manifest.json`.
488///
489/// Every collection is a `BTree*`/sorted `Vec`, so the serialized manifest is
490/// **byte-deterministic** for a given event stream: the same events always
491/// produce the same JSON, which is what makes the manifest a trustworthy cache
492/// of the log. It is disposable — rebuild it any time with
493/// [`crate::release::journal::reduce`].
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct RunState {
496 /// Durable schema version of this state document.
497 pub schema_version: u32,
498 /// The run's unique id (empty only before the `RunCreated` event).
499 pub run_id: String,
500 /// The sealed plan id this run executes.
501 pub plan_id: String,
502 /// The chosen release version this run publishes (from the `RunCreated`
503 /// event) — the input `resume` reconstructs the plan against. Populated from
504 /// the required event field; the manifest is disposable and always rebuilt
505 /// from the log, so no cross-version default is needed here.
506 pub version: String,
507 /// The ordered target set, as declared at creation.
508 pub targets: Vec<String>,
509 /// The git HEAD the plan was sealed against, when the `RunCreated` event carried
510 /// it (v4+). `None` for a pre-v4 or no-bump run. Resume reconstructs a `--bump`
511 /// plan against this sealed commit rather than the moved live HEAD.
512 #[serde(default)]
513 pub head_sha: Option<String>,
514 /// The engine-owned bump inputs (level + pre-bump version) for a `--bump` run;
515 /// `None` for the default no-bump path. Present ⇒ this is a bump run, so resume
516 /// reconstructs the plan via `build_with_bump`.
517 #[serde(default)]
518 pub bump_inputs: Option<BumpInputs>,
519 /// The applied-bump record once the [`Phase::Bump`] barrier committed
520 /// (`BumpApplied`). `None` = not-yet-applied (a bump run mid-flight or a no-bump
521 /// run); `Some` = applied, so resume never re-bumps and the tag points at
522 /// [`BumpRecord::commit`].
523 #[serde(default)]
524 pub bump: Option<BumpRecord>,
525 /// The high-water sequence number folded into this state — the append-then-
526 /// apply watermark (ADR-0003 §2).
527 pub applied_seq: u64,
528 /// Derived run status.
529 pub status: RunStatus,
530 /// The phase currently in progress, if any.
531 pub current_phase: Option<Phase>,
532 /// Completed phases with their outcomes, sorted by phase order.
533 pub phases: Vec<PhaseRecord>,
534 /// Targets that cleared dry-run.
535 pub dry_run: BTreeSet<String>,
536 /// Targets whose artifact was built.
537 pub built: BTreeSet<String>,
538 /// Published targets → their receipts.
539 pub published: BTreeMap<String, PublishReceipt>,
540 /// Cancelled targets → their reasons.
541 pub cancelled: BTreeMap<String, String>,
542 /// CI-delegated targets (their ids): skipped in publish because a
543 /// tag-triggered CI job produces their artifact, not the engine. Neither
544 /// engine-published nor missing/failed (`release-engine-cut-cargo-dist-flow`).
545 /// `#[serde(default)]` so a v1 manifest that predates the field still
546 /// deserializes (the manifest is disposable and rebuilt from the log anyway).
547 #[serde(default)]
548 pub delegated: BTreeSet<String>,
549 /// Release tags → their landing progress.
550 pub tags: BTreeMap<String, TagState>,
551 /// The reason recorded by a `run_abandoned` event, if any.
552 pub abandon_reason: Option<String>,
553 /// Timestamp of the `RunCreated` event.
554 pub created_ts: u64,
555 /// Timestamp of the most recently applied event.
556 pub updated_ts: u64,
557}
558
559impl RunState {
560 /// The empty pre-`RunCreated` state the reducer folds events into. Carries
561 /// the current [`JOURNAL_SCHEMA_VERSION`] and [`RunStatus::InProgress`];
562 /// identity fields are filled by the first (`RunCreated`) event.
563 #[must_use]
564 pub fn empty() -> Self {
565 Self {
566 schema_version: JOURNAL_SCHEMA_VERSION,
567 run_id: String::new(),
568 plan_id: String::new(),
569 version: String::new(),
570 targets: Vec::new(),
571 head_sha: None,
572 bump_inputs: None,
573 bump: None,
574 applied_seq: 0,
575 status: RunStatus::InProgress,
576 current_phase: None,
577 phases: Vec::new(),
578 dry_run: BTreeSet::new(),
579 built: BTreeSet::new(),
580 published: BTreeMap::new(),
581 cancelled: BTreeMap::new(),
582 delegated: BTreeSet::new(),
583 tags: BTreeMap::new(),
584 abandon_reason: None,
585 created_ts: 0,
586 updated_ts: 0,
587 }
588 }
589}
590
591impl Default for RunState {
592 fn default() -> Self {
593 Self::empty()
594 }
595}