Skip to main content

ncp_runtime/
envelope.rs

1use anyhow::{Context, Result};
2use minicbor::encode::{self, Encoder, Write};
3
4/// Trigger provenance for a brick invocation.
5pub struct Trigger<'a> {
6    pub source_node_id: &'a str,
7    pub source_step: u64,
8    pub edge_id: &'a str,
9}
10
11/// Root trigger sentinel for the entry node.
12pub const ROOT_TRIGGER: Trigger<'static> = Trigger {
13    source_node_id: "__root__",
14    source_step: 0,
15    edge_id: "__root__",
16};
17
18/// Build a CBOR envelope from JSON input.
19///
20/// Envelope shape (CBOR map, keys sorted alphabetically):
21///   carry_state: null
22///   ctx:         { graph_id, graph_version, node_id, session_id, step, trace_id, trigger }
23///   graph_refs:  {}
24///   input:       `<the user's input value>`
25///
26/// Input auto-detection:
27///   - If JSON has top-level "input" key → use its value
28///   - Else → wrap entire JSON as input
29// Rationale: envelope construction needs these distinct fields (graph
30// context + trace metadata + step + trigger). Refactor into a builder
31// struct in Phase 3B/3C if/when the signature grows further.
32#[allow(clippy::too_many_arguments)]
33pub fn build_envelope(
34    json_input: &serde_json::Value,
35    graph_id: &str,
36    graph_version: &str,
37    node_id: &str,
38    trace_id: &str,
39    session_id: &str,
40    step: u64,
41    trigger: &Trigger<'_>,
42) -> Result<Vec<u8>> {
43    // Extract input value
44    let input_value = if let Some(inner) = json_input.get("input") {
45        inner
46    } else {
47        json_input
48    };
49
50    let mut buf = Vec::with_capacity(1024);
51    let mut enc = Encoder::new(&mut buf);
52
53    // Top-level map with 4 keys (sorted: carry_state, ctx, graph_refs, input)
54    enc.map(4).context("encoding envelope map")?;
55
56    // 1. carry_state = null
57    enc.str("carry_state")
58        .context("encoding 'carry_state' key")?;
59    enc.null().context("encoding carry_state null")?;
60
61    // 2. ctx (7 keys, sorted: graph_id, graph_version, node_id, session_id, step, trace_id, trigger)
62    enc.str("ctx").context("encoding 'ctx' key")?;
63    enc.map(7).context("encoding ctx map")?;
64    enc.str("graph_id").context("encoding ctx.graph_id key")?;
65    enc.str(graph_id).context("encoding ctx.graph_id value")?;
66    enc.str("graph_version")
67        .context("encoding ctx.graph_version key")?;
68    enc.str(graph_version)
69        .context("encoding ctx.graph_version value")?;
70    enc.str("node_id").context("encoding ctx.node_id key")?;
71    enc.str(node_id).context("encoding ctx.node_id value")?;
72    enc.str("session_id")
73        .context("encoding ctx.session_id key")?;
74    enc.str(session_id)
75        .context("encoding ctx.session_id value")?;
76    enc.str("step").context("encoding ctx.step key")?;
77    enc.u64(step).context("encoding ctx.step value")?;
78    enc.str("trace_id").context("encoding ctx.trace_id key")?;
79    enc.str(trace_id).context("encoding ctx.trace_id value")?;
80    enc.str("trigger").context("encoding ctx.trigger key")?;
81    // trigger map (3 keys, sorted: edge_id, source_node_id, source_step)
82    enc.map(3).context("encoding trigger map")?;
83    enc.str("edge_id").context("encoding trigger.edge_id key")?;
84    enc.str(trigger.edge_id)
85        .context("encoding trigger.edge_id value")?;
86    enc.str("source_node_id")
87        .context("encoding trigger.source_node_id key")?;
88    enc.str(trigger.source_node_id)
89        .context("encoding trigger.source_node_id value")?;
90    enc.str("source_step")
91        .context("encoding trigger.source_step key")?;
92    enc.u64(trigger.source_step)
93        .context("encoding trigger.source_step value")?;
94
95    // 3. graph_refs = {}
96    enc.str("graph_refs").context("encoding 'graph_refs' key")?;
97    enc.map(0).context("encoding empty graph_refs")?;
98
99    // 4. input
100    enc.str("input").context("encoding 'input' key")?;
101    encode_json_value(&mut enc, input_value).context("encoding input value")?;
102
103    Ok(buf)
104}
105
106/// Recursively encode a serde_json::Value as CBOR.
107/// Object keys are sorted alphabetically for cross-runtime determinism.
108fn encode_json_value<W: Write>(
109    enc: &mut Encoder<W>,
110    val: &serde_json::Value,
111) -> Result<(), encode::Error<W::Error>> {
112    match val {
113        serde_json::Value::Null => {
114            enc.null()?;
115        }
116        serde_json::Value::Bool(b) => {
117            enc.bool(*b)?;
118        }
119        serde_json::Value::Number(n) => {
120            if let Some(i) = n.as_i64() {
121                enc.i64(i)?;
122            } else if let Some(u) = n.as_u64() {
123                enc.u64(u)?;
124            } else if let Some(f) = n.as_f64() {
125                enc.f64(f)?;
126            } else {
127                return Err(encode::Error::message("unsupported JSON number"));
128            }
129        }
130        serde_json::Value::String(s) => {
131            enc.str(s)?;
132        }
133        serde_json::Value::Array(arr) => {
134            enc.array(arr.len() as u64)?;
135            for item in arr {
136                encode_json_value(enc, item)?;
137            }
138        }
139        serde_json::Value::Object(map) => {
140            enc.map(map.len() as u64)?;
141            let mut keys: Vec<&String> = map.keys().collect();
142            keys.sort();
143            for k in keys {
144                enc.str(k)?;
145                encode_json_value(enc, &map[k])?;
146            }
147        }
148    }
149    Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn envelope_deterministic_regardless_of_key_order() {
158        let json_a: serde_json::Value =
159            serde_json::from_str(r#"{"alpha": 1, "beta": "two", "gamma": [3, 4]}"#).unwrap();
160        let json_b: serde_json::Value =
161            serde_json::from_str(r#"{"gamma": [3, 4], "alpha": 1, "beta": "two"}"#).unwrap();
162
163        let env_a =
164            build_envelope(&json_a, "g1", "v1", "n1", "t1", "s1", 0, &ROOT_TRIGGER).unwrap();
165        let env_b =
166            build_envelope(&json_b, "g1", "v1", "n1", "t1", "s1", 0, &ROOT_TRIGGER).unwrap();
167
168        assert_eq!(
169            env_a, env_b,
170            "envelopes must be byte-identical regardless of JSON key order"
171        );
172    }
173
174    #[test]
175    fn envelope_auto_wraps_raw_input() {
176        let raw: serde_json::Value = serde_json::from_str(r#"{"text": "hello"}"#).unwrap();
177        let wrapped: serde_json::Value =
178            serde_json::from_str(r#"{"input": {"text": "hello"}}"#).unwrap();
179
180        let env_raw = build_envelope(&raw, "g1", "v1", "n1", "t1", "s1", 0, &ROOT_TRIGGER).unwrap();
181        let env_wrapped =
182            build_envelope(&wrapped, "g1", "v1", "n1", "t1", "s1", 0, &ROOT_TRIGGER).unwrap();
183
184        assert_eq!(
185            env_raw, env_wrapped,
186            "raw and wrapped inputs must produce identical envelopes"
187        );
188    }
189}