Skip to main content

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