Skip to main content

mur_common/
channel.rs

1//! Pure Channel types — no I/O; store logic lives in the `mur-channel` crate.
2//!
3//! Durable on-disk formats:
4//!   - `~/.mur/channels/<id>/events.jsonl` — append-only event log
5//!   - `~/.mur/channels/<id>/channel.yaml` — manifest (cached view of log state)
6//!
7//! # Schema versioning
8//!
9//! [`CHANNEL_SCHEMA_VERSION`] guards both the event log rows and the manifest.
10//! Bump it ONLY when:
11//!   1. A required field is renamed or removed, OR
12//!   2. A field's semantic meaning changes, OR
13//!   3. A new `EventKind` variant carries semantics that older readers must not
14//!      silently skip (readers skip unknown/unparseable lines for robustness, so
15//!      bump only when a silent skip would corrupt state rather than merely omit
16//!      optional data).
17//!
18//! Adding a new optional field with `#[serde(default)]` does NOT require a bump.
19//!
20//! ## Backward reads
21//! The event log must always remain fold-able from the beginning. When adding new
22//! optional fields, annotate them with `#[serde(default)]` so older rows written
23//! before the field existed still deserialize cleanly. Manifests follow the same
24//! rule: older `channel.yaml` files must load without error.
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29/// Schema version for the manifest + event log; breaking changes bump this.
30/// v2: `HitlResponse` events carry approval authority — a reader that silently
31/// skips one could re-apply a gated effect (v3c).
32pub const CHANNEL_SCHEMA_VERSION: u32 = 2;
33
34/// A2A v0.3 lifecycle vocabulary, serialized on the wire as kebab-case
35/// (`input-required`, `canceled`, etc.).
36///
37/// ## Spelling note
38/// `Canceled` (→ `"canceled"`) intentionally follows the A2A v0.3 spec spelling
39/// and is a DISTINCT type from [`crate::a2a::TaskState`], which spells the
40/// equivalent variant `Cancelled` (two l's). The two enums are bridged by an
41/// explicit boundary mapping — not string equality — so the spelling difference
42/// is deliberate, not a bug.
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "kebab-case")]
45pub enum ChannelState {
46    Submitted,
47    Working,
48    InputRequired,
49    Completed,
50    Failed,
51    Canceled,
52    Rejected,
53    /// MUR extension (not in A2A v0.3): the channel has had no activity for an
54    /// extended period and was system-marked stale.
55    Stale,
56}
57
58/// Who produced an event / is a participant. Named `ChannelActor` to avoid
59/// colliding with the pre-existing `mur_common::actor::Actor`.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(tag = "kind", rename_all = "kebab-case")]
62pub enum ChannelActor {
63    Human { name: String },
64    Agent { id: String },
65    System,
66}
67
68impl ChannelActor {
69    /// The local human owner, from `$USER`/`$USERNAME`, falling back to `you`.
70    pub fn local_human() -> Self {
71        let name = std::env::var("USER")
72            .or_else(|_| std::env::var("USERNAME"))
73            .ok()
74            .filter(|s| !s.is_empty())
75            .unwrap_or_else(|| "you".to_string());
76        ChannelActor::Human { name }
77    }
78}
79
80/// The role a participant plays within a channel's lifecycle.
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "lowercase")]
83pub enum ParticipantRole {
84    Owner,
85    Router,
86    Delegate,
87    Observer,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Participant {
92    pub actor: ChannelActor,
93    pub role: ParticipantRole,
94    pub joined_at: DateTime<Utc>,
95}
96
97/// The intent a channel is working toward, with optional acceptance criteria.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct Goal {
100    #[serde(default)]
101    pub statement: String,
102    #[serde(default)]
103    pub acceptance_criteria: Vec<String>,
104}
105
106/// The durable manifest (a cache of state derivable from the event log).
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Channel {
109    pub v: u32,
110    pub id: String,
111    pub title: String,
112    #[serde(default)]
113    pub goal: Goal,
114    pub state: ChannelState,
115    pub owner: ChannelActor,
116    #[serde(default)]
117    pub participants: Vec<Participant>,
118    pub created_at: DateTime<Utc>,
119    pub updated_at: DateTime<Utc>,
120}
121
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "kebab-case")]
124pub enum EventKind {
125    Message,
126    Delegation,
127    Handoff,
128    ToolCall,
129    ToolResult,
130    StateChange,
131    Artifact,
132    HitlRequest,
133    HitlResponse,
134    Note,
135}
136
137/// One append-only line in `~/.mur/channels/<id>/events.jsonl`.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ChannelEvent {
140    pub seq: u64,
141    pub ts: DateTime<Utc>,
142    pub actor: ChannelActor,
143    pub kind: EventKind,
144    #[serde(default)]
145    pub payload: serde_json::Value,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub idempotency_key: Option<String>,
148    /// Detached Ed25519 signature (multibase) by the channel's WRITER over the
149    /// canonical sign-input `{v, channel_id, actor, kind, payload,
150    /// idempotency_key}` — EXCLUDING the store-assigned `seq`/`ts` (see
151    /// `mur-channel` `sign::sign_input`). `None` for legacy/unsigned events.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub sig: Option<String>,
154    /// RESERVED — key version for the signing key (v3d).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub key_version: Option<u32>,
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn channel_state_serializes_kebab() {
165        let j = serde_json::to_string(&ChannelState::InputRequired).unwrap();
166        assert_eq!(j, "\"input-required\"");
167    }
168
169    #[test]
170    fn event_round_trips() {
171        let ev = ChannelEvent {
172            seq: 3,
173            ts: Utc::now(),
174            actor: ChannelActor::Agent { id: "qa".into() },
175            kind: EventKind::Message,
176            payload: serde_json::json!({ "text": "hello", "task_id": "t-1" }),
177            idempotency_key: None,
178            sig: None,
179            key_version: None,
180        };
181        let line = serde_json::to_string(&ev).unwrap();
182        let back: ChannelEvent = serde_json::from_str(&line).unwrap();
183        assert_eq!(back.seq, 3);
184        assert_eq!(back.actor, ChannelActor::Agent { id: "qa".into() });
185        assert_eq!(back.payload["text"], "hello");
186    }
187
188    #[test]
189    fn system_actor_round_trips() {
190        let a = ChannelActor::System;
191        let j = serde_json::to_string(&a).unwrap();
192        assert_eq!(j, "{\"kind\":\"system\"}");
193        let back: ChannelActor = serde_json::from_str(&j).unwrap();
194        assert_eq!(back, ChannelActor::System);
195    }
196
197    #[test]
198    fn event_omits_sig_fields_when_absent_and_reads_old_rows() {
199        // New events with sig=None must omit the field from JSON (no schema churn).
200        let ev = ChannelEvent {
201            seq: 0,
202            ts: Utc::now(),
203            actor: ChannelActor::System,
204            kind: EventKind::Note,
205            payload: serde_json::json!({}),
206            idempotency_key: None,
207            sig: None,
208            key_version: None,
209        };
210        let line = serde_json::to_string(&ev).unwrap();
211        assert!(!line.contains("sig"), "sig must be omitted when None");
212        assert!(
213            !line.contains("key_version"),
214            "key_version must be omitted when None"
215        );
216
217        // Old rows without the fields must deserialize cleanly.
218        let old = r#"{"seq":0,"ts":"2026-06-16T00:00:00Z","actor":{"kind":"system"},"kind":"note","payload":{}}"#;
219        let back: ChannelEvent = serde_json::from_str(old).unwrap();
220        assert_eq!(back.sig, None);
221        assert_eq!(back.key_version, None);
222    }
223}