Skip to main content

mur_common/skill/
note.rs

1//! Shared construction for `Category::Note` skills — ONE manifest literal
2//! instead of three drifting copies (`mur notes create`, the runtime's
3//! `remember` tool, and the TUI `/remember` command all build the same note).
4
5use chrono::Utc;
6
7use crate::skill::lifecycle::NoteKind;
8use crate::skill::manifest::{Content, SkillManifest, Visibility};
9use crate::skill::types::{Category, Priority};
10
11/// Inputs that vary between note authors; everything else is fixed note shape.
12pub struct NoteSpec<'a> {
13    pub name: &'a str,
14    /// One-line summary (also becomes `content.abstract`).
15    pub description: &'a str,
16    /// Markdown body (`content.note`).
17    pub body: &'a str,
18    pub kind: NoteKind,
19    /// Author identity, e.g. `human:local` or `agent:<name>`.
20    pub publisher: &'a str,
21}
22
23/// Build the canonical note manifest. Callers still run
24/// `crate::skill::validate` and choose WHERE to write (global vs agent-local)
25/// — scope is the caller's decision, shape is not.
26pub fn note_manifest(spec: &NoteSpec<'_>) -> SkillManifest {
27    SkillManifest {
28        name: spec.name.to_string(),
29        version: "1.0.0".into(),
30        publisher: spec.publisher.to_string(),
31        description: spec.description.to_string(),
32        category: Category::Note,
33        hosts: vec![],
34        scope: Default::default(),
35        visibility: Visibility::default(),
36        origin: None,
37        origin_version: None,
38        origin_hash: None,
39        fleet: None,
40        team: None,
41        governance: None,
42        project: None,
43        content: Content {
44            r#abstract: spec.description.to_string(),
45            context: None,
46            procedure: None,
47            command: None,
48            note: Some(spec.body.to_string()),
49        },
50        requires: vec![],
51        // Kind lives in the tags: a `rule` tag marks a rule; a plain note is a
52        // fact. `lifecycle::note_kind()` is the single reader.
53        tags: match spec.kind {
54            NoteKind::Rule => vec!["rule".into()],
55            NoteKind::Fact => vec![],
56        },
57        triggers: vec![],
58        priority: Priority::Normal,
59        evolution_log: vec![],
60        transfer_chain: vec![],
61        mcp_requirements: vec![],
62        provenance: Default::default(),
63        updated_at: Utc::now(),
64        requires_programs: vec![],
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn note_manifest_validates_and_roundtrips_kind() {
74        for (kind, expect) in [
75            (NoteKind::Rule, Some(NoteKind::Rule)),
76            (NoteKind::Fact, Some(NoteKind::Fact)),
77        ] {
78            let m = note_manifest(&NoteSpec {
79                name: "t-note",
80                description: "d",
81                body: "b",
82                kind,
83                publisher: "human:t",
84            });
85            crate::skill::validate(&m).expect("canonical note must validate");
86            assert_eq!(crate::skill::lifecycle::note_kind(&m), expect);
87        }
88    }
89}
90
91// ── Memory proposals (federation P2c) ────────────────────────────────────
92// The central-curation leg: an agent that remembers something ALSO proposes
93// it for review. The proposal is a file drop under the inbox (the runtime's
94// one granted central-store write surface); `mur session out` reviews it and
95// only an accepted proposal becomes a GLOBAL note — "visibility follows
96// scope, propagation follows maturity" means nothing an agent inferred
97// reaches other agents without either usage-earned maturity or this human
98// gate.
99
100use serde::{Deserialize, Serialize};
101use std::path::{Path, PathBuf};
102
103/// Proposal drop directory, relative to the MUR home.
104pub const MEMORY_PROPOSAL_DIR: &str = "inbox/memory-proposals";
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct MemoryProposal {
108    /// Canonical name of the agent that captured the memory.
109    pub agent: String,
110    pub proposed_at: chrono::DateTime<Utc>,
111    /// The full note manifest — same shape the agent wrote locally.
112    pub manifest: SkillManifest,
113    /// Multibase (Base58Btc) Ed25519 signature over [`proposal_sign_input`]
114    /// (P2c-2; v3d precedent — sign-input excludes `sig`). `None` = legacy
115    /// unsigned proposal, tolerated on review unless `MUR_SIGNAL_REQUIRE_SIG`.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub sig: Option<String>,
118    /// Key-rotation version; 0 = initial identity key.
119    #[serde(default, skip_serializing_if = "crate::signal::is_zero")]
120    pub key_version: u32,
121}
122
123/// Canonicalization version — bump if the sign-input shape changes.
124pub const PROPOSAL_SIG_INPUT_VERSION: u32 = 1;
125
126/// Canonical signed bytes: `sig` excluded; `serde_json` sorts object keys so
127/// the encoding is deterministic. The `domain` tag prevents cross-context
128/// signature reuse.
129fn proposal_sign_input(p: &MemoryProposal) -> Vec<u8> {
130    let canon = serde_json::json!({
131        "domain": "mur-memory-proposal",
132        "v": PROPOSAL_SIG_INPUT_VERSION,
133        "agent": p.agent,
134        "proposed_at": p.proposed_at,
135        "manifest": p.manifest,
136        "key_version": p.key_version,
137    });
138    serde_json::to_vec(&canon).unwrap_or_default()
139}
140
141impl MemoryProposal {
142    /// Sign this proposal in place with the proposing agent's identity key.
143    pub fn sign(&mut self, identity: &crate::identity::AgentIdentity) {
144        self.sig = Some(identity.sign_multibase(&proposal_sign_input(self)));
145    }
146
147    /// Fail-closed signature check against `pubkey`; unsigned never verifies.
148    pub fn verify(&self, pubkey: &[u8; 32]) -> bool {
149        match &self.sig {
150            Some(sig) => crate::identity::verify_bytes(pubkey, &proposal_sign_input(self), sig),
151            None => false,
152        }
153    }
154}
155
156/// Atomically drop `proposal` into the inbox. Returns the written path.
157/// Deterministic name (`<agent>-<note>`): re-remembering the same note
158/// replaces the pending proposal instead of stacking duplicates.
159pub fn write_memory_proposal(
160    mur_home: &Path,
161    proposal: &MemoryProposal,
162) -> std::io::Result<PathBuf> {
163    let dir = mur_home.join(MEMORY_PROPOSAL_DIR);
164    std::fs::create_dir_all(&dir)?;
165    let fname = format!("{}-{}.yaml", proposal.agent, proposal.manifest.name);
166    let dest = dir.join(&fname);
167    let tmp = dir.join(format!(".{fname}.tmp"));
168    let yaml = serde_yaml_ng::to_string(proposal)
169        .map_err(|e| std::io::Error::other(format!("serialize proposal: {e}")))?;
170    std::fs::write(&tmp, yaml)?;
171    std::fs::rename(&tmp, &dest)?;
172    Ok(dest)
173}
174
175#[cfg(test)]
176mod proposal_sig_tests {
177    use super::*;
178    use crate::identity::AgentIdentity;
179    use crate::skill::lifecycle::NoteKind;
180
181    fn proposal(agent: &str) -> MemoryProposal {
182        MemoryProposal {
183            agent: agent.into(),
184            proposed_at: Utc::now(),
185            manifest: note_manifest(&NoteSpec {
186                name: "reply-zh",
187                description: "reply language",
188                body: "always zh-TW",
189                kind: NoteKind::Rule,
190                publisher: &format!("agent:{agent}"),
191            }),
192            sig: None,
193            key_version: 0,
194        }
195    }
196
197    #[test]
198    fn sign_verify_roundtrip_survives_yaml() {
199        let id = AgentIdentity::generate();
200        let mut p = proposal("w1");
201        assert!(
202            !p.verify(&id.verifying_key_bytes()),
203            "unsigned never verifies"
204        );
205        p.sign(&id);
206        assert!(p.verify(&id.verifying_key_bytes()));
207
208        let yaml = serde_yaml_ng::to_string(&p).unwrap();
209        let back: MemoryProposal = serde_yaml_ng::from_str(&yaml).unwrap();
210        assert!(back.verify(&id.verifying_key_bytes()));
211    }
212
213    #[test]
214    fn tampered_manifest_or_agent_fails_verification() {
215        let id = AgentIdentity::generate();
216        let mut p = proposal("w1");
217        p.sign(&id);
218
219        let mut swapped = p.clone();
220        swapped.agent = "w2".into(); // impersonation
221        assert!(!swapped.verify(&id.verifying_key_bytes()));
222
223        let mut edited = p.clone();
224        edited.manifest.content.note = Some("always en-US".into()); // content swap
225        assert!(!edited.verify(&id.verifying_key_bytes()));
226    }
227
228    #[test]
229    fn legacy_unsigned_yaml_deserializes_with_defaults() {
230        let p = proposal("w1");
231        // Serialize WITHOUT sig (legacy P2c shape) — new fields absent on wire.
232        let yaml = serde_yaml_ng::to_string(&p).unwrap();
233        assert!(!yaml.contains("sig:"));
234        let back: MemoryProposal = serde_yaml_ng::from_str(&yaml).unwrap();
235        assert!(back.sig.is_none());
236        assert_eq!(back.key_version, 0);
237    }
238}