nucleus-flow-projection 0.1.0

Flow projection lifter for the Nucleus substrate. Lifts a Denning-lattice FlowTracker snapshot into a Projection::Flow body that the substrate Receipt envelope can carry. Verifier checks well-formedness and exposes the session integrity / confidentiality / authority ceilings to downstream consumers.
Documentation
//! # nucleus-flow-projection — IFC FlowTracker adapter for the substrate
//!
//! Implements the **Flow projection** functor from
//! [`nucleus_substrate_core`]: lifts a Denning-lattice flow-graph
//! snapshot into the typed body of a [`Projection::Flow`] variant.
//!
//! [`Projection::Flow`]: nucleus_substrate_core::Projection::Flow
//!
//! ## What goes in the body
//!
//! Only the snapshot summary — node count, the session's
//! confidentiality / integrity / authority ceilings, the
//! Adversarial-bid flag (G8). The full FlowTracker DAG stays
//! server-side; if external verifiers want it, they ask for it
//! separately. The receipt's job is to commit to the *summary* so
//! that downstream consumers can refuse to act on a session whose
//! integrity ceiling is `Adversarial`.
//!
//! ## Why this crate has no `nucleus-ifc` dependency
//!
//! `nucleus-ifc` and `portcullis-core` are vendored in the umbrella
//! workspace; they aren't published. The flow projection's wire
//! format is pure data — strings + ints + bools — so the lifter
//! defines its own typed body and the server (which DOES have
//! nucleus-ifc) is responsible for converting `FlowTracker` →
//! [`FlowBody`]. That keeps the lifter publishable without
//! transitively pulling vendored deps.
//!
//! ## Wire shape
//!
//! ```json
//! {
//!   "kind": "flow",
//!   "body": {
//!     "version": 1,
//!     "node_count": 4,
//!     "session_confidentiality_ceiling": "internal",
//!     "session_integrity_ceiling": "adversarial",
//!     "session_authority_ceiling": "no_authority",
//!     "session_taint_ceiling": "ai_generated",
//!     "has_adversarial_bid": true,
//!     "has_ai_derived": true,
//!     "has_confidential_data": false
//!   }
//! }
//! ```

use nucleus_substrate_core::Projection;
use serde::{Deserialize, Serialize};

pub const FLOW_BODY_VERSION: u32 = 1;

/// Snake_case strings for the four lattice levels. Stable wire
/// values — see the corresponding `*_LEVELS` constants below.
pub const CONF_LEVELS: &[&str] = &["public", "internal", "secret"];
pub const INTEG_LEVELS: &[&str] = &["adversarial", "untrusted", "trusted"];
pub const AUTHORITY_LEVELS: &[&str] = &[
    "no_authority",
    "informational",
    "suggestive",
    "directive",
];
pub const TAINT_LEVELS: &[&str] = &["clean", "user_derived", "ai_generated"];

/// Wire-stable shape for the Flow projection body.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FlowBody {
    pub version: u32,
    pub node_count: usize,
    /// Max conf label across all observed nodes (snake_case).
    pub session_confidentiality_ceiling: String,
    /// Min integ label (snake_case). `"adversarial"` is the
    /// G8 / signed-vs-unsigned-bid signal.
    pub session_integrity_ceiling: String,
    /// Min authority label (snake_case).
    pub session_authority_ceiling: String,
    /// Max derivation-class (snake_case).
    pub session_taint_ceiling: String,
    /// True iff any observed node carried adversarial integrity at
    /// observation time. Convenience flag for verifiers that only
    /// care about the binary "trust the clearing?" question (G8).
    pub has_adversarial_bid: bool,
    /// True iff any observed node was AI-derived.
    pub has_ai_derived: bool,
    /// True iff any observed node carried `internal` or `secret`
    /// confidentiality.
    pub has_confidential_data: bool,
}

/// Build a `Projection::Flow` carrying the supplied snapshot.
#[allow(clippy::too_many_arguments)]
pub fn flow_projection(
    node_count: usize,
    session_confidentiality_ceiling: impl Into<String>,
    session_integrity_ceiling: impl Into<String>,
    session_authority_ceiling: impl Into<String>,
    session_taint_ceiling: impl Into<String>,
    has_adversarial_bid: bool,
    has_ai_derived: bool,
    has_confidential_data: bool,
) -> Projection {
    let body = FlowBody {
        version: FLOW_BODY_VERSION,
        node_count,
        session_confidentiality_ceiling: session_confidentiality_ceiling.into(),
        session_integrity_ceiling: session_integrity_ceiling.into(),
        session_authority_ceiling: session_authority_ceiling.into(),
        session_taint_ceiling: session_taint_ceiling.into(),
        has_adversarial_bid,
        has_ai_derived,
        has_confidential_data,
    };
    Projection::Flow(serde_json::to_value(body).expect("FlowBody serializes"))
}

/// Verify that a `FlowBody` is well-formed: version matches the
/// lifter's, lattice-level strings are in their stable vocabularies,
/// the "any-X" flags are consistent with the ceiling levels.
///
/// This is structural verification only — the signature check
/// lives on the parent [`Receipt`].
///
/// [`Receipt`]: nucleus_substrate_core::Receipt
pub fn verify_flow_projection_shape(body: &FlowBody) -> Result<(), FlowVerifyError> {
    if body.version != FLOW_BODY_VERSION {
        return Err(FlowVerifyError::UnsupportedBodyVersion(body.version));
    }
    if !CONF_LEVELS.contains(&body.session_confidentiality_ceiling.as_str()) {
        return Err(FlowVerifyError::UnknownLevel {
            axis: "confidentiality",
            value: body.session_confidentiality_ceiling.clone(),
        });
    }
    if !INTEG_LEVELS.contains(&body.session_integrity_ceiling.as_str()) {
        return Err(FlowVerifyError::UnknownLevel {
            axis: "integrity",
            value: body.session_integrity_ceiling.clone(),
        });
    }
    if !AUTHORITY_LEVELS.contains(&body.session_authority_ceiling.as_str()) {
        return Err(FlowVerifyError::UnknownLevel {
            axis: "authority",
            value: body.session_authority_ceiling.clone(),
        });
    }
    if !TAINT_LEVELS.contains(&body.session_taint_ceiling.as_str()) {
        return Err(FlowVerifyError::UnknownLevel {
            axis: "taint",
            value: body.session_taint_ceiling.clone(),
        });
    }
    // Consistency invariant: if `has_adversarial_bid` is true, the
    // session-wide integrity ceiling MUST be `adversarial`. (Min over
    // all node integrities is at most `adversarial` when any node is
    // `adversarial`.)
    if body.has_adversarial_bid && body.session_integrity_ceiling != "adversarial" {
        return Err(FlowVerifyError::CeilingInconsistent {
            flag: "has_adversarial_bid",
            level: "integrity",
            actual: body.session_integrity_ceiling.clone(),
        });
    }
    // Inverse: if integ ceiling is "trusted", no node was adversarial.
    if body.session_integrity_ceiling == "trusted" && body.has_adversarial_bid {
        return Err(FlowVerifyError::CeilingInconsistent {
            flag: "has_adversarial_bid",
            level: "integrity",
            actual: body.session_integrity_ceiling.clone(),
        });
    }
    Ok(())
}

#[derive(Debug, thiserror::Error)]
pub enum FlowVerifyError {
    #[error("flow body version {0} not supported by this lifter")]
    UnsupportedBodyVersion(u32),
    #[error("unknown {axis} level: {value}")]
    UnknownLevel {
        axis: &'static str,
        value: String,
    },
    #[error("flag `{flag}` is inconsistent with {level} ceiling = {actual}")]
    CeilingInconsistent {
        flag: &'static str,
        level: &'static str,
        actual: String,
    },
}

#[cfg(test)]
mod tests {
    use super::*;

    fn happy_body() -> FlowBody {
        FlowBody {
            version: FLOW_BODY_VERSION,
            node_count: 4,
            session_confidentiality_ceiling: "internal".into(),
            session_integrity_ceiling: "trusted".into(),
            session_authority_ceiling: "informational".into(),
            session_taint_ceiling: "user_derived".into(),
            has_adversarial_bid: false,
            has_ai_derived: false,
            has_confidential_data: true,
        }
    }

    #[test]
    fn happy_body_verifies() {
        let body = happy_body();
        verify_flow_projection_shape(&body).expect("happy body verifies");
    }

    #[test]
    fn unknown_integrity_level_rejected() {
        let mut body = happy_body();
        body.session_integrity_ceiling = "questionable".into();
        let err = verify_flow_projection_shape(&body).unwrap_err();
        assert!(matches!(err, FlowVerifyError::UnknownLevel { axis: "integrity", .. }));
    }

    #[test]
    fn adversarial_flag_with_trusted_ceiling_rejected() {
        let mut body = happy_body();
        // contradiction: any node Adversarial but ceiling Trusted
        body.has_adversarial_bid = true;
        let err = verify_flow_projection_shape(&body).unwrap_err();
        assert!(matches!(
            err,
            FlowVerifyError::CeilingInconsistent {
                flag: "has_adversarial_bid",
                ..
            }
        ));
    }

    #[test]
    fn adversarial_flag_with_adversarial_ceiling_accepted() {
        // G8 path: mixed bids → integrity drops to adversarial.
        let mut body = happy_body();
        body.has_adversarial_bid = true;
        body.session_integrity_ceiling = "adversarial".into();
        verify_flow_projection_shape(&body).expect("g8 path");
    }

    #[test]
    fn body_version_mismatch_rejected() {
        let mut body = happy_body();
        body.version = 99;
        let err = verify_flow_projection_shape(&body).unwrap_err();
        assert!(matches!(err, FlowVerifyError::UnsupportedBodyVersion(99)));
    }

    #[test]
    fn flow_projection_helper_packs_correct_wire_shape() {
        let projection = flow_projection(
            7,
            "internal",
            "adversarial",
            "informational",
            "ai_generated",
            true,
            true,
            true,
        );
        assert_eq!(projection.kind(), "flow");
        let v = serde_json::to_value(&projection).unwrap();
        assert_eq!(v["kind"], "flow");
        assert_eq!(v["body"]["node_count"], 7);
        assert_eq!(v["body"]["session_integrity_ceiling"], "adversarial");
        assert_eq!(v["body"]["has_adversarial_bid"], true);
        assert_eq!(v["body"]["has_ai_derived"], true);
        assert_eq!(v["body"]["version"], FLOW_BODY_VERSION);
    }
}