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
68pub 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
132use serde::{Deserialize, Serialize};
142use std::path::{Path, PathBuf};
143
144pub const MEMORY_PROPOSAL_DIR: &str = "inbox/memory-proposals";
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct MemoryProposal {
149 pub agent: String,
151 pub proposed_at: chrono::DateTime<Utc>,
152 pub manifest: SkillManifest,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub sig: Option<String>,
159 #[serde(default, skip_serializing_if = "crate::signal::is_zero")]
161 pub key_version: u32,
162}
163
164pub const PROPOSAL_SIG_INPUT_VERSION: u32 = 1;
166
167fn 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 pub fn sign(&mut self, identity: &crate::identity::AgentIdentity) {
185 self.sig = Some(identity.sign_multibase(&proposal_sign_input(self)));
186 }
187
188 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
197pub 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(); assert!(!swapped.verify(&id.verifying_key_bytes()));
263
264 let mut edited = p.clone();
265 edited.manifest.content.note = Some("always en-US".into()); 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 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}