Skip to main content

majordomus_cli/mesh/
journal.rs

1//! The cooperation journal: the one replicated record of what the runtimes of a repository
2//! said to each other. Discovery ([`super::registry`]) answers "which runtimes exist";
3//! the journal answers "what are they doing" — sessions, claims, handovers, reviews —
4//! as signed events that every linked runtime stores, relays and folds into the same
5//! state ([`super::state`]).
6//!
7//! The guarantees are exactly these, and the tests hold each one:
8//!
9//! - **Identity.** An event is written by one *stream* — one run of one runtime of one
10//!   node ([`StreamId`]) — and numbered densely from 1 within it. `(stream, seq)` is the
11//!   event's identity everywhere.
12//! - **Authenticity end to end.** The origin signs every event with its node key; a relay
13//!   forwards the bytes and vouches for nothing, so a relay cannot forge, alter or
14//!   re-attribute an event.
15//! - **At-least-once delivery, idempotent application.** An event received twice is a
16//!   counted duplicate and changes nothing; the application order within a stream is its
17//!   sequence, never the arrival order — an early arrival waits (bounded) for its gap.
18//! - **No loops.** Replication is a comparison of high-water marks, not a flood: a
19//!   runtime sends a peer only what the peer's marks say it lacks, so an event reaches
20//!   every runtime once per link and stops.
21//! - **StreamLiveness without shared clocks.** Each stream carries a beat its origin raises on
22//!   every heartbeat; a mark relays the beat with how long ago the sender saw it rise.
23//!   A runtime computes freshness on its own monotonic clock — no two machines' wall
24//!   clocks are ever compared.
25//!
26//! What it does not promise: exactly-once delivery, a global order across streams beyond
27//! the Lamport stamp, or consistency under partition. Two sides of a partition may both
28//! act; the fold makes the result deterministic and names the conflict (ADR 0067).
29//!
30//! ```
31//! use majordomus_cli::mesh::identity::NodeIdentity;
32//! use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo};
33//! use std::sync::Arc;
34//!
35//! let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
36//!     "repo".into(), None).unwrap();
37//! let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
38//!     "repo".into(), None).unwrap();
39//! a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "docs") }).unwrap();
40//!
41//! // replication: B tells A what it has, A answers with what B lacks, B ingests it
42//! let missing = a.missing_for(&b.marks(), 1 << 20);
43//! let report = b.ingest(&missing, &|_| Ok(()));
44//! assert_eq!(report.accepted, 1);
45//! // delivered again, it is a duplicate and nothing changes
46//! assert_eq!(b.ingest(&missing, &|_| Ok(())).duplicate, 1);
47//! ```
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::io::Write;
51use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex};
53use std::time::{Duration, Instant};
54
55use schemars::JsonSchema;
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58
59use super::identity::{node_id_of_key, verify, NodeIdentity};
60use super::MeshError;
61
62/// The event format version this executable writes and reads.
63pub const EVENT_VERSION: u32 = 1;
64
65/// The largest event, serialized, in bytes. A handover body is the largest payload and
66/// is bounded below this.
67pub const MAX_EVENT_BYTES: usize = 48 * 1024;
68
69/// The largest handover body an event carries, in bytes.
70pub const MAX_HANDOVER_BYTES: usize = 32 * 1024;
71
72/// The most events one journal holds before dead streams are compacted away.
73pub const MAX_EVENTS: usize = 20_000;
74
75/// The most streams one journal tracks.
76pub const MAX_STREAMS: usize = 1024;
77
78/// The most out-of-order events held per stream while their gap is open.
79pub const MAX_PENDING: usize = 256;
80
81/// The most paths one claim or review names.
82pub const MAX_SCOPE_PATHS: usize = 64;
83
84/// The most streams one node may occupy: runtime and instance ids are free to invent, so a
85/// single trusted key must not be able to fill the stream table.
86pub const MAX_STREAMS_PER_NODE: usize = 64;
87
88/// How many streams of one node make its dead streams due for compaction: three quarters of
89/// [`MAX_STREAMS_PER_NODE`]. Every worktree of a machine signs with the machine's one key
90/// and every server restart opens a new stream, so a machine reaches its quota by running,
91/// not by misbehaving — and one node alone never reaches the journal-wide bounds that
92/// otherwise start a compaction. Compacting the node at three quarters keeps the last
93/// quarter free for the runs to come.
94pub const NODE_COMPACTION_STREAMS: usize = MAX_STREAMS_PER_NODE * 3 / 4;
95
96/// How long past its retention a dead stream is kept for a handover it published that nobody
97/// has taken: a day. A handover is continuity meant to outlive the run that wrote it, so it
98/// holds its stream longer than anything else does, but only this long: a stream held is a
99/// slot of its node's quota ([`MAX_STREAMS_PER_NODE`]), and a machine whose runs each handed
100/// over something nobody took would otherwise fill its quota with the dead and have every
101/// later run refused. A handover somebody has taken holds nothing. One that lapses is still
102/// its author's record, and publishing it again offers it again under the same id.
103pub const HANDOVER_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
104
105/// The most bytes of the journal file a reload reads: 64 MiB.
106///
107/// The journal holds at most [`MAX_EVENTS`] events in memory, and compaction rewrites the
108/// file from what it holds, so a file kept by this executable stays far below the bound. A
109/// file beyond it was written by something else, or grew while compaction could not
110/// replace it; reading it whole would put an unbounded file into memory before a single
111/// bound is checked. A reload reads the first `MAX_RELOAD_BYTES` up to the last whole
112/// line, and the rest is left on disk, counted in [`JournalTallies::unread_bytes`] and
113/// named by `mesh doctor` — never dropped in silence, and kept by compaction.
114pub const MAX_RELOAD_BYTES: u64 = 64 * 1024 * 1024;
115
116/// The most events (held and pending) one node may occupy.
117pub const MAX_EVENTS_PER_NODE: usize = 20_000;
118
119/// The most out-of-order events the whole journal holds at once.
120pub const MAX_PENDING_TOTAL: usize = 4096;
121
122/// How far ahead of this runtime's Lamport clock a received event's stamp may be: 2^32.
123///
124/// A Lamport stamp is what its writer says about itself, and every receiver raises its own
125/// clock to the highest stamp it stores. Unbounded, one event from a trusted key stamped
126/// near `u64::MAX` would drag every clock in the mesh to the end of its range in a single
127/// round, after which every event written anywhere carries the same saturated stamp and the
128/// fold no longer orders them causally. A legitimate stamp is never further ahead than the
129/// number of events written in the repository's whole history — each event rises one above
130/// what its writer had seen — so the bound is far above anything a real mesh reaches, a
131/// fresh runtime joining at clock zero included. What it changes is the cost of the attack:
132/// the clock rises at most this much per event stored, so exhausting it takes 2^32 signed,
133/// stored events rather than one. An event past the bound is refused
134/// [`Rejection::ClockAhead`], on arrival and from a reloaded file alike.
135///
136/// ```
137/// use std::sync::Arc;
138/// use majordomus_cli::mesh::identity::NodeIdentity;
139/// use majordomus_cli::mesh::journal::{EventBody, Journal, Rejection, MAX_LAMPORT_LEAD};
140///
141/// let writer = Arc::new(NodeIdentity::ephemeral().unwrap());
142/// let a = Journal::open(Arc::clone(&writer), "0000000000000001", "repo".into(), None).unwrap();
143/// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
144///     "repo".into(), None).unwrap();
145/// let mut event = a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
146///
147/// // signed by a key B trusts, and stamped further ahead of B's clock than the bound allows
148/// event.lamport = MAX_LAMPORT_LEAD + 1;
149/// event.sig = writer.sign(&event.signing_bytes());
150/// let report = b.ingest(&[event.clone()], &|_| Ok(()));
151/// assert_eq!(report.rejected[&Rejection::ClockAhead], 1);
152/// assert_eq!(b.tallies().lamport, 0, "and B's clock did not move");
153///
154/// // exactly at the bound it is an ordinary event
155/// event.lamport = MAX_LAMPORT_LEAD;
156/// event.sig = writer.sign(&event.signing_bytes());
157/// assert_eq!(b.ingest(&[event], &|_| Ok(())).accepted, 1);
158/// ```
159pub const MAX_LAMPORT_LEAD: u64 = 1 << 32;
160
161/// The domain separator of an event signature: a signature over an event can never be
162/// replayed as a signature over an advertisement or a link message.
163const SIGNING_DOMAIN: &[u8] = b"majordomus-mesh-event/v1\n";
164
165/// One stream: a run of one runtime of one node. `<node 32 hex>-<runtime 16 hex>-<instance
166/// 16 hex>`. The node is the machine's key, the runtime is one checkout's server on that
167/// machine, the instance is one process run of that server — so two worktrees on one
168/// machine are two streams, and a restart is a new stream of the same runtime.
169///
170/// Numbering events within a stream rather than globally is what makes replication cheap
171/// and unambiguous: a gap in one stream is visible without coordinating with anyone, and
172/// `(stream, seq)` identifies an event on every machine that holds it.
173///
174/// ```
175/// use majordomus_cli::mesh::journal::StreamId;
176///
177/// let id = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
178/// assert_eq!(id.node(), "a".repeat(32));
179/// assert_eq!(id.runtime(), "b".repeat(16));
180///
181/// // a restart is a new stream, and the same runtime
182/// let after = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"d".repeat(16)).unwrap();
183/// assert_ne!(after, id);
184/// assert_eq!(after.runtime_key(), id.runtime_key());
185/// ```
186#[derive(
187    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
188)]
189#[serde(try_from = "String", into = "String")]
190pub struct StreamId(String);
191
192/// A stream id off the wire is parsed, never trusted: every accessor slices at fixed
193/// widths, so an unparsed id would be a panic waiting in a peer's marks.
194impl TryFrom<String> for StreamId {
195    type Error = String;
196    fn try_from(text: String) -> Result<Self, Self::Error> {
197        StreamId::parse(&text).ok_or_else(|| format!("'{text}' is not a stream id"))
198    }
199}
200
201impl From<StreamId> for String {
202    fn from(id: StreamId) -> String {
203        id.0
204    }
205}
206
207impl StreamId {
208    /// A stream id from its three parts; `None` unless each is lowercase hex of its width.
209    ///
210    /// The widths are checked here rather than trusted, because every accessor slices the
211    /// text at fixed offsets: an id built from a part of the wrong length would be a panic
212    /// waiting in a peer's marks, and the parts often come from another machine.
213    ///
214    /// ```
215    /// use majordomus_cli::mesh::journal::StreamId;
216    ///
217    /// assert!(StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).is_some());
218    /// assert!(StreamId::new("short", &"b".repeat(16), &"c".repeat(16)).is_none());
219    /// assert!(StreamId::new(&"A".repeat(32), &"b".repeat(16), &"c".repeat(16)).is_none(),
220    ///     "one spelling only: lowercase hex");
221    /// ```
222    pub fn new(node: &str, runtime: &str, instance: &str) -> Option<Self> {
223        Self::parse(&format!("{node}-{runtime}-{instance}"))
224    }
225
226    /// Parse a stream id, refusing anything that is not exactly the three hex parts.
227    ///
228    /// ```
229    /// use majordomus_cli::mesh::journal::StreamId;
230    /// assert!(StreamId::parse("nope").is_none());
231    /// let id = format!("{}-{}-{}", "a".repeat(32), "b".repeat(16), "c".repeat(16));
232    /// assert_eq!(StreamId::parse(&id).unwrap().runtime_key(), format!("{}-{}", "a".repeat(32), "b".repeat(16)));
233    /// ```
234    pub fn parse(text: &str) -> Option<Self> {
235        let parts: Vec<&str> = text.split('-').collect();
236        let ok = parts.len() == 3
237            && parts[0].len() == 32
238            && parts[1].len() == 16
239            && parts[2].len() == 16
240            && parts.iter().all(|p| {
241                p.bytes()
242                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
243            });
244        ok.then(|| StreamId(text.to_string()))
245    }
246
247    /// The node (machine key digest) part: which machine's key signs everything this
248    /// stream carries. Two streams that share a node are two servers of one machine, and
249    /// the per-node bounds of the journal are counted against this.
250    ///
251    /// ```
252    /// use majordomus_cli::mesh::journal::StreamId;
253    ///
254    /// let id = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
255    /// let sibling = StreamId::new(&"a".repeat(32), &"e".repeat(16), &"f".repeat(16)).unwrap();
256    /// assert_eq!(id.node(), sibling.node(), "two runtimes of one machine");
257    /// assert_ne!(id.runtime_key(), sibling.runtime_key());
258    /// ```
259    pub fn node(&self) -> &str {
260        &self.0[..32]
261    }
262
263    /// The runtime part: which server of that machine — one per checkout — so two
264    /// worktrees of one clone are told apart and can claim against each other.
265    ///
266    /// ```
267    /// use majordomus_cli::mesh::journal::StreamId;
268    ///
269    /// let id = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
270    /// assert_eq!(id.runtime(), "b".repeat(16));
271    /// assert_eq!(id.runtime_key(), format!("{}-{}", id.node(), id.runtime()));
272    /// ```
273    pub fn runtime(&self) -> &str {
274        &self.0[33..49]
275    }
276
277    /// The instance part: which run of that server. It is what makes a restart visible —
278    /// the same runtime with a new instance has a fresh sequence, so a peer knows to say
279    /// hello again rather than resume a link whose counters no longer mean anything.
280    ///
281    /// ```
282    /// use majordomus_cli::mesh::journal::StreamId;
283    ///
284    /// let before = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
285    /// let after = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"d".repeat(16)).unwrap();
286    /// assert_ne!(before.instance(), after.instance(), "a restart is a new run");
287    /// assert_eq!(before.runtime(), after.runtime(), "of the same server");
288    /// ```
289    pub fn instance(&self) -> &str {
290        &self.0[50..66]
291    }
292
293    /// `<node>-<runtime>`: the durable identity of a runtime across its restarts. It is
294    /// what the peer table, a claim key and `mesh peer` are all keyed by, because a worker
295    /// cares which server it is talking to and not which run of it.
296    ///
297    /// ```
298    /// use majordomus_cli::mesh::journal::StreamId;
299    ///
300    /// let before = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
301    /// let after = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"d".repeat(16)).unwrap();
302    /// assert_eq!(before.runtime_key(), after.runtime_key(), "one peer, two runs");
303    /// assert_eq!(before.runtime_key().len(), 49);
304    /// ```
305    pub fn runtime_key(&self) -> String {
306        self.0[..49].to_string()
307    }
308
309    /// The id as text, in the one spelling every surface carries: a map key, a mark, a
310    /// claim's prefix and a log line all print this, so an id read anywhere can be compared
311    /// with an id read anywhere else.
312    pub fn as_str(&self) -> &str {
313        &self.0
314    }
315}
316
317impl std::fmt::Display for StreamId {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        f.write_str(&self.0)
320    }
321}
322
323/// What a session tells the mesh about itself. Every field is a public fact about work in
324/// progress; none is a path on the author's disk, a secret or a credential.
325///
326/// Almost everything here is optional, and that is the design: a session says what it
327/// knows, and a field it leaves out is omitted rather than carried empty, so a reader can
328/// tell "no task" from "a task called nothing". Opening the same session id again replaces
329/// this record, which is how a session that learns its branch later tells the mesh.
330///
331/// ```
332/// use majordomus_cli::mesh::journal::SessionInfo;
333///
334/// let mut info = SessionInfo::named("s1", "claude-code");
335/// assert_eq!(info.task, None);
336/// assert!(serde_json::to_value(&info).unwrap().get("task").is_none(), "unsaid, not empty");
337///
338/// info.branch = Some("feature/mesh-cooperation".into());
339/// assert_eq!(serde_json::to_value(&info).unwrap()["branch"], "feature/mesh-cooperation");
340/// ```
341#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342pub struct SessionInfo {
343    /// The session's id within its stream.
344    pub session: String,
345    /// The client: `claude-code`, `codex`, `cli`, ...
346    pub client: String,
347    /// The worker's own name for itself, when it gave one.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub worker: Option<String>,
350    /// What the session says it is doing.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub intent: Option<String>,
353    /// The task id (`t-...`) the session works under.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub task: Option<String>,
356    /// The issue (`I0001`, `#184`) the work belongs to.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub issue: Option<String>,
359    /// The milestone the work belongs to.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub milestone: Option<String>,
362    /// The checkout id (a digest, never a path).
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub checkout: Option<String>,
365    /// The branch checked out.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub branch: Option<String>,
368    /// The commit checked out.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub head: Option<String>,
371    /// Whether the working tree had uncommitted changes when this was said.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub dirty: Option<bool>,
374    /// The context revision the session works from (a digest the context compiler names).
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub context: Option<String>,
377}
378
379impl SessionInfo {
380    /// A session with only an id and a client: what a test or an example needs. The two
381    /// arguments are the two facts a session cannot be without — something to refer to it
382    /// by, and what kind of worker it is — and everything else is filled in afterwards by
383    /// whoever knows it.
384    ///
385    /// ```
386    /// use majordomus_cli::mesh::journal::SessionInfo;
387    ///
388    /// let info = SessionInfo::named("s1", "codex");
389    /// assert_eq!((info.session.as_str(), info.client.as_str()), ("s1", "codex"));
390    /// assert_eq!(info.worker, None, "the rest is the session's to say later");
391    /// ```
392    pub fn named(session: &str, client: &str) -> Self {
393        SessionInfo {
394            session: session.into(),
395            client: client.into(),
396            ..SessionInfo::default()
397        }
398    }
399}
400
401/// Whether a claim excludes others or only tells them. Both are useful and they answer
402/// different questions: an exclusive claim is a worker saying "do not edit this while I
403/// am", an advisory one is saying "I am here". Exclusive is the default because a claim
404/// taken without a thought about the mode is a claim somebody expects to be respected.
405///
406/// ```
407/// use majordomus_cli::mesh::journal::ClaimMode;
408///
409/// assert_eq!(ClaimMode::default(), ClaimMode::Exclusive);
410/// assert_eq!(serde_json::to_value(ClaimMode::Advisory).unwrap(), serde_json::json!("advisory"));
411/// ```
412#[derive(
413    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
414)]
415#[serde(rename_all = "snake_case")]
416pub enum ClaimMode {
417    /// Exclusive: a second exclusive claim meeting this scope is refused while this one
418    /// lives, on every runtime that knows of it.
419    #[default]
420    Exclusive,
421    /// Advisory: an announcement, like the peer board's. Overlaps are reported, never
422    /// refused.
423    Advisory,
424}
425
426/// A handover as the mesh carries it: the record's facts and its body, bounded.
427///
428/// What travels is the writing, plus the few facts that let a receiver file it — the task,
429/// the issue, the branch, the commit. No path of the author's disk is among them, because
430/// the checkout that reads this is somewhere else and a path from another machine is at
431/// best noise. Identity is the digest of the body, so a handover published twice by a
432/// retry or by two runtimes is one handover everywhere.
433///
434/// ```
435/// use majordomus_cli::mesh::journal::HandoverBody;
436///
437/// let body = "# Objective\nship the mesh\n".to_string();
438/// let handover = HandoverBody {
439///     id: HandoverBody::digest_of(&body),
440///     task: Some("t-1".into()),
441///     issue: Some("#184".into()),
442///     milestone: None,
443///     branch: Some("feature/mesh-cooperation".into()),
444///     head: None,
445///     created_at: None,
446///     name: None,
447///     body,
448/// };
449/// assert_eq!(handover.id, HandoverBody::digest_of(&handover.body));
450///
451/// let wire = serde_json::to_value(&handover).unwrap();
452/// assert!(wire.get("milestone").is_none(), "what it does not say is omitted");
453/// ```
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
455pub struct HandoverBody {
456    /// The content digest (32 hex): the same handover published twice is one handover.
457    pub id: String,
458    /// The task the handover closes or continues.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub task: Option<String>,
461    /// The issue it belongs to.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub issue: Option<String>,
464    /// The milestone it belongs to.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub milestone: Option<String>,
467    /// The branch it was written on.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub branch: Option<String>,
470    /// The commit it was written at.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub head: Option<String>,
473    /// When it was written, RFC 3339, as its author's clock said.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub created_at: Option<String>,
476    /// The record's file name at its origin, informational.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub name: Option<String>,
479    /// The Markdown body (`# Objective`, `# Current State`, `# Next Action`, ...).
480    pub body: String,
481}
482
483impl HandoverBody {
484    /// The content digest a handover body is identified by. Identifying a handover by what
485    /// it says rather than by who published it is what makes publication idempotent across
486    /// the mesh: a retry, a relay and a second publisher all produce the same id, and the
487    /// fold keeps one handover with one list of consumers.
488    ///
489    /// ```
490    /// use majordomus_cli::mesh::journal::HandoverBody;
491    ///
492    /// let body = "# Objective\nship\n";
493    /// assert_eq!(HandoverBody::digest_of(body).len(), 32);
494    /// assert_eq!(HandoverBody::digest_of(body), HandoverBody::digest_of(body));
495    /// assert_ne!(HandoverBody::digest_of(body), HandoverBody::digest_of("# Objective\nwait\n"));
496    /// ```
497    pub fn digest_of(body: &str) -> String {
498        use sha2::{Digest, Sha256};
499        let digest = Sha256::digest(body.as_bytes());
500        digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
501    }
502}
503
504/// What an event says. One vocabulary for every runtime; an event of a kind this
505/// executable does not know is stored and relayed but not interpreted, so a newer peer
506/// never breaks an older one's replication.
507///
508/// The vocabulary is small on purpose: everything the mesh coordinates is a session, a
509/// claim, a handover or a review, and an event that would need a ninth kind is a feature
510/// that has not been agreed. The `kind` tag is what goes on the wire, so it is protocol —
511/// two executables of different ages read the same word for the same event.
512///
513/// ```
514/// use majordomus_cli::mesh::journal::{EventBody, SessionInfo, KNOWN_KINDS};
515///
516/// let opened = EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") };
517/// assert_eq!(opened.kind(), "session_opened");
518/// assert!(KNOWN_KINDS.contains(&opened.kind()));
519/// assert_eq!(serde_json::to_value(&opened).unwrap()["kind"], "session_opened");
520/// ```
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
522#[serde(tag = "kind", rename_all = "snake_case")]
523pub enum EventBody {
524    /// A session began, or said something new about itself (the latest wins).
525    SessionOpened {
526        /// The session.
527        info: SessionInfo,
528    },
529    /// A session ended. Its claims end with it.
530    SessionClosed {
531        /// The session id within the stream.
532        session: String,
533    },
534    /// A session claimed a scope.
535    ClaimAcquired {
536        /// The claim id within the stream.
537        claim: String,
538        /// The holding session within the stream.
539        session: String,
540        /// Repository-relative paths.
541        scope: Vec<String>,
542        /// What the claim is for.
543        #[serde(default, skip_serializing_if = "Option::is_none")]
544        intent: Option<String>,
545        /// Exclusive or advisory.
546        #[serde(default)]
547        mode: ClaimMode,
548        /// The issue the claim is for.
549        #[serde(default, skip_serializing_if = "Option::is_none")]
550        issue: Option<String>,
551    },
552    /// A claim was released by its holder.
553    ClaimReleased {
554        /// The claim id within the stream.
555        claim: String,
556    },
557    /// A handover was published for other runtimes to consume.
558    HandoverPublished {
559        /// The handover.
560        handover: HandoverBody,
561    },
562    /// A session consumed a handover (on any runtime).
563    HandoverConsumed {
564        /// The handover's content digest.
565        handover: String,
566        /// The consuming session within the stream.
567        session: String,
568    },
569    /// A session asked for a review.
570    ReviewRequested {
571        /// The review id within the stream.
572        review: String,
573        /// The requesting session within the stream.
574        session: String,
575        /// What is to be reviewed: a branch, a commit, a pull request.
576        subject: String,
577        /// Paths the review covers.
578        #[serde(default)]
579        scope: Vec<String>,
580        /// The issue.
581        #[serde(default, skip_serializing_if = "Option::is_none")]
582        issue: Option<String>,
583        /// The runtime asked (`<node>-<runtime>`), when one was.
584        #[serde(default, skip_serializing_if = "Option::is_none")]
585        reviewer: Option<String>,
586        /// What the reviewer needs beyond the subject line: the evidence, the commands, the
587        /// question. Up to [`REVIEW_BODY_MAX`] bytes. A runtime older than the field reads
588        /// the request without it.
589        #[serde(default, skip_serializing_if = "Option::is_none")]
590        body: Option<String>,
591    },
592    /// A session answered a review request (on any runtime).
593    ReviewAnswered {
594        /// The request: `<stream>/<review>`.
595        request: String,
596        /// The answering session within the stream.
597        session: String,
598        /// `approved`, `changes_requested`, `commented`.
599        verdict: String,
600        /// The answer's note.
601        #[serde(default, skip_serializing_if = "Option::is_none")]
602        note: Option<String>,
603    },
604    /// The runtime that asked for a review ended its request: done, void, or superseded.
605    /// Only the asking runtime writes it — from any of its sessions — so it is never a
606    /// verdict; its note is the requester's own result, shown with the request.
607    ReviewWithdrawn {
608        /// The request: `<stream>/<review>`.
609        request: String,
610        /// The withdrawing session within the stream.
611        session: String,
612        /// The requester's result or reason, up to [`REVIEW_BODY_MAX`] bytes.
613        #[serde(default, skip_serializing_if = "Option::is_none")]
614        note: Option<String>,
615    },
616}
617
618/// The most a review request's body, or a requester's result note, carries: room for the
619/// evidence a reviewer needs without a second channel, and far under the link's bound.
620pub const REVIEW_BODY_MAX: usize = 8 * 1024;
621
622/// The event kinds this executable interprets. An event naming another kind is stored and
623/// relayed, never folded.
624pub const KNOWN_KINDS: &[&str] = &[
625    "session_opened",
626    "session_closed",
627    "claim_acquired",
628    "claim_released",
629    "handover_published",
630    "handover_consumed",
631    "review_requested",
632    "review_answered",
633    "review_withdrawn",
634];
635
636impl EventBody {
637    /// The kind's wire word: the same snake-case spelling the serialized event carries, so
638    /// a counter, a log line and a peer's JSON all name an event the same way. It is
639    /// written out rather than derived from the variant name, because the word is protocol
640    /// and renaming a variant must not change what another runtime reads.
641    ///
642    /// ```
643    /// use majordomus_cli::mesh::journal::{EventBody, KNOWN_KINDS};
644    ///
645    /// let released = EventBody::ClaimReleased { claim: "c1".into() };
646    /// assert_eq!(released.kind(), "claim_released");
647    /// assert_eq!(serde_json::to_value(&released).unwrap()["kind"], released.kind());
648    /// assert!(KNOWN_KINDS.contains(&released.kind()));
649    /// ```
650    pub fn kind(&self) -> &'static str {
651        match self {
652            EventBody::SessionOpened { .. } => "session_opened",
653            EventBody::SessionClosed { .. } => "session_closed",
654            EventBody::ClaimAcquired { .. } => "claim_acquired",
655            EventBody::ClaimReleased { .. } => "claim_released",
656            EventBody::HandoverPublished { .. } => "handover_published",
657            EventBody::HandoverConsumed { .. } => "handover_consumed",
658            EventBody::ReviewRequested { .. } => "review_requested",
659            EventBody::ReviewAnswered { .. } => "review_answered",
660            EventBody::ReviewWithdrawn { .. } => "review_withdrawn",
661        }
662    }
663
664    /// Check the body's bounds: short identifiers, repository-relative scopes, a bounded
665    /// handover. A body out of bounds is refused before it is signed or stored.
666    ///
667    /// It runs on both sides: on the writer, so a runtime never signs something its peers
668    /// will refuse, and on the reader, so nothing a peer sends is stored unchecked. The
669    /// bounds are not arbitrary — a scope must be repository-relative because a claim over
670    /// `/etc` means nothing on another machine, a handover's id must be the digest of its
671    /// body or identity would be forgeable, and the fields that become front matter where
672    /// a handover is consumed must be single lines.
673    ///
674    /// ```
675    /// use majordomus_cli::mesh::journal::{EventBody, HandoverBody, SessionInfo};
676    ///
677    /// assert!(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }.validate().is_ok());
678    ///
679    /// // a scope that is not repository-relative is refused
680    /// let escaping = EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
681    ///     scope: vec!["../../etc".into()], intent: None, mode: Default::default(), issue: None };
682    /// assert!(escaping.validate().is_err());
683    ///
684    /// // and a handover whose id is not the digest of its body is not that handover
685    /// let body = "# Objective\nship\n".to_string();
686    /// let lying = EventBody::HandoverPublished { handover: HandoverBody {
687    ///     id: HandoverBody::digest_of("something else"), task: None, issue: None,
688    ///     milestone: None, branch: None, head: None, created_at: None, name: None, body } };
689    /// assert!(lying.validate().is_err());
690    /// ```
691    pub fn validate(&self) -> Result<(), String> {
692        match self {
693            EventBody::SessionOpened { info } => {
694                ident("session", &info.session)?;
695                text("client", &info.client, 64)?;
696                for (name, value, max) in [
697                    ("worker", &info.worker, 128),
698                    ("intent", &info.intent, 512),
699                    ("task", &info.task, 64),
700                    ("issue", &info.issue, 64),
701                    ("milestone", &info.milestone, 64),
702                    ("checkout", &info.checkout, 64),
703                    ("branch", &info.branch, 256),
704                    ("head", &info.head, 64),
705                    ("context", &info.context, 128),
706                ] {
707                    if let Some(v) = value {
708                        line(name, v, max)?;
709                    }
710                }
711                Ok(())
712            }
713            EventBody::SessionClosed { session } => ident("session", session),
714            EventBody::ClaimAcquired {
715                claim,
716                session,
717                scope,
718                intent,
719                issue,
720                ..
721            } => {
722                ident("claim", claim)?;
723                ident("session", session)?;
724                paths(scope, true)?;
725                if let Some(v) = intent {
726                    text("intent", v, 512)?;
727                }
728                if let Some(v) = issue {
729                    text("issue", v, 64)?;
730                }
731                Ok(())
732            }
733            EventBody::ClaimReleased { claim } => ident("claim", claim),
734            EventBody::HandoverPublished { handover } => {
735                if handover.id != HandoverBody::digest_of(&handover.body) {
736                    return Err("the handover id is not the digest of its body".into());
737                }
738                if handover.body.len() > MAX_HANDOVER_BYTES {
739                    return Err(format!(
740                        "a handover body of {} bytes exceeds {MAX_HANDOVER_BYTES}",
741                        handover.body.len()
742                    ));
743                }
744                for (name, value, max) in [
745                    ("task", &handover.task, 64),
746                    ("issue", &handover.issue, 64),
747                    ("milestone", &handover.milestone, 64),
748                    ("branch", &handover.branch, 256),
749                    ("head", &handover.head, 64),
750                    ("created_at", &handover.created_at, 64),
751                    ("name", &handover.name, 256),
752                ] {
753                    // These become front matter and a file name wherever the handover is
754                    // consumed: one line each, never a place to smuggle a key or a path.
755                    if let Some(v) = value {
756                        line(name, v, max)?;
757                    }
758                }
759                Ok(())
760            }
761            EventBody::HandoverConsumed { handover, session } => {
762                ident("handover", handover)?;
763                ident("session", session)
764            }
765            EventBody::ReviewRequested {
766                review,
767                session,
768                subject,
769                scope,
770                issue,
771                reviewer,
772                body,
773            } => {
774                ident("review", review)?;
775                ident("session", session)?;
776                text("subject", subject, 256)?;
777                paths(scope, false)?;
778                if let Some(v) = issue {
779                    text("issue", v, 64)?;
780                }
781                if let Some(v) = reviewer {
782                    text("reviewer", v, 64)?;
783                }
784                if let Some(v) = body {
785                    text("body", v, REVIEW_BODY_MAX)?;
786                }
787                Ok(())
788            }
789            EventBody::ReviewAnswered {
790                request,
791                session,
792                verdict,
793                note,
794            } => {
795                text("request", request, 128)?;
796                ident("session", session)?;
797                if !["approved", "changes_requested", "commented"].contains(&verdict.as_str()) {
798                    return Err(format!(
799                        "verdict '{verdict}' is not approved, changes_requested or commented"
800                    ));
801                }
802                if let Some(v) = note {
803                    text("note", v, 2048)?;
804                }
805                Ok(())
806            }
807            EventBody::ReviewWithdrawn {
808                request,
809                session,
810                note,
811            } => {
812                text("request", request, 128)?;
813                ident("session", session)?;
814                if let Some(v) = note {
815                    text("note", v, REVIEW_BODY_MAX)?;
816                }
817                Ok(())
818            }
819        }
820    }
821}
822
823fn ident(name: &str, value: &str) -> Result<(), String> {
824    let ok = !value.is_empty()
825        && value.len() <= 64
826        && value
827            .bytes()
828            .all(|b| b.is_ascii_alphanumeric() || b"._:-#".contains(&b));
829    if ok {
830        Ok(())
831    } else {
832        Err(format!(
833            "{name} '{value}' is not an identifier (1-64 of [A-Za-z0-9._:#-])"
834        ))
835    }
836}
837
838fn text(name: &str, value: &str, max: usize) -> Result<(), String> {
839    if value.len() > max || value.chars().any(|c| c.is_control() && c != '\n') {
840        return Err(format!(
841            "{name} is over {max} bytes or carries control characters"
842        ));
843    }
844    Ok(())
845}
846
847fn line(name: &str, value: &str, max: usize) -> Result<(), String> {
848    if value.len() > max || value.chars().any(char::is_control) {
849        return Err(format!(
850            "{name} is over {max} bytes or is not a single line"
851        ));
852    }
853    Ok(())
854}
855
856fn paths(scope: &[String], required: bool) -> Result<(), String> {
857    if required && scope.is_empty() {
858        return Err("a claim names at least one path".into());
859    }
860    if scope.len() > MAX_SCOPE_PATHS {
861        return Err(format!("more than {MAX_SCOPE_PATHS} paths"));
862    }
863    for path in scope {
864        if path.is_empty()
865            || path.len() > 256
866            || path.starts_with('/')
867            || path.split('/').any(|s| s == "..")
868            || path.chars().any(|c| c.is_control())
869        {
870            return Err(format!(
871                "'{path}' is not a repository-relative path (no leading '/', no '..')"
872            ));
873        }
874    }
875    Ok(())
876}
877
878/// One event as the wire and the store carry it. The body is kept as JSON so that an
879/// event of an unknown kind survives storage and relay byte-for-byte; [`MeshEvent::body`]
880/// interprets it.
881///
882/// Every field above the signature is covered by it, which is what makes a relay harmless:
883/// a runtime that forwards an event cannot alter its stream, its sequence, its repository
884/// or its body without the next reader noticing, and so vouches for nothing. The wall
885/// clock is the one field nothing is decided by — freshness is measured with beats and
886/// monotonic clocks, never by comparing two machines' idea of the time.
887///
888/// ```
889/// use std::sync::Arc;
890/// use majordomus_cli::mesh::identity::NodeIdentity;
891/// use majordomus_cli::mesh::journal::{EventBody, Journal, MeshEvent};
892///
893/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
894///     "repo".into(), None).unwrap();
895/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
896///
897/// let event: &MeshEvent = &j.events()[0];
898/// assert_eq!(event.seq, 1, "dense from 1 within its stream");
899/// assert_eq!(event.kind(), "session_closed");
900/// assert_eq!(event.id(), format!("{}/1", event.stream));
901///
902/// // a relayed byte changed anywhere under the signature is a different event
903/// let mut tampered = event.clone();
904/// tampered.repo = "another-repository".into();
905/// assert_ne!(tampered.signing_bytes(), event.signing_bytes());
906/// ```
907#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
908pub struct MeshEvent {
909    /// The event format version.
910    pub v: u32,
911    /// The stream that wrote it.
912    pub stream: StreamId,
913    /// Dense from 1 within the stream.
914    pub seq: u64,
915    /// The Lamport stamp: above every stamp its writer had seen.
916    pub lamport: u64,
917    /// The writer's wall clock, seconds since the Unix epoch. Informational only: nothing
918    /// is decided by comparing it with another machine's clock.
919    pub at: u64,
920    /// The mesh repository id the event belongs to.
921    pub repo: String,
922    /// The writer's Ed25519 public key, hex; its digest is the stream's node.
923    pub pk: String,
924    /// The body, `kind` tagged.
925    pub body: Value,
926    /// Ed25519 over the canonical bytes of everything above, hex.
927    pub sig: String,
928}
929
930/// The causal position of an event: its Lamport stamp, then its stream, then its sequence.
931///
932/// One definition, because every runtime must apply the same events in the same sequence
933/// for the fold to converge, and two comparators that drift apart would be two different
934/// states with one name. Collections key a `BTreeMap` by it rather than sorting themselves.
935///
936/// ```
937/// use majordomus_cli::mesh::journal::{causal_key, EventBody, Journal};
938/// use majordomus_cli::mesh::identity::NodeIdentity;
939/// use std::sync::Arc;
940///
941/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
942///     "repo".into(), None).unwrap();
943/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
944/// let event = &j.events()[0];
945/// assert_eq!(causal_key(event), (event.lamport, event.stream.clone(), event.seq));
946/// ```
947pub fn causal_key(event: &MeshEvent) -> (u64, StreamId, u64) {
948    (event.lamport, event.stream.clone(), event.seq)
949}
950
951/// The canonical bytes of a JSON value: object keys sorted at every depth, no whitespace.
952/// Signer and verifier compute it identically regardless of how either parser orders keys.
953///
954/// ```
955/// use majordomus_cli::mesh::journal::canonical_json;
956/// let a = serde_json::json!({"b": 1, "a": [{"d": 2, "c": 3}]});
957/// assert_eq!(canonical_json(&a), br#"{"a":[{"c":3,"d":2}],"b":1}"#.to_vec());
958/// ```
959pub fn canonical_json(value: &Value) -> Vec<u8> {
960    let mut out = Vec::new();
961    write_canonical(value, &mut out);
962    out
963}
964
965fn write_canonical(value: &Value, out: &mut Vec<u8>) {
966    match value {
967        Value::Object(map) => {
968            // A BTreeSet, not a sorted vector: the signing order is the byte order of the
969            // keys, decided by the container rather than by a comparator written here.
970            let keys: std::collections::BTreeSet<&String> = map.keys().collect();
971            out.push(b'{');
972            for (i, key) in keys.iter().enumerate() {
973                if i > 0 {
974                    out.push(b',');
975                }
976                out.extend(serde_json::to_vec(key).unwrap_or_default());
977                out.push(b':');
978                write_canonical(&map[*key], out);
979            }
980            out.push(b'}');
981        }
982        Value::Array(items) => {
983            out.push(b'[');
984            for (i, item) in items.iter().enumerate() {
985                if i > 0 {
986                    out.push(b',');
987                }
988                write_canonical(item, out);
989            }
990            out.push(b']');
991        }
992        other => out.extend(serde_json::to_vec(other).unwrap_or_default()),
993    }
994}
995
996impl MeshEvent {
997    /// The bytes the signature covers: a domain separator, then the canonical JSON of
998    /// every field of the event except the signature itself. Canonical, so that signer and
999    /// verifier agree however their JSON parsers order keys; domain-separated, so that a
1000    /// signature over an event can never be presented as one over an advertisement or a
1001    /// link message.
1002    ///
1003    /// ```
1004    /// use std::sync::Arc;
1005    /// use majordomus_cli::mesh::identity::NodeIdentity;
1006    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1007    ///
1008    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1009    ///     "repo".into(), None).unwrap();
1010    /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1011    /// let event = j.events()[0].clone();
1012    ///
1013    /// // the signature is not part of what it covers, so re-signing changes nothing here
1014    /// let mut resigned = event.clone();
1015    /// resigned.sig = "00".repeat(64);
1016    /// assert_eq!(resigned.signing_bytes(), event.signing_bytes());
1017    ///
1018    /// // the body is
1019    /// let mut edited = event.clone();
1020    /// edited.body = serde_json::json!({ "kind": "session_closed", "session": "s2" });
1021    /// assert_ne!(edited.signing_bytes(), event.signing_bytes());
1022    /// ```
1023    pub fn signing_bytes(&self) -> Vec<u8> {
1024        let core = serde_json::json!({
1025            "v": self.v,
1026            "stream": self.stream,
1027            "seq": self.seq,
1028            "lamport": self.lamport,
1029            "at": self.at,
1030            "repo": self.repo,
1031            "pk": self.pk,
1032            "body": self.body,
1033        });
1034        let mut bytes = SIGNING_DOMAIN.to_vec();
1035        bytes.extend(canonical_json(&core));
1036        bytes
1037    }
1038
1039    /// `<stream>/<seq>`: the event's identity everywhere. It is the pair rather than a
1040    /// digest because it is also the address replication works with — a peer's marks name
1041    /// a stream and a sequence, and what is missing is arithmetic rather than a search.
1042    ///
1043    /// ```
1044    /// use std::sync::Arc;
1045    /// use majordomus_cli::mesh::identity::NodeIdentity;
1046    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1047    ///
1048    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1049    ///     "repo".into(), None).unwrap();
1050    /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1051    /// j.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
1052    ///
1053    /// let events = j.events();
1054    /// assert_eq!(events[0].id(), format!("{}/1", events[0].stream));
1055    /// assert_ne!(events[0].id(), events[1].id());
1056    /// ```
1057    pub fn id(&self) -> String {
1058        format!("{}/{}", self.stream, self.seq)
1059    }
1060
1061    /// The interpreted body; `None` for a kind this executable does not know. An event
1062    /// this version cannot read is still stored and relayed byte-for-byte, so an older
1063    /// runtime in a mesh with newer ones carries their traffic instead of breaking it —
1064    /// the fold counts what it could not interpret rather than dropping it silently.
1065    ///
1066    /// ```
1067    /// use std::sync::Arc;
1068    /// use majordomus_cli::mesh::identity::NodeIdentity;
1069    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1070    ///
1071    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1072    ///     "repo".into(), None).unwrap();
1073    /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1074    /// let event = j.events()[0].clone();
1075    /// assert!(matches!(event.body(), Some(EventBody::SessionClosed { .. })));
1076    ///
1077    /// // a kind from a newer peer: unreadable here, and still an event
1078    /// let mut newer = event.clone();
1079    /// newer.body = serde_json::json!({ "kind": "telepathy_offered" });
1080    /// assert!(newer.body().is_none());
1081    /// assert_eq!(newer.kind(), "telepathy_offered");
1082    /// ```
1083    pub fn body(&self) -> Option<EventBody> {
1084        serde_json::from_value(self.body.clone()).ok()
1085    }
1086
1087    /// The body's `kind` word, known or not. It reads the tag out of the stored JSON
1088    /// rather than going through [`MeshEvent::body`], so that an event this executable
1089    /// cannot interpret can still be counted and shown by name.
1090    ///
1091    /// ```
1092    /// use std::sync::Arc;
1093    /// use majordomus_cli::mesh::identity::NodeIdentity;
1094    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1095    ///
1096    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1097    ///     "repo".into(), None).unwrap();
1098    /// j.append_own(EventBody::SessionOpened {
1099    ///     info: majordomus_cli::mesh::journal::SessionInfo::named("s1", "cli"),
1100    /// })
1101    /// .unwrap();
1102    /// assert_eq!(j.events()[0].kind(), "session_opened");
1103    /// ```
1104    pub fn kind(&self) -> &str {
1105        self.body.get("kind").and_then(Value::as_str).unwrap_or("")
1106    }
1107}
1108
1109/// Why an event was not stored. A journal that silently drops what it is sent is a journal
1110/// nobody can debug, so every refusal is counted under one of these reasons and shown: a
1111/// mesh whose runtimes will not converge can be diagnosed from the numbers, because the
1112/// reasons distinguish a misconfiguration (`Repository`, `Untrusted`) from an attack
1113/// (`Signature`, `Identity`) from a limit reached (`Capacity`, `Oversized`).
1114///
1115/// ```
1116/// use majordomus_cli::mesh::journal::Rejection;
1117///
1118/// // the wire word is what a counter and a JSON report both carry
1119/// assert_eq!(serde_json::to_value(Rejection::Untrusted).unwrap(), serde_json::json!("untrusted"));
1120/// assert_ne!(Rejection::Untrusted, Rejection::Repository);
1121/// ```
1122#[derive(
1123    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1124)]
1125#[serde(rename_all = "snake_case")]
1126pub enum Rejection {
1127    /// Over [`MAX_EVENT_BYTES`].
1128    Oversized,
1129    /// An event format version this executable does not read.
1130    Version,
1131    /// The stream id, the key, or the key-to-node binding does not hold.
1132    Identity,
1133    /// The signature does not verify.
1134    Signature,
1135    /// Another repository's event.
1136    Repository,
1137    /// The origin is not trusted by this runtime's policy.
1138    Untrusted,
1139    /// A known kind whose body is out of bounds.
1140    Bounds,
1141    /// The journal's stream or pending bound is full.
1142    Capacity,
1143    /// The Lamport stamp is more than [`MAX_LAMPORT_LEAD`] ahead of this runtime's clock.
1144    ClockAhead,
1145}
1146
1147/// What one ingest did: how many events were stored, how many were deliveries of something
1148/// already held, how many are waiting for a gap ahead of them, and what was refused and
1149/// why. Every event handed in is accounted for in exactly one of those, which is what makes
1150/// a sync round auditable — a peer that sends ten events and is told about ten knows
1151/// nothing went missing between the wire and the store.
1152///
1153/// ```
1154/// use std::sync::Arc;
1155/// use majordomus_cli::mesh::identity::NodeIdentity;
1156/// use majordomus_cli::mesh::journal::{EventBody, IngestReport, Journal, SessionInfo};
1157///
1158/// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1159///     "repo".into(), None).unwrap();
1160/// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
1161///     "repo".into(), None).unwrap();
1162/// a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
1163///
1164/// let events = a.missing_for(&b.marks(), 1 << 20);
1165/// let first: IngestReport = b.ingest(&events, &|_| Ok(()));
1166/// assert_eq!((first.accepted, first.duplicate, first.rejected_total()), (1, 0, 0));
1167///
1168/// // at-least-once delivery: the same event again is absorbed, and changes nothing
1169/// let second = b.ingest(&events, &|_| Ok(()));
1170/// assert_eq!((second.accepted, second.duplicate), (0, 1));
1171/// ```
1172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1173pub struct IngestReport {
1174    /// Newly stored and applied, in stream order.
1175    pub accepted: u64,
1176    /// Already held: at-least-once delivery, absorbed.
1177    pub duplicate: u64,
1178    /// Held back until the gap before them arrives.
1179    pub pending: u64,
1180    /// Refused, by reason.
1181    pub rejected: BTreeMap<Rejection, u64>,
1182}
1183
1184impl IngestReport {
1185    fn reject(&mut self, why: Rejection) {
1186        *self.rejected.entry(why).or_default() += 1;
1187    }
1188
1189    /// Every refused event, summed over reasons.
1190    pub fn rejected_total(&self) -> u64 {
1191        self.rejected.values().sum()
1192    }
1193}
1194
1195/// What a runtime knows of one stream: the contiguous high-water sequence, the highest
1196/// beat with its origin's signature, and how long ago that beat was seen to rise (`None`
1197/// when never).
1198///
1199/// A mark is both halves of replication in one value. The sequence is what a peer compares
1200/// to decide what to send, which is why replication is a comparison rather than a flood.
1201/// The beat is how liveness crosses machines without comparing clocks: the origin signs it,
1202/// a relay forwards the signature and adds how long ago *it* saw the beat rise, and the
1203/// reader converts that to freshness on its own monotonic clock. Because only the origin
1204/// can sign its beat, a relay can carry a stream's liveness without being able to invent it.
1205///
1206/// ```
1207/// use std::sync::Arc;
1208/// use majordomus_cli::mesh::identity::NodeIdentity;
1209/// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamMark};
1210///
1211/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1212///     "repo".into(), None).unwrap();
1213/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1214/// j.beat_own();
1215///
1216/// let marks = j.marks();
1217/// let mark: &StreamMark = &marks[&j.own_stream()];
1218/// assert_eq!(mark.seq, 1, "every event up to here is held");
1219/// assert!(mark.beat > 0 && mark.sig.is_some(), "the beat is the origin's signed statement");
1220/// ```
1221#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1222pub struct StreamMark {
1223    /// Every event up to and including this sequence is held.
1224    pub seq: u64,
1225    /// The highest beat heard.
1226    pub beat: u64,
1227    /// Milliseconds since that beat rose, on the sender's monotonic clock. Clamped by the
1228    /// reader to its expiry: a relay can report a beat stale, never older than dead.
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub age_ms: Option<u64>,
1231    /// The origin's public key, hex: the beat is its statement.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub pk: Option<String>,
1234    /// The origin's signature over the stream and the beat, hex. A relay forwards it
1235    /// verbatim; only the origin can raise its own beat.
1236    #[serde(default, skip_serializing_if = "Option::is_none")]
1237    pub sig: Option<String>,
1238}
1239
1240/// The bytes a beat signature covers.
1241fn beat_bytes(stream: &StreamId, beat: u64) -> Vec<u8> {
1242    format!("majordomus-mesh-beat/v1\n{stream}\n{beat}").into_bytes()
1243}
1244
1245/// Every stream's mark: what a sync request and answer carry.
1246pub type Marks = BTreeMap<StreamId, StreamMark>;
1247
1248/// Whether a stream is speaking now, on this runtime's clock. It is a verdict about a
1249/// stream and not about a link: a runtime reachable only through a relay is alive, and a
1250/// runtime that crashed stops beating for everyone at once. `Own` is kept apart from `Live`
1251/// because a runtime's own stream needs no evidence — it is the thing doing the beating.
1252///
1253/// ```
1254/// use majordomus_cli::mesh::journal::StreamLiveness;
1255///
1256/// assert!(StreamLiveness::Own.is_alive() && StreamLiveness::Live.is_alive());
1257/// assert!(!StreamLiveness::Expired.is_alive());
1258/// assert_eq!(
1259///     serde_json::to_value(StreamLiveness::Expired).unwrap(),
1260///     serde_json::json!("expired"),
1261/// );
1262/// ```
1263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1264#[serde(rename_all = "snake_case")]
1265pub enum StreamLiveness {
1266    /// This runtime's own current stream.
1267    Own,
1268    /// Its beat rose within the expiry.
1269    Live,
1270    /// Its beat has not risen within the expiry, or was never heard.
1271    Expired,
1272}
1273
1274impl StreamLiveness {
1275    /// Whether the stream counts as alive: its claims hold, its sessions are present. The
1276    /// two living verdicts are collapsed here because everything downstream treats them
1277    /// alike — a claim of this runtime's own and a claim of a peer that still beats both
1278    /// exclude — while the distinction is kept in the enum for anything that reports it.
1279    ///
1280    /// ```
1281    /// use majordomus_cli::mesh::journal::StreamLiveness;
1282    ///
1283    /// assert!(StreamLiveness::Own.is_alive(), "no evidence needed for one's own stream");
1284    /// assert!(StreamLiveness::Live.is_alive());
1285    /// assert!(!StreamLiveness::Expired.is_alive(), "its claims end without anyone releasing them");
1286    /// ```
1287    pub fn is_alive(self) -> bool {
1288        !matches!(self, StreamLiveness::Expired)
1289    }
1290}
1291
1292/// The journal's counters since start: how much it holds, how much it has written,
1293/// received, absorbed, refused, held back and compacted away. They are what makes a
1294/// replication problem diagnosable from one machine — a runtime whose `received` never
1295/// rises is not linked, one whose `rejected` rises is being sent something it will not
1296/// take, and one whose `pending` stays high is missing an event ahead of what it has.
1297///
1298/// ```
1299/// use std::sync::Arc;
1300/// use majordomus_cli::mesh::identity::NodeIdentity;
1301/// use majordomus_cli::mesh::journal::{EventBody, Journal, JournalTallies};
1302///
1303/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1304///     "repo".into(), None).unwrap();
1305/// let before: JournalTallies = j.tallies();
1306/// assert_eq!((before.written, before.events), (0, 0));
1307///
1308/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1309/// let after = j.tallies();
1310/// assert_eq!((after.written, after.events, after.streams), (1, 1, 1));
1311/// assert!(after.lamport > before.lamport);
1312/// ```
1313#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1314pub struct JournalTallies {
1315    /// Streams tracked.
1316    pub streams: usize,
1317    /// Events held.
1318    pub events: usize,
1319    /// Events written by this runtime since start.
1320    pub written: u64,
1321    /// Events received and stored.
1322    pub received: u64,
1323    /// Duplicate deliveries absorbed.
1324    pub duplicates: u64,
1325    /// Events refused.
1326    pub rejected: u64,
1327    /// Events held for a gap right now.
1328    pub pending: usize,
1329    /// Events dropped by compaction since start.
1330    pub compacted: u64,
1331    /// Events of a kind this executable does not interpret, held and relayed.
1332    pub opaque: usize,
1333    /// The highest Lamport stamp seen.
1334    pub lamport: u64,
1335    /// Appends to the journal file that failed since start: the events were held in memory
1336    /// and replicated, and are not on this machine's disk. Nonzero fails `mesh doctor`.
1337    #[serde(default)]
1338    pub write_failures: u64,
1339    /// Lines of the journal file the last reload could not read: torn, corrupt, or written
1340    /// by something that is not this journal. Counted, never silently dropped.
1341    #[serde(default)]
1342    pub unreadable: u64,
1343    /// Bytes of the journal file the last reload left unread because they lie beyond
1344    /// [`MAX_RELOAD_BYTES`]: on disk and kept by compaction, and not in memory. Nonzero fails
1345    /// `mesh doctor`.
1346    #[serde(default)]
1347    pub unread_bytes: u64,
1348}
1349
1350/// Whether an event this runtime wrote reached this machine's disk: what an append
1351/// acknowledges, and the only thing a caller may say about it.
1352///
1353/// ```
1354/// use majordomus_cli::mesh::journal::Durability;
1355/// assert_eq!(serde_json::to_value(Durability::NotDurable).unwrap(), "not_durable");
1356/// assert_eq!(Durability::default(), Durability::Unknown, "an answer that did not say");
1357/// assert!(Durability::Synced.is_on_disk() && !Durability::NotDurable.is_on_disk());
1358/// ```
1359#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1360#[serde(rename_all = "snake_case")]
1361pub enum Durability {
1362    /// Appended to the journal file and synced (`fsync` of its data) before the append
1363    /// returned.
1364    Synced,
1365    /// The journal has no file: it lives in memory and its peers, by configuration.
1366    Memory,
1367    /// The write to the journal file failed. The event is held in memory and replicated to
1368    /// peers, and is lost to this machine when the runtime restarts unless a peer still holds
1369    /// it. It is not acknowledged as durable.
1370    NotDurable,
1371    /// Not reported: an answer from an executable that predates this field.
1372    #[default]
1373    Unknown,
1374}
1375
1376impl Durability {
1377    /// Whether the event is on this machine's disk: only an append that was synced is.
1378    ///
1379    /// ```
1380    /// use majordomus_cli::mesh::journal::Durability;
1381    /// assert!(Durability::Synced.is_on_disk());
1382    /// for not in [Durability::Memory, Durability::NotDurable, Durability::Unknown] {
1383    ///     assert!(!not.is_on_disk(), "{not:?} is not on disk");
1384    /// }
1385    /// ```
1386    pub fn is_on_disk(self) -> bool {
1387        self == Durability::Synced
1388    }
1389}
1390
1391/// An event this runtime wrote, with what its append acknowledges about the disk.
1392///
1393/// ```
1394/// use std::sync::Arc;
1395/// use majordomus_cli::mesh::identity::NodeIdentity;
1396/// use majordomus_cli::mesh::journal::{Appended, Durability, EventBody, Journal};
1397///
1398/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1399///     "repo".into(), None).unwrap();
1400/// let appended: Appended =
1401///     j.append_acknowledged(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1402/// assert_eq!(appended.event.seq, 1);
1403/// assert_eq!(appended.durability, Durability::Memory);
1404/// ```
1405#[derive(Debug, Clone)]
1406pub struct Appended {
1407    /// The event, signed and stamped, held and offered to peers.
1408    pub event: MeshEvent,
1409    /// Whether it reached this machine's disk.
1410    pub durability: Durability,
1411}
1412
1413struct StreamLog {
1414    events: BTreeMap<u64, MeshEvent>,
1415    pending: BTreeMap<u64, MeshEvent>,
1416    beat: u64,
1417    beat_pk: Option<String>,
1418    beat_sig: Option<String>,
1419    fresh_at: Option<Instant>,
1420    /// When this journal first held the stream: a stream whose beat has never been heard
1421    /// here has been silent since then, not since forever.
1422    learned_at: Instant,
1423}
1424
1425impl StreamLog {
1426    fn new() -> Self {
1427        StreamLog {
1428            events: BTreeMap::new(),
1429            pending: BTreeMap::new(),
1430            beat: 0,
1431            beat_pk: None,
1432            beat_sig: None,
1433            fresh_at: None,
1434            learned_at: Instant::now(),
1435        }
1436    }
1437
1438    fn high_water(&self) -> u64 {
1439        self.events.keys().next_back().copied().unwrap_or(0)
1440    }
1441
1442    /// How long the stream has been silent on this runtime's clock: since its beat last
1443    /// rose, or — never heard — since the journal learned of it. A reloaded journal learns
1444    /// every stream at its start, so a restart does not make a running sibling look dead.
1445    fn silent_for(&self, now: Instant) -> Duration {
1446        now.saturating_duration_since(self.fresh_at.unwrap_or(self.learned_at))
1447    }
1448}
1449
1450/// What is left of a stream compacted away: how far it got, and the beat it was last heard
1451/// at. The sequence is advertised as its mark, so a peer that still holds the stream does not
1452/// send it again; the beat is what the stream must rise above to be heard again.
1453#[derive(Clone, Copy)]
1454struct Tombstone {
1455    seq: u64,
1456    beat: u64,
1457}
1458
1459struct Inner {
1460    /// The bytes of the journal file the last reload left unread, as `start..end` offsets in
1461    /// the file as it is now: beyond [`MAX_RELOAD_BYTES`]. Compaction copies them into the
1462    /// file it writes instead of dropping them.
1463    unread_tail: Option<std::ops::Range<u64>>,
1464    /// Streams compacted away. A stream is here or in `streams`, never in both.
1465    tombstones: BTreeMap<StreamId, Tombstone>,
1466    streams: BTreeMap<StreamId, StreamLog>,
1467    lamport: u64,
1468    tallies: JournalTallies,
1469}
1470
1471/// The journal of one runtime. `Mutex<BTreeMap>`: a handful of events a second, and a
1472/// deterministic order falls out for free.
1473///
1474/// One journal holds every stream this runtime knows of, not only its own: what arrives
1475/// from a peer is stored beside what this runtime wrote, which is what lets it relay. It
1476/// is the only thing in the mesh that writes, and everything a surface shows about
1477/// cooperation is a fold of what it holds.
1478///
1479/// ```
1480/// use std::sync::Arc;
1481/// use majordomus_cli::mesh::identity::NodeIdentity;
1482/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo};
1483///
1484/// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1485///     "repo".into(), None).unwrap();
1486/// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
1487///     "repo".into(), None).unwrap();
1488/// a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
1489///
1490/// // what B receives is held beside what B writes, so B can relay A's stream onwards
1491/// b.ingest(&a.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
1492/// assert_eq!(b.tallies().streams, 2, "B's own stream, and the one it heard from A");
1493/// assert!(b.events().iter().any(|e| &e.stream == a.own_stream()));
1494/// ```
1495pub struct Journal {
1496    identity: Arc<NodeIdentity>,
1497    own: StreamId,
1498    repo: String,
1499    path: Option<PathBuf>,
1500    inner: Mutex<Inner>,
1501    rotation: std::sync::atomic::AtomicUsize,
1502    reloaded: IngestReport,
1503    /// Failed appends; outside `inner`, because an append runs with `inner` held.
1504    write_failures: std::sync::atomic::AtomicU64,
1505    /// Unreadable lines of the last reload.
1506    unreadable: std::sync::atomic::AtomicU64,
1507}
1508
1509impl Journal {
1510    /// Open the journal of this runtime: the own stream is `<node>-<runtime>-<instance>` of
1511    /// the identity given, and `path`, when given, is the JSONL file events persist to and
1512    /// are reloaded from. Reloaded events keep their streams; none of them is fresh until
1513    /// a beat is heard again, so a restart never resurrects anybody's ownership.
1514    ///
1515    /// That last part is the reason persistence is safe here: reloading events would
1516    /// otherwise bring back claims whose holders are long gone, and a restarted runtime
1517    /// would refuse work on behalf of machines that are not running. Persistence is
1518    /// optional because a journal without a path is a complete journal — it simply starts
1519    /// empty and refills from its peers.
1520    ///
1521    /// ```
1522    /// use std::sync::Arc;
1523    /// use std::time::Duration;
1524    /// use majordomus_cli::mesh::identity::NodeIdentity;
1525    /// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamLiveness};
1526    ///
1527    /// let dir = tempfile::tempdir().unwrap();
1528    /// let path = dir.path().join("journal.jsonl");
1529    ///
1530    /// let before = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1531    ///     "0000000000000001", "repo".into(), Some(path.clone())).unwrap();
1532    /// before.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1533    ///
1534    /// // another runtime opens the same file: the events are back, and whoever wrote them
1535    /// // is not alive here until a beat is heard again
1536    /// let after = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1537    ///     "0000000000000002", "repo".into(), Some(path)).unwrap();
1538    /// assert_eq!(after.events().len(), 1);
1539    /// assert_eq!(
1540    ///     after.liveness(before.own_stream(), Duration::from_secs(30)),
1541    ///     StreamLiveness::Expired,
1542    /// );
1543    ///
1544    /// // a runtime slot that is not 16 hex is not a runtime
1545    /// let ephemeral = Arc::new(NodeIdentity::ephemeral().unwrap());
1546    /// assert!(Journal::open(ephemeral, "nope", "repo".into(), None).is_err());
1547    /// ```
1548    ///
1549    /// This opening believes every origin the file holds, which is what a journal without a
1550    /// trust policy of its own can do. A runtime that has one opens with
1551    /// [`Journal::open_trusting`], so that a key withdrawn from its allowlist is not
1552    /// believed again from its own disk.
1553    pub fn open(
1554        identity: Arc<NodeIdentity>,
1555        runtime: &str,
1556        repo: String,
1557        path: Option<PathBuf>,
1558    ) -> Result<Self, MeshError> {
1559        Self::open_trusting(identity, runtime, repo, path, &|_| Ok(()))
1560    }
1561
1562    /// [`Journal::open`], with the trust decision the live path makes applied to every
1563    /// event reloaded from `path`.
1564    ///
1565    /// A journal file is input, the same as a sync round: it holds whatever was trusted
1566    /// when it was written, and the policy may have changed since. A key removed from the
1567    /// allowlist and a runtime restarted would otherwise bring that key's sessions, claims
1568    /// and handovers back from disk — events the live path would refuse the moment they
1569    /// arrived over a link. So every reloaded event passes the same checks as a received
1570    /// one and then `accept`, the same predicate the caller hands to [`Journal::ingest`];
1571    /// an event signed by this node's own key is accepted without asking, because the key
1572    /// is the machine's own and a restarted runtime of it is the same machine. What is
1573    /// refused is not dropped silently: it is in [`Journal::reload_report`], by reason.
1574    ///
1575    /// ```
1576    /// use std::sync::Arc;
1577    /// use majordomus_cli::mesh::identity::NodeIdentity;
1578    /// use majordomus_cli::mesh::journal::{EventBody, Journal, Rejection};
1579    ///
1580    /// let dir = tempfile::tempdir().unwrap();
1581    /// let path = dir.path().join("journal.jsonl");
1582    /// let machine = Arc::new(NodeIdentity::ephemeral().unwrap());
1583    /// let peer = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1584    ///     "0000000000000001", "repo".into(), None).unwrap();
1585    /// let peers_event = peer.append_own(EventBody::SessionClosed { session: "p".into() })
1586    ///     .unwrap();
1587    ///
1588    /// // this machine stores a peer's event and one of its own
1589    /// let before = Journal::open(Arc::clone(&machine), "0000000000000002", "repo".into(),
1590    ///     Some(path.clone())).unwrap();
1591    /// before.ingest(&[peers_event], &|_| Ok(()));
1592    /// before.append_own(EventBody::SessionClosed { session: "own".into() }).unwrap();
1593    ///
1594    /// // the peer is no longer trusted when another server of this machine — the same key,
1595    /// // another stream, as a restart is — opens the file
1596    /// let after = Journal::open_trusting(Arc::clone(&machine), "0000000000000003",
1597    ///     "repo".into(), Some(path), &|_| Err(Rejection::Untrusted)).unwrap();
1598    /// assert_eq!(after.events().len(), 1, "the machine's own event, and only it");
1599    /// assert_eq!(after.reload_report().rejected[&Rejection::Untrusted], 1);
1600    /// ```
1601    pub fn open_trusting(
1602        identity: Arc<NodeIdentity>,
1603        runtime: &str,
1604        repo: String,
1605        path: Option<PathBuf>,
1606        accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
1607    ) -> Result<Self, MeshError> {
1608        Self::open_bounded(identity, runtime, repo, path, accept, MAX_RELOAD_BYTES)
1609    }
1610
1611    /// [`Journal::open_trusting`] with the reload bound given: what the tests use to prove
1612    /// the bound without writing [`MAX_RELOAD_BYTES`] to disk.
1613    pub(crate) fn open_bounded(
1614        identity: Arc<NodeIdentity>,
1615        runtime: &str,
1616        repo: String,
1617        path: Option<PathBuf>,
1618        accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
1619        reload_limit: u64,
1620    ) -> Result<Self, MeshError> {
1621        let own = StreamId::new(
1622            identity.public.node_id.as_str(),
1623            runtime,
1624            identity.public.instance_id.as_str(),
1625        )
1626        .ok_or_else(|| {
1627            MeshError::Protocol(format!("runtime '{runtime}' is not 16 hex characters"))
1628        })?;
1629        let mut streams = BTreeMap::new();
1630        streams.insert(own.clone(), StreamLog::new());
1631        let mut journal = Journal {
1632            identity,
1633            own,
1634            repo,
1635            path,
1636            inner: Mutex::new(Inner {
1637                unread_tail: None,
1638                tombstones: BTreeMap::new(),
1639                streams,
1640                lamport: 0,
1641                tallies: JournalTallies::default(),
1642            }),
1643            rotation: std::sync::atomic::AtomicUsize::new(0),
1644            reloaded: IngestReport::default(),
1645            write_failures: std::sync::atomic::AtomicU64::new(0),
1646            unreadable: std::sync::atomic::AtomicU64::new(0),
1647        };
1648        if let Some(path) = journal.path.clone() {
1649            journal.reloaded = journal.reload(&path, accept, reload_limit);
1650        }
1651        Ok(journal)
1652    }
1653
1654    fn reload(
1655        &self,
1656        path: &Path,
1657        accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
1658        limit: u64,
1659    ) -> IngestReport {
1660        let mut report = IngestReport::default();
1661        let Some((bytes, unread_tail)) = read_bounded(path, limit) else {
1662            return report;
1663        };
1664        if let Some(tail) = &unread_tail {
1665            tracing::warn!(
1666                path = %path.display(),
1667                unread_bytes = tail.end - tail.start,
1668                limit,
1669                "mesh journal: the file is larger than the reload bound; the rest is left on disk unread"
1670            );
1671        }
1672        self.inner.lock().expect("journal lock").unread_tail = unread_tail;
1673        // Line by line, and a line that does not read is counted rather than dropped in
1674        // silence: a torn tail after a crash, or bytes that are not this journal's.
1675        let mut events: Vec<MeshEvent> = Vec::new();
1676        let mut unreadable = 0u64;
1677        for line in bytes.split(|b| *b == b'\n') {
1678            if line.iter().all(u8::is_ascii_whitespace) {
1679                continue;
1680            }
1681            match serde_json::from_slice::<MeshEvent>(line) {
1682                Ok(event) => events.push(event),
1683                Err(_) => unreadable += 1,
1684            }
1685        }
1686        if unreadable > 0 {
1687            tracing::warn!(
1688                path = %path.display(),
1689                unreadable,
1690                "mesh journal: lines that do not read were skipped on reload"
1691            );
1692        }
1693        self.unreadable
1694            .store(unreadable, std::sync::atomic::Ordering::SeqCst);
1695        // Reloaded events pass the same checks and the same trust decision as received
1696        // ones: a file is input too, written under a policy that may have changed since.
1697        // This node's own key is its own, whatever the policy says of other keys.
1698        let own_key = self.identity.public.public_key.as_str();
1699        let trusted = |event: &MeshEvent| {
1700            if event.pk == own_key {
1701                Ok(())
1702            } else {
1703                accept(event)
1704            }
1705        };
1706        let mut inner = self.inner.lock().expect("journal lock");
1707        for event in events {
1708            self.ingest_locked(&mut inner, event, &trusted, &mut report, false);
1709        }
1710        inner.tallies.received = 0;
1711        inner.tallies.duplicates = 0;
1712        report
1713    }
1714
1715    /// What reloading the journal file did when this journal was opened: how many events
1716    /// came back, and how many were refused and why — a key no longer trusted is refused
1717    /// [`Rejection::Untrusted`] here rather than believed again from disk. Empty for a
1718    /// journal without a file, or one whose file did not exist yet.
1719    ///
1720    /// ```
1721    /// use std::sync::Arc;
1722    /// use majordomus_cli::mesh::identity::NodeIdentity;
1723    /// use majordomus_cli::mesh::journal::Journal;
1724    ///
1725    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1726    ///     "repo".into(), None).unwrap();
1727    /// assert_eq!(j.reload_report().accepted + j.reload_report().rejected_total(), 0);
1728    /// ```
1729    pub fn reload_report(&self) -> &IngestReport {
1730        &self.reloaded
1731    }
1732
1733    /// This runtime's own stream: the one stream this journal may write to, and the prefix
1734    /// every claim, session and review key it creates carries. Nothing else in the journal
1735    /// is this runtime's to number or to sign.
1736    pub fn own_stream(&self) -> &StreamId {
1737        &self.own
1738    }
1739
1740    /// The mesh repository id this journal belongs to.
1741    pub fn repository(&self) -> &str {
1742        &self.repo
1743    }
1744
1745    /// Sign, store and persist an event of this runtime. The body's bounds are checked
1746    /// first: nothing out of bounds is ever signed.
1747    ///
1748    /// The sequence is dense from 1 and the Lamport stamp rises above every stamp this
1749    /// runtime has seen, so a peer can tell from the numbers alone whether it is missing
1750    /// something, and every runtime folds concurrent events in the same order. Writing is
1751    /// the only way an event enters the mesh; a relay never creates one.
1752    ///
1753    /// ```
1754    /// use std::sync::Arc;
1755    /// use majordomus_cli::mesh::identity::NodeIdentity;
1756    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1757    ///
1758    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1759    ///     "repo".into(), None).unwrap();
1760    /// let first = j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1761    /// let second = j.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
1762    /// assert_eq!((first.seq, second.seq), (1, 2), "dense from 1");
1763    /// assert!(second.lamport > first.lamport);
1764    /// assert_eq!(first.stream, *j.own_stream());
1765    ///
1766    /// // out of bounds is refused before anything is signed or stored
1767    /// let escaping = EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
1768    ///     scope: vec!["/etc".into()], intent: None, mode: Default::default(), issue: None };
1769    /// assert!(j.append_own(escaping).is_err());
1770    /// assert_eq!(j.events().len(), 2, "and nothing was written");
1771    /// ```
1772    ///
1773    /// This answers the event and not what happened on disk; a caller that reports the
1774    /// write to anyone uses [`Journal::append_acknowledged`], which says.
1775    pub fn append_own(&self, body: EventBody) -> Result<MeshEvent, MeshError> {
1776        self.append_acknowledged(body)
1777            .map(|appended| appended.event)
1778    }
1779
1780    /// [`Journal::append_own`], answering whether the event reached this machine's disk.
1781    ///
1782    /// An event whose write failed is still held and replicated — a peer that receives it
1783    /// is the only copy left once this runtime restarts — and is answered
1784    /// [`Durability::NotDurable`]: the append is not acknowledged as durable, the failure is
1785    /// counted in [`JournalTallies::write_failures`], and `mesh doctor` fails on it.
1786    ///
1787    /// ```
1788    /// use std::sync::Arc;
1789    /// use majordomus_cli::mesh::identity::NodeIdentity;
1790    /// use majordomus_cli::mesh::journal::{Durability, EventBody, Journal};
1791    ///
1792    /// let dir = tempfile::tempdir().unwrap();
1793    /// let on_disk = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1794    ///     "0000000000000001", "repo".into(), Some(dir.path().join("journal.jsonl"))).unwrap();
1795    /// let written = on_disk.append_acknowledged(EventBody::SessionClosed { session: "s1".into() })
1796    ///     .unwrap();
1797    /// assert_eq!(written.durability, Durability::Synced);
1798    ///
1799    /// let in_memory = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1800    ///     "0000000000000002", "repo".into(), None).unwrap();
1801    /// let held = in_memory.append_acknowledged(EventBody::SessionClosed { session: "s1".into() })
1802    ///     .unwrap();
1803    /// assert_eq!(held.durability, Durability::Memory, "no file, by configuration");
1804    /// ```
1805    pub fn append_acknowledged(&self, body: EventBody) -> Result<Appended, MeshError> {
1806        body.validate().map_err(MeshError::Protocol)?;
1807        let body = serde_json::to_value(&body).map_err(|e| MeshError::Protocol(e.to_string()))?;
1808        let mut inner = self.inner.lock().expect("journal lock");
1809        let log = inner
1810            .streams
1811            .entry(self.own.clone())
1812            .or_insert_with(StreamLog::new);
1813        let seq = log.high_water().saturating_add(1);
1814        let lamport = inner.lamport.saturating_add(1);
1815        let mut event = MeshEvent {
1816            v: EVENT_VERSION,
1817            stream: self.own.clone(),
1818            seq,
1819            lamport,
1820            at: super::protocol::now(),
1821            repo: self.repo.clone(),
1822            pk: self.identity.public.public_key.clone(),
1823            body,
1824            sig: String::new(),
1825        };
1826        event.sig = self.identity.sign(&event.signing_bytes());
1827        let size = serde_json::to_vec(&event)
1828            .map(|b| b.len())
1829            .unwrap_or(usize::MAX);
1830        if size > MAX_EVENT_BYTES {
1831            return Err(MeshError::Protocol(format!(
1832                "an event of {size} bytes exceeds the {MAX_EVENT_BYTES}-byte bound"
1833            )));
1834        }
1835        inner.lamport = lamport;
1836        inner
1837            .streams
1838            .get_mut(&self.own)
1839            .expect("own stream")
1840            .events
1841            .insert(seq, event.clone());
1842        inner.tallies.written += 1;
1843        let durability = self.persist(std::slice::from_ref(&event));
1844        Ok(Appended { event, durability })
1845    }
1846
1847    /// Raise this runtime's own beat: one heartbeat, signed, so that no relay can raise it.
1848    ///
1849    /// The beat is how a runtime says "still here" without writing an event for it — it
1850    /// travels in marks, which every sync round carries anyway, so liveness costs nothing
1851    /// extra. The signature is what makes a relay safe: a runtime can pass on somebody
1852    /// else's beat and cannot invent one, so a crashed runtime stops being alive for
1853    /// everybody at once and its claims end on their own.
1854    ///
1855    /// ```
1856    /// use std::sync::Arc;
1857    /// use majordomus_cli::mesh::identity::NodeIdentity;
1858    /// use majordomus_cli::mesh::journal::Journal;
1859    ///
1860    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1861    ///     "repo".into(), None).unwrap();
1862    /// j.beat_own();
1863    /// let first = j.marks()[j.own_stream()].clone();
1864    /// j.beat_own();
1865    /// let second = j.marks()[j.own_stream()].clone();
1866    ///
1867    /// assert!(second.beat > first.beat, "the beat only rises");
1868    /// assert_ne!(second.sig, first.sig, "each beat is its own signed statement");
1869    /// ```
1870    pub fn beat_own(&self) {
1871        let mut inner = self.inner.lock().expect("journal lock");
1872        let log = inner
1873            .streams
1874            .entry(self.own.clone())
1875            .or_insert_with(StreamLog::new);
1876        log.beat += 1;
1877        log.beat_sig = Some(self.identity.sign(&beat_bytes(&self.own, log.beat)));
1878        log.beat_pk = Some(self.identity.public.public_key.clone());
1879        log.fresh_at = Some(Instant::now());
1880    }
1881
1882    /// Every stream's mark, own included, ages measured now. This is the whole of what a
1883    /// runtime tells a peer about its state: a sequence per stream and a beat per stream,
1884    /// from which the peer computes what to send. Nothing is asked for and nothing is
1885    /// flooded, which is why an event reaches every runtime once per link and stops.
1886    ///
1887    /// The ages are measured at the moment of the call rather than stored, because an age
1888    /// is only meaningful relative to when it was taken.
1889    ///
1890    /// ```
1891    /// use std::sync::Arc;
1892    /// use majordomus_cli::mesh::identity::NodeIdentity;
1893    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1894    ///
1895    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1896    ///     "repo".into(), None).unwrap();
1897    /// assert_eq!(j.marks()[j.own_stream()].seq, 0, "nothing written yet");
1898    ///
1899    /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1900    /// j.beat_own();
1901    /// let mark = j.marks()[j.own_stream()].clone();
1902    /// assert_eq!(mark.seq, 1);
1903    /// assert!(mark.age_ms.is_some(), "a beat has been heard, and how long ago");
1904    /// ```
1905    pub fn marks(&self) -> Marks {
1906        let now = Instant::now();
1907        let inner = self.inner.lock().expect("journal lock");
1908        inner
1909            .streams
1910            .iter()
1911            .map(|(id, log)| {
1912                (
1913                    id.clone(),
1914                    StreamMark {
1915                        seq: log.high_water(),
1916                        beat: log.beat,
1917                        age_ms: log
1918                            .fresh_at
1919                            .map(|t| now.saturating_duration_since(t).as_millis() as u64),
1920                        pk: log.beat_pk.clone(),
1921                        sig: log.beat_sig.clone(),
1922                    },
1923                )
1924            })
1925            .chain(inner.tombstones.iter().map(|(id, tombstone)| {
1926                (
1927                    id.clone(),
1928                    StreamMark {
1929                        seq: tombstone.seq,
1930                        ..StreamMark::default()
1931                    },
1932                )
1933            }))
1934            .collect()
1935    }
1936
1937    /// The marks to tell one peer, whose own marks are `peer`: every stream this runtime
1938    /// holds, and a compacted stream's tombstone only when the peer holds that stream.
1939    ///
1940    /// A tombstone is in the marks to stop a peer re-sending a stream this runtime dropped,
1941    /// so it says something only to a peer that has the stream to send. Told to everyone,
1942    /// the up to four tombstones per stream slot weighed as much as the live marks, and
1943    /// together with a full event budget they took a sync message past the link bound
1944    /// (I2139). A peer whose marks are not known yet is told no tombstone; what it then
1945    /// sends of a dropped stream is absorbed by the tombstone, once, and its marks are known
1946    /// from that round on.
1947    ///
1948    /// ```
1949    /// use std::sync::Arc;
1950    /// use std::time::Duration;
1951    /// use majordomus_cli::mesh::identity::NodeIdentity;
1952    /// use majordomus_cli::mesh::journal::{EventBody, Journal, Marks, HANDOVER_RETENTION};
1953    ///
1954    /// let open = |n: &str| Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), n,
1955    ///     "repo".into(), None).unwrap();
1956    /// let (a, b) = (open("0000000000000001"), open("0000000000000002"));
1957    /// a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1958    /// b.ingest(&a.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
1959    /// let zero = Duration::ZERO;
1960    /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, true), 1);
1961    ///
1962    /// // a peer that holds the dropped stream is told how far it got, so it sends nothing
1963    /// assert_eq!(b.marks_for(&a.marks())[a.own_stream()].seq, 1);
1964    /// // a peer that does not is told nothing about it
1965    /// assert!(!b.marks_for(&Marks::new()).contains_key(a.own_stream()));
1966    /// // and the streams b holds are told to every peer
1967    /// assert!(b.marks_for(&Marks::new()).contains_key(b.own_stream()));
1968    /// ```
1969    pub fn marks_for(&self, peer: &Marks) -> Marks {
1970        let mut marks = self.marks();
1971        let inner = self.inner.lock().expect("journal lock");
1972        marks.retain(|id, _| !inner.tombstones.contains_key(id) || peer.contains_key(id));
1973        marks
1974    }
1975
1976    /// Merge a peer's marks. Only a higher beat carrying its origin's valid signature, from
1977    /// an origin `trusted` accepts, makes a stream fresher here — a relay forwards beats and
1978    /// cannot mint one — and the relayed age is clamped to the expiry, so a stale report can
1979    /// age a stream but never hold a dead one alive past one expiry after its last real beat.
1980    /// Sequences are not merged: only events move high-water marks. A stream is created from
1981    /// a mark only when the mark verifies, only within the stream quotas, and only when the
1982    /// beat it carries is fresh: a mark that can only report a stream dead — a relay still
1983    /// advertising a stream this runtime has compacted — gives the journal nothing to hold,
1984    /// and must not take a slot of its node's quota.
1985    ///
1986    /// A stream this runtime has compacted comes back the same way, and only on a beat above
1987    /// the one it was compacted at: a laptop that slept past the retention wakes as the same
1988    /// stream and beats again, while a relay replaying the old beat is remembering the
1989    /// stream, not hearing it. Heard again, the stream's tombstone is lifted and its mark
1990    /// falls to what is held of it — nothing — so the peers send it again from its first
1991    /// event. The whole stream, not the part after the tombstone's mark: what it said before
1992    /// it was compacted (a claim it still holds, a session still open) is live again with
1993    /// it, and a suffix would leave this runtime admitting claims that conflict with it.
1994    ///
1995    /// ```
1996    /// use std::sync::Arc;
1997    /// use std::time::Duration;
1998    /// use majordomus_cli::mesh::identity::NodeIdentity;
1999    /// use majordomus_cli::mesh::journal::{Journal, StreamLiveness};
2000    ///
2001    /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2002    ///     "repo".into(), None).unwrap();
2003    /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2004    ///     "repo".into(), None).unwrap();
2005    /// a.beat_own();
2006    ///
2007    /// // B learns that A is beating, because A signed the beat
2008    /// b.merge_marks(&a.marks(), &|_| true, Duration::from_secs(30));
2009    /// assert_eq!(b.liveness(a.own_stream(), Duration::from_secs(30)), StreamLiveness::Live);
2010    ///
2011    /// // an untrusted origin's beat tells B nothing, however well signed
2012    /// let c = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000003",
2013    ///     "repo".into(), None).unwrap();
2014    /// c.beat_own();
2015    /// b.merge_marks(&c.marks(), &|_| false, Duration::from_secs(30));
2016    /// assert_eq!(b.liveness(c.own_stream(), Duration::from_secs(30)), StreamLiveness::Expired);
2017    ///
2018    /// // a trusted beat heard a minute ago says only that C is dead: B holds nothing for it
2019    /// let mut stale = c.marks();
2020    /// stale.get_mut(c.own_stream()).unwrap().age_ms = Some(60_000);
2021    /// b.merge_marks(&stale, &|_| true, Duration::from_secs(30));
2022    /// assert_eq!(b.tallies().streams, 2, "B's own stream and A's");
2023    /// ```
2024    pub fn merge_marks(&self, marks: &Marks, trusted: &dyn Fn(&str) -> bool, expiry: Duration) {
2025        let now = Instant::now();
2026        let ceiling = (expiry + Duration::from_secs(1)).as_millis() as u64;
2027        let mut inner = self.inner.lock().expect("journal lock");
2028        for (id, mark) in marks {
2029            if *id == self.own || mark.beat == 0 {
2030                continue;
2031            }
2032            let (Some(pk), Some(sig)) = (&mark.pk, &mark.sig) else {
2033                continue;
2034            };
2035            if node_id_of_key(pk).map(|n| n.as_str().to_string()) != Some(id.node().to_string())
2036                || !verify(pk, &beat_bytes(id, mark.beat), sig)
2037                || !trusted(pk)
2038            {
2039                continue;
2040            }
2041            let tombstone = inner.tombstones.get(id).copied();
2042            if tombstone.is_some_and(|t| mark.beat <= t.beat) {
2043                continue;
2044            }
2045            if !inner.streams.contains_key(id) {
2046                if !mark
2047                    .age_ms
2048                    .is_some_and(|age| Duration::from_millis(age) <= expiry)
2049                {
2050                    continue;
2051                }
2052                let of_node = inner
2053                    .streams
2054                    .keys()
2055                    .filter(|s| s.node() == id.node())
2056                    .count();
2057                if inner.streams.len() >= MAX_STREAMS || of_node >= MAX_STREAMS_PER_NODE {
2058                    continue;
2059                }
2060            }
2061            if tombstone.is_some() {
2062                // Compacted as dead, and beating again: it is fetched again from its first event.
2063                inner.tombstones.remove(id);
2064            }
2065            let log = inner
2066                .streams
2067                .entry(id.clone())
2068                .or_insert_with(StreamLog::new);
2069            if mark.beat > log.beat {
2070                log.beat = mark.beat;
2071                log.beat_pk = Some(pk.clone());
2072                log.beat_sig = Some(sig.clone());
2073                log.fresh_at = mark
2074                    .age_ms
2075                    .map(|age| age.min(ceiling))
2076                    .and_then(|age| now.checked_sub(Duration::from_millis(age)));
2077            }
2078        }
2079    }
2080
2081    /// The events a peer lacks by its marks, in stream-then-sequence order, at most
2082    /// `budget` serialized bytes (always at least one event when any is missing).
2083    ///
2084    /// This is the half of replication that decides what travels, and it decides it from
2085    /// the peer's own marks rather than from a request: a runtime sends what the peer says
2086    /// it lacks and nothing else, so an event crosses each link once and stops. The budget
2087    /// keeps one round bounded, and the streams are visited from a rotating start, so a
2088    /// stream a peer keeps refusing cannot spend the whole budget round after round and
2089    /// starve the streams behind it.
2090    ///
2091    /// ```
2092    /// use std::sync::Arc;
2093    /// use majordomus_cli::mesh::identity::NodeIdentity;
2094    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2095    ///
2096    /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2097    ///     "repo".into(), None).unwrap();
2098    /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2099    ///     "repo".into(), None).unwrap();
2100    /// a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2101    /// a.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2102    ///
2103    /// // B has nothing, so both events are missing for it
2104    /// let missing = a.missing_for(&b.marks(), 1 << 20);
2105    /// assert_eq!(missing.len(), 2);
2106    ///
2107    /// // once B holds them, its marks say so and nothing is sent again
2108    /// b.ingest(&missing, &|_| Ok(()));
2109    /// assert!(a.missing_for(&b.marks(), 1 << 20).is_empty());
2110    ///
2111    /// // a budget of nothing still moves one event: progress is never zero
2112    /// assert_eq!(a.missing_for(&Default::default(), 0).len(), 1);
2113    /// ```
2114    pub fn missing_for(&self, peer: &Marks, budget: usize) -> Vec<MeshEvent> {
2115        let inner = self.inner.lock().expect("journal lock");
2116        let mut out = Vec::new();
2117        let mut spent = 0usize;
2118        // Start at a different stream each round, so that one stream a peer refuses — and
2119        // keeps lacking — cannot spend the whole budget round after round and starve the
2120        // streams behind it.
2121        let count = inner.streams.len().max(1);
2122        let start = self
2123            .rotation
2124            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2125            % count;
2126        let order = inner
2127            .streams
2128            .iter()
2129            .skip(start)
2130            .chain(inner.streams.iter().take(start));
2131        for (id, log) in order {
2132            let have = peer.get(id).map(|m| m.seq).unwrap_or(0);
2133            let Some(from) = have.checked_add(1) else {
2134                continue;
2135            };
2136            for (_, event) in log.events.range(from..) {
2137                let size = serde_json::to_vec(event).map(|b| b.len()).unwrap_or(0);
2138                if !out.is_empty() && spent + size > budget {
2139                    return out;
2140                }
2141                spent += size;
2142                out.push(event.clone());
2143            }
2144        }
2145        out
2146    }
2147
2148    /// Verify and store received events. `accept` is the trust decision for an event's
2149    /// origin, made above the journal; everything else — size, version, identity,
2150    /// signature, repository, bounds, order, duplicates — is decided here, once, for every
2151    /// transport and relay alike.
2152    ///
2153    /// Trust is the caller's because it is policy, and everything else is decided here
2154    /// because it must be decided the same way for every arrival: a datagram, a sync round
2155    /// and a file reloaded from disk are all input, and none of them is believed. Events
2156    /// are applied in stream order, so one that arrives ahead of its gap waits rather than
2157    /// being applied out of turn, and one that arrives twice is counted and changes nothing.
2158    ///
2159    /// ```
2160    /// use std::sync::Arc;
2161    /// use majordomus_cli::mesh::identity::NodeIdentity;
2162    /// use majordomus_cli::mesh::journal::{EventBody, Journal, Rejection};
2163    ///
2164    /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2165    ///     "repo".into(), None).unwrap();
2166    /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2167    ///     "repo".into(), None).unwrap();
2168    /// let first = a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2169    /// let second = a.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2170    ///
2171    /// // the second event alone arrives first: it waits for the gap ahead of it
2172    /// let report = b.ingest(&[second.clone()], &|_| Ok(()));
2173    /// assert_eq!((report.accepted, report.pending), (0, 1));
2174    ///
2175    /// // the first arrives and both are applied, in the stream's order
2176    /// let report = b.ingest(&[first], &|_| Ok(()));
2177    /// assert_eq!(report.accepted, 2);
2178    /// assert_eq!(b.events().len(), 2);
2179    ///
2180    /// // an untrusted origin is refused, with the reason the caller gave
2181    /// let c = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000003",
2182    ///     "repo".into(), None).unwrap();
2183    /// let theirs = c.append_own(EventBody::SessionClosed { session: "s9".into() }).unwrap();
2184    /// let report = b.ingest(&[theirs], &|_| Err(Rejection::Untrusted));
2185    /// assert_eq!(report.rejected[&Rejection::Untrusted], 1);
2186    /// ```
2187    pub fn ingest(
2188        &self,
2189        events: &[MeshEvent],
2190        accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
2191    ) -> IngestReport {
2192        let mut report = IngestReport::default();
2193        let mut stored = Vec::new();
2194        {
2195            let mut inner = self.inner.lock().expect("journal lock");
2196            for event in events {
2197                let before = inner.tallies.received;
2198                if let Some(applied) =
2199                    self.ingest_locked(&mut inner, event.clone(), accept, &mut report, true)
2200                {
2201                    stored.extend(applied);
2202                }
2203                debug_assert!(inner.tallies.received >= before);
2204            }
2205        }
2206        self.persist(&stored);
2207        report
2208    }
2209
2210    /// One event under the lock. Returns the events it made contiguous (itself and any
2211    /// pending successors), which the caller persists.
2212    fn ingest_locked(
2213        &self,
2214        inner: &mut Inner,
2215        event: MeshEvent,
2216        accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
2217        report: &mut IngestReport,
2218        count: bool,
2219    ) -> Option<Vec<MeshEvent>> {
2220        let refuse = |inner: &mut Inner, report: &mut IngestReport, why: Rejection| {
2221            report.reject(why);
2222            if count {
2223                inner.tallies.rejected += 1;
2224            }
2225            None
2226        };
2227        if serde_json::to_vec(&event)
2228            .map(|b| b.len())
2229            .unwrap_or(usize::MAX)
2230            > MAX_EVENT_BYTES
2231        {
2232            return refuse(inner, report, Rejection::Oversized);
2233        }
2234        if event.v != EVENT_VERSION {
2235            return refuse(inner, report, Rejection::Version);
2236        }
2237        if StreamId::parse(event.stream.as_str()).is_none()
2238            || node_id_of_key(&event.pk).map(|n| n.as_str().to_string())
2239                != Some(event.stream.node().to_string())
2240            || event.seq == 0
2241        {
2242            return refuse(inner, report, Rejection::Identity);
2243        }
2244        if !verify(&event.pk, &event.signing_bytes(), &event.sig) {
2245            return refuse(inner, report, Rejection::Signature);
2246        }
2247        if event.repo != self.repo {
2248            return refuse(inner, report, Rejection::Repository);
2249        }
2250        if let Some(body) = event.body() {
2251            if body.validate().is_err() {
2252                return refuse(inner, report, Rejection::Bounds);
2253            }
2254        } else if KNOWN_KINDS.contains(&event.kind()) || event.kind().is_empty() {
2255            // A kind this executable knows, in a shape it does not: out of bounds, not new.
2256            return refuse(inner, report, Rejection::Bounds);
2257        }
2258        if let Err(why) = accept(&event) {
2259            return refuse(inner, report, why);
2260        }
2261        if event.lamport > inner.lamport.saturating_add(MAX_LAMPORT_LEAD) {
2262            // A trusted key's stamp, but further ahead of this clock than any history
2263            // reaches: storing it would carry every clock that hears it towards u64::MAX.
2264            return refuse(inner, report, Rejection::ClockAhead);
2265        }
2266        if event.stream == self.own {
2267            // Our own events coming back through a relay: we hold them already, or they
2268            // are from a future we did not write — either way, nothing to store.
2269            report.duplicate += 1;
2270            if count {
2271                inner.tallies.duplicates += 1;
2272            }
2273            return None;
2274        }
2275        if let Some(tombstone) = inner.tombstones.get_mut(&event.stream) {
2276            // Compacted away as dead. A late copy of what was held changes nothing, and neither
2277            // does an event the stream wrote after: until the stream beats again this runtime
2278            // holds nothing of it, and when it does the stream is fetched again from its first
2279            // event. The mark rises over a later event, so a relay holding it stops offering it.
2280            tombstone.seq = tombstone.seq.max(event.seq);
2281            report.duplicate += 1;
2282            if count {
2283                inner.tallies.duplicates += 1;
2284            }
2285            return None;
2286        }
2287        if !inner.streams.contains_key(&event.stream) && inner.streams.len() >= MAX_STREAMS {
2288            return refuse(inner, report, Rejection::Capacity);
2289        }
2290        // Quotas per node: stream, runtime and instance ids cost nothing to invent, so one
2291        // key — even a trusted one — gets a bounded share of the journal.
2292        let node = event.stream.node().to_string();
2293        if !inner.streams.contains_key(&event.stream)
2294            && inner.streams.keys().filter(|s| s.node() == node).count() >= MAX_STREAMS_PER_NODE
2295        {
2296            return refuse(inner, report, Rejection::Capacity);
2297        }
2298        let held_by_node: usize = inner
2299            .streams
2300            .iter()
2301            .filter(|(s, _)| s.node() == node)
2302            .map(|(_, l)| l.events.len() + l.pending.len())
2303            .sum();
2304        if held_by_node >= MAX_EVENTS_PER_NODE {
2305            return refuse(inner, report, Rejection::Capacity);
2306        }
2307        let pending_total: usize = inner.streams.values().map(|l| l.pending.len()).sum();
2308        let lamport = event.lamport;
2309        let log = inner
2310            .streams
2311            .entry(event.stream.clone())
2312            .or_insert_with(StreamLog::new);
2313        let high = log.high_water();
2314        if event.seq <= high || log.pending.contains_key(&event.seq) {
2315            report.duplicate += 1;
2316            if count {
2317                inner.tallies.duplicates += 1;
2318            }
2319            return None;
2320        }
2321        if event.seq > high + 1 {
2322            if log.pending.len() >= MAX_PENDING || pending_total >= MAX_PENDING_TOTAL {
2323                return refuse(inner, report, Rejection::Capacity);
2324            }
2325            log.pending.insert(event.seq, event);
2326            report.pending += 1;
2327            return None;
2328        }
2329        let mut applied = vec![event.clone()];
2330        log.events.insert(event.seq, event);
2331        let mut next = high + 2;
2332        while let Some(successor) = log.pending.remove(&next) {
2333            applied.push(successor.clone());
2334            log.events.insert(next, successor);
2335            next += 1;
2336        }
2337        let max_lamport = applied.iter().map(|e| e.lamport).max().unwrap_or(lamport);
2338        inner.lamport = inner.lamport.max(max_lamport);
2339        report.accepted += applied.len() as u64;
2340        if count {
2341            inner.tallies.received += applied.len() as u64;
2342        }
2343        Some(applied)
2344    }
2345
2346    /// Append `events` to the journal file and sync it, answering whether they reached the
2347    /// disk. A failure is counted and logged here, once, whoever asked.
2348    fn persist(&self, events: &[MeshEvent]) -> Durability {
2349        let Some(path) = &self.path else {
2350            return Durability::Memory;
2351        };
2352        if events.is_empty() {
2353            return Durability::Synced;
2354        }
2355        // An event that does not serialise is left out of the append and counted as lost with
2356        // the ones a failed write loses.
2357        let lines: Vec<String> = events
2358            .iter()
2359            .filter_map(|event| serde_json::to_string(event).ok())
2360            .collect();
2361        let unserialised = events.len() - lines.len();
2362        let mut text = String::new();
2363        for line in &lines {
2364            text.push_str(line);
2365            text.push('\n');
2366        }
2367        let written = append_synced(path, &text);
2368        if written.is_ok() && unserialised == 0 {
2369            return Durability::Synced;
2370        }
2371        let lost = written
2372            .as_ref()
2373            .err()
2374            .map_or(unserialised, |_| events.len());
2375        self.write_failures
2376            .fetch_add(lost as u64, std::sync::atomic::Ordering::SeqCst);
2377        let error = written
2378            .err()
2379            .map_or("an event did not serialise".to_string(), |e| e.to_string());
2380        tracing::warn!(
2381            path = %path.display(),
2382            error = %error,
2383            events = lost,
2384            "mesh journal: an append did not reach the disk; the events are held in memory and replicated only"
2385        );
2386        Durability::NotDurable
2387    }
2388
2389    /// A stream's liveness on this runtime's clock, against `expiry`. The measurement is
2390    /// local and monotonic — how long since this process saw the beat rise — so two
2391    /// machines never compare wall clocks and a machine with a wrong clock cannot hold a
2392    /// dead runtime's claims alive. A stream nobody has ever beaten for is expired, which
2393    /// is the right answer for a stream learned from a relay that has not heard from it.
2394    ///
2395    /// ```
2396    /// use std::sync::Arc;
2397    /// use std::time::Duration;
2398    /// use majordomus_cli::mesh::identity::NodeIdentity;
2399    /// use majordomus_cli::mesh::journal::{Journal, StreamLiveness};
2400    ///
2401    /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2402    ///     "repo".into(), None).unwrap();
2403    /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2404    ///     "repo".into(), None).unwrap();
2405    /// let expiry = Duration::from_secs(30);
2406    ///
2407    /// assert_eq!(a.liveness(a.own_stream(), expiry), StreamLiveness::Own);
2408    /// assert_eq!(a.liveness(b.own_stream(), expiry), StreamLiveness::Expired, "never heard of");
2409    ///
2410    /// b.beat_own();
2411    /// a.merge_marks(&b.marks(), &|_| true, expiry);
2412    /// assert_eq!(a.liveness(b.own_stream(), expiry), StreamLiveness::Live);
2413    /// ```
2414    pub fn liveness(&self, stream: &StreamId, expiry: Duration) -> StreamLiveness {
2415        if *stream == self.own {
2416            return StreamLiveness::Own;
2417        }
2418        let now = Instant::now();
2419        let inner = self.inner.lock().expect("journal lock");
2420        match inner.streams.get(stream).and_then(|l| l.fresh_at) {
2421            Some(t) if now.saturating_duration_since(t) <= expiry => StreamLiveness::Live,
2422            _ => StreamLiveness::Expired,
2423        }
2424    }
2425
2426    /// Every stream's liveness and time since its beat rose, in stream order. The age
2427    /// travels with the verdict because "expired" alone is not diagnosable: a stream whose
2428    /// beat rose a minute ago is a runtime that stopped, and one whose beat was never heard
2429    /// is a runtime this one has only been told about.
2430    ///
2431    /// ```
2432    /// use std::sync::Arc;
2433    /// use std::time::Duration;
2434    /// use majordomus_cli::mesh::identity::NodeIdentity;
2435    /// use majordomus_cli::mesh::journal::{Journal, StreamLiveness};
2436    ///
2437    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2438    ///     "repo".into(), None).unwrap();
2439    /// let all = j.stream_liveness(Duration::from_secs(30));
2440    /// let (liveness, age) = all[j.own_stream()];
2441    /// assert_eq!(liveness, StreamLiveness::Own);
2442    /// assert_eq!(age, None, "no beat has been raised yet");
2443    ///
2444    /// j.beat_own();
2445    /// assert!(j.stream_liveness(Duration::from_secs(30))[j.own_stream()].1.is_some());
2446    /// ```
2447    pub fn stream_liveness(
2448        &self,
2449        expiry: Duration,
2450    ) -> BTreeMap<StreamId, (StreamLiveness, Option<Duration>)> {
2451        let now = Instant::now();
2452        let inner = self.inner.lock().expect("journal lock");
2453        inner
2454            .streams
2455            .iter()
2456            .map(|(id, log)| {
2457                let age = log.fresh_at.map(|t| now.saturating_duration_since(t));
2458                let liveness = if *id == self.own {
2459                    StreamLiveness::Own
2460                } else if age.is_some_and(|a| a <= expiry) {
2461                    StreamLiveness::Live
2462                } else {
2463                    StreamLiveness::Expired
2464                };
2465                (id.clone(), (liveness, age))
2466            })
2467            .collect()
2468    }
2469
2470    /// Every held event, in stream-then-sequence order. Events waiting for a gap are not
2471    /// here: what this returns is what the journal is prepared to stand behind, and the
2472    /// fold takes it whole, which is why the order it comes out in does not matter to the
2473    /// result.
2474    ///
2475    /// ```
2476    /// use std::sync::Arc;
2477    /// use majordomus_cli::mesh::identity::NodeIdentity;
2478    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2479    ///
2480    /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2481    ///     "repo".into(), None).unwrap();
2482    /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2483    ///     "repo".into(), None).unwrap();
2484    /// let first = a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2485    /// let second = a.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2486    ///
2487    /// // an event still waiting for the gap ahead of it is not yet held
2488    /// b.ingest(&[second], &|_| Ok(()));
2489    /// assert!(b.events().is_empty());
2490    /// b.ingest(&[first], &|_| Ok(()));
2491    /// assert_eq!(b.events().len(), 2);
2492    /// assert_eq!(b.events()[0].seq, 1, "stream order, whatever the arrival order was");
2493    /// ```
2494    pub fn events(&self) -> Vec<MeshEvent> {
2495        let inner = self.inner.lock().expect("journal lock");
2496        inner
2497            .streams
2498            .values()
2499            .flat_map(|log| log.events.values().cloned())
2500            .collect()
2501    }
2502
2503    /// Held events whose Lamport stamp is above `after`, in Lamport order, at most `limit`.
2504    ///
2505    /// The Lamport stamp is what a reader can resume from: it rises with causality rather
2506    /// than with anybody's clock, so a caller that remembers the last stamp it saw reads
2507    /// each event once however the machines involved disagree about the time. This is what
2508    /// `mesh events --after` is.
2509    ///
2510    /// ```
2511    /// use std::sync::Arc;
2512    /// use majordomus_cli::mesh::identity::NodeIdentity;
2513    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2514    ///
2515    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2516    ///     "repo".into(), None).unwrap();
2517    /// let first = j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2518    /// j.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2519    ///
2520    /// assert_eq!(j.events_after(0, 100).len(), 2, "from the beginning");
2521    /// let rest = j.events_after(first.lamport, 100);
2522    /// assert_eq!(rest.len(), 1, "resumed where the reader left off");
2523    /// assert!(rest[0].lamport > first.lamport);
2524    /// assert_eq!(j.events_after(0, 1).len(), 1, "one page");
2525    /// ```
2526    pub fn events_after(&self, after: u64, limit: usize) -> Vec<MeshEvent> {
2527        let ordered: Vec<MeshEvent> = self
2528            .events()
2529            .into_iter()
2530            .filter(|e| e.lamport > after)
2531            .map(|e| (causal_key(&e), e))
2532            .collect::<BTreeMap<_, _>>()
2533            .into_values()
2534            .collect();
2535        // A page never ends inside a run of equal stamps: the next page resumes after the
2536        // last stamp this one carries, so an event that shares it and was cut off would never
2537        // be read. Such a run is as long as the streams that wrote concurrently, not unbounded.
2538        let mut page = limit.min(ordered.len());
2539        if let Some(last) = page.checked_sub(1).map(|i| ordered[i].lamport) {
2540            while page < ordered.len() && ordered[page].lamport == last {
2541                page += 1;
2542            }
2543        }
2544        ordered.into_iter().take(page).collect()
2545    }
2546
2547    /// Drop every stream that has been expired for longer than `retention` and that nothing
2548    /// still needs, when the journal is over its bound or `force` is set; the file is
2549    /// rewritten to what remains. Only dead streams go, so no live claim can lose its
2550    /// release to compaction.
2551    ///
2552    /// A node that holds [`NODE_COMPACTION_STREAMS`] streams is due on its own, whatever the
2553    /// journal's size: its dead streams go by the same rule, the other nodes' stay. One
2554    /// machine's worktrees share its key and every server restart is a new stream, so
2555    /// without this a machine's own runs would fill its quota and nothing would ever free
2556    /// it — the journal-wide bounds are out of one node's reach — and every later run of
2557    /// a sibling would be refused here, its claims with it.
2558    ///
2559    /// What makes dropping events safe is what is kept. Only a stream that has been silent
2560    /// well past its expiry goes, so no claim can lose the release that would have ended it.
2561    /// A dead stream stays while it holds a published handover nobody has taken, because a
2562    /// handover is continuity somebody may still be waiting to pick up — for at most
2563    /// `handovers` past the retention ([`HANDOVER_RETENTION`] in a running server), or its
2564    /// node's quota would fill with the dead. And a dead stream stays while it holds the
2565    /// taking of a handover or the answer to a review that stays, so that a handover that is
2566    /// still offered never looks untaken again and an answered review never opens again.
2567    /// Who took what and who answered what are read by the fold ([`super::state::fold`]),
2568    /// the one reading every surface shows.
2569    ///
2570    /// A stream whose beat was never heard here counts as silent from when this journal
2571    /// learned of it, so the streams a restart reloads are not taken for dead before their
2572    /// runtimes could be heard. What is dropped leaves a tombstone carrying the sequence it
2573    /// reached, so a peer that still holds the stream is not sent it all over again, and the
2574    /// beat it was last heard at, which a stream that returns must rise above
2575    /// ([`Journal::merge_marks`]).
2576    ///
2577    /// ```
2578    /// use std::sync::Arc;
2579    /// use std::time::Duration;
2580    /// use majordomus_cli::mesh::identity::NodeIdentity;
2581    /// use majordomus_cli::mesh::journal::{EventBody, HandoverBody, Journal, HANDOVER_RETENTION};
2582    ///
2583    /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2584    ///     "repo".into(), None).unwrap();
2585    /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2586    ///     "repo".into(), None).unwrap();
2587    /// a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2588    /// b.ingest(&a.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
2589    /// assert_eq!(b.events().len(), 1);
2590    ///
2591    /// // under its bounds, compaction does nothing unless it is asked
2592    /// let zero = Duration::ZERO;
2593    /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, false), 0);
2594    ///
2595    /// // asked, the silent stream goes, and its mark stays so it is not re-sent
2596    /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, true), 1);
2597    /// assert!(b.events().is_empty());
2598    /// assert_eq!(b.marks()[a.own_stream()].seq, 1, "the tombstone remembers how far it got");
2599    ///
2600    /// // a handover nobody has taken keeps its stream, until the handover retention passes
2601    /// let c = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000003",
2602    ///     "repo".into(), None).unwrap();
2603    /// let body = "# Objective\nship\n".to_string();
2604    /// let handover = HandoverBody { id: HandoverBody::digest_of(&body), task: None, issue: None,
2605    ///     milestone: None, branch: None, head: None, created_at: None, name: None, body };
2606    /// c.append_own(EventBody::HandoverPublished { handover }).unwrap();
2607    /// b.ingest(&c.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
2608    /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, true), 0);
2609    /// assert_eq!(b.compact(zero, zero, zero, true), 1);
2610    /// ```
2611    pub fn compact(
2612        &self,
2613        expiry: Duration,
2614        retention: Duration,
2615        handovers: Duration,
2616        force: bool,
2617    ) -> u64 {
2618        let now = Instant::now();
2619        let mut inner = self.inner.lock().expect("journal lock");
2620        let total: usize = inner.streams.values().map(|l| l.events.len()).sum();
2621        let over = force || total > MAX_EVENTS || inner.streams.len() > MAX_STREAMS * 3 / 4;
2622        let mut per_node: BTreeMap<&str, usize> = BTreeMap::new();
2623        for id in inner.streams.keys() {
2624            *per_node.entry(id.node()).or_default() += 1;
2625        }
2626        let crowded: Vec<String> = per_node
2627            .into_iter()
2628            .filter(|(_, streams)| *streams >= NODE_COMPACTION_STREAMS)
2629            .map(|(node, _)| node.to_string())
2630            .collect();
2631        if !over && crowded.is_empty() {
2632            return 0;
2633        }
2634        let due: BTreeSet<StreamId> = inner
2635            .streams
2636            .iter()
2637            .filter(|(id, log)| {
2638                **id != self.own
2639                    && (over || crowded.iter().any(|node| node == id.node()))
2640                    && log.silent_for(now) > expiry + retention
2641            })
2642            .map(|(id, _)| id.clone())
2643            .collect();
2644        let offer_lapsed = |log: &StreamLog| log.silent_for(now) > expiry + retention + handovers;
2645        let dead = unneeded(&inner.streams, due, &offer_lapsed);
2646        let mut dropped = 0u64;
2647        for id in dead {
2648            if let Some(log) = inner.streams.remove(&id) {
2649                dropped += log.events.len() as u64;
2650                if inner.tombstones.len() >= MAX_STREAMS * 4 {
2651                    if let Some(first) = inner.tombstones.keys().next().cloned() {
2652                        inner.tombstones.remove(&first);
2653                    }
2654                }
2655                let tombstone = Tombstone {
2656                    seq: log.high_water(),
2657                    beat: log.beat,
2658                };
2659                inner.tombstones.insert(id.clone(), tombstone);
2660            }
2661        }
2662        inner.tallies.compacted += dropped;
2663        if dropped > 0 {
2664            if let Some(path) = &self.path {
2665                let mut text = String::new();
2666                for event in inner.streams.values().flat_map(|l| l.events.values()) {
2667                    if let Ok(line) = serde_json::to_string(event) {
2668                        text.push_str(&line);
2669                        text.push('\n');
2670                    }
2671                }
2672                // A new file, synced, renamed over the old one: a crash leaves the old journal
2673                // or the new one, never a mix of both. What the reload left unread beyond its
2674                // bound is copied into the new file, byte for byte, rather than dropped.
2675                let tmp = path.with_extension("jsonl.tmp");
2676                let tail = inner.unread_tail.clone();
2677                let replaced = match &tail {
2678                    None => write_synced(&tmp, &text),
2679                    Some(range) => write_synced_with_tail(&tmp, &text, path, range.clone()),
2680                }
2681                .and_then(|()| std::fs::rename(&tmp, path));
2682                match replaced {
2683                    Ok(()) => {
2684                        sync_parent(path);
2685                        inner.unread_tail = tail.map(|range| {
2686                            let start = text.len() as u64;
2687                            start..start + (range.end - range.start)
2688                        });
2689                    }
2690                    Err(e) => {
2691                        let _ = std::fs::remove_file(&tmp);
2692                        tracing::warn!(path = %path.display(), error = %e, "mesh journal: compaction could not replace the file; the old one stands");
2693                    }
2694                }
2695            }
2696        }
2697        dropped
2698    }
2699
2700    /// The tallies now: a snapshot of the counters, taken under the lock so that the
2701    /// numbers in one answer are consistent with each other rather than read one at a time
2702    /// while the journal moves. This is what `mesh status` and the cooperation status show.
2703    ///
2704    /// ```
2705    /// use std::sync::Arc;
2706    /// use majordomus_cli::mesh::identity::NodeIdentity;
2707    /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2708    ///
2709    /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2710    ///     "repo".into(), None).unwrap();
2711    /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2712    ///
2713    /// let snapshot = j.tallies();
2714    /// assert_eq!(snapshot.written, 1);
2715    /// assert_eq!(snapshot.events, j.events().len());
2716    /// assert_eq!(snapshot.received, 0, "nothing came from a peer");
2717    /// ```
2718    pub fn tallies(&self) -> JournalTallies {
2719        let inner = self.inner.lock().expect("journal lock");
2720        let mut tallies = inner.tallies;
2721        tallies.streams = inner.streams.len();
2722        tallies.events = inner.streams.values().map(|l| l.events.len()).sum();
2723        tallies.pending = inner.streams.values().map(|l| l.pending.len()).sum();
2724        tallies.opaque = inner
2725            .streams
2726            .values()
2727            .flat_map(|l| l.events.values())
2728            .filter(|e| e.body().is_none())
2729            .count();
2730        tallies.lamport = inner.lamport;
2731        tallies.write_failures = self
2732            .write_failures
2733            .load(std::sync::atomic::Ordering::SeqCst);
2734        tallies.unreadable = self.unreadable.load(std::sync::atomic::Ordering::SeqCst);
2735        tallies.unread_bytes = inner
2736            .unread_tail
2737            .as_ref()
2738            .map_or(0, |range| range.end - range.start);
2739        tallies
2740    }
2741}
2742
2743/// The first `limit` bytes of the journal at `path`, cut after the last whole line, and the
2744/// byte range left unread when the file is larger: `None` when the file cannot be read. Only
2745/// what is kept is held in memory; the rest is measured by the file's length, not read.
2746fn read_bounded(path: &Path, limit: u64) -> Option<(Vec<u8>, Option<std::ops::Range<u64>>)> {
2747    use std::io::Read;
2748    let (file, len) = std::fs::File::open(path)
2749        .and_then(|file| file.metadata().map(|meta| (file, meta.len())))
2750        .ok()?;
2751    let mut bytes = Vec::new();
2752    file.take(limit).read_to_end(&mut bytes).ok()?;
2753    if len <= limit {
2754        return Some((bytes, None));
2755    }
2756    // A line that straddles the bound is not half-read into an unreadable line: it is the
2757    // first line of what is left on disk.
2758    let kept = bytes
2759        .iter()
2760        .rposition(|b| *b == b'\n')
2761        .map_or(0, |at| at + 1);
2762    bytes.truncate(kept);
2763    Some((bytes, Some(kept as u64..len)))
2764}
2765
2766/// [`write_synced`] of `text`, followed by the bytes `range` of `source` copied as they are:
2767/// the part of the journal a bounded reload left on disk, kept by the compaction that
2768/// replaces the file. Fails when fewer bytes than the range could be copied.
2769fn write_synced_with_tail(
2770    path: &Path,
2771    text: &str,
2772    source: &Path,
2773    range: std::ops::Range<u64>,
2774) -> std::io::Result<()> {
2775    use std::io::{Read, Seek, SeekFrom};
2776    let mut file = owner_only(
2777        std::fs::OpenOptions::new()
2778            .create(true)
2779            .write(true)
2780            .truncate(true),
2781    )
2782    .open(path)?;
2783    file.write_all(text.as_bytes())?;
2784    let mut from = std::fs::File::open(source)?;
2785    from.seek(SeekFrom::Start(range.start))?;
2786    let want = range.end - range.start;
2787    let copied = std::io::copy(&mut from.take(want), &mut file)?;
2788    if copied != want {
2789        return Err(std::io::Error::other(format!(
2790            "copied {copied} of the {want} unread byte(s)"
2791        )));
2792    }
2793    file.sync_all()
2794}
2795
2796/// Open a journal file for writing as its owner alone: it holds other runtimes' events and
2797/// handover bodies, which are nobody else's to read on this machine.
2798fn owner_only(options: &mut std::fs::OpenOptions) -> &mut std::fs::OpenOptions {
2799    #[cfg(unix)]
2800    {
2801        use std::os::unix::fs::OpenOptionsExt;
2802        options.mode(0o600);
2803    }
2804    options
2805}
2806
2807/// Append `text` to the journal at `path` and sync it. A file whose last byte is not a
2808/// newline — the tail a crash tore — gets one first, so the torn line stays one unreadable
2809/// line and the events appended after it read.
2810fn append_synced(path: &Path, text: &str) -> std::io::Result<()> {
2811    if let Some(dir) = path.parent() {
2812        std::fs::create_dir_all(dir)?;
2813    }
2814    let mut file = owner_only(
2815        std::fs::OpenOptions::new()
2816            .create(true)
2817            .read(true)
2818            .append(true),
2819    )
2820    .open(path)?;
2821    append_mended(&mut file, text).and_then(|()| file.sync_data())
2822}
2823
2824/// Append `text` to `file`, open for reading and appending, after a newline first when its
2825/// last byte is not one: the torn tail [`append_synced`] mends.
2826fn append_mended<F>(file: &mut F, text: &str) -> std::io::Result<()>
2827where
2828    F: std::io::Read + std::io::Write + std::io::Seek,
2829{
2830    use std::io::SeekFrom;
2831    if file.seek(SeekFrom::End(0))? > 0 {
2832        let mut last = [0u8; 1];
2833        file.seek(SeekFrom::End(-1))?;
2834        file.read_exact(&mut last)?;
2835        if last[0] != b'\n' {
2836            file.write_all(b"\n")?;
2837        }
2838    }
2839    file.write_all(text.as_bytes())
2840}
2841
2842/// Write `text` to a new file at `path`, owner-only, and sync it before it is renamed anywhere.
2843fn write_synced(path: &Path, text: &str) -> std::io::Result<()> {
2844    let mut file = owner_only(
2845        std::fs::OpenOptions::new()
2846            .create(true)
2847            .write(true)
2848            .truncate(true),
2849    )
2850    .open(path)?;
2851    file.write_all(text.as_bytes())
2852        .and_then(|()| file.sync_all())
2853}
2854
2855/// Sync the directory holding `path`, so a rename into it survives a crash too.
2856fn sync_parent(path: &Path) {
2857    if let Some(dir) = path.parent() {
2858        if let Ok(handle) = std::fs::File::open(dir) {
2859            let _ = handle.sync_all();
2860        }
2861    }
2862}
2863
2864/// Of the streams `due` for compaction, the ones nothing still needs. A due stream is needed
2865/// while it offers a handover nobody has taken and its offer has not lapsed
2866/// (`offer_lapsed`), and while it holds the taking of a handover or the answer to a review
2867/// whose own stream is needed or not due — to a fixed point, because keeping one stream can
2868/// keep the stream that answered it. Who took what and who answered what is the fold's
2869/// reading, so that compaction and every surface agree on it.
2870fn unneeded(
2871    streams: &BTreeMap<StreamId, StreamLog>,
2872    due: BTreeSet<StreamId>,
2873    offer_lapsed: &dyn Fn(&StreamLog) -> bool,
2874) -> Vec<StreamId> {
2875    if due.is_empty() {
2876        return Vec::new();
2877    }
2878    let held: Vec<MeshEvent> = streams
2879        .values()
2880        .flat_map(|log| log.events.values().cloned())
2881        .collect();
2882    let state = super::state::fold(&held, &|_| StreamLiveness::Expired);
2883    let mut publishers: BTreeMap<String, Vec<StreamId>> = BTreeMap::new();
2884    for (id, log) in streams {
2885        let published = log
2886            .events
2887            .values()
2888            .filter(|e| e.kind() == "handover_published");
2889        for event in published {
2890            if let Some(EventBody::HandoverPublished { handover }) = event.body() {
2891                publishers.entry(handover.id).or_default().push(id.clone());
2892            }
2893        }
2894    }
2895    let stream_of = |key: &str| key.split_once('/').and_then(|(s, _)| StreamId::parse(s));
2896    // What each taking and each answer refers to: the streams holding its handover or review.
2897    let mut refers: Vec<(StreamId, Vec<StreamId>)> = Vec::new();
2898    let mut kept: BTreeSet<StreamId> = streams
2899        .keys()
2900        .filter(|id| !due.contains(*id))
2901        .cloned()
2902        .collect();
2903    for handover in &state.handovers {
2904        let by = publishers.get(&handover.id).cloned().unwrap_or_default();
2905        if handover.consumed_by.is_empty() {
2906            let offering = by.iter().filter(|id| due.contains(*id));
2907            kept.extend(offering.filter(|id| !offer_lapsed(&streams[*id])).cloned());
2908        }
2909        for taker in handover.consumed_by.iter().filter_map(|key| stream_of(key)) {
2910            refers.push((taker, by.clone()));
2911        }
2912    }
2913    for review in &state.reviews {
2914        let Some(asked) = stream_of(&review.key) else {
2915            continue;
2916        };
2917        for answerer in review.answers.iter().filter_map(|a| stream_of(&a.session)) {
2918            refers.push((answerer, vec![asked.clone()]));
2919        }
2920    }
2921    loop {
2922        let more: Vec<StreamId> = refers
2923            .iter()
2924            .filter(|(from, to)| !kept.contains(from) && to.iter().any(|t| kept.contains(t)))
2925            .map(|(from, _)| from.clone())
2926            .collect();
2927        if more.is_empty() {
2928            break;
2929        }
2930        kept.extend(more);
2931    }
2932    due.into_iter().filter(|id| !kept.contains(id)).collect()
2933}
2934
2935#[cfg(test)]
2936mod tests {
2937    use super::*;
2938
2939    /// A file in memory whose `fail_at`-th seek, read or write fails, counted from one.
2940    struct Faulty {
2941        inner: std::io::Cursor<Vec<u8>>,
2942        ops: usize,
2943        fail_at: usize,
2944    }
2945
2946    impl Faulty {
2947        fn new(content: &[u8], fail_at: usize) -> Self {
2948            Self {
2949                inner: std::io::Cursor::new(content.to_vec()),
2950                ops: 0,
2951                fail_at,
2952            }
2953        }
2954
2955        fn step(&mut self) -> std::io::Result<()> {
2956            self.ops += 1;
2957            if self.ops == self.fail_at {
2958                return Err(std::io::Error::other(format!(
2959                    "operation {} fails",
2960                    self.ops
2961                )));
2962            }
2963            Ok(())
2964        }
2965    }
2966
2967    impl std::io::Read for Faulty {
2968        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2969            self.step()?;
2970            self.inner.read(buf)
2971        }
2972    }
2973
2974    impl std::io::Write for Faulty {
2975        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2976            self.step()?;
2977            self.inner.write(buf)
2978        }
2979
2980        fn flush(&mut self) -> std::io::Result<()> {
2981            Ok(())
2982        }
2983    }
2984
2985    impl std::io::Seek for Faulty {
2986        fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result<u64> {
2987            self.step()?;
2988            self.inner.seek(to)
2989        }
2990    }
2991
2992    #[test]
2993    fn a_torn_tail_is_mended_before_the_append_and_every_failing_step_is_an_error() {
2994        let mut empty = Faulty::new(b"", 0);
2995        append_mended(&mut empty, "a\n").unwrap();
2996        std::io::Write::flush(&mut empty).unwrap();
2997        assert_eq!(
2998            empty.inner.get_ref(),
2999            b"a\n",
3000            "an empty file takes the text as it is"
3001        );
3002        let mut whole = Faulty::new(b"a\n", 0);
3003        append_mended(&mut whole, "b\n").unwrap();
3004        assert_eq!(
3005            whole.inner.get_ref(),
3006            b"a\nb\n",
3007            "a whole last line needs no newline"
3008        );
3009        let mut torn = Faulty::new(b"a\n{\"to", 0);
3010        append_mended(&mut torn, "b\n").unwrap();
3011        assert_eq!(
3012            torn.inner.get_ref(),
3013            b"a\n{\"to\nb\n",
3014            "the torn line stays one line"
3015        );
3016        // the length, the last byte's seek, its read, the mending newline, the text
3017        for step in 1..=5 {
3018            let mut file = Faulty::new(b"torn", step);
3019            let failed = append_mended(&mut file, "b\n").expect_err("the step fails");
3020            assert_eq!(failed.to_string(), format!("operation {step} fails"));
3021        }
3022    }
3023
3024    #[test]
3025    fn a_journal_path_that_cannot_be_written_is_an_error_not_a_panic() {
3026        let dir = tempfile::tempdir().unwrap();
3027        let file = dir.path().join("file");
3028        std::fs::write(&file, "x").unwrap();
3029        // a parent that is a file cannot be created
3030        assert!(append_synced(&file.join("sub").join("journal"), "a\n").is_err());
3031        // a path without a parent is opened as it is, and the empty path does not open
3032        assert!(append_synced(Path::new(""), "a\n").is_err());
3033        assert!(write_synced(&dir.path().join("missing").join("snapshot"), "a").is_err());
3034        // a path without a parent has no directory to sync, and that is not an error
3035        sync_parent(Path::new(""));
3036        let written = dir.path().join("snapshot");
3037        write_synced(&written, "a").unwrap();
3038        sync_parent(&written);
3039        assert_eq!(std::fs::read_to_string(&written).unwrap(), "a");
3040        let appended = dir.path().join("journal");
3041        append_synced(&appended, "a").unwrap();
3042        append_synced(&appended, "b\n").unwrap();
3043        assert_eq!(std::fs::read_to_string(&appended).unwrap(), "a\nb\n");
3044    }
3045
3046    fn journal(runtime: &str) -> Journal {
3047        Journal::open(
3048            Arc::new(NodeIdentity::ephemeral().unwrap()),
3049            runtime,
3050            "repo".into(),
3051            None,
3052        )
3053        .unwrap()
3054    }
3055
3056    fn opened(j: &Journal, session: &str) -> MeshEvent {
3057        j.append_own(EventBody::SessionOpened {
3058            info: SessionInfo::named(session, "test"),
3059        })
3060        .unwrap()
3061    }
3062
3063    fn accept_all(_: &MeshEvent) -> Result<(), Rejection> {
3064        Ok(())
3065    }
3066
3067    #[test]
3068    fn own_events_are_dense_signed_and_lamport_stamped() {
3069        let j = journal("0000000000000001");
3070        let a = opened(&j, "s1");
3071        let b = opened(&j, "s2");
3072        assert_eq!((a.seq, b.seq), (1, 2));
3073        assert!(b.lamport > a.lamport);
3074        assert!(verify(&a.pk, &a.signing_bytes(), &a.sig));
3075        assert_eq!(j.marks()[j.own_stream()].seq, 2);
3076    }
3077
3078    #[test]
3079    fn replication_sends_only_what_the_peer_lacks() {
3080        let a = journal("0000000000000001");
3081        let b = journal("0000000000000002");
3082        opened(&a, "s1");
3083        opened(&a, "s2");
3084        let first = a.missing_for(&b.marks(), usize::MAX);
3085        assert_eq!(first.len(), 2);
3086        assert_eq!(b.ingest(&first, &accept_all).accepted, 2);
3087        assert!(
3088            a.missing_for(&b.marks(), usize::MAX).is_empty(),
3089            "nothing left to send: marks converge, so replication stops"
3090        );
3091    }
3092
3093    #[test]
3094    fn a_relay_carries_events_it_cannot_forge() {
3095        let a = journal("0000000000000001");
3096        let b = journal("0000000000000002");
3097        let c = journal("0000000000000003");
3098        opened(&a, "s1");
3099        b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3100        // B relays A's event to C: C verifies A's signature, not B's word.
3101        let relayed = b.missing_for(&c.marks(), usize::MAX);
3102        assert_eq!(relayed.len(), 1);
3103        assert_eq!(relayed[0].stream, *a.own_stream());
3104        assert_eq!(c.ingest(&relayed, &accept_all).accepted, 1);
3105        // A tampering relay is caught at the consumer.
3106        let mut forged = relayed[0].clone();
3107        forged.body = serde_json::json!({"kind": "session_closed", "session": "s1"});
3108        let d = journal("0000000000000004");
3109        let report = d.ingest(&[forged], &accept_all);
3110        assert_eq!(report.rejected.get(&Rejection::Signature), Some(&1));
3111    }
3112
3113    #[test]
3114    fn out_of_order_delivery_waits_for_the_gap() {
3115        let a = journal("0000000000000001");
3116        let b = journal("0000000000000002");
3117        let e1 = opened(&a, "s1");
3118        let e2 = opened(&a, "s2");
3119        let e3 = opened(&a, "s3");
3120        let report = b.ingest(&[e3.clone(), e2.clone()], &accept_all);
3121        assert_eq!((report.accepted, report.pending), (0, 2));
3122        assert_eq!(b.marks()[a.own_stream()].seq, 0, "a gap holds the mark");
3123        let report = b.ingest(&[e1], &accept_all);
3124        assert_eq!(
3125            report.accepted, 3,
3126            "the gap closes and its successors apply"
3127        );
3128        assert_eq!(b.marks()[a.own_stream()].seq, 3);
3129    }
3130
3131    #[test]
3132    fn hostile_events_are_refused_by_reason() {
3133        let a = journal("0000000000000001");
3134        let b = journal("0000000000000002");
3135        let good = opened(&a, "s1");
3136
3137        let mut other_repo = good.clone();
3138        other_repo.repo = "elsewhere".into();
3139        other_repo.sig = String::new();
3140        let wrong_version = MeshEvent {
3141            v: 2,
3142            ..good.clone()
3143        };
3144        let mut stolen_stream = good.clone();
3145        stolen_stream.stream = b.own_stream().clone();
3146        let mut huge = good.clone();
3147        huge.body = serde_json::json!({"kind": "future", "blob": "x".repeat(MAX_EVENT_BYTES)});
3148
3149        let r = b.ingest(
3150            &[other_repo, wrong_version, stolen_stream, huge],
3151            &accept_all,
3152        );
3153        assert_eq!(r.rejected.get(&Rejection::Signature), Some(&1));
3154        assert_eq!(r.rejected.get(&Rejection::Version), Some(&1));
3155        assert_eq!(r.rejected.get(&Rejection::Identity), Some(&1));
3156        assert_eq!(r.rejected.get(&Rejection::Oversized), Some(&1));
3157        let untrusted = b.ingest(&[good], &|_| Err(Rejection::Untrusted));
3158        assert_eq!(untrusted.rejected.get(&Rejection::Untrusted), Some(&1));
3159        assert!(b.events().is_empty());
3160    }
3161
3162    #[test]
3163    fn a_repository_mismatch_is_refused_even_when_signed() {
3164        let foreign = Journal::open(
3165            Arc::new(NodeIdentity::ephemeral().unwrap()),
3166            "0000000000000001",
3167            "another-repository".into(),
3168            None,
3169        )
3170        .unwrap();
3171        let e = opened(&foreign, "s1");
3172        let ours = journal("0000000000000002");
3173        let r = ours.ingest(&[e], &accept_all);
3174        assert_eq!(r.rejected.get(&Rejection::Repository), Some(&1));
3175    }
3176
3177    #[test]
3178    fn an_unknown_kind_is_held_and_relayed_but_a_bad_known_kind_is_refused() {
3179        let a = journal("0000000000000001");
3180        let b = journal("0000000000000002");
3181        // A future executable's event, signed properly.
3182        let mut future = opened(&a, "s1");
3183        future.body = serde_json::json!({"kind": "vote_cast", "ballot": 7});
3184        future.seq = 1;
3185        future.sig = a.identity.sign(&future.signing_bytes());
3186        assert_eq!(b.ingest(&[future], &accept_all).accepted, 1);
3187        assert_eq!(b.tallies().opaque, 1);
3188
3189        let mut bad = opened(&a, "s2");
3190        bad.body = serde_json::json!({"kind": "claim_acquired", "claim": "c", "session": "s", "scope": ["/etc/passwd"]});
3191        bad.sig = a.identity.sign(&bad.signing_bytes());
3192        let r = b.ingest(&[bad], &accept_all);
3193        assert_eq!(r.rejected.get(&Rejection::Bounds), Some(&1));
3194    }
3195
3196    #[test]
3197    fn bounds_are_checked_before_signing() {
3198        let j = journal("0000000000000001");
3199        let err = j.append_own(EventBody::ClaimAcquired {
3200            claim: "c1".into(),
3201            session: "s1".into(),
3202            scope: vec!["../outside".into()],
3203            intent: None,
3204            mode: ClaimMode::Exclusive,
3205            issue: None,
3206        });
3207        assert!(err.is_err());
3208        assert!(j.events().is_empty());
3209    }
3210
3211    #[test]
3212    fn beats_relay_freshness_without_comparing_clocks() {
3213        let a = journal("0000000000000001");
3214        let b = journal("0000000000000002");
3215        let c = journal("0000000000000003");
3216        let expiry = Duration::from_secs(30);
3217        assert_eq!(
3218            b.liveness(a.own_stream(), expiry),
3219            StreamLiveness::Expired,
3220            "never heard"
3221        );
3222        a.beat_own();
3223        b.merge_marks(&a.marks(), &|_| true, expiry);
3224        assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Live);
3225        // C hears A only through B.
3226        c.merge_marks(&b.marks(), &|_| true, expiry);
3227        assert_eq!(c.liveness(a.own_stream(), expiry), StreamLiveness::Live);
3228        // An old beat relayed with a huge age is clamped to the expiry, never fresh.
3229        let d = journal("0000000000000004");
3230        let mut stale = b.marks();
3231        stale.get_mut(a.own_stream()).unwrap().age_ms = Some(u64::MAX);
3232        d.merge_marks(&stale, &|_| true, expiry);
3233        assert_eq!(d.liveness(a.own_stream(), expiry), StreamLiveness::Expired);
3234        assert_eq!(d.liveness(d.own_stream(), expiry), StreamLiveness::Own);
3235    }
3236
3237    #[test]
3238    fn a_relay_cannot_mint_a_beat_and_an_untrusted_origin_is_not_heard() {
3239        let a = journal("0000000000000001");
3240        let b = journal("0000000000000002");
3241        let expiry = Duration::from_secs(30);
3242        a.beat_own();
3243        let genuine = a.marks();
3244        // A relay raises A's beat to the ceiling: the signature no longer covers it.
3245        let mut forged = genuine.clone();
3246        forged.get_mut(a.own_stream()).unwrap().beat = u64::MAX;
3247        b.merge_marks(&forged, &|_| true, expiry);
3248        assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Expired);
3249        // An unsigned mark is ignored, and creates no stream.
3250        let mut unsigned = genuine.clone();
3251        unsigned.get_mut(a.own_stream()).unwrap().sig = None;
3252        b.merge_marks(&unsigned, &|_| true, expiry);
3253        assert_eq!(b.tallies().streams, 1, "only B's own stream exists");
3254        // A genuine beat from an origin the policy does not trust is not heard.
3255        b.merge_marks(&genuine, &|_| false, expiry);
3256        assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Expired);
3257        // And the genuine, trusted beat is.
3258        b.merge_marks(&genuine, &|_| true, expiry);
3259        assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Live);
3260        // A later beat after the original one cannot be replayed backwards.
3261        a.beat_own();
3262        b.merge_marks(&a.marks(), &|_| true, expiry);
3263        b.merge_marks(&genuine, &|_| true, expiry);
3264        assert_eq!(b.marks()[a.own_stream()].beat, 2);
3265    }
3266
3267    #[test]
3268    fn a_stream_id_off_the_wire_is_parsed_or_refused() {
3269        let bad: Result<StreamId, _> = serde_json::from_value(serde_json::json!("x"));
3270        assert!(
3271            bad.is_err(),
3272            "an unparsed stream id would panic a later slice"
3273        );
3274        let marks: Result<Marks, _> =
3275            serde_json::from_value(serde_json::json!({"x": {"seq": 1, "beat": 1}}));
3276        assert!(marks.is_err());
3277    }
3278
3279    #[test]
3280    fn one_node_gets_a_bounded_share_of_the_journal() {
3281        let own = journal("0000000000000002");
3282        let identity = Arc::new(NodeIdentity::ephemeral().unwrap());
3283        let mut refused = 0;
3284        for slot in 0..(MAX_STREAMS_PER_NODE + 4) {
3285            let j = Journal::open(
3286                Arc::clone(&identity),
3287                &format!("{slot:016x}"),
3288                "repo".into(),
3289                None,
3290            )
3291            .unwrap();
3292            let event = opened(&j, "s1");
3293            refused += own.ingest(&[event], &accept_all).rejected_total();
3294        }
3295        assert_eq!(
3296            refused, 4,
3297            "the streams past the per-node quota are refused"
3298        );
3299    }
3300
3301    #[test]
3302    fn a_compacted_stream_is_not_sent_again() {
3303        let a = journal("0000000000000001");
3304        let b = journal("0000000000000002");
3305        opened(&a, "s1");
3306        b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3307        assert_eq!(
3308            b.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, true),
3309            1
3310        );
3311        assert_eq!(
3312            b.marks()[a.own_stream()].seq,
3313            1,
3314            "the tombstone keeps the mark"
3315        );
3316        assert!(a.missing_for(&b.marks(), usize::MAX).is_empty());
3317        let again = b.ingest(&a.events(), &accept_all);
3318        assert_eq!((again.accepted, again.duplicate), (0, 1));
3319    }
3320
3321    fn on_disk(path: &Path, key: &Path) -> Journal {
3322        Journal::open(
3323            Arc::new(NodeIdentity::load_or_create(key).unwrap()),
3324            "0000000000000002",
3325            "repo".into(),
3326            Some(path.to_path_buf()),
3327        )
3328        .unwrap()
3329    }
3330
3331    #[test]
3332    fn a_torn_tail_is_one_unreadable_line_and_the_next_append_reads() {
3333        let dir = tempfile::tempdir().unwrap();
3334        let path = dir.path().join("journal.jsonl");
3335        let key = dir.path().join("node.json");
3336        let first = on_disk(&path, &key);
3337        for name in ["s1", "s2"] {
3338            first
3339                .append_own(EventBody::SessionOpened {
3340                    info: SessionInfo::named(name, "test"),
3341                })
3342                .unwrap();
3343        }
3344        drop(first);
3345        // a crash in the middle of an append: half a line, no newline
3346        let mut file = std::fs::OpenOptions::new()
3347            .append(true)
3348            .open(&path)
3349            .unwrap();
3350        file.write_all(br#"{"stream":"torn","seq":"#).unwrap();
3351        drop(file);
3352
3353        let second = on_disk(&path, &key);
3354        let after_crash = second.tallies();
3355        assert_eq!(
3356            after_crash.unreadable, 1,
3357            "the torn line is counted, not dropped in silence"
3358        );
3359        assert_eq!(after_crash.events, 2, "every complete event reloads");
3360        second
3361            .append_own(EventBody::SessionOpened {
3362                info: SessionInfo::named("s3", "test"),
3363            })
3364            .unwrap();
3365        drop(second);
3366
3367        let third = on_disk(&path, &key);
3368        let tallies = third.tallies();
3369        assert_eq!(
3370            tallies.unreadable, 1,
3371            "the append after the tear did not fuse into it"
3372        );
3373        assert_eq!(tallies.events, 3);
3374        assert_eq!(tallies.write_failures, 0);
3375    }
3376
3377    #[cfg(unix)]
3378    #[test]
3379    fn a_failed_append_is_counted_and_the_file_is_the_owners_alone() {
3380        use std::os::unix::fs::PermissionsExt;
3381        let dir = tempfile::tempdir().unwrap();
3382        let key = dir.path().join("node.json");
3383        let path = dir.path().join("state").join("journal.jsonl");
3384        let journal = on_disk(&path, &key);
3385        journal
3386            .append_own(EventBody::SessionClosed {
3387                session: "a".into(),
3388            })
3389            .unwrap();
3390        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3391        assert_eq!(
3392            mode, 0o600,
3393            "the journal holds other runtimes' events: {mode:o}"
3394        );
3395        assert_eq!(journal.tallies().write_failures, 0);
3396
3397        // the file can no longer be written: the event is held, and the loss is counted
3398        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
3399        let held = journal.append_own(EventBody::SessionClosed {
3400            session: "b".into(),
3401        });
3402        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3403        assert!(held.is_ok(), "the event is held in memory and replicated");
3404        assert_eq!(journal.tallies().write_failures, 1);
3405        assert!(!crate::mesh::doctor::journal_check(&journal.tallies()).ok);
3406    }
3407
3408    #[test]
3409    fn a_failed_append_is_not_acknowledged_as_durable() {
3410        let dir = tempfile::tempdir().unwrap();
3411        let key = dir.path().join("node.json");
3412        let path = dir.path().join("journal.jsonl");
3413        let journal = on_disk(&path, &key);
3414        let first = journal
3415            .append_acknowledged(EventBody::SessionClosed {
3416                session: "a".into(),
3417            })
3418            .unwrap();
3419        assert_eq!(first.durability, Durability::Synced);
3420
3421        // the journal's place is taken by a directory: no write can reach the file, for any
3422        // user, root included — a fault the test can inject without a full disk
3423        std::fs::remove_file(&path).unwrap();
3424        std::fs::create_dir(&path).unwrap();
3425        let lost = journal
3426            .append_acknowledged(EventBody::SessionClosed {
3427                session: "b".into(),
3428            })
3429            .unwrap();
3430        assert_eq!(
3431            lost.durability,
3432            Durability::NotDurable,
3433            "a write that failed is never acknowledged as durable"
3434        );
3435        assert!(!lost.durability.is_on_disk());
3436        assert!(
3437            journal.events().iter().any(|e| e.id() == lost.event.id()),
3438            "the event is still held, and offered to peers"
3439        );
3440        assert_eq!(journal.tallies().write_failures, 1);
3441        assert!(!crate::mesh::doctor::journal_check(&journal.tallies()).ok);
3442    }
3443
3444    /// The bytes of `events`, one line each, as the journal writes them.
3445    fn lines_of(events: &[MeshEvent]) -> Vec<Vec<u8>> {
3446        events
3447            .iter()
3448            .map(|e| {
3449                let mut line = serde_json::to_vec(e).unwrap();
3450                line.push(b'\n');
3451                line
3452            })
3453            .collect()
3454    }
3455
3456    #[test]
3457    fn a_reload_is_bounded_and_the_unread_part_is_counted() {
3458        let dir = tempfile::tempdir().unwrap();
3459        let path = dir.path().join("journal.jsonl");
3460        let writer = on_disk(&path, &dir.path().join("writer.json"));
3461        let written: Vec<MeshEvent> = (1..=5).map(|n| opened(&writer, &format!("s{n}"))).collect();
3462        drop(writer);
3463        let lines = lines_of(&written);
3464        assert_eq!(std::fs::read(&path).unwrap(), lines.concat());
3465        let whole: u64 = lines.iter().map(|l| l.len() as u64).sum();
3466        let kept: u64 = lines[..2].iter().map(|l| l.len() as u64).sum();
3467
3468        // the bound falls three bytes into the third line: two lines are read, and the third
3469        // is not torn into an unreadable one — it is the first line of what is left on disk
3470        let bounded = Journal::open_bounded(
3471            Arc::new(NodeIdentity::ephemeral().unwrap()),
3472            "0000000000000003",
3473            "repo".into(),
3474            Some(path.clone()),
3475            &accept_all,
3476            kept + 3,
3477        )
3478        .unwrap();
3479        let tallies = bounded.tallies();
3480        assert_eq!(tallies.events, 2, "what lies within the bound reloads");
3481        assert_eq!(
3482            tallies.unreadable, 0,
3483            "a line cut by the bound is not unreadable"
3484        );
3485        assert_eq!(
3486            tallies.unread_bytes,
3487            whole - kept,
3488            "the rest is counted, to the byte"
3489        );
3490        let check = crate::mesh::doctor::journal_check(&tallies);
3491        assert!(
3492            !check.ok,
3493            "mesh doctor names a journal it did not read whole"
3494        );
3495        assert!(check.detail.contains(&format!("{} byte(s)", whole - kept)));
3496
3497        // within the bound, nothing is left unread
3498        let whole_read = Journal::open_bounded(
3499            Arc::new(NodeIdentity::ephemeral().unwrap()),
3500            "0000000000000004",
3501            "repo".into(),
3502            Some(path.clone()),
3503            &accept_all,
3504            whole,
3505        )
3506        .unwrap();
3507        assert_eq!(whole_read.tallies().events, 5);
3508        assert_eq!(whole_read.tallies().unread_bytes, 0);
3509        assert!(crate::mesh::doctor::journal_check(&whole_read.tallies()).ok);
3510    }
3511
3512    #[test]
3513    fn a_compaction_keeps_what_a_bounded_reload_left_unread() {
3514        let dir = tempfile::tempdir().unwrap();
3515        let path = dir.path().join("journal.jsonl");
3516        let writer = on_disk(&path, &dir.path().join("writer.json"));
3517        let written: Vec<MeshEvent> = (1..=5).map(|n| opened(&writer, &format!("s{n}"))).collect();
3518        drop(writer);
3519        let lines = lines_of(&written);
3520        let kept: u64 = lines[..2].iter().map(|l| l.len() as u64).sum();
3521        let bounded = Journal::open_bounded(
3522            Arc::new(NodeIdentity::ephemeral().unwrap()),
3523            "0000000000000003",
3524            "repo".into(),
3525            Some(path.clone()),
3526            &accept_all,
3527            kept,
3528        )
3529        .unwrap();
3530        // the writer's stream is silent: compaction drops the two events it reloaded and
3531        // rewrites the file from memory — which must not lose the three it never read
3532        let dropped = bounded.compact(Duration::ZERO, Duration::ZERO, Duration::ZERO, true);
3533        assert_eq!(dropped, 2);
3534        assert_eq!(
3535            std::fs::read(&path).unwrap(),
3536            lines[2..].concat(),
3537            "the unread part is copied into the new file, byte for byte"
3538        );
3539        assert_eq!(
3540            bounded.tallies().unread_bytes,
3541            lines[2..].iter().map(|l| l.len() as u64).sum::<u64>(),
3542            "and is still counted as unread"
3543        );
3544    }
3545
3546    /// A journal that cannot be read is nothing read, never an empty journal read whole: a
3547    /// missing file, and a directory in the file's place, which opens and does not read.
3548    #[test]
3549    fn a_journal_that_cannot_be_read_is_not_read_as_empty() {
3550        let dir = tempfile::tempdir().unwrap();
3551        assert!(read_bounded(&dir.path().join("absent.jsonl"), 1024).is_none());
3552        assert!(read_bounded(dir.path(), 1024).is_none(), "a directory");
3553
3554        let path = dir.path().join("journal.jsonl");
3555        std::fs::write(&path, b"one\ntwo\nthr").unwrap();
3556        assert_eq!(
3557            read_bounded(&path, 6),
3558            Some((b"one\n".to_vec(), Some(4..11))),
3559            "cut after the last whole line, the rest measured"
3560        );
3561        assert_eq!(
3562            read_bounded(&path, 64),
3563            Some((b"one\ntwo\nthr".to_vec(), None))
3564        );
3565    }
3566
3567    /// The rewrite that keeps a bounded reload's unread tail copies the tail whole or fails:
3568    /// a target that cannot be opened, a source that is gone, a start no seek can reach, a
3569    /// source that does not read and a range longer than the source are each an error.
3570    #[test]
3571    fn a_tail_that_cannot_be_copied_whole_fails_the_rewrite() {
3572        let dir = tempfile::tempdir().unwrap();
3573        let source = dir.path().join("journal.jsonl");
3574        std::fs::write(&source, b"0123456789").unwrap();
3575        let out = dir.path().join("journal.jsonl.tmp");
3576
3577        write_synced_with_tail(&out, "head\n", &source, 3..7).unwrap();
3578        assert_eq!(std::fs::read(&out).unwrap(), b"head\n3456");
3579
3580        let blocked = dir.path().join("blocked");
3581        std::fs::create_dir(&blocked).unwrap();
3582        assert!(
3583            write_synced_with_tail(&blocked, "x", &source, 0..1).is_err(),
3584            "a directory stands where the new file goes"
3585        );
3586        assert!(
3587            write_synced_with_tail(&out, "x", &dir.path().join("gone"), 0..1).is_err(),
3588            "the source is gone"
3589        );
3590        assert!(
3591            write_synced_with_tail(&out, "x", &source, u64::MAX..u64::MAX).is_err(),
3592            "a start past any offset a seek can name"
3593        );
3594        assert!(
3595            write_synced_with_tail(&out, "x", dir.path(), 0..1).is_err(),
3596            "a directory opens and seeks, and no byte of it reads"
3597        );
3598        let short = write_synced_with_tail(&out, "x", &source, 8..20).unwrap_err();
3599        assert!(
3600            short
3601                .to_string()
3602                .contains("copied 2 of the 12 unread byte(s)"),
3603            "{short}"
3604        );
3605    }
3606
3607    /// The text before the tail that does not reach the disk fails the rewrite: `/dev/full`
3608    /// opens for writing and refuses every byte.
3609    #[cfg(target_os = "linux")]
3610    #[test]
3611    fn a_text_that_cannot_be_written_fails_the_rewrite() {
3612        let dir = tempfile::tempdir().unwrap();
3613        let source = dir.path().join("journal.jsonl");
3614        std::fs::write(&source, b"0123456789").unwrap();
3615        let full =
3616            write_synced_with_tail(Path::new("/dev/full"), "head\n", &source, 0..1).unwrap_err();
3617        assert_eq!(full.raw_os_error(), Some(libc::ENOSPC), "{full}");
3618    }
3619
3620    #[test]
3621    fn paging_by_stamp_returns_every_event_exactly_once() {
3622        // three runtimes that wrote concurrently: their events share stamps
3623        let reader = journal("0000000000000001");
3624        for runtime in ["0000000000000002", "0000000000000003", "0000000000000004"] {
3625            let writer = journal(runtime);
3626            opened(&writer, "s1");
3627            opened(&writer, "s2");
3628            reader.merge_marks(&writer.marks(), &|_| true, Duration::from_secs(30));
3629            reader.ingest(
3630                &writer.missing_for(&reader.marks(), usize::MAX),
3631                &accept_all,
3632            );
3633        }
3634        let all = reader.events_after(0, usize::MAX);
3635        assert_eq!(all.len(), 6);
3636        assert!(
3637            all.windows(2).any(|w| w[0].lamport == w[1].lamport),
3638            "the fixture holds a tie, or it proves nothing"
3639        );
3640        // page by one, resuming after the last stamp each page carries
3641        let mut read = Vec::new();
3642        let mut after = 0;
3643        loop {
3644            let page = reader.events_after(after, 1);
3645            let Some(last) = page.last() else { break };
3646            after = last.lamport;
3647            read.extend(page);
3648        }
3649        assert_eq!(
3650            read, all,
3651            "every event once, in order, however small the page"
3652        );
3653    }
3654
3655    #[test]
3656    fn an_append_after_a_torn_tail_starts_a_line_of_its_own() {
3657        let dir = tempfile::tempdir().unwrap();
3658        // the parent is created on the first append
3659        let path = dir.path().join("deeper").join("journal.jsonl");
3660        append_synced(&path, "{\"a\":1}\n").unwrap();
3661        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"a\":1}\n");
3662        // a crash tore the last line: the next append begins on a new line
3663        std::fs::write(&path, "{\"a\":1}\n{\"torn\"").unwrap();
3664        append_synced(&path, "{\"b\":2}\n").unwrap();
3665        assert_eq!(
3666            std::fs::read_to_string(&path).unwrap(),
3667            "{\"a\":1}\n{\"torn\"\n{\"b\":2}\n"
3668        );
3669        // an intact tail gets nothing in between
3670        append_synced(&path, "{\"c\":3}\n").unwrap();
3671        assert!(std::fs::read_to_string(&path)
3672            .unwrap()
3673            .ends_with("{\"b\":2}\n{\"c\":3}\n"));
3674    }
3675
3676    #[test]
3677    fn a_synced_write_replaces_the_whole_file_and_its_directory_is_synced() {
3678        let dir = tempfile::tempdir().unwrap();
3679        let path = dir.path().join("journal.jsonl.tmp");
3680        write_synced(&path, "first, and longer\n").unwrap();
3681        write_synced(&path, "second\n").unwrap();
3682        assert_eq!(std::fs::read_to_string(&path).unwrap(), "second\n");
3683        sync_parent(&path);
3684        // a path with no directory to open is not an error
3685        sync_parent(Path::new("journal.jsonl"));
3686    }
3687
3688    #[test]
3689    fn a_compaction_that_cannot_replace_the_file_leaves_the_old_one_and_no_temporary() {
3690        let dir = tempfile::tempdir().unwrap();
3691        let path = dir.path().join("journal.jsonl");
3692        let a = journal("0000000000000001");
3693        opened(&a, "s1");
3694        let b = Journal::open(
3695            Arc::new(NodeIdentity::ephemeral().unwrap()),
3696            "0000000000000002",
3697            "repo".into(),
3698            Some(path.clone()),
3699        )
3700        .unwrap();
3701        b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3702        // the journal's place is taken by a directory that is not empty: the rename must fail
3703        std::fs::remove_file(&path).unwrap();
3704        std::fs::create_dir(&path).unwrap();
3705        std::fs::write(path.join("occupied"), "x").unwrap();
3706        let dropped = b.compact(Duration::ZERO, Duration::ZERO, Duration::ZERO, true);
3707        assert!(
3708            dropped > 0,
3709            "a's silent stream was due and dropped in memory"
3710        );
3711        assert!(
3712            path.join("occupied").exists(),
3713            "what stood there still stands"
3714        );
3715        assert!(
3716            !path.with_extension("jsonl.tmp").exists(),
3717            "the temporary file is removed"
3718        );
3719    }
3720
3721    #[test]
3722    fn a_reloaded_journal_keeps_events_and_resurrects_no_liveness() {
3723        let dir = tempfile::tempdir().unwrap();
3724        let path = dir.path().join("journal.jsonl");
3725        let key = dir.path().join("node.json");
3726        let a = journal("0000000000000001");
3727        a.beat_own();
3728        opened(&a, "s1");
3729        let b = Journal::open(
3730            Arc::new(NodeIdentity::load_or_create(&key).unwrap()),
3731            "0000000000000002",
3732            "repo".into(),
3733            Some(path.clone()),
3734        )
3735        .unwrap();
3736        b.merge_marks(&a.marks(), &|_| true, Duration::from_secs(30));
3737        b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3738        let own_before = b.own_stream().clone();
3739        b.append_own(EventBody::SessionOpened {
3740            info: SessionInfo::named("mine", "test"),
3741        })
3742        .unwrap();
3743        drop(b);
3744
3745        // The same node restarts: a new instance, so a new stream; the old events reload.
3746        let restarted = Journal::open(
3747            Arc::new(NodeIdentity::load_or_create(&key).unwrap()),
3748            "0000000000000002",
3749            "repo".into(),
3750            Some(path),
3751        )
3752        .unwrap();
3753        assert_ne!(restarted.own_stream(), &own_before);
3754        assert_eq!(restarted.events().len(), 2);
3755        let expiry = Duration::from_secs(30);
3756        assert_eq!(
3757            restarted.liveness(a.own_stream(), expiry),
3758            StreamLiveness::Expired
3759        );
3760        assert_eq!(
3761            restarted.liveness(&own_before, expiry),
3762            StreamLiveness::Expired,
3763            "the previous run of this very runtime is dead, not resurrected"
3764        );
3765    }
3766
3767    #[test]
3768    fn compaction_drops_only_dead_streams_without_handovers() {
3769        let a = journal("0000000000000001");
3770        let h = journal("0000000000000003");
3771        let b = journal("0000000000000002");
3772        opened(&a, "s1");
3773        let body = "# Objective\nx\n".to_string();
3774        h.append_own(EventBody::HandoverPublished {
3775            handover: HandoverBody {
3776                id: HandoverBody::digest_of(&body),
3777                task: None,
3778                issue: None,
3779                milestone: None,
3780                branch: None,
3781                head: None,
3782                created_at: None,
3783                name: None,
3784                body,
3785            },
3786        })
3787        .unwrap();
3788        b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3789        b.ingest(&h.missing_for(&b.marks(), usize::MAX), &accept_all);
3790        let dropped = b.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, true);
3791        assert_eq!(dropped, 1, "A's session event goes; the handover stays");
3792        assert_eq!(b.events().len(), 1);
3793    }
3794
3795    /// Every worktree of one machine signs with the machine's one key, and every restart of
3796    /// every server is a new stream of that node: a machine reaches its stream quota by
3797    /// running, not by misbehaving. The supervisor's periodic compaction is never forced, so
3798    /// it alone must keep the node under its quota, or a sibling's run past the quota is
3799    /// refused here for good — and a claim it takes is then invisible to this runtime.
3800    #[test]
3801    fn a_machine_that_restarts_past_its_stream_quota_still_reaches_its_siblings() {
3802        let dir = tempfile::tempdir().unwrap();
3803        let key = dir.path().join("node.json");
3804        let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3805        // One worktree's server, running throughout.
3806        let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3807        for run in 0..(MAX_STREAMS_PER_NODE + 6) {
3808            // Another worktree's server of the same machine, restarted: a new instance.
3809            let restarted =
3810                Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3811            let report = observer.ingest(&[opened(&restarted, "s1")], &accept_all);
3812            assert_eq!(
3813                report.accepted, 1,
3814                "run {run} of a sibling server was refused: {:?}",
3815                report.rejected
3816            );
3817            // The supervisor's compaction, as it runs: unforced, and every earlier run has
3818            // been silent for longer than the retention (zero here).
3819            observer.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, false);
3820        }
3821    }
3822
3823    /// A relay that has not compacted yet keeps advertising the beats of streams this
3824    /// runtime has just compacted. Those beats are stale — the streams are long dead — and
3825    /// a stale beat must not bring a stream back as an empty entry that holds a slot of
3826    /// its node's quota for another retention period.
3827    #[test]
3828    fn a_stale_relayed_beat_takes_no_slot_of_its_node() {
3829        let dir = tempfile::tempdir().unwrap();
3830        let key = dir.path().join("node.json");
3831        let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3832        let expiry = Duration::from_secs(30);
3833        let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3834        let relay = Journal::open(machine(), "0000000000000003", "repo".into(), None).unwrap();
3835        for _ in 0..(MAX_STREAMS_PER_NODE - 1) {
3836            let run = Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3837            run.beat_own();
3838            relay.merge_marks(&run.marks(), &|_| true, expiry);
3839            let event = [opened(&run, "s1")];
3840            assert_eq!(relay.ingest(&event, &accept_all).accepted, 1);
3841            assert_eq!(observer.ingest(&event, &accept_all).accepted, 1);
3842        }
3843        // The relay heard those runs beat, long ago: its marks say how long.
3844        let mut stale = relay.marks();
3845        for mark in stale.values_mut().filter(|m| m.beat > 0) {
3846            mark.age_ms = Some(10 * 60 * 1000);
3847        }
3848        observer.merge_marks(&stale, &|_| true, expiry);
3849        let dropped = observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true);
3850        assert_eq!(dropped, (MAX_STREAMS_PER_NODE - 1) as u64);
3851
3852        // The relay advertises the same stale beats in its next round.
3853        observer.merge_marks(&stale, &|_| true, expiry);
3854        assert_eq!(
3855            observer.tallies().streams,
3856            1,
3857            "a stale beat brought compacted streams back"
3858        );
3859        let next = Journal::open(machine(), "0000000000000004", "repo".into(), None).unwrap();
3860        let report = observer.ingest(&[opened(&next, "s1")], &accept_all);
3861        assert_eq!(report.accepted, 1, "{:?}", report.rejected);
3862    }
3863
3864    /// A restarted runtime reloads its siblings' streams without having heard any of them
3865    /// beat yet. Silence is measured from when the journal learned of a stream, so a
3866    /// sibling that is running but not yet linked is not compacted away — with its claims —
3867    /// the first time compaction runs after the restart.
3868    #[test]
3869    fn a_restart_does_not_compact_the_streams_it_has_not_heard_yet() {
3870        let dir = tempfile::tempdir().unwrap();
3871        let path = dir.path().join("journal.jsonl");
3872        let sibling = journal("0000000000000001");
3873        opened(&sibling, "s1");
3874        let before = Journal::open(
3875            Arc::new(NodeIdentity::ephemeral().unwrap()),
3876            "0000000000000002",
3877            "repo".into(),
3878            Some(path.clone()),
3879        )
3880        .unwrap();
3881        before.ingest(
3882            &sibling.missing_for(&before.marks(), usize::MAX),
3883            &accept_all,
3884        );
3885        drop(before);
3886
3887        let restarted = Journal::open(
3888            Arc::new(NodeIdentity::ephemeral().unwrap()),
3889            "0000000000000002",
3890            "repo".into(),
3891            Some(path),
3892        )
3893        .unwrap();
3894        let (expiry, retention) = (Duration::from_secs(30), Duration::from_secs(15 * 60));
3895        assert_eq!(
3896            restarted.compact(expiry, retention, HANDOVER_RETENTION, true),
3897            0,
3898            "the sibling may still be running: it has not been silent here for a retention"
3899        );
3900        assert_eq!(restarted.events().len(), 1);
3901        // Once it has been silent for as long as any stream must be, it goes.
3902        assert_eq!(
3903            restarted.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, true),
3904            1
3905        );
3906    }
3907
3908    /// A handover saying `text`, published by `j`: the event and the handover's id.
3909    fn publish(j: &Journal, text: &str) -> (MeshEvent, String) {
3910        let body = format!("# Objective\n{text}\n");
3911        let id = HandoverBody::digest_of(&body);
3912        let handover = HandoverBody {
3913            id: id.clone(),
3914            task: None,
3915            issue: None,
3916            milestone: None,
3917            branch: None,
3918            head: None,
3919            created_at: None,
3920            name: None,
3921            body,
3922        };
3923        let event = j
3924            .append_own(EventBody::HandoverPublished { handover })
3925            .unwrap();
3926        (event, id)
3927    }
3928
3929    /// The events `j` wrote itself, without what it holds of other streams.
3930    fn own_events(j: &Journal) -> Vec<MeshEvent> {
3931        let mut own = j.events();
3932        own.retain(|e| e.stream == *j.own_stream());
3933        own
3934    }
3935
3936    /// Tell `observer` that `j` beat once, long ago: it has been silent past any expiry.
3937    fn heard_long_ago(observer: &Journal, j: &Journal, expiry: Duration) {
3938        j.beat_own();
3939        let mut stale = j.marks();
3940        stale.get_mut(j.own_stream()).unwrap().age_ms = Some(10 * 60 * 1000);
3941        observer.merge_marks(&stale, &|_| true, expiry);
3942    }
3943
3944    /// Every server run of a machine that publishes a handover leaves a stream holding one.
3945    /// A handover somebody has taken is continuity that has been picked up: it must not keep
3946    /// its dead stream in the node's quota, or a machine whose runs each handed over — and
3947    /// each was taken — fills its quota with the dead and every later run is refused here.
3948    #[test]
3949    fn a_node_whose_dead_streams_published_taken_handovers_is_not_starved() {
3950        let dir = tempfile::tempdir().unwrap();
3951        let key = dir.path().join("node.json");
3952        let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3953        let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3954        // A runtime on another machine takes every handover. Each is its run's first event,
3955        // Lamport 1, so the taking follows it in every fold.
3956        let taker = journal("0000000000000003");
3957        opened(&taker, "t1");
3958        for run in 0..(MAX_STREAMS_PER_NODE + 6) {
3959            let restarted =
3960                Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3961            let (published, id) = publish(&restarted, &format!("run {run}"));
3962            let report = observer.ingest(&[opened(&restarted, "s1"), published], &accept_all);
3963            assert_eq!(
3964                report.accepted, 2,
3965                "run {run} of a sibling server was refused: {:?}",
3966                report.rejected
3967            );
3968            taker
3969                .append_own(EventBody::HandoverConsumed {
3970                    handover: id,
3971                    session: "t1".into(),
3972                })
3973                .unwrap();
3974            observer.ingest(
3975                &taker.missing_for(&observer.marks(), usize::MAX),
3976                &accept_all,
3977            );
3978            observer.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, false);
3979        }
3980    }
3981
3982    /// A handover nobody takes pins its dead stream, and only for a bounded retention: past
3983    /// it, the stream is compacted like any other, or a machine whose runs each handed over
3984    /// something nobody took would be refused here for good.
3985    #[test]
3986    fn an_untaken_handover_pins_its_stream_only_for_the_handover_retention() {
3987        let dir = tempfile::tempdir().unwrap();
3988        let key = dir.path().join("node.json");
3989        let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3990        let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3991        for run in 0..(MAX_STREAMS_PER_NODE + 6) {
3992            let restarted =
3993                Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3994            let (published, _) = publish(&restarted, &format!("run {run}"));
3995            let report = observer.ingest(&[opened(&restarted, "s1"), published], &accept_all);
3996            assert_eq!(
3997                report.accepted, 2,
3998                "run {run} of a sibling server was refused: {:?}",
3999                report.rejected
4000            );
4001            // Every earlier run has been silent past the retention and the handover retention
4002            // (both zero here).
4003            observer.compact(Duration::ZERO, Duration::ZERO, Duration::ZERO, false);
4004        }
4005    }
4006
4007    /// Who took a handover and who answered a review are records of the taker's stream, not
4008    /// of the handover's or the review's. When the taker has stopped and the publisher has
4009    /// not, compacting the taker away would make a live handover look untaken and an
4010    /// answered review open again: those records stay as long as what they refer to does.
4011    #[test]
4012    fn compaction_keeps_the_takers_and_the_answers_of_what_it_keeps() {
4013        use crate::mesh::state::{fold, ReviewState};
4014
4015        let expiry = Duration::from_secs(30);
4016        let observer = journal("0000000000000001");
4017        let publisher = journal("0000000000000002");
4018        let taker = journal("0000000000000003");
4019        let reviewer = journal("0000000000000004");
4020        opened(&publisher, "s1");
4021        let (_, id) = publish(&publisher, "carry the mesh");
4022        publisher
4023            .append_own(EventBody::ReviewRequested {
4024                review: "r1".into(),
4025                session: "s1".into(),
4026                subject: "feature/mesh".into(),
4027                scope: Vec::new(),
4028                issue: None,
4029                reviewer: None,
4030                body: None,
4031            })
4032            .unwrap();
4033        // The taker and the reviewer hear of the handover and the request before they act.
4034        for j in [&taker, &reviewer] {
4035            j.ingest(&publisher.events(), &accept_all);
4036        }
4037        opened(&taker, "t1");
4038        taker
4039            .append_own(EventBody::HandoverConsumed {
4040                handover: id,
4041                session: "t1".into(),
4042            })
4043            .unwrap();
4044        opened(&reviewer, "v1");
4045        reviewer
4046            .append_own(EventBody::ReviewAnswered {
4047                request: format!("{}/r1", publisher.own_stream()),
4048                session: "v1".into(),
4049                verdict: "approved".into(),
4050                note: None,
4051            })
4052            .unwrap();
4053        for j in [&publisher, &taker, &reviewer] {
4054            j.beat_own();
4055            observer.merge_marks(&j.marks(), &|_| true, expiry);
4056            observer.ingest(&own_events(j), &accept_all);
4057        }
4058        // The taker and the reviewer stopped long ago; the publisher is beating.
4059        heard_long_ago(&observer, &taker, expiry);
4060        heard_long_ago(&observer, &reviewer, expiry);
4061        publisher.beat_own();
4062        observer.merge_marks(&publisher.marks(), &|_| true, expiry);
4063
4064        let state = |j: &Journal| fold(&j.events(), &|s| j.liveness(s, expiry));
4065        let before = state(&observer);
4066        assert_eq!(before.handovers[0].consumed_by.len(), 1);
4067        assert_eq!(before.reviews[0].state, ReviewState::Answered);
4068        observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true);
4069        let after = state(&observer);
4070        assert_eq!(
4071            after.handovers, before.handovers,
4072            "a live handover looks untaken again"
4073        );
4074        assert_eq!(
4075            after.reviews, before.reviews,
4076            "an answered review is open again"
4077        );
4078    }
4079
4080    /// A laptop that sleeps past the expiry and the retention is compacted away by its peers;
4081    /// it wakes as the same process, so as the same stream, and goes on writing. Its peers
4082    /// must hear it again — the whole stream, from its first event, because the claim it took
4083    /// before it slept holds again the moment it beats and a suffix would not show it.
4084    #[test]
4085    fn a_compacted_stream_that_beats_again_is_fetched_again_from_its_first_event() {
4086        use crate::mesh::state::{fold, ClaimState};
4087
4088        let expiry = Duration::from_secs(30);
4089        let laptop = journal("0000000000000001");
4090        let observer = journal("0000000000000002");
4091        // One sync round as the supervisor runs it: the events the observer lacks, then marks.
4092        let round = || {
4093            observer.ingest(
4094                &laptop.missing_for(&observer.marks(), usize::MAX),
4095                &accept_all,
4096            );
4097            observer.merge_marks(&laptop.marks(), &|_| true, expiry);
4098        };
4099        let claim = |claim: &str, scope: &str| EventBody::ClaimAcquired {
4100            claim: claim.into(),
4101            session: "s1".into(),
4102            scope: vec![scope.into()],
4103            intent: None,
4104            mode: ClaimMode::Exclusive,
4105            issue: None,
4106        };
4107        opened(&laptop, "s1");
4108        laptop.append_own(claim("c1", "apps")).unwrap();
4109        laptop.beat_own();
4110        round();
4111        assert_eq!(observer.events().len(), 2);
4112
4113        // It sleeps: the observer last heard it long ago, and compacts it away.
4114        heard_long_ago(&observer, &laptop, expiry);
4115        assert_eq!(
4116            observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true),
4117            2
4118        );
4119        assert!(observer.events().is_empty());
4120
4121        // It wakes, takes another claim, and beats.
4122        laptop.append_own(claim("c2", "docs")).unwrap();
4123        laptop.beat_own();
4124        for _ in 0..3 {
4125            round();
4126        }
4127        assert_eq!(
4128            observer.events(),
4129            laptop.events(),
4130            "the stream is held again, whole, from its first event"
4131        );
4132        let state = fold(&observer.events(), &|s| observer.liveness(s, expiry));
4133        assert_eq!(state.claims.len(), 2);
4134        assert!(
4135            state.claims.iter().all(|c| c.state == ClaimState::Held),
4136            "the claim taken before the sleep holds again: {:?}",
4137            state.claims
4138        );
4139    }
4140
4141    /// What a peer that has not compacted yet still says about a stream this runtime has
4142    /// compacted brings nothing back: a beat no higher than the one it was compacted at,
4143    /// however fresh the relay says it is, and an event the stream wrote but never beat for.
4144    /// The late event is absorbed into the tombstone's mark, so the relay stops offering it.
4145    #[test]
4146    fn a_replayed_beat_or_a_late_event_of_a_compacted_stream_brings_nothing_back() {
4147        let expiry = Duration::from_secs(30);
4148        let gone = journal("0000000000000001");
4149        let observer = journal("0000000000000002");
4150        opened(&gone, "s1");
4151        gone.beat_own();
4152        observer.merge_marks(&gone.marks(), &|_| true, expiry);
4153        observer.ingest(
4154            &gone.missing_for(&observer.marks(), usize::MAX),
4155            &accept_all,
4156        );
4157        heard_long_ago(&observer, &gone, expiry);
4158        let mut replayed = gone.marks();
4159        assert_eq!(
4160            observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true),
4161            1
4162        );
4163
4164        // A relay reports the last beat it heard as if it had just risen.
4165        replayed.get_mut(gone.own_stream()).unwrap().age_ms = Some(0);
4166        observer.merge_marks(&replayed, &|_| true, expiry);
4167        assert_eq!(
4168            observer.tallies().streams,
4169            1,
4170            "a replayed beat brought it back"
4171        );
4172        assert_eq!(observer.marks()[gone.own_stream()].seq, 1);
4173
4174        // An event it wrote after the observer compacted it arrives through a relay.
4175        let late = opened(&gone, "s2");
4176        let report = observer.ingest(&[late], &accept_all);
4177        assert_eq!((report.accepted, report.pending), (0, 0));
4178        assert_eq!(observer.tallies().streams, 1, "a late event took a slot");
4179        assert_eq!(
4180            observer.marks()[gone.own_stream()].seq,
4181            2,
4182            "the tombstone covers it, so it is not offered again"
4183        );
4184    }
4185
4186    /// The scopes the fold property's claims are drawn from: some meet, some do not.
4187    const SCOPES: [&str; 4] = ["apps", "apps/majordomus-cli", "docs", "site"];
4188
4189    /// One run's history for the fold property: a session, a claim in it over one of
4190    /// [`SCOPES`], and how it ended — `0` still held, `1` released, `2` its session closed,
4191    /// `3` nothing written at all (a run that only beat).
4192    fn history(j: &Journal, scope: usize, exclusive: bool, ending: u8) -> Vec<MeshEvent> {
4193        if ending == 3 {
4194            return Vec::new();
4195        }
4196        let mode = if exclusive {
4197            ClaimMode::Exclusive
4198        } else {
4199            ClaimMode::Advisory
4200        };
4201        let mut out = vec![opened(j, "s1")];
4202        let acquired = EventBody::ClaimAcquired {
4203            claim: "c1".into(),
4204            session: "s1".into(),
4205            scope: vec![SCOPES[scope].into()],
4206            intent: None,
4207            mode,
4208            issue: None,
4209        };
4210        out.push(j.append_own(acquired).unwrap());
4211        let ended = match ending {
4212            1 => Some(EventBody::ClaimReleased { claim: "c1".into() }),
4213            2 => Some(EventBody::SessionClosed {
4214                session: "s1".into(),
4215            }),
4216            _ => None,
4217        };
4218        out.extend(ended.map(|body| j.append_own(body).unwrap()));
4219        out
4220    }
4221
4222    /// One run of the fold property: its claim's scope, its mode and how it ended (see
4223    /// [`history`]), then what it offers — a bit set, `1` a handover and `2` a review
4224    /// request — and what it takes: `(1, k)` consumes run `k`'s handover, `(2, k)` answers
4225    /// run `k`'s review, `(0, _)` neither. `k` counts over every run, dead and live; a run
4226    /// takes nothing of its own, and nothing of a run that offered nothing.
4227    type Run = (usize, bool, u8, u8, (u8, usize));
4228
4229    fn runs(count: std::ops::Range<usize>) -> impl proptest::strategy::Strategy<Value = Vec<Run>> {
4230        proptest::collection::vec(
4231            (
4232                0usize..4,
4233                proptest::bool::ANY,
4234                0u8..4,
4235                0u8..4,
4236                (0u8..3, 0usize..64),
4237            ),
4238            count,
4239        )
4240    }
4241
4242    proptest::proptest! {
4243        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(32))]
4244
4245        /// Compacting a crowded node takes away the records of the streams it drops — every
4246        /// one of them long dead — and nothing else. The claims that hold or conflict, the
4247        /// sessions still open and the overlaps between them are the same before and after;
4248        /// every record of a stream that stays is the same, a handover with every taker and a
4249        /// review with every answer; a handover nobody has taken stays within its retention;
4250        /// a dead stream stays only while it holds such a handover, or a taking or an answer
4251        /// of something that stays; and the digest moves exactly when compaction forgets
4252        /// something that had been said, because the fold lists ended sessions and claims too.
4253        #[test]
4254        fn compacting_a_crowded_node_changes_nothing_live(
4255            dead in runs(NODE_COMPACTION_STREAMS..MAX_STREAMS_PER_NODE - 8),
4256            live in runs(1..6),
4257            dead_were_silent in proptest::bool::weighted(0.2),
4258        ) {
4259            use crate::mesh::state::{fold, CooperationState, SessionState};
4260            use std::collections::BTreeSet;
4261
4262            let dir = tempfile::tempdir().unwrap();
4263            let key = dir.path().join("node.json");
4264            let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
4265            let expiry = Duration::from_secs(30);
4266            let observer =
4267                Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
4268            // Earlier runs of one sibling server, then the servers of the machine's other
4269            // worktrees: every journal first, so that any run can take what another offers.
4270            let runs: Vec<(Journal, bool, Run)> = dead
4271                .iter()
4272                .map(|&(scope, exclusive, ending, offers, takes)| {
4273                    let run =
4274                        Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
4275                    let ending = if dead_were_silent { 3 } else { ending };
4276                    (run, true, (scope, exclusive, ending, offers, takes))
4277                })
4278                .chain(live.iter().enumerate().map(|(slot, &run)| {
4279                    let runtime = format!("{:016x}", 16 + slot);
4280                    (Journal::open(machine(), &runtime, "repo".into(), None).unwrap(), false, run)
4281                }))
4282                .collect();
4283            let mut handovers: Vec<Option<String>> = vec![None; runs.len()];
4284            let mut reviews = vec![false; runs.len()];
4285            for (i, (run, _, (scope, exclusive, ending, offers, _))) in runs.iter().enumerate() {
4286                history(run, *scope, *exclusive, *ending);
4287                if *ending == 3 {
4288                    continue;
4289                }
4290                if offers & 1 == 1 {
4291                    handovers[i] = Some(publish(run, &format!("run {i}")).1);
4292                }
4293                if offers & 2 == 2 {
4294                    let asked = EventBody::ReviewRequested {
4295                        review: "r1".into(),
4296                        session: "s1".into(),
4297                        subject: format!("run {i}"),
4298                        scope: Vec::new(),
4299                        issue: None,
4300                        reviewer: None,
4301                        body: None,
4302                    };
4303                    run.append_own(asked).unwrap();
4304                    reviews[i] = true;
4305                }
4306            }
4307            for (i, (run, _, (_, _, ending, _, (take, of)))) in runs.iter().enumerate() {
4308                let of = of % runs.len();
4309                if *ending == 3 || of == i {
4310                    continue;
4311                }
4312                let body = match (*take, &handovers[of]) {
4313                    (1, Some(id)) => EventBody::HandoverConsumed {
4314                        handover: id.clone(),
4315                        session: "s1".into(),
4316                    },
4317                    (2, _) if reviews[of] => EventBody::ReviewAnswered {
4318                        request: format!("{}/r1", runs[of].0.own_stream()),
4319                        session: "s1".into(),
4320                        verdict: "approved".into(),
4321                        note: None,
4322                    },
4323                    _ => continue,
4324                };
4325                // It heard of the offer before it took it, as a taker does.
4326                run.ingest(&own_events(&runs[of].0), &accept_all);
4327                run.append_own(body).unwrap();
4328            }
4329            let mut ended: BTreeSet<StreamId> = BTreeSet::new();
4330            for (run, dead, _) in &runs {
4331                run.beat_own();
4332                if *dead {
4333                    // Heard beating, then silent long ago.
4334                    observer.merge_marks(&run.marks(), &|_| true, expiry);
4335                    observer.ingest(&own_events(run), &accept_all);
4336                    heard_long_ago(&observer, run, expiry);
4337                    ended.insert(run.own_stream().clone());
4338                } else {
4339                    // Beating now.
4340                    observer.ingest(&own_events(run), &accept_all);
4341                    observer.merge_marks(&run.marks(), &|_| true, expiry);
4342                }
4343            }
4344            let state = |j: &Journal| fold(&j.events(), &|s| j.liveness(s, expiry));
4345            let held = |j: &Journal| j.stream_liveness(expiry).into_keys().collect::<BTreeSet<_>>();
4346            let before = state(&observer);
4347            let held_before = held(&observer);
4348
4349            // Unforced, and the journal is far under its own bounds: the node alone is due.
4350            let dropped = observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, false);
4351            let after = state(&observer);
4352            let gone: BTreeSet<StreamId> =
4353                held_before.difference(&held(&observer)).cloned().collect();
4354
4355            proptest::prop_assert!(gone.is_subset(&ended), "a stream that is not dead went");
4356            let alive = |s: &CooperationState| {
4357                (
4358                    s.claims.iter().filter(|c| c.state.is_live()).cloned().collect::<Vec<_>>(),
4359                    s.sessions
4360                        .iter()
4361                        .filter(|v| v.state == SessionState::Active)
4362                        .cloned()
4363                        .collect::<Vec<_>>(),
4364                    s.overlaps.clone(),
4365                )
4366            };
4367            proptest::prop_assert_eq!(alive(&before), alive(&after));
4368            // A review, a taker and an answerer are keyed `<stream>/<local id>`.
4369            let stream_of = |key: &str| key.split_once('/').map(|(s, _)| s.to_string());
4370            let stays = |key: &str| !gone.iter().any(|g| stream_of(key).as_deref() == Some(g.as_str()));
4371            let kept = |s: &CooperationState| {
4372                (
4373                    s.sessions.iter().filter(|v| !gone.contains(&v.stream)).cloned().collect::<Vec<_>>(),
4374                    s.claims.iter().filter(|c| !gone.contains(&c.stream)).cloned().collect::<Vec<_>>(),
4375                    s.handovers.iter().filter(|h| !gone.contains(&h.stream)).cloned().collect::<Vec<_>>(),
4376                    s.reviews.iter().filter(|r| stays(&r.key)).cloned().collect::<Vec<_>>(),
4377                )
4378            };
4379            proptest::prop_assert_eq!(
4380                kept(&before),
4381                (after.sessions.clone(), after.claims.clone(), after.handovers.clone(), after.reviews.clone())
4382            );
4383            proptest::prop_assert!(
4384                before.handovers.iter().filter(|h| h.consumed_by.is_empty()).all(|h| !gone.contains(&h.stream)),
4385                "a handover nobody has taken went within its retention"
4386            );
4387            for stream in ended.difference(&gone) {
4388                let its = |key: &String| stream_of(key).as_deref() == Some(stream.as_str());
4389                let pinned = after.handovers.iter().any(|h| {
4390                    (h.stream == *stream && h.consumed_by.is_empty()) || h.consumed_by.iter().any(its)
4391                }) || after.reviews.iter().any(|r| r.answers.iter().any(|a| its(&a.session)));
4392                proptest::prop_assert!(pinned, "the dead stream {} stayed for nothing", stream);
4393            }
4394            proptest::prop_assert_eq!(before.digest == after.digest, dropped == 0);
4395        }
4396    }
4397
4398    proptest::proptest! {
4399        /// Any delivery order and any duplication of one stream's events converges on the
4400        /// same held set as in-order delivery.
4401        #[test]
4402        fn any_permutation_with_duplicates_converges(order in proptest::collection::vec(0usize..6, 1..24)) {
4403            let a = journal("0000000000000001");
4404            let events: Vec<MeshEvent> = (0..6).map(|i| opened(&a, &format!("s{i}"))).collect();
4405            let b = journal("0000000000000002");
4406            let shuffled: Vec<MeshEvent> = order.iter().map(|i| events[*i].clone()).collect();
4407            b.ingest(&shuffled, &accept_all);
4408            b.ingest(&events, &accept_all);
4409            proptest::prop_assert_eq!(b.events(), events.clone());
4410            proptest::prop_assert_eq!(b.tallies().pending, 0);
4411        }
4412
4413        /// Arbitrary JSON posing as an event never panics the ingest path.
4414        #[test]
4415        fn hostile_json_never_panics(seq in 0u64..5, lamport in proptest::prelude::any::<u64>(), kind in "[a-z_]{0,20}", sig in "[0-9a-f]{0,130}") {
4416            let b = journal("0000000000000002");
4417            let event = MeshEvent {
4418                v: 1,
4419                stream: StreamId(format!("{}-{}-{}", "a".repeat(32), "b".repeat(16), "c".repeat(16))),
4420                seq,
4421                lamport,
4422                at: 0,
4423                repo: "repo".into(),
4424                pk: "00".repeat(32),
4425                body: serde_json::json!({"kind": kind}),
4426                sig,
4427            };
4428            let r = b.ingest(&[event], &accept_all);
4429            proptest::prop_assert_eq!(r.accepted, 0);
4430        }
4431    }
4432}