1use chrono::Utc;
6
7use crate::skill::lifecycle::NoteKind;
8use crate::skill::manifest::{Content, SkillManifest, Visibility};
9use crate::skill::types::{Category, Priority};
10
11pub struct NoteSpec<'a> {
13 pub name: &'a str,
14 pub description: &'a str,
16 pub body: &'a str,
18 pub kind: NoteKind,
19 pub publisher: &'a str,
21}
22
23pub 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 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
91use serde::{Deserialize, Serialize};
101use std::path::{Path, PathBuf};
102
103pub const MEMORY_PROPOSAL_DIR: &str = "inbox/memory-proposals";
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct MemoryProposal {
108 pub agent: String,
110 pub proposed_at: chrono::DateTime<Utc>,
111 pub manifest: SkillManifest,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub sig: Option<String>,
118 #[serde(default, skip_serializing_if = "crate::signal::is_zero")]
120 pub key_version: u32,
121}
122
123pub const PROPOSAL_SIG_INPUT_VERSION: u32 = 1;
125
126fn 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 pub fn sign(&mut self, identity: &crate::identity::AgentIdentity) {
144 self.sig = Some(identity.sign_multibase(&proposal_sign_input(self)));
145 }
146
147 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
156pub 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(); assert!(!swapped.verify(&id.verifying_key_bytes()));
222
223 let mut edited = p.clone();
224 edited.manifest.content.note = Some("always en-US".into()); 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 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}