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/// Narrow a note to one project: `scope: Project` plus the project id that
69/// `scope_visible` matches against (the repo root, per
70/// [`crate::project::active_project_id`]). Both fields must move together —
71/// `Project` scope with no `project` is invisible everywhere — so no caller
72/// sets them by hand.
73pub fn scoped_to_project(mut manifest: SkillManifest, project_id: &str) -> SkillManifest {
74    manifest.scope = crate::skill::manifest::SkillScope::Project;
75    manifest.project = Some(project_id.to_string());
76    manifest
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn scoped_to_project_sets_scope_and_matches_only_that_project() {
85        let m = scoped_to_project(
86            note_manifest(&NoteSpec {
87                name: "repo-note",
88                description: "d",
89                body: "b",
90                kind: NoteKind::Fact,
91                publisher: "agent:w1",
92            }),
93            "/repos/alpha",
94        );
95        crate::skill::validate(&m).expect("project-scoped note must validate");
96        assert_eq!(m.scope, crate::skill::manifest::SkillScope::Project);
97
98        let visible = |active| {
99            crate::skill::manifest::scope_visible(
100                m.scope,
101                m.fleet.as_deref(),
102                m.project.as_deref(),
103                m.team.as_deref(),
104                None,
105                Some(active),
106                None,
107            )
108        };
109        assert!(visible("/repos/alpha"));
110        assert!(!visible("/repos/beta"));
111    }
112
113    #[test]
114    fn note_manifest_validates_and_roundtrips_kind() {
115        for (kind, expect) in [
116            (NoteKind::Rule, Some(NoteKind::Rule)),
117            (NoteKind::Fact, Some(NoteKind::Fact)),
118        ] {
119            let m = note_manifest(&NoteSpec {
120                name: "t-note",
121                description: "d",
122                body: "b",
123                kind,
124                publisher: "human:t",
125            });
126            crate::skill::validate(&m).expect("canonical note must validate");
127            assert_eq!(crate::skill::lifecycle::note_kind(&m), expect);
128        }
129    }
130}
131
132// ── Memory proposals (federation P2c) ────────────────────────────────────
133// The central-curation leg: an agent that remembers something ALSO proposes
134// it for review. The proposal is a file drop under the inbox (the runtime's
135// one granted central-store write surface); `mur session out` reviews it and
136// only an accepted proposal becomes a GLOBAL note — "visibility follows
137// scope, propagation follows maturity" means nothing an agent inferred
138// reaches other agents without either usage-earned maturity or this human
139// gate.
140
141use serde::{Deserialize, Serialize};
142use std::path::{Path, PathBuf};
143
144/// Proposal drop directory, relative to the MUR home.
145pub const MEMORY_PROPOSAL_DIR: &str = "inbox/memory-proposals";
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct MemoryProposal {
149    /// Canonical name of the agent that captured the memory.
150    pub agent: String,
151    pub proposed_at: chrono::DateTime<Utc>,
152    /// The full note manifest — same shape the agent wrote locally.
153    pub manifest: SkillManifest,
154    /// Multibase (Base58Btc) Ed25519 signature over [`proposal_sign_input`]
155    /// (P2c-2; v3d precedent — sign-input excludes `sig`). `None` = legacy
156    /// unsigned proposal, tolerated on review unless `MUR_SIGNAL_REQUIRE_SIG`.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub sig: Option<String>,
159    /// Key-rotation version; 0 = initial identity key.
160    #[serde(default, skip_serializing_if = "crate::signal::is_zero")]
161    pub key_version: u32,
162}
163
164/// Canonicalization version — bump if the sign-input shape changes.
165pub const PROPOSAL_SIG_INPUT_VERSION: u32 = 1;
166
167/// Canonical signed bytes: `sig` excluded; `serde_json` sorts object keys so
168/// the encoding is deterministic. The `domain` tag prevents cross-context
169/// signature reuse.
170fn proposal_sign_input(p: &MemoryProposal) -> Vec<u8> {
171    let canon = serde_json::json!({
172        "domain": "mur-memory-proposal",
173        "v": PROPOSAL_SIG_INPUT_VERSION,
174        "agent": p.agent,
175        "proposed_at": p.proposed_at,
176        "manifest": p.manifest,
177        "key_version": p.key_version,
178    });
179    serde_json::to_vec(&canon).unwrap_or_default()
180}
181
182impl MemoryProposal {
183    /// Sign this proposal in place with the proposing agent's identity key.
184    pub fn sign(&mut self, identity: &crate::identity::AgentIdentity) {
185        self.sig = Some(identity.sign_multibase(&proposal_sign_input(self)));
186    }
187
188    /// Fail-closed signature check against `pubkey`; unsigned never verifies.
189    pub fn verify(&self, pubkey: &[u8; 32]) -> bool {
190        match &self.sig {
191            Some(sig) => crate::identity::verify_bytes(pubkey, &proposal_sign_input(self), sig),
192            None => false,
193        }
194    }
195}
196
197/// Atomically drop `proposal` into the inbox. Returns the written path.
198/// Deterministic name (`<agent>-<note>`): re-remembering the same note
199/// replaces the pending proposal instead of stacking duplicates.
200pub fn write_memory_proposal(
201    mur_home: &Path,
202    proposal: &MemoryProposal,
203) -> std::io::Result<PathBuf> {
204    let dir = mur_home.join(MEMORY_PROPOSAL_DIR);
205    std::fs::create_dir_all(&dir)?;
206    let fname = format!("{}-{}.yaml", proposal.agent, proposal.manifest.name);
207    let dest = dir.join(&fname);
208    let tmp = dir.join(format!(".{fname}.tmp"));
209    let yaml = serde_yaml_ng::to_string(proposal)
210        .map_err(|e| std::io::Error::other(format!("serialize proposal: {e}")))?;
211    std::fs::write(&tmp, yaml)?;
212    std::fs::rename(&tmp, &dest)?;
213    Ok(dest)
214}
215
216#[cfg(test)]
217mod proposal_sig_tests {
218    use super::*;
219    use crate::identity::AgentIdentity;
220    use crate::skill::lifecycle::NoteKind;
221
222    fn proposal(agent: &str) -> MemoryProposal {
223        MemoryProposal {
224            agent: agent.into(),
225            proposed_at: Utc::now(),
226            manifest: note_manifest(&NoteSpec {
227                name: "reply-zh",
228                description: "reply language",
229                body: "always zh-TW",
230                kind: NoteKind::Rule,
231                publisher: &format!("agent:{agent}"),
232            }),
233            sig: None,
234            key_version: 0,
235        }
236    }
237
238    #[test]
239    fn sign_verify_roundtrip_survives_yaml() {
240        let id = AgentIdentity::generate();
241        let mut p = proposal("w1");
242        assert!(
243            !p.verify(&id.verifying_key_bytes()),
244            "unsigned never verifies"
245        );
246        p.sign(&id);
247        assert!(p.verify(&id.verifying_key_bytes()));
248
249        let yaml = serde_yaml_ng::to_string(&p).unwrap();
250        let back: MemoryProposal = serde_yaml_ng::from_str(&yaml).unwrap();
251        assert!(back.verify(&id.verifying_key_bytes()));
252    }
253
254    #[test]
255    fn tampered_manifest_or_agent_fails_verification() {
256        let id = AgentIdentity::generate();
257        let mut p = proposal("w1");
258        p.sign(&id);
259
260        let mut swapped = p.clone();
261        swapped.agent = "w2".into(); // impersonation
262        assert!(!swapped.verify(&id.verifying_key_bytes()));
263
264        let mut edited = p.clone();
265        edited.manifest.content.note = Some("always en-US".into()); // content swap
266        assert!(!edited.verify(&id.verifying_key_bytes()));
267    }
268
269    #[test]
270    fn legacy_unsigned_yaml_deserializes_with_defaults() {
271        let p = proposal("w1");
272        // Serialize WITHOUT sig (legacy P2c shape) — new fields absent on wire.
273        let yaml = serde_yaml_ng::to_string(&p).unwrap();
274        assert!(!yaml.contains("sig:"));
275        let back: MemoryProposal = serde_yaml_ng::from_str(&yaml).unwrap();
276        assert!(back.sig.is_none());
277        assert_eq!(back.key_version, 0);
278    }
279}