Skip to main content

graphrefly/
protocol.rs

1//! Wave-protocol data types — the decision-locked, language-neutral core.
2//!
3//! These types are pinned by the spec and the D# log, so they are concrete Rust
4//! representations of the current protocol surface.
5//! Representation is per-language (D11: in-process tuple vs wire protobuf are
6//! decoupled); the *semantics* below match `~/src/graphrefly/spec/rules.jsonl`.
7
8use std::rc::Rc;
9
10/// The erased in-process value type — the Rust analogue of TS's `unknown`
11/// (value representation decision, per-language impl, see `CLEAN-SLATE.md`).
12///
13/// `Rc<dyn Any>` so one DATA payload fans out to N sinks + the node cache +
14/// `prev_data` sharing a single allocation via refcount (single-thread D22 ⇒
15/// `Rc`, not `Arc`; `dyn Any`, not `dyn Any + Send + Sync`). The substrate moves
16/// values erased; the user fn downcasts. A typed `Node<T>` facade re-types the
17/// boundary.
18pub type AnyValue = Rc<dyn std::any::Any>;
19
20/// Caller-chosen opaque pause-lock identifier (D10).
21///
22/// `[[PAUSE, lock_id]]` — the caller generates the id; the same id is
23/// idempotent; `RESUME` of an unknown id is a no-op. Multiple independent pause
24/// sources hold distinct ids in a node's lockset so they cannot fight each other
25/// (R-pause-lockset). Opaque on purpose — the substrate never interprets it.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub struct LockId(pub String);
28
29impl LockId {
30    /// Creates or computes `new`.
31    pub fn new(id: impl Into<String>) -> Self {
32        Self(id.into())
33    }
34}
35
36impl From<String> for LockId {
37    fn from(s: String) -> Self {
38        Self(s)
39    }
40}
41
42impl From<&str> for LockId {
43    fn from(s: &str) -> Self {
44        Self(s.to_owned())
45    }
46}
47
48/// A pool-relative pool callback handle — **pure data, no methods** (D7).
49///
50/// `(pool_id, handle_id)` per D7/DR-2; the Rust pool adds a local `generation`
51/// (B32) so a recycled slotmap slot can be told apart from a stale handle (the
52/// pool frees a dropped node's fn slot and reuses it). `generation` is a
53/// PER-LANGUAGE impl detail (D24), NOT part of the protocol/IDL Handle — it is
54/// wire-meaningless and is dropped at the wire boundary (a wired handle is
55/// re-resolved against the remote pool). User-approved Handle-widening 2026-05-29
56/// (see `CLEAN-SLATE.md`). A node is NOT a handle; a handle is inert routing data.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub struct Handle {
59    /// `pool_id` field for pool id.
60    pub pool_id: u32,
61    /// `handle_id` field for handle id.
62    pub handle_id: u32,
63    /// Local slotmap generation (B32, per-language) — NOT in the protocol IDL.
64    pub generation: u32,
65}
66
67/// The cross-language "top" error type — error is **unknown** (D31).
68///
69/// `Node<T>` carries a single value generic; the error channel is untyped to
70/// avoid a type-combinatorial explosion. The cross-language analogue is
71/// `Box<dyn Error>` / `Exception`. Single-thread (D22) ⇒ no `Send + Sync` bound.
72pub type GraphError = Box<dyn std::error::Error + 'static>;
73
74/// Explicit pull demand payload (D269/D272).
75///
76/// Params are holder-visible context for the pullId-holder invocation. They are
77/// never DATA-up and never become a second dep-value input channel.
78#[derive(Clone)]
79pub struct PullDemand {
80    /// `pull_id` field for pull id.
81    pub pull_id: LockId,
82    /// `params` field for params.
83    pub params: Option<AnyValue>,
84}
85
86impl PullDemand {
87    #[must_use]
88    /// Creates or computes `new`.
89    pub fn new(pull_id: impl Into<LockId>) -> Self {
90        Self {
91            pull_id: pull_id.into(),
92            params: None,
93        }
94    }
95
96    #[must_use]
97    /// Creates or computes `with_params`.
98    pub fn with_params<T: 'static>(pull_id: impl Into<LockId>, params: T) -> Self {
99        Self {
100            pull_id: pull_id.into(),
101            params: Some(Rc::new(params)),
102        }
103    }
104
105    #[must_use]
106    /// Updates or reads `params`.
107    pub fn params<T: 'static>(&self) -> Option<Rc<T>> {
108        self.params.clone().and_then(|p| p.downcast::<T>().ok())
109    }
110}
111
112impl std::fmt::Debug for PullDemand {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("PullDemand")
115            .field("pull_id", &self.pull_id)
116            .field("params", &self.params.as_ref().map(|_| "<params>"))
117            .finish()
118    }
119}
120
121/// The 7-tier const table (D34, amends R-tier numbering).
122///
123/// Ordering encodes **priority + batch timing**: `immediate` (`< Value`) flows
124/// during the current wave; `batch-deferred` (`>= Value`) is held to the batch
125/// commit / wave boundary. PAUSE/RESUME sit *below* DIRTY (control before
126/// notification); PULL joins that existing control/demand tier (D269). `START`
127/// is the handshake type.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
129#[repr(u8)]
130pub enum Tier {
131    /// 0 — subscribe handshake (`START`).
132    Start = 0,
133    /// 1 — control/demand (`PAUSE` / `RESUME` / `PULL`).
134    Control = 1,
135    /// 2 — notification (`DIRTY`).
136    Notification = 2,
137    /// 3 — value (`DATA` / `RESOLVED`).
138    Value = 3,
139    /// 4 — settle (`INVALIDATE`).
140    Settle = 4,
141    /// 5 — terminal (`COMPLETE` / `ERROR`).
142    Terminal = 5,
143    /// 6 — teardown (`TEARDOWN`).
144    Teardown = 6,
145}
146
147impl Tier {
148    #[inline]
149    /// Updates or reads `as_u8`.
150    pub fn as_u8(self) -> u8 {
151        self as u8
152    }
153
154    /// Immediate tiers (`< Value`) propagate during the current wave (D34).
155    #[inline]
156    pub fn is_immediate(self) -> bool {
157        (self as u8) < (Tier::Value as u8)
158    }
159
160    /// Batch-deferred tiers (`>= Value`) are held to the wave/batch boundary (D34).
161    #[inline]
162    pub fn is_batch_deferred(self) -> bool {
163        !self.is_immediate()
164    }
165
166    /// Pause buffering holds the settle slice: value-tier DATA/RESOLVED plus INVALIDATE.
167    #[inline]
168    pub fn is_pause_buffered(self) -> bool {
169        matches!(self, Tier::Value | Tier::Settle)
170    }
171}
172
173/// One protocol message. A `Vec<Message<T>>` ([`Wave`]) is one wave (D8) and may
174/// mix tiers. The closed set is 11 kinds (D9 + D269 + the `Start` handshake);
175/// adding a kind is a constitutional change (`/spec-amend`).
176///
177/// `Debug` is hand-written to print only the **kind tag** (not the payload), so
178/// `Message<AnyValue>` — whose payload `Rc<dyn Any>` is not `Debug` — stays
179/// assertable: tests compare wave *shapes* like `["DIRTY", "DATA"]`.
180pub enum Message<T> {
181    /// Subscribe handshake (substrate-internal, not a user `ctx.up` kind).
182    Start,
183    /// Acquire a pause lock (control, up-allowed).
184    Pause(LockId),
185    /// Release a pause lock (control, up-allowed).
186    Resume(LockId),
187    /// Demand one pull delivery from the matching pullId holder (control/demand,
188    /// up-allowed). `RESUME` is pause-lock release only (D269).
189    Pull(PullDemand),
190    /// Dirty notification — phase 1 of the two-phase wave (notification, up-allowed).
191    Dirty,
192    /// A real value (value tier, **down-only**). Absence-of-DATA is the SENTINEL
193    /// (`None` per D16), represented at the node's per-dep cache, not as a message.
194    Data(T),
195    /// Settle with no value change (value tier, **down-only**).
196    Resolved,
197    /// Invalidate-request — cache-drop, fire `onInvalidate`, cascade (settle,
198    /// up-allowed: a depless source honors it at the terminus per D38/R-up-at-source).
199    Invalidate,
200    /// Terminal success (**down-only**).
201    Complete,
202    /// Terminal failure carrying the untyped error (**down-only**, D31).
203    Error(GraphError),
204    /// Teardown (up-allowed; a depless source drops it per D38).
205    Teardown,
206}
207
208impl<T> Message<T> {
209    /// The tier of this message per the D34 const table.
210    pub fn tier(&self) -> Tier {
211        match self {
212            Message::Start => Tier::Start,
213            Message::Pause(_) | Message::Resume(_) | Message::Pull(_) => Tier::Control,
214            Message::Dirty => Tier::Notification,
215            Message::Data(_) | Message::Resolved => Tier::Value,
216            Message::Invalidate => Tier::Settle,
217            Message::Complete | Message::Error(_) => Tier::Terminal,
218            Message::Teardown => Tier::Teardown,
219        }
220    }
221
222    /// Whether this kind may travel **upstream** via `ctx.up` (R-ctx-up).
223    ///
224    /// Control/demand-tier only: `DIRTY` / `PAUSE` / `RESUME` / `PULL` /
225    /// `INVALIDATE` / `TEARDOWN`.
226    /// `DATA` / `RESOLVED` / `COMPLETE` / `ERROR` are down-only. `START` is a
227    /// substrate handshake, not a user `ctx.up` kind.
228    pub fn is_up_allowed(&self) -> bool {
229        matches!(
230            self,
231            Message::Dirty
232                | Message::Pause(_)
233                | Message::Resume(_)
234                | Message::Pull(_)
235                | Message::Invalidate
236                | Message::Teardown
237        )
238    }
239}
240
241impl<T> std::fmt::Debug for Message<T> {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.write_str(match self {
244            Message::Start => "START",
245            Message::Pause(_) => "PAUSE",
246            Message::Resume(_) => "RESUME",
247            Message::Pull(_) => "PULL",
248            Message::Dirty => "DIRTY",
249            Message::Data(_) => "DATA",
250            Message::Resolved => "RESOLVED",
251            Message::Invalidate => "INVALIDATE",
252            Message::Complete => "COMPLETE",
253            Message::Error(_) => "ERROR",
254            Message::Teardown => "TEARDOWN",
255        })
256    }
257}
258
259/// One wave — a single `msgs` array, may mix tiers (D8).
260pub type Wave<T> = Vec<Message<T>>;
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // Pin the D34 tier table: ordering + the immediate/batch-deferred cut at Value.
267    #[test]
268    fn tier_table_numbering_and_cut() {
269        assert_eq!(Tier::Start.as_u8(), 0);
270        assert_eq!(Tier::Control.as_u8(), 1);
271        assert_eq!(Tier::Notification.as_u8(), 2);
272        assert_eq!(Tier::Value.as_u8(), 3);
273        assert_eq!(Tier::Settle.as_u8(), 4);
274        assert_eq!(Tier::Terminal.as_u8(), 5);
275        assert_eq!(Tier::Teardown.as_u8(), 6);
276
277        // immediate < Value; batch-deferred >= Value.
278        for t in [Tier::Start, Tier::Control, Tier::Notification] {
279            assert!(t.is_immediate(), "{t:?} should be immediate");
280        }
281        for t in [Tier::Value, Tier::Settle, Tier::Terminal, Tier::Teardown] {
282            assert!(t.is_batch_deferred(), "{t:?} should be batch-deferred");
283        }
284    }
285
286    #[test]
287    fn message_tier_mapping() {
288        assert_eq!(Message::<i32>::Dirty.tier(), Tier::Notification);
289        assert_eq!(Message::Data(1).tier(), Tier::Value);
290        assert_eq!(Message::<i32>::Resolved.tier(), Tier::Value);
291        assert_eq!(
292            Message::<i32>::Pause(LockId::new("a")).tier(),
293            Tier::Control
294        );
295        assert_eq!(
296            Message::<i32>::Pull(PullDemand::new("p")).tier(),
297            Tier::Control
298        );
299        assert_eq!(Message::<i32>::Teardown.tier(), Tier::Teardown);
300    }
301
302    // R-ctx-up: control-tier kinds are up-allowed; value/terminal are down-only.
303    #[test]
304    fn up_allowed_is_control_tier_only() {
305        assert!(Message::<i32>::Dirty.is_up_allowed());
306        assert!(Message::<i32>::Pause(LockId::new("l")).is_up_allowed());
307        assert!(Message::<i32>::Resume(LockId::new("l")).is_up_allowed());
308        assert!(Message::<i32>::Pull(PullDemand::new("p")).is_up_allowed());
309        assert!(Message::<i32>::Invalidate.is_up_allowed());
310        assert!(Message::<i32>::Teardown.is_up_allowed());
311
312        assert!(!Message::Data(1).is_up_allowed());
313        assert!(!Message::<i32>::Resolved.is_up_allowed());
314        assert!(!Message::<i32>::Complete.is_up_allowed());
315        assert!(!Message::<i32>::Start.is_up_allowed());
316    }
317}