Skip to main content

nucleus_flow_projection/
lib.rs

1//! # nucleus-flow-projection — IFC FlowTracker adapter for the substrate
2//!
3//! Implements the **Flow projection** functor from
4//! [`nucleus_substrate_core`]: lifts a Denning-lattice flow-graph
5//! snapshot into the typed body of a [`Projection::Flow`] variant.
6//!
7//! [`Projection::Flow`]: nucleus_substrate_core::Projection::Flow
8//!
9//! ## What goes in the body
10//!
11//! Only the snapshot summary — node count, the session's
12//! confidentiality / integrity / authority ceilings, the
13//! Adversarial-bid flag (G8). The full FlowTracker DAG stays
14//! server-side; if external verifiers want it, they ask for it
15//! separately. The receipt's job is to commit to the *summary* so
16//! that downstream consumers can refuse to act on a session whose
17//! integrity ceiling is `Adversarial`.
18//!
19//! ## Why this crate has no `nucleus-ifc` dependency
20//!
21//! `nucleus-ifc` and `portcullis-core` are vendored in the umbrella
22//! workspace; they aren't published. The flow projection's wire
23//! format is pure data — strings + ints + bools — so the lifter
24//! defines its own typed body and the server (which DOES have
25//! nucleus-ifc) is responsible for converting `FlowTracker` →
26//! [`FlowBody`]. That keeps the lifter publishable without
27//! transitively pulling vendored deps.
28//!
29//! ## Wire shape
30//!
31//! ```json
32//! {
33//!   "kind": "flow",
34//!   "body": {
35//!     "version": 1,
36//!     "node_count": 4,
37//!     "session_confidentiality_ceiling": "internal",
38//!     "session_integrity_ceiling": "adversarial",
39//!     "session_authority_ceiling": "no_authority",
40//!     "session_taint_ceiling": "ai_generated",
41//!     "has_adversarial_bid": true,
42//!     "has_ai_derived": true,
43//!     "has_confidential_data": false
44//!   }
45//! }
46//! ```
47
48use nucleus_substrate_core::Projection;
49use serde::{Deserialize, Serialize};
50
51pub const FLOW_BODY_VERSION: u32 = 1;
52
53/// Snake_case strings for the four lattice levels. Stable wire
54/// values — see the corresponding `*_LEVELS` constants below.
55pub const CONF_LEVELS: &[&str] = &["public", "internal", "secret"];
56pub const INTEG_LEVELS: &[&str] = &["adversarial", "untrusted", "trusted"];
57pub const AUTHORITY_LEVELS: &[&str] = &[
58    "no_authority",
59    "informational",
60    "suggestive",
61    "directive",
62];
63pub const TAINT_LEVELS: &[&str] = &["clean", "user_derived", "ai_generated"];
64
65/// Wire-stable shape for the Flow projection body.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub struct FlowBody {
68    pub version: u32,
69    pub node_count: usize,
70    /// Max conf label across all observed nodes (snake_case).
71    pub session_confidentiality_ceiling: String,
72    /// Min integ label (snake_case). `"adversarial"` is the
73    /// G8 / signed-vs-unsigned-bid signal.
74    pub session_integrity_ceiling: String,
75    /// Min authority label (snake_case).
76    pub session_authority_ceiling: String,
77    /// Max derivation-class (snake_case).
78    pub session_taint_ceiling: String,
79    /// True iff any observed node carried adversarial integrity at
80    /// observation time. Convenience flag for verifiers that only
81    /// care about the binary "trust the clearing?" question (G8).
82    pub has_adversarial_bid: bool,
83    /// True iff any observed node was AI-derived.
84    pub has_ai_derived: bool,
85    /// True iff any observed node carried `internal` or `secret`
86    /// confidentiality.
87    pub has_confidential_data: bool,
88}
89
90/// Build a `Projection::Flow` carrying the supplied snapshot.
91#[allow(clippy::too_many_arguments)]
92pub fn flow_projection(
93    node_count: usize,
94    session_confidentiality_ceiling: impl Into<String>,
95    session_integrity_ceiling: impl Into<String>,
96    session_authority_ceiling: impl Into<String>,
97    session_taint_ceiling: impl Into<String>,
98    has_adversarial_bid: bool,
99    has_ai_derived: bool,
100    has_confidential_data: bool,
101) -> Projection {
102    let body = FlowBody {
103        version: FLOW_BODY_VERSION,
104        node_count,
105        session_confidentiality_ceiling: session_confidentiality_ceiling.into(),
106        session_integrity_ceiling: session_integrity_ceiling.into(),
107        session_authority_ceiling: session_authority_ceiling.into(),
108        session_taint_ceiling: session_taint_ceiling.into(),
109        has_adversarial_bid,
110        has_ai_derived,
111        has_confidential_data,
112    };
113    Projection::Flow(serde_json::to_value(body).expect("FlowBody serializes"))
114}
115
116/// Verify that a `FlowBody` is well-formed: version matches the
117/// lifter's, lattice-level strings are in their stable vocabularies,
118/// the "any-X" flags are consistent with the ceiling levels.
119///
120/// This is structural verification only — the signature check
121/// lives on the parent [`Receipt`].
122///
123/// [`Receipt`]: nucleus_substrate_core::Receipt
124pub fn verify_flow_projection_shape(body: &FlowBody) -> Result<(), FlowVerifyError> {
125    if body.version != FLOW_BODY_VERSION {
126        return Err(FlowVerifyError::UnsupportedBodyVersion(body.version));
127    }
128    if !CONF_LEVELS.contains(&body.session_confidentiality_ceiling.as_str()) {
129        return Err(FlowVerifyError::UnknownLevel {
130            axis: "confidentiality",
131            value: body.session_confidentiality_ceiling.clone(),
132        });
133    }
134    if !INTEG_LEVELS.contains(&body.session_integrity_ceiling.as_str()) {
135        return Err(FlowVerifyError::UnknownLevel {
136            axis: "integrity",
137            value: body.session_integrity_ceiling.clone(),
138        });
139    }
140    if !AUTHORITY_LEVELS.contains(&body.session_authority_ceiling.as_str()) {
141        return Err(FlowVerifyError::UnknownLevel {
142            axis: "authority",
143            value: body.session_authority_ceiling.clone(),
144        });
145    }
146    if !TAINT_LEVELS.contains(&body.session_taint_ceiling.as_str()) {
147        return Err(FlowVerifyError::UnknownLevel {
148            axis: "taint",
149            value: body.session_taint_ceiling.clone(),
150        });
151    }
152    // Consistency invariant: if `has_adversarial_bid` is true, the
153    // session-wide integrity ceiling MUST be `adversarial`. (Min over
154    // all node integrities is at most `adversarial` when any node is
155    // `adversarial`.)
156    if body.has_adversarial_bid && body.session_integrity_ceiling != "adversarial" {
157        return Err(FlowVerifyError::CeilingInconsistent {
158            flag: "has_adversarial_bid",
159            level: "integrity",
160            actual: body.session_integrity_ceiling.clone(),
161        });
162    }
163    // Inverse: if integ ceiling is "trusted", no node was adversarial.
164    if body.session_integrity_ceiling == "trusted" && body.has_adversarial_bid {
165        return Err(FlowVerifyError::CeilingInconsistent {
166            flag: "has_adversarial_bid",
167            level: "integrity",
168            actual: body.session_integrity_ceiling.clone(),
169        });
170    }
171    Ok(())
172}
173
174#[derive(Debug, thiserror::Error)]
175pub enum FlowVerifyError {
176    #[error("flow body version {0} not supported by this lifter")]
177    UnsupportedBodyVersion(u32),
178    #[error("unknown {axis} level: {value}")]
179    UnknownLevel {
180        axis: &'static str,
181        value: String,
182    },
183    #[error("flag `{flag}` is inconsistent with {level} ceiling = {actual}")]
184    CeilingInconsistent {
185        flag: &'static str,
186        level: &'static str,
187        actual: String,
188    },
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn happy_body() -> FlowBody {
196        FlowBody {
197            version: FLOW_BODY_VERSION,
198            node_count: 4,
199            session_confidentiality_ceiling: "internal".into(),
200            session_integrity_ceiling: "trusted".into(),
201            session_authority_ceiling: "informational".into(),
202            session_taint_ceiling: "user_derived".into(),
203            has_adversarial_bid: false,
204            has_ai_derived: false,
205            has_confidential_data: true,
206        }
207    }
208
209    #[test]
210    fn happy_body_verifies() {
211        let body = happy_body();
212        verify_flow_projection_shape(&body).expect("happy body verifies");
213    }
214
215    #[test]
216    fn unknown_integrity_level_rejected() {
217        let mut body = happy_body();
218        body.session_integrity_ceiling = "questionable".into();
219        let err = verify_flow_projection_shape(&body).unwrap_err();
220        assert!(matches!(err, FlowVerifyError::UnknownLevel { axis: "integrity", .. }));
221    }
222
223    #[test]
224    fn adversarial_flag_with_trusted_ceiling_rejected() {
225        let mut body = happy_body();
226        // contradiction: any node Adversarial but ceiling Trusted
227        body.has_adversarial_bid = true;
228        let err = verify_flow_projection_shape(&body).unwrap_err();
229        assert!(matches!(
230            err,
231            FlowVerifyError::CeilingInconsistent {
232                flag: "has_adversarial_bid",
233                ..
234            }
235        ));
236    }
237
238    #[test]
239    fn adversarial_flag_with_adversarial_ceiling_accepted() {
240        // G8 path: mixed bids → integrity drops to adversarial.
241        let mut body = happy_body();
242        body.has_adversarial_bid = true;
243        body.session_integrity_ceiling = "adversarial".into();
244        verify_flow_projection_shape(&body).expect("g8 path");
245    }
246
247    #[test]
248    fn body_version_mismatch_rejected() {
249        let mut body = happy_body();
250        body.version = 99;
251        let err = verify_flow_projection_shape(&body).unwrap_err();
252        assert!(matches!(err, FlowVerifyError::UnsupportedBodyVersion(99)));
253    }
254
255    #[test]
256    fn flow_projection_helper_packs_correct_wire_shape() {
257        let projection = flow_projection(
258            7,
259            "internal",
260            "adversarial",
261            "informational",
262            "ai_generated",
263            true,
264            true,
265            true,
266        );
267        assert_eq!(projection.kind(), "flow");
268        let v = serde_json::to_value(&projection).unwrap();
269        assert_eq!(v["kind"], "flow");
270        assert_eq!(v["body"]["node_count"], 7);
271        assert_eq!(v["body"]["session_integrity_ceiling"], "adversarial");
272        assert_eq!(v["body"]["has_adversarial_bid"], true);
273        assert_eq!(v["body"]["has_ai_derived"], true);
274        assert_eq!(v["body"]["version"], FLOW_BODY_VERSION);
275    }
276}