Skip to main content

spine_mechgen/
lib.rs

1//! MechGen ABL ↔ SPINE bridge — the **type-safe join**.
2//!
3//! MechGen's `MechGen-parse --spine={profile,swarm,frame}` emits SPINE-protocol-
4//! shaped JSON (see `MechGen/SPINE_COLLABORATION.md`). This crate deserializes
5//! those envelopes into the *real* `spine-agentic` types, so the mapping is
6//! **compile-checked** here rather than matched only by field name on the
7//! MechGen side. It is intentionally tiny: a few envelope structs + conversions.
8//!
9//! The fact that an ABL agent's `capabilities` deserialize directly into
10//! `Vec<spine_agentic::AgentCapability>` is the proof that the two capability
11//! vocabularies line up.
12
13use serde::Deserialize;
14use spine_agentic::swe::{SweArtifact, SweArtifactKind};
15use spine_agentic::{AgentCapability, AgentId, AgentProfile, Goal, SwarmTask, TrustLevel};
16use uuid::Uuid;
17
18/// Envelope emitted by `MechGen-parse --spine=profile`. Unknown fields
19/// (`miras_variant`, …) are ignored by serde.
20#[derive(Debug, Clone, Deserialize)]
21pub struct AblAgentEnvelope {
22    /// Agent name.
23    pub name: String,
24    /// Capabilities — deserialized straight into the SPINE enum (the join proof).
25    pub capabilities: Vec<AgentCapability>,
26    /// Trust level (defaults to `Unknown` if absent).
27    #[serde(default)]
28    pub trust_level: Option<TrustLevel>,
29    /// MechGen approval-gated ops (enforced MechGen-side; kept for the receiver).
30    #[serde(default)]
31    pub requires_approval: Vec<String>,
32}
33
34impl AblAgentEnvelope {
35    /// Parse the `--spine=profile` JSON.
36    pub fn from_json(s: &str) -> Result<Self, String> {
37        serde_json::from_str(s).map_err(|e| format!("bad agent envelope: {e}"))
38    }
39    /// Convert into a real `spine_agentic::AgentProfile`.
40    pub fn into_profile(self) -> AgentProfile {
41        AgentProfile::new(self.name)
42            .with_capabilities(self.capabilities)
43            .with_trust(self.trust_level.unwrap_or(TrustLevel::Unknown))
44    }
45}
46
47/// Envelope emitted by `MechGen-parse --spine=swarm`.
48#[derive(Debug, Clone, Deserialize)]
49pub struct AblSwarmEnvelope {
50    /// Swarm description (its name).
51    pub description: String,
52    /// Required capabilities — straight into the SPINE enum.
53    pub required_capabilities: Vec<AgentCapability>,
54    /// Minimum members.
55    pub min_members: usize,
56    /// Maximum members.
57    pub max_members: usize,
58    /// ABL topology (coordination metadata; not part of SwarmTask).
59    #[serde(default)]
60    pub topology: Option<String>,
61    /// ABL consensus strategy (coordination metadata).
62    #[serde(default)]
63    pub consensus: Option<String>,
64}
65
66impl AblSwarmEnvelope {
67    /// Parse the `--spine=swarm` JSON.
68    pub fn from_json(s: &str) -> Result<Self, String> {
69        serde_json::from_str(s).map_err(|e| format!("bad swarm envelope: {e}"))
70    }
71    /// Convert into a real `spine_agentic::SwarmTask` (goal = assemble agents
72    /// with the required capabilities).
73    pub fn into_task(self) -> SwarmTask {
74        SwarmTask {
75            id: Uuid::new_v4(),
76            description: self.description,
77            goal: Box::new(Goal::FindAgents { capabilities: self.required_capabilities.clone() }),
78            min_members: self.min_members,
79            max_members: self.max_members,
80            required_capabilities: self.required_capabilities,
81            deadline: None,
82        }
83    }
84}
85
86/// Envelope emitted by `MechGen-parse --spine=frame` — an ABL binary artifact.
87#[derive(Debug, Clone, Deserialize)]
88pub struct AblArtifactFrame {
89    /// Byte length of the artifact.
90    pub byte_len: usize,
91    /// MechGen's FNV-1a-64 content digest (lowercase hex).
92    pub content_digest: String,
93    /// Always false — ABL load never executes code.
94    pub exec: bool,
95    /// Hex-encoded payload (inspection view; the real wire carries raw bytes).
96    pub payload_hex: String,
97}
98
99/// FNV-1a 64 — must match MechGen's `spine_bridge::content_digest`.
100fn fnv1a64(bytes: &[u8]) -> u64 {
101    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
102    for &b in bytes {
103        h ^= b as u64;
104        h = h.wrapping_mul(0x0000_0100_0000_01b3);
105    }
106    h
107}
108
109fn from_hex(s: &str) -> Result<Vec<u8>, String> {
110    if s.len() % 2 != 0 {
111        return Err("odd-length hex".into());
112    }
113    (0..s.len())
114        .step_by(2)
115        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| format!("bad hex: {e}")))
116        .collect()
117}
118
119impl AblArtifactFrame {
120    /// Parse the `--spine=frame` JSON.
121    pub fn from_json(s: &str) -> Result<Self, String> {
122        serde_json::from_str(s).map_err(|e| format!("bad frame: {e}"))
123    }
124
125    /// Decode + **cross-validate** the frame (recompute the FNV digest over the
126    /// decoded bytes and check it matches), then produce a content-addressed
127    /// (SHA-256) `SweArtifact` for the SPINE artifact store. Errors on a digest
128    /// mismatch or a non-`false` `exec` flag.
129    pub fn into_artifact(self, producer: AgentId) -> Result<SweArtifact, String> {
130        if self.exec {
131            return Err("frame.exec must be false (ABL load is no-exec)".into());
132        }
133        let bytes = from_hex(&self.payload_hex)?;
134        if bytes.len() != self.byte_len {
135            return Err(format!("byte_len {} != decoded {}", self.byte_len, bytes.len()));
136        }
137        let got = format!("{:016x}", fnv1a64(&bytes));
138        if got != self.content_digest {
139            return Err(format!("digest mismatch: frame={} computed={}", self.content_digest, got));
140        }
141        Ok(SweArtifact::new(&bytes, SweArtifactKind::Other("abl-artifact".into()), producer))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    // The EXACT JSON shapes `MechGen-parse --spine=profile/swarm` emit.
150    #[test]
151    fn agent_envelope_maps_to_profile() {
152        let json = r#"{"name":"Builder","capabilities":["AgentCommunication","SwarmParticipation","ContentExtraction","CodeExecution"],"trust_level":"Verified","miras_variant":"Titans","requires_approval":["write_files"]}"#;
153        let env = AblAgentEnvelope::from_json(json).expect("parse");
154        // capabilities deserialized into the real SPINE enum — the join proof.
155        assert!(env.capabilities.contains(&AgentCapability::CodeExecution));
156        assert_eq!(env.requires_approval, vec!["write_files".to_string()]);
157        let profile = env.into_profile();
158        assert_eq!(profile.name, "Builder");
159        assert_eq!(profile.trust_level, TrustLevel::Verified);
160        assert!(profile.capabilities.contains(&AgentCapability::SwarmParticipation));
161    }
162
163    #[test]
164    fn custom_capability_round_trips_through_the_enum() {
165        let json = r#"{"name":"Reviewer","capabilities":[{"Custom":"review_pr"}]}"#;
166        let env = AblAgentEnvelope::from_json(json).expect("parse");
167        assert_eq!(env.capabilities, vec![AgentCapability::Custom("review_pr".into())]);
168    }
169
170    #[test]
171    fn swarm_envelope_maps_to_task() {
172        let json = r#"{"description":"Reviewers","required_capabilities":[{"Custom":"Reviewer"}],"min_members":5,"max_members":5,"topology":"ring","consensus":"quorum"}"#;
173        let env = AblSwarmEnvelope::from_json(json).expect("parse");
174        let task = env.into_task();
175        assert_eq!(task.description, "Reviewers");
176        assert_eq!(task.min_members, 5);
177        assert!(matches!(*task.goal, Goal::FindAgents { .. }));
178        assert_eq!(task.required_capabilities, vec![AgentCapability::Custom("Reviewer".into())]);
179    }
180
181    #[test]
182    fn frame_cross_validates_and_yields_artifact() {
183        // Build a frame exactly as MechGen's --spine=frame does.
184        let bytes = b"ABL1\x02\x00demo-net";
185        let digest = format!("{:016x}", fnv1a64(bytes));
186        let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
187        let json = format!(
188            r#"{{"kind":"abl-artifact","byte_len":{},"content_digest":"{}","exec":false,"signed":false,"payload_hex":"{}"}}"#,
189            bytes.len(), digest, hex
190        );
191        let frame = AblArtifactFrame::from_json(&json).expect("parse");
192        let art = frame.into_artifact(AgentId::new()).expect("valid frame");
193        assert_eq!(art.size, bytes.len());
194        assert!(!art.content_hash.is_empty(), "sha-256 content address assigned");
195    }
196
197    #[test]
198    fn frame_rejects_digest_mismatch() {
199        let json = r#"{"kind":"abl-artifact","byte_len":3,"content_digest":"deadbeefdeadbeef","exec":false,"signed":false,"payload_hex":"414243"}"#;
200        let frame = AblArtifactFrame::from_json(json).unwrap();
201        assert!(frame.into_artifact(AgentId::new()).is_err(), "tampered digest must be rejected");
202    }
203}