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.
41pub const JOURNAL_SCHEMA_VERSION: u32 = 1;
42
43/// The four coordinator phases, in barrier order (ADR-0002): the derived
44/// `PartialOrd`/`Ord` follows declaration order, so `DryRun < Build < Publish <
45/// Tag` — the order the projection sorts phase records in.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Phase {
49 /// Dry-run-all: every adapter proves it *can* publish, nothing lands.
50 DryRun,
51 /// Build-all: every adapter produces its release artifact.
52 Build,
53 /// Publish-all: artifacts are pushed to their registries (point of no return
54 /// per target — receipts are written per target before the next is tried).
55 Publish,
56 /// Tag-once: the coordinator (never an adapter) creates and pushes the tag
57 /// and the GitHub Release.
58 Tag,
59}
60
61impl Phase {
62 /// The wire string for this phase (matches the `Serialize` derive), so text
63 /// diagnostics and JSON never drift.
64 #[must_use]
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Self::DryRun => "dry_run",
68 Self::Build => "build",
69 Self::Publish => "publish",
70 Self::Tag => "tag",
71 }
72 }
73}
74
75/// How a phase barrier finished.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum PhaseOutcome {
79 /// Every target cleared the barrier.
80 Ok,
81 /// The barrier failed (at least one target did not clear it); the run does
82 /// not advance past a failed barrier.
83 Failed,
84}
85
86/// Terminal-or-not status of a run, derived from the event stream.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum RunStatus {
90 /// The run is live (created, not yet completed or abandoned).
91 InProgress,
92 /// The final [`Phase::Tag`] barrier completed [`PhaseOutcome::Ok`].
93 Completed,
94 /// A `run_abandoned` event was recorded (see [`RunState::abandon_reason`]).
95 Abandoned,
96}
97
98impl RunStatus {
99 /// The wire string for this status (matches the `Serialize` derive), so text
100 /// diagnostics and JSON never drift.
101 #[must_use]
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::InProgress => "in_progress",
105 Self::Completed => "completed",
106 Self::Abandoned => "abandoned",
107 }
108 }
109}
110
111/// The per-target publish receipt the journal persists — the fact "this exact
112/// artifact landed" that resume/reconcile checks against the registry (the
113/// remote is ground truth, ADR-0003 §4).
114///
115/// Written **per target before the next target is attempted**, never batched, so
116/// an interrupted publish-all leaves an accurate record of exactly what landed.
117/// Every descriptive field is optional so an adapter that cannot supply (say) a
118/// content digest still records a usable receipt.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct PublishReceipt {
121 /// The ecosystem the artifact was published to (`cargo`, `npm`, …).
122 pub ecosystem: String,
123 /// The published package/crate name, when the ecosystem has one.
124 pub package: Option<String>,
125 /// The version string that was published.
126 pub version: String,
127 /// The registry URL of the published artifact, when the adapter reports one.
128 pub registry_url: Option<String>,
129 /// A content digest of the published artifact, when available — the strongest
130 /// signal for `verify()`'s `Matches`/`Conflicts` decision on resume.
131 pub digest: Option<String>,
132}
133
134/// The progress of one release tag through its three landing steps. Every field
135/// is monotonic (`false → true`), so re-applying a tag event is a no-op.
136#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct TagState {
138 /// The annotated tag exists in the local repository.
139 pub created_local: bool,
140 /// The tag has been pushed to the remote.
141 pub pushed_remote: bool,
142 /// The GitHub Release for the tag has been created.
143 pub github_release: bool,
144 /// The GitHub Release URL, once created.
145 pub github_release_url: Option<String>,
146}
147
148/// One completed-phase record in the [`RunState`] projection.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct PhaseRecord {
151 /// Which barrier completed.
152 pub phase: Phase,
153 /// How it completed.
154 pub outcome: PhaseOutcome,
155}
156
157/// The payload of a journal event — the ADR-0002 event classes. Serialized
158/// **internally tagged** on a `kind` discriminator, flattened into the
159/// [`JournalEvent`] envelope so each JSONL line is one flat object.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(tag = "kind", rename_all = "snake_case")]
162pub enum EventKind {
163 /// The run was created: fixes its identity (`run_id`), the approved plan it
164 /// executes (`plan_id`), and the ordered target set. Always the first event.
165 RunCreated {
166 /// The run's unique id (a ULID from the injected `IdGen`).
167 run_id: String,
168 /// The sealed, content-addressed plan id this run executes (ADR-0002).
169 plan_id: String,
170 /// The chosen release version this run publishes — the human's approved
171 /// bump, sealed into `plan_id` and journalled as an input so `resume`
172 /// (wave-3) can reconstruct the plan from the durable record alone
173 /// (ADR-0002 §3; the plan module persists exactly `plan_id` + `version`).
174 ///
175 /// A **required** field of the v1 event, deliberately *not* `#[serde(default)]`:
176 /// a `RunCreated` without it is corrupt (a resume must never fabricate an
177 /// empty version and hash it into a wrong `plan_id`), so [`crate::release::journal::read_events`]
178 /// refuses such a line with an actionable error rather than defaulting to `""`.
179 version: String,
180 /// The ordered target set (e.g. `["rust", "node"]`).
181 targets: Vec<String>,
182 },
183 /// A phase barrier was entered.
184 PhaseEntered {
185 /// The barrier now in progress.
186 phase: Phase,
187 },
188 /// A phase barrier completed.
189 PhaseCompleted {
190 /// The barrier that completed.
191 phase: Phase,
192 /// Its outcome.
193 outcome: PhaseOutcome,
194 },
195 /// A target cleared its dry-run.
196 TargetDryRun {
197 /// The target id.
198 target: String,
199 },
200 /// A target's release artifact was built.
201 TargetBuilt {
202 /// The target id.
203 target: String,
204 },
205 /// A target was published — the point-of-no-return fact, with its receipt.
206 TargetPublished {
207 /// The target id.
208 target: String,
209 /// The receipt proving exactly what landed.
210 receipt: PublishReceipt,
211 },
212 /// A target was cancelled (skipped) with a reason.
213 TargetCancelled {
214 /// The target id.
215 target: String,
216 /// Why it was cancelled.
217 reason: String,
218 },
219 /// The release tag was created locally.
220 TagCreatedLocal {
221 /// The tag name.
222 tag: String,
223 },
224 /// The release tag was pushed to the remote.
225 TagPushedRemote {
226 /// The tag name.
227 tag: String,
228 },
229 /// The GitHub Release for the tag was created.
230 GithubReleaseCreated {
231 /// The tag name.
232 tag: String,
233 /// The Release URL, when GitHub reports one.
234 url: Option<String>,
235 },
236 /// The run was abandoned. Terminal; there is **no** auto-rollback (ADR-0002).
237 RunAbandoned {
238 /// Why the run was abandoned.
239 reason: String,
240 },
241}
242
243/// One line of `journal.jsonl`: a schema-versioned, sequenced, timestamped
244/// envelope around an [`EventKind`].
245///
246/// The `kind` payload is `#[serde(flatten)]`ed so the on-disk line is a single
247/// flat JSON object (`{"schema_version":1,"seq":3,"ts":…,"idempotency_key":…,
248/// "kind":"target_published","target":"cargo","receipt":{…}}`), keeping it
249/// `jq`/`tail`-friendly (AGENTS-AI-FIRST-CLI §2).
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct JournalEvent {
252 /// The durable schema version of this event (see [`JOURNAL_SCHEMA_VERSION`]).
253 pub schema_version: u32,
254 /// Monotonic sequence number within the run, starting at 1. The reducer's
255 /// high-water mark: an event at or below the applied `seq` is a no-op on
256 /// replay (append-then-apply crash recovery, ADR-0003 §2).
257 pub seq: u64,
258 /// Event time as whole seconds since the Unix epoch, from the injected
259 /// `Clock` (never wall-clock directly, so runs are deterministic under test).
260 pub ts: u64,
261 /// Stable semantic key identifying *what subject* this event records (e.g.
262 /// `published:cargo`, `phase_completed:build`). It is **metadata**, not the
263 /// append gate: it deliberately ignores the payload (a phase's `outcome`, a
264 /// receipt's version), so it is safe for diagnostics and for a coordinator's
265 /// own "have I already acted on this subject?" lookups, but it must **not**
266 /// be used to suppress an append — a phase that completed `Failed` and then,
267 /// after a resume, completes `Ok` shares this key yet is a distinct fact that
268 /// must be recorded. Replay idempotency is provided by [`Self::seq`] (the
269 /// high-water mark), not by this key.
270 pub idempotency_key: String,
271 /// The event payload, flattened into this envelope.
272 #[serde(flatten)]
273 pub kind: EventKind,
274}
275
276impl EventKind {
277 /// The stable idempotency key for this event — its natural semantic identity,
278 /// so a retried step (re-publishing an already-published target, re-entering a
279 /// phase) resolves to the same key.
280 ///
281 /// This is **metadata only**: it is deliberately *not* used to suppress an
282 /// append (see [`JournalEvent::idempotency_key`]). Two events can share a key
283 /// yet be distinct facts that must both be recorded — a `phase_completed`
284 /// `Failed` and, after a resume, a `phase_completed` `Ok` for the same phase.
285 /// Replay idempotency comes from [`JournalEvent::seq`] (the watermark), never
286 /// from this key.
287 #[must_use]
288 pub fn idempotency_key(&self) -> String {
289 match self {
290 Self::RunCreated { .. } => "run_created".to_string(),
291 Self::PhaseEntered { phase } => format!("phase_entered:{}", phase.as_str()),
292 Self::PhaseCompleted { phase, .. } => {
293 format!("phase_completed:{}", phase.as_str())
294 }
295 Self::TargetDryRun { target } => format!("dry_run:{target}"),
296 Self::TargetBuilt { target } => format!("built:{target}"),
297 Self::TargetPublished { target, .. } => format!("published:{target}"),
298 Self::TargetCancelled { target, .. } => format!("cancelled:{target}"),
299 Self::TagCreatedLocal { tag } => format!("tag_created_local:{tag}"),
300 Self::TagPushedRemote { tag } => format!("tag_pushed_remote:{tag}"),
301 Self::GithubReleaseCreated { tag, .. } => format!("github_release_created:{tag}"),
302 Self::RunAbandoned { .. } => "run_abandoned".to_string(),
303 }
304 }
305}
306
307/// The materialized run state — the projection reduced from the event log and
308/// cached in `manifest.json`.
309///
310/// Every collection is a `BTree*`/sorted `Vec`, so the serialized manifest is
311/// **byte-deterministic** for a given event stream: the same events always
312/// produce the same JSON, which is what makes the manifest a trustworthy cache
313/// of the log. It is disposable — rebuild it any time with
314/// [`crate::release::journal::reduce`].
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct RunState {
317 /// Durable schema version of this state document.
318 pub schema_version: u32,
319 /// The run's unique id (empty only before the `RunCreated` event).
320 pub run_id: String,
321 /// The sealed plan id this run executes.
322 pub plan_id: String,
323 /// The chosen release version this run publishes (from the `RunCreated`
324 /// event) — the input `resume` reconstructs the plan against. Populated from
325 /// the required event field; the manifest is disposable and always rebuilt
326 /// from the log, so no cross-version default is needed here.
327 pub version: String,
328 /// The ordered target set, as declared at creation.
329 pub targets: Vec<String>,
330 /// The high-water sequence number folded into this state — the append-then-
331 /// apply watermark (ADR-0003 §2).
332 pub applied_seq: u64,
333 /// Derived run status.
334 pub status: RunStatus,
335 /// The phase currently in progress, if any.
336 pub current_phase: Option<Phase>,
337 /// Completed phases with their outcomes, sorted by phase order.
338 pub phases: Vec<PhaseRecord>,
339 /// Targets that cleared dry-run.
340 pub dry_run: BTreeSet<String>,
341 /// Targets whose artifact was built.
342 pub built: BTreeSet<String>,
343 /// Published targets → their receipts.
344 pub published: BTreeMap<String, PublishReceipt>,
345 /// Cancelled targets → their reasons.
346 pub cancelled: BTreeMap<String, String>,
347 /// Release tags → their landing progress.
348 pub tags: BTreeMap<String, TagState>,
349 /// The reason recorded by a `run_abandoned` event, if any.
350 pub abandon_reason: Option<String>,
351 /// Timestamp of the `RunCreated` event.
352 pub created_ts: u64,
353 /// Timestamp of the most recently applied event.
354 pub updated_ts: u64,
355}
356
357impl RunState {
358 /// The empty pre-`RunCreated` state the reducer folds events into. Carries
359 /// the current [`JOURNAL_SCHEMA_VERSION`] and [`RunStatus::InProgress`];
360 /// identity fields are filled by the first (`RunCreated`) event.
361 #[must_use]
362 pub fn empty() -> Self {
363 Self {
364 schema_version: JOURNAL_SCHEMA_VERSION,
365 run_id: String::new(),
366 plan_id: String::new(),
367 version: String::new(),
368 targets: Vec::new(),
369 applied_seq: 0,
370 status: RunStatus::InProgress,
371 current_phase: None,
372 phases: Vec::new(),
373 dry_run: BTreeSet::new(),
374 built: BTreeSet::new(),
375 published: BTreeMap::new(),
376 cancelled: BTreeMap::new(),
377 tags: BTreeMap::new(),
378 abandon_reason: None,
379 created_ts: 0,
380 updated_ts: 0,
381 }
382 }
383}
384
385impl Default for RunState {
386 fn default() -> Self {
387 Self::empty()
388 }
389}