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}