Skip to main content

majordomus_cli/mesh/
state.rs

1//! The cooperation state: what the journal's events mean, folded. Pure: the same set of
2//! events and the same liveness verdicts yield the same state on every runtime, whatever
3//! order the events arrived in and however often — events are applied in `(lamport,
4//! stream, seq)` order, and a set has no duplicates. That is the whole convergence
5//! argument, and a property test holds it.
6//!
7//! Exclusivity is decided here, and decided the same way everywhere:
8//!
9//! - A claim **holds** while it is not released, its session is not closed, and its
10//!   stream is alive (its beat rose within the expiry on this runtime's clock). A dead
11//!   runtime's claims therefore end on their own; nobody releases them for it.
12//! - Two live **exclusive** claims of different sessions whose scopes meet are a
13//!   **conflict**. The claim with the lower `(lamport, stream, seq)` of its acquisition
14//!   wins; the other is `conflicted` and names the winner. Admission refuses such a claim
15//!   up front, so a conflict can only arise from genuinely concurrent claims — two sides
16//!   of a partition, or two runtimes in the same round — and then every runtime names the
17//!   same winner. Never "last packet wins".
18//! - Scopes meet by [`crate::peers::claims_meet`], the peer board's own predicate: one
19//!   semantics for a claim on this machine and on another.
20//!
21//! ```
22//! use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
23//! use majordomus_cli::mesh::identity::NodeIdentity;
24//! use majordomus_cli::mesh::state::{fold, ClaimState};
25//! use std::sync::Arc;
26//!
27//! let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
28//!     "repo".into(), None).unwrap();
29//! j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
30//!     scope: vec!["apps".into()], intent: None, mode: ClaimMode::Exclusive, issue: None }).unwrap();
31//! let state = fold(&j.events(), &|_| StreamLiveness::Own);
32//! assert_eq!(state.claims[0].state, ClaimState::Held);
33//! ```
34
35use std::collections::BTreeMap;
36
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39
40use super::journal::{
41    canonical_json, causal_key, ClaimMode, EventBody, HandoverBody, MeshEvent, SessionInfo,
42    StreamId, StreamLiveness,
43};
44
45/// A session's standing, as every runtime of the mesh reads it. The distinction that
46/// matters is between `Closed` and `Expired`: the first is a session that said it was
47/// finished, the second one whose runtime stopped beating and can no longer say anything.
48/// Both end the session's claims, but only the second is a machine nobody should expect an
49/// answer from.
50///
51/// ```
52/// use std::sync::Arc;
53/// use majordomus_cli::mesh::identity::NodeIdentity;
54/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
55/// use majordomus_cli::mesh::state::{fold, SessionState};
56///
57/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
58///     "repo".into(), None).unwrap();
59/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
60/// assert_eq!(fold(&j.events(), &|_| StreamLiveness::Own).sessions[0].state, SessionState::Active);
61///
62/// // the same events, read on a runtime that no longer hears the writer
63/// let gone = fold(&j.events(), &|_| StreamLiveness::Expired);
64/// assert_eq!(gone.sessions[0].state, SessionState::Expired);
65/// ```
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum SessionState {
69    /// Open, and its runtime is alive.
70    Active,
71    /// Closed by its own runtime.
72    Closed,
73    /// Never closed, but its runtime stopped beating: gone, as far as anyone can tell.
74    Expired,
75}
76
77/// One session anywhere in the mesh, as the fold sees it: what the session said about
78/// itself, where it is writing from, and whether it still counts. The key is
79/// `<stream>/<session>` rather than the session's own id, because two runtimes may both
80/// call a session `s1` and the mesh has to hold them apart.
81///
82/// ```
83/// use std::sync::Arc;
84/// use majordomus_cli::mesh::identity::NodeIdentity;
85/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
86/// use majordomus_cli::mesh::state::{fold, SessionView};
87///
88/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
89///     "repo".into(), None).unwrap();
90/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "claude-code") })
91///     .unwrap();
92///
93/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
94/// let view: &SessionView = &state.sessions[0];
95/// assert!(view.key.ends_with("/s1"), "qualified by the stream that wrote it: {}", view.key);
96/// assert_eq!(view.info.client, "claude-code");
97/// assert_eq!(view.runtime, view.stream.runtime_key());
98/// ```
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct SessionView {
101    /// `<stream>/<session>`: unique across the mesh.
102    pub key: String,
103    /// The writing stream.
104    pub stream: StreamId,
105    /// `<node>-<runtime>`.
106    pub runtime: String,
107    /// The node (machine).
108    pub node: String,
109    /// What the session last said about itself.
110    pub info: SessionInfo,
111    /// Its standing.
112    pub state: SessionState,
113    /// The Lamport stamp of its latest word.
114    pub updated_lamport: u64,
115}
116
117/// A claim's standing, and — where it ended or lost — what ended it. The two variants that
118/// carry a string exist because the bare verdict is not actionable: a worker told its claim
119/// expired needs to know whether its session closed or its runtime stopped beating, and one
120/// told its claim is conflicted needs the key of the claim that beat it, which is the same
121/// key on every runtime.
122///
123/// ```
124/// use majordomus_cli::mesh::state::ClaimState;
125///
126/// assert!(ClaimState::Held.is_live());
127/// assert!(ClaimState::Conflicted("s/c1".into()).is_live(), "still held, and not the winner");
128/// assert!(!ClaimState::Released.is_live());
129///
130/// // the detail travels with the verdict, so a reader is never left asking who won
131/// let value = serde_json::to_value(ClaimState::Conflicted("s/c1".into())).unwrap();
132/// assert_eq!(value["state"], "conflicted");
133/// assert_eq!(value["detail"], "s/c1");
134/// ```
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
137pub enum ClaimState {
138    /// Live; for an exclusive claim, the winner of its scope.
139    Held,
140    /// Released by its holder.
141    Released,
142    /// Ended without a release: the holder's session closed or its runtime expired.
143    Expired(String),
144    /// Live, exclusive, and beaten to its scope by the claim whose key this names.
145    Conflicted(String),
146}
147
148impl ClaimState {
149    /// Whether the claim is live (held or conflicted). A conflicted claim counts as live
150    /// because it has not gone away: its holder is still working, and the next claim over
151    /// that scope has to meet it too. Only a release, a closed session or an expired
152    /// runtime ends a claim.
153    ///
154    /// ```
155    /// use majordomus_cli::mesh::state::ClaimState;
156    ///
157    /// assert!(ClaimState::Held.is_live());
158    /// assert!(ClaimState::Conflicted("s/c1".into()).is_live(), "losing is not ending");
159    /// assert!(!ClaimState::Released.is_live());
160    /// assert!(!ClaimState::Expired("its runtime stopped beating".into()).is_live());
161    /// ```
162    pub fn is_live(&self) -> bool {
163        matches!(self, ClaimState::Held | ClaimState::Conflicted(_))
164    }
165}
166
167/// One claim anywhere in the mesh: who took what, for what, and where it stands. The
168/// acquisition's Lamport stamp and sequence are carried rather than derived on demand,
169/// because they are what exclusivity is decided by — every runtime ranks two competing
170/// claims by the same triple and so names the same winner, with no message passing and no
171/// "last packet wins".
172///
173/// ```
174/// use std::sync::Arc;
175/// use majordomus_cli::mesh::identity::NodeIdentity;
176/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
177/// use majordomus_cli::mesh::state::{fold, ClaimState, ClaimView};
178///
179/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
180///     "repo".into(), None).unwrap();
181/// j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
182///     scope: vec!["apps/majordomus-cli".into()], intent: Some("document the mesh".into()),
183///     mode: ClaimMode::Exclusive, issue: Some("#184".into()) }).unwrap();
184///
185/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
186/// let claim: &ClaimView = &state.claims[0];
187/// assert_eq!(claim.scope, ["apps/majordomus-cli"]);
188/// assert_eq!(claim.state, ClaimState::Held);
189/// assert!(claim.acquired_lamport > 0, "the order exclusivity is decided in");
190/// ```
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192pub struct ClaimView {
193    /// `<stream>/<claim>`.
194    pub key: String,
195    /// The writing stream.
196    pub stream: StreamId,
197    /// `<node>-<runtime>`.
198    pub runtime: String,
199    /// The holding session's key.
200    pub session: String,
201    /// The claimed paths.
202    pub scope: Vec<String>,
203    /// What it is for.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub intent: Option<String>,
206    /// Exclusive or advisory.
207    pub mode: ClaimMode,
208    /// The issue it is for.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub issue: Option<String>,
211    /// The Lamport stamp of the acquisition.
212    pub acquired_lamport: u64,
213    /// The sequence of the acquisition within its stream: with the stamp and the stream,
214    /// the total order in which exclusivity is decided.
215    pub acquired_seq: u64,
216    /// Its standing.
217    pub state: ClaimState,
218}
219
220impl ClaimView {
221    /// Where the claim stands in the one order exclusivity is decided by: the Lamport stamp
222    /// of its acquisition, then its stream, then its sequence. Owned, because a collection
223    /// keys a map by it rather than sorting itself with a comparator of its own.
224    fn order_key(&self) -> (u64, StreamId, u64) {
225        (
226            self.acquired_lamport,
227            self.stream.clone(),
228            self.acquired_seq,
229        )
230    }
231}
232
233/// Two live claims of different sessions whose scopes meet, at least one advisory:
234/// reported, never refused.
235///
236/// An overlap is information, not a verdict — it is how two workers find out they are in
237/// the same place without either being stopped. The meeting paths are listed pairwise
238/// rather than summarized, because the useful question is which path of mine meets which
239/// path of theirs.
240///
241/// ```
242/// use std::sync::Arc;
243/// use majordomus_cli::mesh::identity::NodeIdentity;
244/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
245/// use majordomus_cli::mesh::state::{fold, ClaimOverlap};
246///
247/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
248///     "repo".into(), None).unwrap();
249/// for (claim, session, mode) in [
250///     ("c1", "s1", ClaimMode::Exclusive),
251///     ("c2", "s2", ClaimMode::Advisory),
252/// ] {
253///     j.append_own(EventBody::ClaimAcquired { claim: claim.into(), session: session.into(),
254///         scope: vec!["apps".into()], intent: None, mode, issue: None }).unwrap();
255/// }
256///
257/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
258/// let overlap: &ClaimOverlap = &state.overlaps[0];
259/// assert_ne!(overlap.first, overlap.second, "two claims, two sessions");
260/// assert_eq!(overlap.paths, [("apps".to_string(), "apps".to_string())]);
261/// ```
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263pub struct ClaimOverlap {
264    /// The earlier claim's key.
265    pub first: String,
266    /// The later claim's key.
267    pub second: String,
268    /// The paths that meet, as `first ↔ second` pairs.
269    pub paths: Vec<(String, String)>,
270}
271
272/// A handover anywhere in the mesh, with everyone who has taken it. The id is the digest
273/// of the body, so the same handover published twice — by a retry, or by two runtimes that
274/// both saw it — is one handover here; and `consumed_by` is what keeps two workers from
275/// silently picking up the same continuity, since the list is folded from events every
276/// runtime holds.
277///
278/// ```
279/// use std::sync::Arc;
280/// use majordomus_cli::mesh::identity::NodeIdentity;
281/// use majordomus_cli::mesh::journal::{EventBody, HandoverBody, Journal, StreamLiveness};
282/// use majordomus_cli::mesh::state::{fold, HandoverView};
283///
284/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
285///     "repo".into(), None).unwrap();
286/// let body = "# Objective\nship\n".to_string();
287/// let handover = HandoverBody { id: HandoverBody::digest_of(&body), task: None, issue: None,
288///     milestone: None, branch: Some("feature/x".into()), head: None, created_at: None,
289///     name: None, body };
290/// let id = handover.id.clone();
291/// j.append_own(EventBody::HandoverPublished { handover }).unwrap();
292/// j.append_own(EventBody::HandoverConsumed { handover: id.clone(), session: "s1".into() })
293///     .unwrap();
294///
295/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
296/// let view: &HandoverView = &state.handovers[0];
297/// assert_eq!(view.id, id, "identified by what it says, not by who said it");
298/// assert_eq!(view.consumed_by.len(), 1, "somebody has picked this one up");
299/// ```
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301pub struct HandoverView {
302    /// The content digest.
303    pub id: String,
304    /// The stream that first published it.
305    pub stream: StreamId,
306    /// `<node>-<runtime>` of the publisher.
307    pub runtime: String,
308    /// The Lamport stamp of the first publication.
309    pub published_lamport: u64,
310    /// The record's facts and body.
311    pub handover: HandoverBody,
312    /// Every session that consumed it, by key, in Lamport order.
313    pub consumed_by: Vec<String>,
314}
315
316/// A review request's standing: whether anybody has answered it yet. There is deliberately
317/// no `Approved` or `Rejected` here — a request may be answered by several runtimes and the
318/// verdicts are the answers' own, so collapsing them into one state would throw away the
319/// disagreement that makes a second reviewer worth asking.
320///
321/// ```
322/// use std::sync::Arc;
323/// use majordomus_cli::mesh::identity::NodeIdentity;
324/// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamLiveness};
325/// use majordomus_cli::mesh::state::{fold, ReviewState};
326///
327/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
328///     "repo".into(), None).unwrap();
329/// j.append_own(EventBody::ReviewRequested { review: "r1".into(), session: "s1".into(),
330///     subject: "feature/x".into(), scope: vec![], issue: None, reviewer: None, body: None }).unwrap();
331/// let asked = fold(&j.events(), &|_| StreamLiveness::Own);
332/// assert_eq!(asked.reviews[0].state, ReviewState::Open);
333///
334/// let request = asked.reviews[0].key.clone();
335/// j.append_own(EventBody::ReviewAnswered { request, session: "s2".into(),
336///     verdict: "approved".into(), note: None }).unwrap();
337/// assert_eq!(fold(&j.events(), &|_| StreamLiveness::Own).reviews[0].state, ReviewState::Answered);
338/// ```
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
340#[serde(rename_all = "snake_case")]
341pub enum ReviewState {
342    /// Nobody has answered.
343    Open,
344    /// At least one answer.
345    Answered,
346    /// The runtime that asked ended the request: done, void or superseded. Whatever was
347    /// answered before stays with it.
348    Withdrawn,
349    /// Nobody answered and the runtime that asked stopped beating. A reading of liveness,
350    /// like a claim's expiry, decided from the journal and the streams' beats so that no
351    /// runtime needs another's clock — a runtime that beats again reads open again.
352    Expired,
353}
354
355impl ReviewState {
356    /// Whether the request still waits for a verdict: open, and nothing else.
357    ///
358    /// ```
359    /// use majordomus_cli::mesh::state::ReviewState;
360    /// assert!(ReviewState::Open.is_waiting());
361    /// assert!(!ReviewState::Answered.is_waiting() && !ReviewState::Withdrawn.is_waiting());
362    /// assert!(!ReviewState::Expired.is_waiting());
363    /// ```
364    pub fn is_waiting(self) -> bool {
365        self == ReviewState::Open
366    }
367
368    /// The state's word, as every surface prints it.
369    ///
370    /// ```
371    /// use majordomus_cli::mesh::state::ReviewState;
372    /// assert_eq!(ReviewState::Withdrawn.as_str(), "withdrawn");
373    /// assert_eq!(serde_json::to_value(ReviewState::Expired).unwrap(), ReviewState::Expired.as_str());
374    /// ```
375    pub fn as_str(self) -> &'static str {
376        match self {
377            ReviewState::Open => "open",
378            ReviewState::Answered => "answered",
379            ReviewState::Withdrawn => "withdrawn",
380            ReviewState::Expired => "expired",
381        }
382    }
383}
384
385/// One answer to a review request: who answered, what they decided, and why. The verdict
386/// is one of the three declared words, so a requester can act on it without reading the
387/// note; the note is where the reasoning goes and is optional, because "approved" often
388/// needs none.
389///
390/// ```
391/// use majordomus_cli::mesh::state::ReviewAnswer;
392///
393/// let answer = ReviewAnswer {
394///     session: "a1b2c3d4-r1-0f/s2".into(),
395///     verdict: "changes_requested".into(),
396///     note: Some("the fold is not deterministic".into()),
397/// };
398/// assert_eq!(answer.verdict, "changes_requested");
399///
400/// // the note is omitted rather than carried empty when there is nothing to say
401/// let bare = ReviewAnswer { note: None, ..answer };
402/// let value = serde_json::to_value(&bare).unwrap();
403/// assert!(value.get("note").is_none());
404/// ```
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
406pub struct ReviewAnswer {
407    /// The answering session's key.
408    pub session: String,
409    /// The verdict.
410    pub verdict: String,
411    /// The note.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub note: Option<String>,
414}
415
416/// One review request anywhere in the mesh, with every answer it has drawn. A request with
417/// no `reviewer` is an offer to the whole mesh rather than to nobody, which is why the
418/// answers are a list: any linked runtime carrying the `reviews` feature may add one, and
419/// they are kept in Lamport order so that every runtime reads them in the same sequence.
420///
421/// ```
422/// use std::sync::Arc;
423/// use majordomus_cli::mesh::identity::NodeIdentity;
424/// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamLiveness};
425/// use majordomus_cli::mesh::state::{fold, ReviewView};
426///
427/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
428///     "repo".into(), None).unwrap();
429/// j.append_own(EventBody::ReviewRequested { review: "r1".into(), session: "s1".into(),
430///     subject: "feature/mesh-cooperation".into(), scope: vec!["src/mesh".into()],
431///     issue: Some("#184".into()), reviewer: None, body: None }).unwrap();
432/// let request = fold(&j.events(), &|_| StreamLiveness::Own).reviews[0].key.clone();
433/// j.append_own(EventBody::ReviewAnswered { request, session: "s2".into(),
434///     verdict: "approved".into(), note: Some("reads well".into()) }).unwrap();
435///
436/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
437/// let review: &ReviewView = &state.reviews[0];
438/// assert_eq!(review.subject, "feature/mesh-cooperation");
439/// assert_eq!(review.reviewer, None, "offered to the mesh, not addressed");
440/// assert_eq!(review.answers[0].verdict, "approved");
441/// ```
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
443pub struct ReviewView {
444    /// `<stream>/<review>`.
445    pub key: String,
446    /// The requesting session's key.
447    pub session: String,
448    /// What is to be reviewed.
449    pub subject: String,
450    /// Paths it covers.
451    pub scope: Vec<String>,
452    /// The issue.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub issue: Option<String>,
455    /// The runtime asked, when one was.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub reviewer: Option<String>,
458    /// What the reviewer needs beyond the subject, when the requester wrote it.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub body: Option<String>,
461    /// Its standing.
462    pub state: ReviewState,
463    /// The answers, in Lamport order.
464    pub answers: Vec<ReviewAnswer>,
465    /// The requester's own result or reason, written when it withdrew the request. Never an
466    /// answer: a review is another session's verdict.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub result: Option<String>,
469}
470
471/// The whole folded state, every collection in key order, and its digest: two runtimes
472/// that agree print the same digest.
473///
474/// The digest is the point. Convergence is otherwise a claim nobody can check from one
475/// machine; with it, two operators compare one short string and know whether their
476/// runtimes are looking at the same world. `opaque_events` is the other half of that
477/// honesty: events of kinds this executable does not interpret are counted and relayed
478/// rather than dropped, so a newer peer's traffic is visible instead of silently missing.
479///
480/// ```
481/// use std::sync::Arc;
482/// use majordomus_cli::mesh::identity::NodeIdentity;
483/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo, StreamLiveness};
484/// use majordomus_cli::mesh::state::{fold, CooperationState};
485///
486/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
487///     "repo".into(), None).unwrap();
488/// j.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
489///
490/// let events = j.events();
491/// let state: CooperationState = fold(&events, &|_| StreamLiveness::Own);
492/// assert_eq!(state.digest.len(), 32);
493///
494/// // the same events in another order, and twice over, are the same state
495/// let mut shuffled = events.clone();
496/// shuffled.reverse();
497/// shuffled.extend(events);
498/// assert_eq!(fold(&shuffled, &|_| StreamLiveness::Own).digest, state.digest);
499/// ```
500#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
501pub struct CooperationState {
502    /// Every session.
503    pub sessions: Vec<SessionView>,
504    /// Every claim.
505    pub claims: Vec<ClaimView>,
506    /// Advisory overlaps between live claims.
507    pub overlaps: Vec<ClaimOverlap>,
508    /// Every handover.
509    pub handovers: Vec<HandoverView>,
510    /// Every review request.
511    pub reviews: Vec<ReviewView>,
512    /// Events of kinds this executable does not interpret.
513    pub opaque_events: usize,
514    /// 32 hex of the SHA-256 of the canonical JSON of everything above.
515    pub digest: String,
516}
517
518/// Fold events into state. `liveness` answers for each stream; the caller supplies the
519/// journal's verdicts, a test supplies whatever it is testing.
520///
521/// Liveness is a parameter rather than something read here because it is the one input
522/// that is not in the events: whether a stream still beats is a local observation, and
523/// making it an argument is what keeps the fold itself pure — the same events with the
524/// same verdicts give the same state, in any order and however often they are repeated.
525///
526/// ```
527/// use std::sync::Arc;
528/// use majordomus_cli::mesh::identity::NodeIdentity;
529/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
530/// use majordomus_cli::mesh::state::{fold, ClaimState};
531///
532/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
533///     "repo".into(), None).unwrap();
534/// j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
535///     scope: vec!["apps".into()], intent: None, mode: ClaimMode::Exclusive, issue: None })
536///     .unwrap();
537///
538/// // read where the writer is alive, the claim holds
539/// let here = fold(&j.events(), &|_| StreamLiveness::Own);
540/// assert_eq!(here.claims[0].state, ClaimState::Held);
541///
542/// // read where its beat has stopped, it ended on its own; nobody released it
543/// let elsewhere = fold(&j.events(), &|_| StreamLiveness::Expired);
544/// assert!(matches!(elsewhere.claims[0].state, ClaimState::Expired(_)));
545/// ```
546pub fn fold(
547    events: &[MeshEvent],
548    liveness: &dyn Fn(&StreamId) -> StreamLiveness,
549) -> CooperationState {
550    // Keyed by the causal position, so the map both orders and deduplicates: an event is
551    // identified by `(stream, seq)`, and the same event twice is one entry.
552    let ordered: Vec<&MeshEvent> = events
553        .iter()
554        .map(|e| (causal_key(e), e))
555        .collect::<BTreeMap<_, _>>()
556        .into_values()
557        .collect();
558
559    let mut sessions: BTreeMap<String, SessionView> = BTreeMap::new();
560    let mut claims: BTreeMap<String, ClaimView> = BTreeMap::new();
561    let mut handovers: BTreeMap<String, HandoverView> = BTreeMap::new();
562    let mut reviews: BTreeMap<String, ReviewView> = BTreeMap::new();
563    // Where and when each request was asked: what its expiry is judged by.
564    let mut asked_at: BTreeMap<String, StreamId> = BTreeMap::new();
565    let mut opaque = 0usize;
566    let mut closed_at: BTreeMap<String, u64> = BTreeMap::new();
567
568    for event in ordered {
569        let Some(body) = event.body() else {
570            opaque += 1;
571            continue;
572        };
573        let stream = &event.stream;
574        let key = |local: &str| format!("{stream}/{local}");
575        match body {
576            EventBody::SessionOpened { info } => {
577                let k = key(&info.session);
578                sessions.insert(
579                    k.clone(),
580                    SessionView {
581                        key: k,
582                        stream: stream.clone(),
583                        runtime: stream.runtime_key(),
584                        node: stream.node().to_string(),
585                        info,
586                        state: SessionState::Active,
587                        updated_lamport: event.lamport,
588                    },
589                );
590            }
591            EventBody::SessionClosed { session } => {
592                let k = key(&session);
593                // A close ends every claim acquired before it, even if the same session id
594                // opens again later (a board position is reused): those claims stay ended.
595                let closed = closed_at.entry(k.clone()).or_insert(0);
596                *closed = (*closed).max(event.lamport);
597                if let Some(view) = sessions.get_mut(&k) {
598                    view.state = SessionState::Closed;
599                    view.updated_lamport = event.lamport;
600                }
601            }
602            EventBody::ClaimAcquired {
603                claim,
604                session,
605                scope,
606                intent,
607                mode,
608                issue,
609            } => {
610                let k = key(&claim);
611                claims.insert(
612                    k.clone(),
613                    ClaimView {
614                        key: k,
615                        stream: stream.clone(),
616                        runtime: stream.runtime_key(),
617                        session: key(&session),
618                        scope,
619                        intent,
620                        mode,
621                        issue,
622                        acquired_lamport: event.lamport,
623                        acquired_seq: event.seq,
624                        state: ClaimState::Held,
625                    },
626                );
627            }
628            EventBody::ClaimReleased { claim } => {
629                if let Some(view) = claims.get_mut(&key(&claim)) {
630                    view.state = ClaimState::Released;
631                }
632            }
633            EventBody::HandoverPublished { handover } => {
634                handovers
635                    .entry(handover.id.clone())
636                    .or_insert_with(|| HandoverView {
637                        id: handover.id.clone(),
638                        stream: stream.clone(),
639                        runtime: stream.runtime_key(),
640                        published_lamport: event.lamport,
641                        handover,
642                        consumed_by: Vec::new(),
643                    });
644            }
645            EventBody::HandoverConsumed { handover, session } => {
646                if let Some(view) = handovers.get_mut(&handover) {
647                    let consumer = key(&session);
648                    if !view.consumed_by.contains(&consumer) {
649                        view.consumed_by.push(consumer);
650                    }
651                }
652            }
653            EventBody::ReviewRequested {
654                review,
655                session,
656                subject,
657                scope,
658                issue,
659                reviewer,
660                body,
661            } => {
662                let k = key(&review);
663                asked_at.entry(k.clone()).or_insert(event.stream.clone());
664                reviews.entry(k.clone()).or_insert(ReviewView {
665                    key: k,
666                    session: key(&session),
667                    subject,
668                    scope,
669                    issue,
670                    reviewer,
671                    body,
672                    state: ReviewState::Open,
673                    answers: Vec::new(),
674                    result: None,
675                });
676            }
677            EventBody::ReviewAnswered {
678                request,
679                session,
680                verdict,
681                note,
682            } => {
683                // A review is an independent verdict: the session that asked for it cannot
684                // answer it. Admission refuses that; a peer that writes one anyway is ignored.
685                if let Some(view) = reviews
686                    .get_mut(&request)
687                    .filter(|view| view.session != key(&session))
688                {
689                    if view.state != ReviewState::Withdrawn {
690                        view.state = ReviewState::Answered;
691                    }
692                    view.answers.push(ReviewAnswer {
693                        session: key(&session),
694                        verdict,
695                        note,
696                    });
697                }
698            }
699            EventBody::ReviewWithdrawn {
700                request,
701                session: _,
702                note,
703            } => {
704                // Only the runtime that asked ends its request — any of its sessions, because
705                // an MCP session is one connection and a reconnected asker is another
706                // session of the same runtime. A withdrawal another runtime wrote is
707                // ignored, as admission refuses it. The withdrawing session is recorded in
708                // the event and not needed here.
709                if let Some(view) = reviews
710                    .get_mut(&request)
711                    .filter(|view| view.key.starts_with(&format!("{stream}/")))
712                {
713                    view.state = ReviewState::Withdrawn;
714                    view.result = note;
715                }
716            }
717        }
718    }
719
720    // A request nobody answered ends with the runtime that asked: when its stream stops
721    // beating. Not when the asking session closes — an MCP session is one connection, and a
722    // lane request outlives many of them. This is a reading of liveness, like a claim's: a
723    // runtime that sleeps and beats again reads open again.
724    for view in reviews.values_mut() {
725        if view.state != ReviewState::Open {
726            continue;
727        }
728        if let Some(stream) = asked_at.get(&view.key) {
729            if !liveness(stream).is_alive() {
730                view.state = ReviewState::Expired;
731            }
732        }
733    }
734
735    // StreamLiveness: a session or claim of a dead stream has ended, whatever it last said.
736    for view in sessions.values_mut() {
737        if view.state == SessionState::Active && !liveness(&view.stream).is_alive() {
738            view.state = SessionState::Expired;
739        }
740    }
741    for view in claims.values_mut() {
742        if view.state != ClaimState::Held {
743            continue;
744        }
745        if !liveness(&view.stream).is_alive() {
746            view.state = ClaimState::Expired("its runtime stopped beating".into());
747        } else if closed_at
748            .get(&view.session)
749            .is_some_and(|closed| view.acquired_lamport < *closed)
750        {
751            view.state = ClaimState::Expired("its session closed".into());
752        } else if let Some(session) = sessions.get(&view.session) {
753            if session.state != SessionState::Active {
754                view.state = ClaimState::Expired("its session closed".into());
755            }
756        }
757    }
758
759    // Exclusivity: live exclusive claims in acquisition order; the first to a scope wins.
760    // The map is what orders them — the same `(lamport, stream, seq)` on every runtime, so
761    // every runtime names the same winner.
762    let exclusive: Vec<String> = claims
763        .values()
764        .filter(|c| c.state.is_live() && c.mode == ClaimMode::Exclusive)
765        .map(|c| (c.order_key(), c.key.clone()))
766        .collect::<BTreeMap<_, _>>()
767        .into_values()
768        .collect();
769    let mut winners: Vec<(String, String, Vec<String>)> = Vec::new();
770    for key in exclusive {
771        let Some(claim) = claims.get_mut(&key) else {
772            continue;
773        };
774        let beaten = winners.iter().find(|(_, session, scope)| {
775            *session != claim.session && scopes_meet(scope, &claim.scope)
776        });
777        match beaten {
778            Some((winner, _, _)) => claim.state = ClaimState::Conflicted(winner.clone()),
779            None => {
780                claim.state = ClaimState::Held;
781                winners.push((
782                    claim.key.clone(),
783                    claim.session.clone(),
784                    claim.scope.clone(),
785                ));
786            }
787        }
788    }
789
790    // Advisory overlaps: live claims of different sessions that meet, not both exclusive.
791    let live: Vec<&ClaimView> = claims
792        .values()
793        .filter(|c| c.state.is_live())
794        .map(|c| (c.order_key(), c))
795        .collect::<BTreeMap<_, _>>()
796        .into_values()
797        .collect();
798    let mut overlaps = Vec::new();
799    for (i, first) in live.iter().enumerate() {
800        for second in &live[i + 1..] {
801            if first.session == second.session
802                || (first.mode == ClaimMode::Exclusive && second.mode == ClaimMode::Exclusive)
803            {
804                continue;
805            }
806            let paths = meeting_paths(&first.scope, &second.scope);
807            if !paths.is_empty() {
808                overlaps.push(ClaimOverlap {
809                    first: first.key.clone(),
810                    second: second.key.clone(),
811                    paths,
812                });
813            }
814        }
815    }
816
817    let mut state = CooperationState {
818        sessions: sessions.into_values().collect(),
819        claims: claims.into_values().collect(),
820        overlaps,
821        handovers: handovers.into_values().collect(),
822        reviews: reviews.into_values().collect(),
823        opaque_events: opaque,
824        digest: String::new(),
825    };
826    state.digest = digest(&state);
827    state
828}
829
830fn digest(state: &CooperationState) -> String {
831    use sha2::{Digest, Sha256};
832    let value = serde_json::to_value(state).unwrap_or_default();
833    let hash = Sha256::digest(canonical_json(&value));
834    hash.iter().take(16).map(|b| format!("{b:02x}")).collect()
835}
836
837/// Whether any path of one scope meets any path of the other. Paths meet by
838/// [`crate::peers::claims_meet`], the peer board's own predicate, so that a claim means the
839/// same thing on this machine and on another: a directory contains what is under it, and
840/// two claims that share no path do not meet however similar they look.
841///
842/// ```
843/// use majordomus_cli::mesh::state::scopes_meet;
844///
845/// assert!(scopes_meet(&["apps".into()], &["apps/majordomus-cli/src".into()]), "a tree contains it");
846/// assert!(!scopes_meet(&["apps".into()], &["docs".into()]));
847/// assert!(!scopes_meet(&[], &["apps".into()]), "a claim over nothing meets nothing");
848/// ```
849pub fn scopes_meet(a: &[String], b: &[String]) -> bool {
850    a.iter()
851        .any(|x| b.iter().any(|y| crate::peers::claims_meet(x, y)))
852}
853
854fn meeting_paths(a: &[String], b: &[String]) -> Vec<(String, String)> {
855    let mut out = Vec::new();
856    for x in a {
857        for y in b {
858            if crate::peers::claims_meet(x, y) {
859                out.push((x.clone(), y.clone()));
860            }
861        }
862    }
863    out
864}
865
866/// The live exclusive claims of other sessions a new exclusive claim of `session` over
867/// `scope` would meet: empty means admissible. An advisory claim is always admissible.
868///
869/// This is the difference between refusing a claim and reporting a conflict after the
870/// fact. Admission is checked against the folded state before anything is written, so a
871/// claim that could have been refused is refused; what is left — genuinely concurrent
872/// claims from two sides of a partition — is what the fold ranks afterwards, and both
873/// runtimes name the same winner.
874///
875/// ```
876/// use std::sync::Arc;
877/// use majordomus_cli::mesh::identity::NodeIdentity;
878/// use majordomus_cli::mesh::journal::{ClaimMode, EventBody, Journal, StreamLiveness};
879/// use majordomus_cli::mesh::state::{admission_conflicts, fold};
880///
881/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
882///     "repo".into(), None).unwrap();
883/// j.append_own(EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
884///     scope: vec!["apps".into()], intent: None, mode: ClaimMode::Exclusive, issue: None })
885///     .unwrap();
886/// let state = fold(&j.events(), &|_| StreamLiveness::Own);
887/// let held = &state.claims[0];
888///
889/// // another session cannot take a scope inside one already held exclusively
890/// let met = admission_conflicts(&state, "s2", &["apps/majordomus-cli".into()],
891///     ClaimMode::Exclusive);
892/// assert_eq!(met.len(), 1);
893/// assert_eq!(met[0].key, held.key, "the refusal names what it met");
894///
895/// // the holder is not in its own way, and an advisory claim is never refused
896/// assert!(admission_conflicts(&state, &held.session, &["apps".into()], ClaimMode::Exclusive)
897///     .is_empty());
898/// assert!(admission_conflicts(&state, "s2", &["apps".into()], ClaimMode::Advisory).is_empty());
899/// ```
900pub fn admission_conflicts<'a>(
901    state: &'a CooperationState,
902    session: &str,
903    scope: &[String],
904    mode: ClaimMode,
905) -> Vec<&'a ClaimView> {
906    if mode == ClaimMode::Advisory {
907        return Vec::new();
908    }
909    state
910        .claims
911        .iter()
912        .filter(|c| {
913            c.state.is_live()
914                && c.mode == ClaimMode::Exclusive
915                && c.session != session
916                && scopes_meet(&c.scope, scope)
917        })
918        .collect()
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924    use crate::mesh::identity::NodeIdentity;
925    use crate::mesh::journal::{Journal, Rejection};
926    use std::sync::Arc;
927
928    fn journal(runtime: &str) -> Journal {
929        Journal::open(
930            Arc::new(NodeIdentity::ephemeral().unwrap()),
931            runtime,
932            "repo".into(),
933            None,
934        )
935        .unwrap()
936    }
937
938    fn claim(j: &Journal, claim: &str, session: &str, scope: &[&str], mode: ClaimMode) {
939        j.append_own(EventBody::ClaimAcquired {
940            claim: claim.into(),
941            session: session.into(),
942            scope: scope.iter().map(|s| s.to_string()).collect(),
943            intent: None,
944            mode,
945            issue: Some("#184".into()),
946        })
947        .unwrap();
948    }
949
950    fn sync(from: &Journal, to: &Journal) {
951        to.ingest(
952            &from.missing_for(&to.marks(), usize::MAX),
953            &|_: &MeshEvent| Ok::<(), Rejection>(()),
954        );
955    }
956
957    fn all_live(_: &StreamId) -> StreamLiveness {
958        StreamLiveness::Live
959    }
960
961    #[test]
962    fn a_remote_claim_is_seen_and_refuses_a_conflicting_admission() {
963        let a = journal("0000000000000001");
964        let b = journal("0000000000000002");
965        claim(
966            &a,
967            "c1",
968            "s1",
969            &["apps/majordomus-cli"],
970            ClaimMode::Exclusive,
971        );
972        sync(&a, &b);
973        let state = fold(&b.events(), &all_live);
974        assert_eq!(state.claims.len(), 1);
975        assert_eq!(state.claims[0].state, ClaimState::Held);
976        let conflicts = admission_conflicts(
977            &state,
978            &format!("{}/s9", b.own_stream()),
979            &["apps".into()],
980            ClaimMode::Exclusive,
981        );
982        assert_eq!(
983            conflicts.len(),
984            1,
985            "B's exclusive claim over apps meets A's"
986        );
987        assert!(
988            admission_conflicts(&state, "x", &["docs".into()], ClaimMode::Exclusive).is_empty()
989        );
990        assert!(
991            admission_conflicts(&state, "x", &["apps".into()], ClaimMode::Advisory).is_empty(),
992            "advisory never refuses"
993        );
994    }
995
996    #[test]
997    fn a_dead_holder_releases_nothing_yet_its_claim_expires() {
998        let a = journal("0000000000000001");
999        let b = journal("0000000000000002");
1000        claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1001        sync(&a, &b);
1002        let dead = fold(&b.events(), &|_| StreamLiveness::Expired);
1003        assert!(matches!(dead.claims[0].state, ClaimState::Expired(_)));
1004        assert!(
1005            admission_conflicts(&dead, "other", &["apps".into()], ClaimMode::Exclusive).is_empty(),
1006            "an expired lease no longer excludes"
1007        );
1008    }
1009
1010    #[test]
1011    fn concurrent_claims_name_the_same_winner_everywhere() {
1012        // Two sides of a partition claim the same scope; after healing, both runtimes fold
1013        // the same events and agree on the winner, regardless of who synced first.
1014        let a = journal("0000000000000001");
1015        let b = journal("0000000000000002");
1016        claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1017        claim(
1018            &b,
1019            "c1",
1020            "s1",
1021            &["apps/majordomus-cli"],
1022            ClaimMode::Exclusive,
1023        );
1024        sync(&a, &b);
1025        sync(&b, &a);
1026        let at_a = fold(&a.events(), &all_live);
1027        let at_b = fold(&b.events(), &all_live);
1028        assert_eq!(at_a.digest, at_b.digest, "both runtimes converge");
1029        let held: Vec<_> = at_a
1030            .claims
1031            .iter()
1032            .filter(|c| c.state == ClaimState::Held)
1033            .collect();
1034        let conflicted: Vec<_> = at_a
1035            .claims
1036            .iter()
1037            .filter(|c| matches!(c.state, ClaimState::Conflicted(_)))
1038            .collect();
1039        assert_eq!((held.len(), conflicted.len()), (1, 1));
1040        assert_eq!(
1041            conflicted[0].state,
1042            ClaimState::Conflicted(held[0].key.clone())
1043        );
1044    }
1045
1046    #[test]
1047    fn release_and_session_close_end_claims() {
1048        let a = journal("0000000000000001");
1049        claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1050        claim(&a, "c2", "s2", &["docs"], ClaimMode::Exclusive);
1051        a.append_own(EventBody::SessionOpened {
1052            info: SessionInfo::named("s2", "test"),
1053        })
1054        .unwrap();
1055        a.append_own(EventBody::ClaimReleased { claim: "c1".into() })
1056            .unwrap();
1057        a.append_own(EventBody::SessionClosed {
1058            session: "s2".into(),
1059        })
1060        .unwrap();
1061        let state = fold(&a.events(), &|_| StreamLiveness::Own);
1062        assert_eq!(state.claims[0].state, ClaimState::Released);
1063        assert!(matches!(state.claims[1].state, ClaimState::Expired(_)));
1064        assert_eq!(state.sessions[0].state, SessionState::Closed);
1065    }
1066
1067    #[test]
1068    fn a_reopened_session_id_does_not_bring_back_the_claims_its_close_ended() {
1069        let a = journal("0000000000000001");
1070        claim(&a, "c1", "p1", &["apps"], ClaimMode::Exclusive);
1071        a.append_own(EventBody::SessionClosed {
1072            session: "p1".into(),
1073        })
1074        .unwrap();
1075        a.append_own(EventBody::SessionOpened {
1076            info: SessionInfo::named("p1", "test"),
1077        })
1078        .unwrap();
1079        let state = fold(&a.events(), &|_| StreamLiveness::Own);
1080        assert!(matches!(state.claims[0].state, ClaimState::Expired(_)));
1081        claim(&a, "c2", "p1", &["apps"], ClaimMode::Exclusive);
1082        let state = fold(&a.events(), &|_| StreamLiveness::Own);
1083        let second = state
1084            .claims
1085            .iter()
1086            .find(|c| c.key.ends_with("/c2"))
1087            .unwrap();
1088        assert_eq!(
1089            second.state,
1090            ClaimState::Held,
1091            "a claim made after the reopen holds"
1092        );
1093    }
1094
1095    #[test]
1096    fn advisory_overlaps_are_reported_not_refused() {
1097        let a = journal("0000000000000001");
1098        claim(&a, "c1", "s1", &["apps"], ClaimMode::Advisory);
1099        claim(&a, "c2", "s2", &["apps/x"], ClaimMode::Exclusive);
1100        let state = fold(&a.events(), &|_| StreamLiveness::Own);
1101        assert!(state.claims.iter().all(|c| c.state == ClaimState::Held));
1102        assert_eq!(state.overlaps.len(), 1);
1103        assert_eq!(
1104            state.overlaps[0].paths,
1105            vec![("apps".into(), "apps/x".into())]
1106        );
1107    }
1108
1109    #[test]
1110    fn handovers_and_reviews_link_across_streams() {
1111        let a = journal("0000000000000001");
1112        let b = journal("0000000000000002");
1113        let body = "# Objective\nship\n# Current State\nhalf\n# Next Action\nrest\n".to_string();
1114        let id = HandoverBody::digest_of(&body);
1115        a.append_own(EventBody::HandoverPublished {
1116            handover: HandoverBody {
1117                id: id.clone(),
1118                task: Some("t-1".into()),
1119                issue: Some("#184".into()),
1120                milestone: None,
1121                branch: Some("feature/x".into()),
1122                head: Some("abc".into()),
1123                created_at: None,
1124                name: None,
1125                body: body.clone(),
1126            },
1127        })
1128        .unwrap();
1129        a.append_own(EventBody::ReviewRequested {
1130            review: "r1".into(),
1131            session: "s1".into(),
1132            subject: "feature/x".into(),
1133            scope: vec!["apps".into()],
1134            issue: Some("#184".into()),
1135            reviewer: None,
1136            body: None,
1137        })
1138        .unwrap();
1139        sync(&a, &b);
1140        b.append_own(EventBody::HandoverConsumed {
1141            handover: id.clone(),
1142            session: "s2".into(),
1143        })
1144        .unwrap();
1145        b.append_own(EventBody::ReviewAnswered {
1146            request: format!("{}/r1", a.own_stream()),
1147            session: "s2".into(),
1148            verdict: "approved".into(),
1149            note: None,
1150        })
1151        .unwrap();
1152        sync(&b, &a);
1153        let at_a = fold(&a.events(), &all_live);
1154        assert_eq!(
1155            at_a.handovers[0].consumed_by,
1156            vec![format!("{}/s2", b.own_stream())]
1157        );
1158        assert_eq!(at_a.reviews[0].state, ReviewState::Answered);
1159        assert_eq!(at_a.reviews[0].answers[0].verdict, "approved");
1160        assert_eq!(at_a.digest, fold(&b.events(), &all_live).digest);
1161    }
1162
1163    #[test]
1164    fn a_review_request_ends_by_withdrawal_or_with_its_asker() {
1165        let a = journal("0000000000000001");
1166        let b = journal("0000000000000002");
1167        let ask = |j: &Journal, review: &str, session: &str| {
1168            j.append_own(EventBody::ReviewRequested {
1169                review: review.into(),
1170                session: session.into(),
1171                subject: format!("subject {review}"),
1172                scope: vec![],
1173                issue: None,
1174                reviewer: None,
1175                body: Some(format!("{}\nthe evidence", "b".repeat(8000))),
1176            })
1177            .unwrap();
1178        };
1179        let open = |j: &Journal, session: &str| {
1180            j.append_own(EventBody::SessionOpened {
1181                info: SessionInfo {
1182                    session: session.into(),
1183                    client: "test".into(),
1184                    ..SessionInfo::default()
1185                },
1186            })
1187            .unwrap();
1188        };
1189        open(&a, "s1");
1190        open(&a, "s3");
1191        ask(&a, "r1", "s1"); // withdrawn by its asker, with a result
1192        ask(&a, "r2", "s1"); // withdrawn by another session of the same runtime (a reconnect)
1193        ask(&a, "r3", "s3"); // its session closes: a lane request outlives it, still open
1194        ask(&a, "r4", "s3"); // a withdrawal written by another runtime: ignored
1195        ask(&a, "r5", "s3"); // answered, then the session closes: stays answered
1196        let r = |n: &str| format!("{}/{n}", a.own_stream());
1197        a.append_own(EventBody::ReviewWithdrawn {
1198            request: r("r1"),
1199            session: "s1".into(),
1200            note: Some("superseded by the rebuilt head".into()),
1201        })
1202        .unwrap();
1203        a.append_own(EventBody::ReviewWithdrawn {
1204            request: r("r2"),
1205            session: "s2".into(),
1206            note: None,
1207        })
1208        .unwrap();
1209        sync(&a, &b);
1210        b.append_own(EventBody::ReviewWithdrawn {
1211            request: r("r4"),
1212            session: "s1".into(),
1213            note: Some("not mine to end".into()),
1214        })
1215        .unwrap();
1216        b.append_own(EventBody::ReviewAnswered {
1217            request: r("r5"),
1218            session: "s9".into(),
1219            verdict: "approved".into(),
1220            note: None,
1221        })
1222        .unwrap();
1223        sync(&b, &a);
1224        a.append_own(EventBody::SessionClosed {
1225            session: "s3".into(),
1226        })
1227        .unwrap();
1228        sync(&a, &b);
1229
1230        let at_a = fold(&a.events(), &all_live);
1231        let at_b = fold(&b.events(), &all_live);
1232        assert_eq!(at_a.digest, at_b.digest, "both runtimes fold the same ends");
1233        let state_of = |n: &str| {
1234            at_a.reviews
1235                .iter()
1236                .find(|v| v.key == r(n))
1237                .map(|v| v.state)
1238                .unwrap()
1239        };
1240        assert_eq!(state_of("r1"), ReviewState::Withdrawn);
1241        assert_eq!(
1242            state_of("r2"),
1243            ReviewState::Withdrawn,
1244            "any session of the asking runtime"
1245        );
1246        assert_eq!(
1247            state_of("r3"),
1248            ReviewState::Open,
1249            "a closed session does not end it"
1250        );
1251        assert_eq!(
1252            state_of("r4"),
1253            ReviewState::Open,
1254            "only the asking runtime withdraws"
1255        );
1256        assert_eq!(state_of("r5"), ReviewState::Answered);
1257        let r1 = at_b.reviews.iter().find(|v| v.key == r("r1")).unwrap();
1258        assert_eq!(r1.result.as_deref(), Some("superseded by the rebuilt head"));
1259        assert!(r1.answers.is_empty(), "a result is not an answer");
1260        assert!(
1261            r1.body.as_deref().is_some_and(|b| b.len() > 8000),
1262            "an 8 KiB body replicates and folds"
1263        );
1264
1265        // where the asking runtime stopped beating, an unanswered request has ended; one that
1266        // was answered or withdrawn keeps that ending
1267        let elsewhere = fold(&b.events(), &|s: &StreamId| {
1268            if s == a.own_stream() {
1269                StreamLiveness::Expired
1270            } else {
1271                StreamLiveness::Live
1272            }
1273        });
1274        let at = |n: &str| {
1275            elsewhere
1276                .reviews
1277                .iter()
1278                .find(|v| v.key == r(n))
1279                .unwrap()
1280                .state
1281        };
1282        assert_eq!(at("r3"), ReviewState::Expired);
1283        assert_eq!(at("r1"), ReviewState::Withdrawn);
1284        assert_eq!(at("r5"), ReviewState::Answered);
1285    }
1286
1287    #[test]
1288    fn a_body_or_result_over_the_bound_is_refused() {
1289        let over = "x".repeat(crate::mesh::journal::REVIEW_BODY_MAX + 1);
1290        let asked = EventBody::ReviewRequested {
1291            review: "r1".into(),
1292            session: "s1".into(),
1293            subject: "s".into(),
1294            scope: vec![],
1295            issue: None,
1296            reviewer: None,
1297            body: Some(over.clone()),
1298        };
1299        assert!(asked.validate().is_err());
1300        let withdrawn = EventBody::ReviewWithdrawn {
1301            request: "a/r1".into(),
1302            session: "s1".into(),
1303            note: Some(over),
1304        };
1305        assert!(withdrawn.validate().is_err());
1306        assert_eq!(withdrawn.kind(), "review_withdrawn");
1307        assert!(crate::mesh::journal::KNOWN_KINDS.contains(&withdrawn.kind()));
1308    }
1309
1310    #[test]
1311    fn the_session_that_asked_for_a_review_cannot_answer_it() {
1312        let a = journal("0000000000000001");
1313        a.append_own(EventBody::ReviewRequested {
1314            review: "r1".into(),
1315            session: "s1".into(),
1316            subject: "feature/x".into(),
1317            scope: vec![],
1318            issue: None,
1319            reviewer: None,
1320            body: None,
1321        })
1322        .unwrap();
1323        a.append_own(EventBody::ReviewAnswered {
1324            request: format!("{}/r1", a.own_stream()),
1325            session: "s1".into(),
1326            verdict: "approved".into(),
1327            note: None,
1328        })
1329        .unwrap();
1330        let state = fold(&a.events(), &|_| StreamLiveness::Own);
1331        assert_eq!(state.reviews[0].state, ReviewState::Open);
1332        assert!(state.reviews[0].answers.is_empty());
1333    }
1334
1335    proptest::proptest! {
1336        /// Folding any permutation of the same events, with any duplicates, yields the same
1337        /// state: arrival order can never change canonical state.
1338        #[test]
1339        fn fold_is_order_and_duplicate_independent(
1340            order in proptest::collection::vec(0usize..8, 8..32),
1341            dead in proptest::prelude::any::<bool>(),
1342        ) {
1343            let a = journal("0000000000000001");
1344            let b = journal("0000000000000002");
1345            claim(&a, "c1", "s1", &["apps"], ClaimMode::Exclusive);
1346            claim(&b, "c1", "s1", &["apps/majordomus-cli"], ClaimMode::Exclusive);
1347            claim(&a, "c2", "s1", &["docs"], ClaimMode::Advisory);
1348            claim(&b, "c2", "s2", &["docs/MESH.md"], ClaimMode::Advisory);
1349            a.append_own(EventBody::ClaimReleased { claim: "c2".into() }).unwrap();
1350            b.append_own(EventBody::SessionOpened { info: SessionInfo::named("s2", "t") }).unwrap();
1351            a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "t") }).unwrap();
1352            b.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
1353            let mut events = a.events();
1354            events.extend(b.events());
1355            let dead_stream = a.own_stream().clone();
1356            let live = move |s: &StreamId| if dead && *s == dead_stream { StreamLiveness::Expired } else { StreamLiveness::Live };
1357            let reference = fold(&events, &live);
1358            let shuffled: Vec<MeshEvent> = order.iter().map(|i| events[*i].clone()).chain(events.iter().cloned()).collect();
1359            proptest::prop_assert_eq!(fold(&shuffled, &live), reference);
1360        }
1361
1362        /// However claims interleave, no scope ever has two held exclusive winners from
1363        /// different sessions.
1364        #[test]
1365        fn exclusivity_holds_for_any_interleaving(picks in proptest::collection::vec((0usize..3, 0usize..4), 1..20)) {
1366            let journals = [journal("0000000000000001"), journal("0000000000000002"), journal("0000000000000003")];
1367            let scopes = ["apps", "apps/x", "docs", "apps/x/y"];
1368            for (i, (who, scope)) in picks.iter().enumerate() {
1369                claim(&journals[*who], &format!("c{i}"), &format!("s{who}"), &[scopes[*scope]], ClaimMode::Exclusive);
1370            }
1371            let events: Vec<MeshEvent> = journals.iter().flat_map(|j| j.events()).collect();
1372            let state = fold(&events, &all_live);
1373            let held: Vec<&ClaimView> = state.claims.iter().filter(|c| c.state == ClaimState::Held).collect();
1374            for (i, x) in held.iter().enumerate() {
1375                for y in &held[i + 1..] {
1376                    proptest::prop_assert!(x.session == y.session || !scopes_meet(&x.scope, &y.scope));
1377                }
1378            }
1379        }
1380    }
1381}