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/// Why a channel exists. Deliberately smaller than the ways a UI may render a
59/// channel: Direct vs Group is derived from participants, and Companion/HITL
60/// are event-derived states — none of them are purposes.
61///
62/// `Option<ChannelPurpose>` on `Channel`: `None` means "written before this
63/// field existed" and MUST NOT be treated as an explicit `Conversation`.
64/// Resolve it for display with `mur_channel::purpose::effective_purpose`;
65/// correct it on disk only with `mur channel backfill-purpose`.
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "kebab-case")]
68pub enum ChannelPurpose {
69    Conversation,
70    FleetRun,
71    WorkflowRun,
72}
73
74/// Who produced an event / is a participant. Named `ChannelActor` to avoid
75/// colliding with the pre-existing `mur_common::actor::Actor`.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(tag = "kind", rename_all = "kebab-case")]
78pub enum ChannelActor {
79    Human { name: String },
80    Agent { id: String },
81    System,
82}
83
84impl ChannelActor {
85    /// The local human owner, from `$USER`/`$USERNAME`, falling back to `you`.
86    pub fn local_human() -> Self {
87        let name = std::env::var("USER")
88            .or_else(|_| std::env::var("USERNAME"))
89            .ok()
90            .filter(|s| !s.is_empty())
91            .unwrap_or_else(|| "you".to_string());
92        ChannelActor::Human { name }
93    }
94}
95
96/// The role a participant plays within a channel's lifecycle.
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "lowercase")]
99pub enum ParticipantRole {
100    Owner,
101    Router,
102    Delegate,
103    Observer,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Participant {
108    pub actor: ChannelActor,
109    pub role: ParticipantRole,
110    pub joined_at: DateTime<Utc>,
111}
112
113/// The intent a channel is working toward, with optional acceptance criteria.
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct Goal {
116    #[serde(default)]
117    pub statement: String,
118    #[serde(default)]
119    pub acceptance_criteria: Vec<String>,
120}
121
122/// The durable manifest (a cache of state derivable from the event log).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Channel {
125    pub v: u32,
126    pub id: String,
127    pub title: String,
128    #[serde(default)]
129    pub goal: Goal,
130    pub state: ChannelState,
131    /// Why this channel exists. `None` = legacy manifest; see `ChannelPurpose`.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub purpose: Option<ChannelPurpose>,
134    pub owner: ChannelActor,
135    #[serde(default)]
136    pub participants: Vec<Participant>,
137    pub created_at: DateTime<Utc>,
138    pub updated_at: DateTime<Utc>,
139}
140
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "kebab-case")]
143pub enum EventKind {
144    Message,
145    Delegation,
146    Handoff,
147    ToolCall,
148    ToolResult,
149    StateChange,
150    Artifact,
151    HitlRequest,
152    HitlResponse,
153    Note,
154}
155
156/// One append-only line in `~/.mur/channels/<id>/events.jsonl`.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ChannelEvent {
159    pub seq: u64,
160    pub ts: DateTime<Utc>,
161    pub actor: ChannelActor,
162    pub kind: EventKind,
163    #[serde(default)]
164    pub payload: serde_json::Value,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub idempotency_key: Option<String>,
167    /// Detached Ed25519 signature (multibase) by the channel's WRITER over the
168    /// canonical sign-input `{v, channel_id, actor, kind, payload,
169    /// idempotency_key}` — EXCLUDING the store-assigned `seq`/`ts` (see
170    /// `mur-channel` `sign::sign_input`). `None` for legacy/unsigned events.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub sig: Option<String>,
173    /// RESERVED — key version for the signing key (v3d).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub key_version: Option<u32>,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn channel_state_serializes_kebab() {
184        let j = serde_json::to_string(&ChannelState::InputRequired).unwrap();
185        assert_eq!(j, "\"input-required\"");
186    }
187
188    #[test]
189    fn event_round_trips() {
190        let ev = ChannelEvent {
191            seq: 3,
192            ts: Utc::now(),
193            actor: ChannelActor::Agent { id: "qa".into() },
194            kind: EventKind::Message,
195            payload: serde_json::json!({ "text": "hello", "task_id": "t-1" }),
196            idempotency_key: None,
197            sig: None,
198            key_version: None,
199        };
200        let line = serde_json::to_string(&ev).unwrap();
201        let back: ChannelEvent = serde_json::from_str(&line).unwrap();
202        assert_eq!(back.seq, 3);
203        assert_eq!(back.actor, ChannelActor::Agent { id: "qa".into() });
204        assert_eq!(back.payload["text"], "hello");
205    }
206
207    #[test]
208    fn system_actor_round_trips() {
209        let a = ChannelActor::System;
210        let j = serde_json::to_string(&a).unwrap();
211        assert_eq!(j, "{\"kind\":\"system\"}");
212        let back: ChannelActor = serde_json::from_str(&j).unwrap();
213        assert_eq!(back, ChannelActor::System);
214    }
215
216    #[test]
217    fn event_omits_sig_fields_when_absent_and_reads_old_rows() {
218        // New events with sig=None must omit the field from JSON (no schema churn).
219        let ev = ChannelEvent {
220            seq: 0,
221            ts: Utc::now(),
222            actor: ChannelActor::System,
223            kind: EventKind::Note,
224            payload: serde_json::json!({}),
225            idempotency_key: None,
226            sig: None,
227            key_version: None,
228        };
229        let line = serde_json::to_string(&ev).unwrap();
230        assert!(!line.contains("sig"), "sig must be omitted when None");
231        assert!(
232            !line.contains("key_version"),
233            "key_version must be omitted when None"
234        );
235
236        // Old rows without the fields must deserialize cleanly.
237        let old = r#"{"seq":0,"ts":"2026-06-16T00:00:00Z","actor":{"kind":"system"},"kind":"note","payload":{}}"#;
238        let back: ChannelEvent = serde_json::from_str(old).unwrap();
239        assert_eq!(back.sig, None);
240        assert_eq!(back.key_version, None);
241    }
242
243    #[test]
244    fn legacy_manifest_without_purpose_deserializes_as_none() {
245        // A manifest written before this field existed. Must still load.
246        let json = r#"{
247            "v": 2,
248            "id": "019ed0af-5e38-7912-b554-dc335a8fc2db",
249            "title": "chat with mur",
250            "state": "working",
251            "owner": {"kind": "human", "name": "david"},
252            "participants": [],
253            "created_at": "2026-08-01T10:00:00Z",
254            "updated_at": "2026-08-01T10:00:00Z"
255        }"#;
256        let ch: Channel = serde_json::from_str(json).expect("legacy manifest must deserialize");
257        assert_eq!(
258            ch.purpose, None,
259            "absent purpose must be None, not a default"
260        );
261    }
262
263    #[test]
264    fn purpose_round_trips_in_kebab_case() {
265        let json = r#"{
266            "v": 2,
267            "id": "x",
268            "title": "t",
269            "state": "working",
270            "owner": {"kind": "system"},
271            "participants": [],
272            "purpose": "fleet-run",
273            "created_at": "2026-08-01T10:00:00Z",
274            "updated_at": "2026-08-01T10:00:00Z"
275        }"#;
276        let ch: Channel = serde_json::from_str(json).unwrap();
277        assert_eq!(ch.purpose, Some(ChannelPurpose::FleetRun));
278
279        let back = serde_json::to_string(&ch).unwrap();
280        assert!(back.contains(r#""purpose":"fleet-run""#), "got: {back}");
281    }
282
283    #[test]
284    fn purpose_is_omitted_when_none() {
285        let json = r#"{
286            "v": 2, "id": "x", "title": "t", "state": "working",
287            "owner": {"kind": "system"}, "participants": [],
288            "created_at": "2026-08-01T10:00:00Z",
289            "updated_at": "2026-08-01T10:00:00Z"
290        }"#;
291        let ch: Channel = serde_json::from_str(json).unwrap();
292        let back = serde_json::to_string(&ch).unwrap();
293        assert!(
294            !back.contains("purpose"),
295            "a None purpose must not be written back as null: {back}"
296        );
297    }
298}