Skip to main content

telltale_vm/
session.rs

1//! Session lifecycle and store.
2//!
3//! Matches the Lean `SessionState`, `SessionStore` from `runtime.md §7`.
4//! Local type state lives here — the session store is the single source
5//! of truth for per-endpoint type advancement.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value as JsonValue;
11use telltale_types::{LocalTypeR, ValType};
12
13use crate::buffer::{BoundedBuffer, BufferConfig, SignedBuffer, SignedValue};
14use crate::coroutine::Value;
15use crate::instr::Endpoint;
16use crate::verification::{
17    signValue, signing_key_for_endpoint, verifySignedValue, verifying_key_for_endpoint, AuthTree,
18    DefaultVerificationModel, Hash, HashTag, Signature, VerificationModel,
19};
20
21/// Session identifier. Each session gets a unique ID within the VM.
22pub type SessionId = usize;
23
24/// Handler identifier for edge-bound runtime dispatch.
25pub type HandlerId = String;
26
27/// Built-in fallback handler id used when no edge-specific binding exists.
28pub const DEFAULT_HANDLER_ID: &str = "default_handler";
29
30fn default_handler_id() -> HandlerId {
31    DEFAULT_HANDLER_ID.to_string()
32}
33
34/// Edge between two roles in a session (directed: sender → receiver).
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct Edge {
37    /// Session scope for this edge.
38    pub sid: SessionId,
39    /// Sender role name.
40    pub sender: String,
41    /// Receiver role name.
42    pub receiver: String,
43}
44
45impl Edge {
46    /// Construct a sid-qualified edge.
47    #[must_use]
48    pub fn new(sid: SessionId, sender: impl Into<String>, receiver: impl Into<String>) -> Self {
49        Self {
50            sid,
51            sender: sender.into(),
52            receiver: receiver.into(),
53        }
54    }
55}
56
57#[derive(Debug, Deserialize)]
58struct EdgeJson {
59    sid: Option<SessionId>,
60    sender: String,
61    receiver: String,
62}
63
64/// Decode an edge from JSON.
65///
66/// # Errors
67///
68/// Returns an error when fields are missing.
69pub fn decode_edge_json(
70    value: &JsonValue,
71    session_hint: Option<SessionId>,
72) -> Result<Edge, String> {
73    let raw: EdgeJson =
74        serde_json::from_value(value.clone()).map_err(|e| format!("invalid edge json: {e}"))?;
75
76    let sid = raw
77        .sid
78        .or(session_hint)
79        .ok_or_else(|| "missing sid in edge json".to_string())?;
80    Ok(Edge::new(sid, raw.sender, raw.receiver))
81}
82
83/// Session status.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub enum SessionStatus {
86    /// Session is active and processing messages.
87    Active,
88    /// Session is draining buffered messages before close.
89    Draining,
90    /// Session is closed normally.
91    Closed,
92    /// Session was cancelled.
93    Cancelled,
94    /// Session faulted.
95    Faulted {
96        /// Reason for the fault.
97        reason: String,
98    },
99}
100
101/// Per-endpoint type tracking: current state + original for unfolding.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct TypeEntry {
104    /// Current local type (advances with each completed instruction).
105    pub current: LocalTypeR,
106    /// Original local type (for unfolding recursive variables).
107    pub original: LocalTypeR,
108}
109
110/// State of a single session.
111///
112/// Stores per-endpoint local types (the type truth), message buffers,
113/// and lifecycle status. Matches Lean `SessionState`.
114#[derive(Debug, Serialize, Deserialize)]
115pub struct SessionState {
116    /// Session identifier.
117    pub sid: SessionId,
118    /// Role names in this session.
119    pub roles: Vec<String>,
120    /// Per-endpoint local type state. This IS the type truth.
121    ///
122    /// Matches Lean `localTypes : List (Endpoint × LocalType)`.
123    pub local_types: BTreeMap<Endpoint, TypeEntry>,
124    /// Message buffers keyed by directed edge.
125    pub buffers: BTreeMap<Edge, SignedBuffer<Signature>>,
126    /// Per-edge authenticated leaves for Merkle-auth tracking.
127    pub auth_leaves: BTreeMap<Edge, Vec<Hash>>,
128    /// Per-edge Merkle trees for incremental authenticated updates.
129    #[serde(default)]
130    pub auth_trees: BTreeMap<Edge, AuthTree>,
131    /// Per-edge Merkle roots for signed-buffer history.
132    pub auth_roots: BTreeMap<Edge, Hash>,
133    /// Optional handler binding per edge.
134    pub edge_handlers: BTreeMap<Edge, HandlerId>,
135    /// Session-wide fallback handler id.
136    #[serde(default = "default_handler_id")]
137    pub default_handler: HandlerId,
138    /// Coherence trace by edge.
139    pub edge_traces: BTreeMap<Edge, Vec<ValType>>,
140    /// Current status.
141    pub status: SessionStatus,
142    /// Epoch counter for draining.
143    pub epoch: usize,
144}
145
146impl SessionState {
147    fn update_auth_tree(&mut self, edge: &Edge, signed: &SignedValue<Signature>) {
148        let bytes = serde_json::to_vec(signed).unwrap_or_default();
149        let leaf = DefaultVerificationModel::hash(HashTag::MerkleLeaf, &bytes);
150        self.auth_leaves.entry(edge.clone()).or_default().push(leaf);
151        let tree = self
152            .auth_trees
153            .entry(edge.clone())
154            .or_insert_with(|| AuthTree::new(Vec::new()));
155        tree.append_leaf(leaf);
156        self.auth_roots.insert(edge.clone(), tree.root());
157    }
158
159    /// Send a signed value from one role to another.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if no buffer exists for the given edge.
164    pub fn send_signed(
165        &mut self,
166        from: &str,
167        to: &str,
168        signed: &SignedValue<Signature>,
169    ) -> Result<crate::buffer::EnqueueResult, String> {
170        let edge = Edge::new(self.sid, from, to);
171        let buf = self
172            .buffers
173            .get_mut(&edge)
174            .ok_or_else(|| format!("no buffer for edge {from} → {to}"))?;
175        let result = buf.enqueue(signed.clone());
176        if matches!(result, crate::buffer::EnqueueResult::Ok) {
177            self.update_auth_tree(&edge, signed);
178        }
179        Ok(result)
180    }
181
182    /// Send a value from one role to another.
183    ///
184    /// Returns the enqueue result from the buffer.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if no buffer exists for the given edge.
189    pub fn send(
190        &mut self,
191        from: &str,
192        to: &str,
193        val: Value,
194    ) -> Result<crate::buffer::EnqueueResult, String> {
195        let signer = signing_key_for_endpoint(&Endpoint {
196            sid: self.sid,
197            role: from.to_string(),
198        });
199        let signature = signValue(&val, &signer);
200        self.send_signed(
201            from,
202            to,
203            &SignedValue {
204                payload: val,
205                signature,
206            },
207        )
208    }
209
210    /// Receive a signed value destined for a role from a specific sender.
211    pub fn recv_signed(&mut self, from: &str, to: &str) -> Option<SignedValue<Signature>> {
212        let edge = Edge::new(self.sid, from, to);
213        self.buffers.get_mut(&edge).and_then(|buf| buf.dequeue())
214    }
215
216    /// Receive and verify a value destined for a role from a specific sender.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if signature verification fails.
221    pub fn recv_verified(&mut self, from: &str, to: &str) -> Result<Option<Value>, String> {
222        let sender = Endpoint {
223            sid: self.sid,
224            role: from.to_string(),
225        };
226        let verifying = verifying_key_for_endpoint(&sender);
227        let signed = self.recv_signed(from, to);
228        let Some(signed) = signed else {
229            return Ok(None);
230        };
231        if !verifySignedValue(&signed.payload, &signed.signature, &verifying) {
232            return Err(format!(
233                "signature verification failed on edge {from} -> {to}"
234            ));
235        }
236        Ok(Some(signed.payload))
237    }
238
239    /// Receive a value destined for a role from a specific sender.
240    pub fn recv(&mut self, from: &str, to: &str) -> Option<Value> {
241        self.recv_verified(from, to).ok().flatten()
242    }
243
244    /// Check if there is a message available on an edge.
245    #[must_use]
246    pub fn has_message(&self, from: &str, to: &str) -> bool {
247        let edge = Edge::new(self.sid, from, to);
248        self.buffers.get(&edge).is_some_and(|buf| !buf.is_empty())
249    }
250}
251
252/// Store of all sessions managed by the VM.
253///
254/// Provides type lookup/update methods that match the Lean
255/// `SessionStore.lookupType` / `SessionStore.updateType` pattern.
256#[derive(Debug, Default, Serialize, Deserialize)]
257pub struct SessionStore {
258    sessions: BTreeMap<SessionId, SessionState>,
259    next_id: SessionId,
260}
261
262impl SessionStore {
263    /// Create an empty session store.
264    #[must_use]
265    pub fn new() -> Self {
266        Self::default()
267    }
268
269    /// Open a new session with an externally supplied session id.
270    ///
271    /// Callers should source ids from `SessionStore::next_session_id()`.
272    pub fn open_with_sid(
273        &mut self,
274        sid: SessionId,
275        roles: Vec<String>,
276        buffer_config: &BufferConfig,
277        initial_types: &BTreeMap<String, LocalTypeR>,
278    ) -> SessionId {
279        // Build per-endpoint local types with initial unfolding.
280        let mut local_types = BTreeMap::new();
281        for role in &roles {
282            if let Some(lt) = initial_types.get(role) {
283                let ep = Endpoint {
284                    sid,
285                    role: role.clone(),
286                };
287                local_types.insert(
288                    ep,
289                    TypeEntry {
290                        current: unfold_mu(lt),
291                        original: lt.clone(),
292                    },
293                );
294            }
295        }
296
297        // Create buffers for each directed edge.
298        let mut buffers = BTreeMap::new();
299        for from in &roles {
300            for to in &roles {
301                if from != to {
302                    let edge = Edge::new(sid, from.clone(), to.clone());
303                    buffers.insert(edge, BoundedBuffer::new(buffer_config));
304                }
305            }
306        }
307
308        let state = SessionState {
309            sid,
310            roles,
311            local_types,
312            buffers,
313            auth_leaves: BTreeMap::new(),
314            auth_trees: BTreeMap::new(),
315            auth_roots: BTreeMap::new(),
316            edge_handlers: BTreeMap::new(),
317            default_handler: default_handler_id(),
318            edge_traces: BTreeMap::new(),
319            status: SessionStatus::Active,
320            epoch: 0,
321        };
322
323        self.sessions.insert(sid, state);
324        self.next_id = self.next_id.max(sid.saturating_add(1));
325        sid
326    }
327
328    /// Open a new session with the given roles, buffer config, and initial local types.
329    ///
330    /// Returns the session ID. Endpoints are constructed as `Endpoint { sid, role }`.
331    pub fn open(
332        &mut self,
333        roles: Vec<String>,
334        buffer_config: &BufferConfig,
335        initial_types: &BTreeMap<String, LocalTypeR>,
336    ) -> SessionId {
337        let sid = self.next_id;
338        self.open_with_sid(sid, roles, buffer_config, initial_types)
339    }
340
341    /// Next session identifier that will be allocated by `open`.
342    #[must_use]
343    pub fn next_session_id(&self) -> SessionId {
344        self.next_id
345    }
346
347    // ---- Type state methods (match Lean SessionStore.lookupType / updateType) ----
348
349    /// Lookup the current local type for an endpoint.
350    ///
351    /// Matches Lean `SessionStore.lookupType`.
352    #[must_use]
353    pub fn lookup_type(&self, ep: &Endpoint) -> Option<&LocalTypeR> {
354        self.sessions
355            .get(&ep.sid)?
356            .local_types
357            .get(ep)
358            .map(|e| &e.current)
359    }
360
361    /// Update the local type for an endpoint (type advancement on commit).
362    ///
363    /// Matches Lean `SessionStore.updateType`.
364    pub fn update_type(&mut self, ep: &Endpoint, new_type: LocalTypeR) {
365        if let Some(session) = self.sessions.get_mut(&ep.sid) {
366            if let Some(entry) = session.local_types.get_mut(ep) {
367                entry.current = new_type;
368            }
369        }
370    }
371
372    /// Update the original type (when entering a new Mu scope).
373    pub fn update_original(&mut self, ep: &Endpoint, new_original: LocalTypeR) {
374        if let Some(session) = self.sessions.get_mut(&ep.sid) {
375            if let Some(entry) = session.local_types.get_mut(ep) {
376                entry.original = new_original;
377            }
378        }
379    }
380
381    /// Get the original type for recursive unfolding.
382    #[must_use]
383    pub fn original_type(&self, ep: &Endpoint) -> Option<&LocalTypeR> {
384        self.sessions
385            .get(&ep.sid)?
386            .local_types
387            .get(ep)
388            .map(|e| &e.original)
389    }
390
391    /// Remove type entry (on Halt/End — session endpoint completed).
392    pub fn remove_type(&mut self, ep: &Endpoint) {
393        if let Some(session) = self.sessions.get_mut(&ep.sid) {
394            session.local_types.remove(ep);
395        }
396    }
397
398    // ---- Session access methods ----
399
400    /// Get a reference to a session.
401    #[must_use]
402    pub fn get(&self, sid: SessionId) -> Option<&SessionState> {
403        self.sessions.get(&sid)
404    }
405
406    /// Get a mutable reference to a session.
407    pub fn get_mut(&mut self, sid: SessionId) -> Option<&mut SessionState> {
408        self.sessions.get_mut(&sid)
409    }
410
411    /// Iterate over all sessions.
412    pub fn iter(&self) -> impl Iterator<Item = &SessionState> {
413        self.sessions.values()
414    }
415
416    /// Close a session.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error if the session is not found.
421    pub fn close(&mut self, sid: SessionId) -> Result<(), String> {
422        let session = self
423            .sessions
424            .get_mut(&sid)
425            .ok_or_else(|| format!("session {sid} not found"))?;
426
427        session.status = SessionStatus::Closed;
428        session.buffers.clear();
429        session.edge_traces.clear();
430        session.epoch = session.epoch.saturating_add(1);
431        Ok(())
432    }
433
434    /// Number of active sessions.
435    #[must_use]
436    pub fn active_count(&self) -> usize {
437        self.sessions
438            .values()
439            .filter(|s| s.status == SessionStatus::Active)
440            .count()
441    }
442
443    /// All session IDs.
444    #[must_use]
445    pub fn session_ids(&self) -> Vec<SessionId> {
446        self.sessions.keys().copied().collect()
447    }
448
449    /// Lookup edge-bound handler id.
450    #[must_use]
451    pub fn lookup_handler(&self, edge: &Edge) -> Option<&HandlerId> {
452        self.sessions.get(&edge.sid)?.edge_handlers.get(edge)
453    }
454
455    /// Lookup a default handler id for a session.
456    #[must_use]
457    pub fn default_handler_for_session(&self, sid: SessionId) -> Option<&HandlerId> {
458        Some(&self.sessions.get(&sid)?.default_handler)
459    }
460
461    /// Set the default handler id for a session.
462    pub fn set_default_handler_for_session(&mut self, sid: SessionId, handler: HandlerId) {
463        if let Some(session) = self.sessions.get_mut(&sid) {
464            session.default_handler = handler;
465        }
466    }
467
468    /// Update edge-bound handler id.
469    pub fn update_handler(&mut self, edge: &Edge, handler: HandlerId) {
470        if let Some(session) = self.sessions.get_mut(&edge.sid) {
471            session.edge_handlers.insert(edge.clone(), handler);
472        }
473    }
474
475    /// Lookup coherence trace for an edge.
476    #[must_use]
477    pub fn lookup_trace(&self, edge: &Edge) -> Option<&[ValType]> {
478        self.sessions
479            .get(&edge.sid)?
480            .edge_traces
481            .get(edge)
482            .map(Vec::as_slice)
483    }
484
485    /// Update coherence trace for an edge.
486    pub fn update_trace(&mut self, edge: &Edge, trace: Vec<ValType>) {
487        if let Some(session) = self.sessions.get_mut(&edge.sid) {
488            session.edge_traces.insert(edge.clone(), trace);
489        }
490    }
491}
492
493// ---- Type unfolding utilities ----
494
495/// Unfold top-level `Mu` to its body.
496///
497/// Recursively strips `Mu` constructors to reach the first action.
498#[must_use]
499// RECURSION_SAFE: each step unwraps one Mu node from a finite local type tree.
500pub fn unfold_mu(lt: &LocalTypeR) -> LocalTypeR {
501    match lt {
502        LocalTypeR::Mu { body, .. } => unfold_mu(body),
503        other => other.clone(),
504    }
505}
506
507/// Resolve a continuation that may be a `Var` (recursive reference).
508///
509/// If `cont` is `Var`, unfolds back to the original type's mu body.
510/// If `cont` is `Mu`, unfolds it. Otherwise returns as-is.
511#[must_use]
512pub fn unfold_if_var(cont: &LocalTypeR, original: &LocalTypeR) -> LocalTypeR {
513    match cont {
514        LocalTypeR::Var(_) => unfold_mu(original),
515        LocalTypeR::Mu { .. } => unfold_mu(cont),
516        other => other.clone(),
517    }
518}
519
520/// Like `unfold_if_var`, but also returns the new Mu scope (original) if one was entered.
521///
522/// When the continuation is a `Mu`, the Mu itself becomes the new original
523/// for subsequent `Var` resolution. Returns `(resolved_type, Some(mu))` when
524/// entering a new Mu scope, `(resolved_type, None)` otherwise.
525#[must_use]
526pub fn unfold_if_var_with_scope(
527    cont: &LocalTypeR,
528    original: &LocalTypeR,
529) -> (LocalTypeR, Option<LocalTypeR>) {
530    match cont {
531        LocalTypeR::Var(_) => (unfold_mu(original), None),
532        LocalTypeR::Mu { .. } => (unfold_mu(cont), Some(cont.clone())),
533        other => (other.clone(), None),
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use serde_json::json;
541    use telltale_types::Label;
542
543    fn default_types() -> BTreeMap<String, LocalTypeR> {
544        let mut m = BTreeMap::new();
545        m.insert(
546            "A".to_string(),
547            LocalTypeR::mu(
548                "step",
549                LocalTypeR::Send {
550                    partner: "B".into(),
551                    branches: vec![(Label::new("msg"), None, LocalTypeR::var("step"))],
552                },
553            ),
554        );
555        m.insert(
556            "B".to_string(),
557            LocalTypeR::mu(
558                "step",
559                LocalTypeR::Recv {
560                    partner: "A".into(),
561                    branches: vec![(Label::new("msg"), None, LocalTypeR::var("step"))],
562                },
563            ),
564        );
565        m
566    }
567
568    #[test]
569    fn test_session_open_with_types() {
570        let mut store = SessionStore::new();
571        let types = default_types();
572        let sid = store.open(
573            vec!["A".into(), "B".into()],
574            &BufferConfig::default(),
575            &types,
576        );
577
578        let ep_a = Endpoint {
579            sid,
580            role: "A".into(),
581        };
582        let ep_b = Endpoint {
583            sid,
584            role: "B".into(),
585        };
586
587        // Types should be unfolded (mu stripped).
588        assert!(matches!(
589            store.lookup_type(&ep_a),
590            Some(LocalTypeR::Send { .. })
591        ));
592        assert!(matches!(
593            store.lookup_type(&ep_b),
594            Some(LocalTypeR::Recv { .. })
595        ));
596    }
597
598    #[test]
599    fn test_type_advance_and_unfold() {
600        let mut store = SessionStore::new();
601        let types = default_types();
602        let sid = store.open(
603            vec!["A".into(), "B".into()],
604            &BufferConfig::default(),
605            &types,
606        );
607
608        let ep_a = Endpoint {
609            sid,
610            role: "A".into(),
611        };
612
613        // Get current type: Send { ... Var("step") }
614        let lt = store.lookup_type(&ep_a).unwrap().clone();
615        let (_, _vt, continuation) = match &lt {
616            LocalTypeR::Send { branches, .. } => branches.first().unwrap().clone(),
617            _ => panic!("expected Send"),
618        };
619
620        // Continuation is Var("step") — resolve it.
621        let original = store.original_type(&ep_a).unwrap();
622        let resolved = unfold_if_var(&continuation, original);
623        assert!(matches!(resolved, LocalTypeR::Send { .. }));
624
625        // Advance type.
626        store.update_type(&ep_a, resolved);
627        assert!(matches!(
628            store.lookup_type(&ep_a),
629            Some(LocalTypeR::Send { .. })
630        ));
631    }
632
633    #[test]
634    fn test_session_send_recv() {
635        let mut store = SessionStore::new();
636        let sid = store.open(
637            vec!["A".into(), "B".into()],
638            &BufferConfig::default(),
639            &BTreeMap::new(),
640        );
641
642        let session = store.get_mut(sid).unwrap();
643        session.send("A", "B", Value::Nat(42)).unwrap();
644        assert!(session.has_message("A", "B"));
645        assert!(!session.has_message("B", "A"));
646
647        let val = session.recv("A", "B");
648        assert_eq!(val, Some(Value::Nat(42)));
649    }
650
651    #[test]
652    fn test_close_clears_buffers_and_traces_even_when_messages_pending() {
653        let mut store = SessionStore::new();
654        let sid = store.open(
655            vec!["A".into(), "B".into()],
656            &BufferConfig::default(),
657            &BTreeMap::new(),
658        );
659        let edge = Edge::new(sid, "A", "B");
660        store
661            .get_mut(sid)
662            .expect("session exists")
663            .send("A", "B", Value::Nat(7))
664            .expect("enqueue pending message");
665        store.update_trace(&edge, vec![ValType::Nat]);
666
667        store.close(sid).expect("close session");
668        let session = store.get(sid).expect("session exists after close");
669        assert_eq!(session.status, SessionStatus::Closed);
670        assert!(session.buffers.is_empty());
671        assert!(session.edge_traces.is_empty());
672    }
673
674    #[test]
675    fn test_namespace_isolation() {
676        let mut store = SessionStore::new();
677        let sid1 = store.open(
678            vec!["A".into(), "B".into()],
679            &BufferConfig::default(),
680            &BTreeMap::new(),
681        );
682        let sid2 = store.open(
683            vec!["A".into(), "B".into()],
684            &BufferConfig::default(),
685            &BTreeMap::new(),
686        );
687
688        assert_ne!(sid1, sid2);
689
690        store
691            .get_mut(sid1)
692            .unwrap()
693            .send("A", "B", Value::Nat(1))
694            .unwrap();
695        assert!(!store.get(sid2).unwrap().has_message("A", "B"));
696    }
697
698    #[test]
699    fn test_remove_type() {
700        let mut store = SessionStore::new();
701        let types = default_types();
702        let sid = store.open(
703            vec!["A".into(), "B".into()],
704            &BufferConfig::default(),
705            &types,
706        );
707
708        let ep_a = Endpoint {
709            sid,
710            role: "A".into(),
711        };
712        assert!(store.lookup_type(&ep_a).is_some());
713
714        store.remove_type(&ep_a);
715        assert!(store.lookup_type(&ep_a).is_none());
716    }
717
718    #[test]
719    fn test_cross_session_role_name_edge_collision_regression() {
720        let mut store = SessionStore::new();
721        let sid1 = store.open(
722            vec!["A".into(), "B".into()],
723            &BufferConfig::default(),
724            &BTreeMap::new(),
725        );
726        let sid2 = store.open(
727            vec!["A".into(), "B".into()],
728            &BufferConfig::default(),
729            &BTreeMap::new(),
730        );
731
732        let e1 = Edge::new(sid1, "A", "B");
733        let e2 = Edge::new(sid2, "A", "B");
734        assert_ne!(e1, e2, "edges from distinct sessions must not collide");
735        assert!(store
736            .get(sid1)
737            .expect("sid1 exists")
738            .buffers
739            .contains_key(&e1));
740        assert!(store
741            .get(sid2)
742            .expect("sid2 exists")
743            .buffers
744            .contains_key(&e2));
745    }
746
747    #[test]
748    fn test_edge_handler_and_trace_bindings() {
749        let mut store = SessionStore::new();
750        let sid = store.open(
751            vec!["A".into(), "B".into()],
752            &BufferConfig::default(),
753            &BTreeMap::new(),
754        );
755        let edge = Edge::new(sid, "A", "B");
756
757        assert!(store.lookup_handler(&edge).is_none());
758        store.update_handler(&edge, "handler/send".to_string());
759        assert_eq!(
760            store.lookup_handler(&edge).map(String::as_str),
761            Some("handler/send")
762        );
763
764        assert!(store.lookup_trace(&edge).is_none());
765        store.update_trace(&edge, vec![ValType::Nat]);
766        assert_eq!(store.lookup_trace(&edge), Some([ValType::Nat].as_slice()));
767    }
768
769    #[test]
770    fn test_decode_edge_json_requires_sid_sender_receiver() {
771        let sid_qualified = json!({
772            "sid": 7,
773            "sender": "A",
774            "receiver": "B"
775        });
776        let e = decode_edge_json(&sid_qualified, None).expect("decode sid-qualified edge");
777        assert_eq!(e, Edge::new(7, "A", "B"));
778
779        let no_sid = json!({
780            "sender": "A",
781            "receiver": "B"
782        });
783        let e2 = decode_edge_json(&no_sid, Some(11)).expect("decode edge with sid hint");
784        assert_eq!(e2, Edge::new(11, "A", "B"));
785
786        let legacy = json!({
787            "from": "A",
788            "to": "B",
789            "sid": 11
790        });
791        let err = decode_edge_json(&legacy, None).expect_err("legacy edge shape must be rejected");
792        assert!(err.contains("invalid edge json"), "unexpected error: {err}");
793    }
794}