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}