Skip to main content

clawft_kernel/workload_governance/
effect.rs

1//! Workload effect fields (ADR-099 section 4) and their mapping onto the
2//! governance engine's 5D [`EffectVector`].
3//!
4//! Callers describe a workload action in domain terms (package trust, node
5//! trust tier, network policy, secrets, emulation, accelerator use, resource
6//! cost). The gate derives the 5D vector itself from those fields, so a caller
7//! cannot lower its own score by sending a hand-written `effect` object.
8
9use serde::{Deserialize, Serialize};
10
11use crate::governance::EffectVector;
12
13/// Maximum length of a workload kind string (`cog`, `inference`, ...).
14pub const MAX_KIND_LEN: usize = 64;
15/// Maximum length of an accelerator capability id.
16pub const MAX_ACCEL_LEN: usize = 128;
17/// Maximum number of signer keys or artifact hashes in one request.
18pub const MAX_REFS: usize = 64;
19
20/// How far the package's provenance can be trusted (ADR-099 section 8).
21///
22/// Ordered from least to most trusted, so `>=` comparisons express
23/// "at least this trusted".
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum PackageTrust {
27    /// No valid signature.
28    Unsigned,
29    /// A server somebody else started, registered and observed by an
30    /// adopted-mode inference adapter (ADR-101): nothing about it was
31    /// verified. Ordered just above `Unsigned` so no signed or attested
32    /// minimum is satisfied by it; only a permit that names it matches.
33    AdoptedUnverified,
34    /// Authorised by a project certificate chained to the user key (the
35    /// `project` kind, ADR-103 A6). Ordered just above `Unsigned` so it never
36    /// satisfies the signed-package minimums cog permits ask for; only a
37    /// permit that names it (`min_package_trust = project_cert`) matches.
38    ProjectCert,
39    /// Signed, but not by a pinned signer.
40    SignedUnpinned,
41    /// Operator attestation over a hash manifest (model weights, ADR-101).
42    OperatorAttested,
43    /// At least one valid signature from a pinned signer.
44    PinnedSigner,
45}
46
47/// Node trust tier (ADR-099 section 8.3).
48///
49/// Local placeholder: card 02 owns the capability vocabulary
50/// (`trust.tier.{discovered,paired,pinned}`); this enum mirrors those ids
51/// until that type lands.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum NodeTrustTier {
55    /// Seen on the mesh, not paired. Runs nothing by default.
56    Discovered,
57    /// Operator-paired. Runs operator-signed workloads.
58    Paired,
59    /// Pinned. May run workloads that carry secrets.
60    Pinned,
61}
62
63/// Network exposure the workload asks for, ordered by exposure.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum NetworkPolicy {
67    /// No network access.
68    None,
69    /// Local network only (sensor feeds, mesh peers).
70    Lan,
71    /// Outbound internet access.
72    Egress,
73}
74
75/// The effect fields of one workload governance request.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct WorkloadEffect {
78    /// Workload kind (open string: `cog`, `inference`, `wasm-module`, ...).
79    pub kind: String,
80    /// Package provenance.
81    pub package_trust: PackageTrust,
82    /// Trust tier of the target node.
83    pub node_tier: NodeTrustTier,
84    /// Requested network exposure.
85    pub network: NetworkPolicy,
86    /// Whether secrets are delivered to the instance.
87    #[serde(default)]
88    pub secrets: bool,
89    /// Whether the placement runs under emulation (operator opt-in only).
90    #[serde(default)]
91    pub emulated: bool,
92    /// Accelerator capability id used, if any (e.g. `accel.gpu.metal`).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub accelerator: Option<String>,
95    /// Normalised resource cost in `[0.0, 1.0]` (share of the node).
96    #[serde(default)]
97    pub resource_cost: f64,
98}
99
100/// Revocable references carried with a workload request.
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub struct WorkloadRefs {
103    /// Package id.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub package_id: Option<String>,
106    /// Hex Ed25519 public keys of the package's signers.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub signer_keys: Vec<String>,
109    /// BLAKE3 hex hashes of the manifest and payload artifacts.
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub artifact_hashes: Vec<String>,
112}
113
114/// Parsed and validated gate context for a workload action.
115///
116/// Wire shape (inside the gate `context` JSON):
117/// `{"workload": {<WorkloadEffect fields>, "package_id": .., "signer_keys": [..],
118/// "artifact_hashes": [..]}}`.
119#[derive(Debug, Clone, PartialEq)]
120pub struct WorkloadRequest {
121    /// Effect fields.
122    pub effect: WorkloadEffect,
123    /// Revocable references.
124    pub refs: WorkloadRefs,
125}
126
127fn is_kind_char(c: char) -> bool {
128    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'
129}
130
131fn is_capability_id(s: &str) -> bool {
132    !s.is_empty()
133        && s.len() <= MAX_ACCEL_LEN
134        && !s.starts_with('.')
135        && !s.ends_with('.')
136        && !s.contains("..")
137        && s
138            .chars()
139            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_'))
140}
141
142impl WorkloadEffect {
143    /// Validate field ranges and formats (system-boundary check).
144    pub fn validate(&self) -> Result<(), String> {
145        if self.kind.is_empty() || self.kind.len() > MAX_KIND_LEN {
146            return Err(format!("workload kind must be 1..={MAX_KIND_LEN} chars"));
147        }
148        if !self.kind.chars().all(is_kind_char) {
149            return Err(format!(
150                "workload kind '{}' must be lowercase [a-z0-9_-]",
151                self.kind
152            ));
153        }
154        if let Some(acc) = &self.accelerator
155            && !is_capability_id(acc)
156        {
157            return Err(format!("accelerator '{acc}' is not a dotted lowercase capability id"));
158        }
159        if !self.resource_cost.is_finite() || !(0.0..=1.0).contains(&self.resource_cost) {
160            return Err(format!(
161                "resource_cost {} must be a finite value in [0.0, 1.0]",
162                self.resource_cost
163            ));
164        }
165        Ok(())
166    }
167
168    /// Derive the 5D effect vector.
169    ///
170    /// - risk: package provenance, raised by emulation.
171    /// - security: node tier plus network exposure.
172    /// - privacy: secrets plus internet egress.
173    /// - novelty: emulation and accelerator use.
174    /// - fairness: resource cost (share of a shared node).
175    pub fn to_effect_vector(&self) -> EffectVector {
176        let clamp = |v: f64| v.clamp(0.0, 1.0);
177        let trust_risk = match self.package_trust {
178            PackageTrust::Unsigned => 0.9,
179            PackageTrust::AdoptedUnverified => 0.5,
180            PackageTrust::SignedUnpinned => 0.6,
181            PackageTrust::OperatorAttested => 0.3,
182            PackageTrust::PinnedSigner => 0.1,
183            // Chained to the user's own key: between a pinned signer and an
184            // operator attestation.
185            PackageTrust::ProjectCert => 0.2,
186        };
187        let tier_sec = match self.node_tier {
188            NodeTrustTier::Discovered => 0.8,
189            NodeTrustTier::Paired => 0.3,
190            NodeTrustTier::Pinned => 0.1,
191        };
192        let net_sec = match self.network {
193            NetworkPolicy::None => 0.0,
194            NetworkPolicy::Lan => 0.1,
195            NetworkPolicy::Egress => 0.3,
196        };
197        let egress_priv = if self.network == NetworkPolicy::Egress { 0.2 } else { 0.0 };
198        EffectVector {
199            risk: clamp(trust_risk + if self.emulated { 0.1 } else { 0.0 }),
200            fairness: clamp(self.resource_cost),
201            privacy: clamp(if self.secrets { 0.5 } else { 0.0 } + egress_priv),
202            novelty: clamp(
203                if self.emulated { 0.4 } else { 0.0 }
204                    + if self.accelerator.is_some() { 0.2 } else { 0.0 },
205            ),
206            security: clamp(tier_sec + net_sec),
207        }
208    }
209
210    /// Flatten the fields into the string context map the engine takes.
211    pub fn context_map(&self) -> std::collections::HashMap<String, String> {
212        let to_s = |v: serde_json::Value| v.as_str().map(str::to_owned).unwrap_or_default();
213        let mut m = std::collections::HashMap::new();
214        m.insert("kind".into(), self.kind.clone());
215        m.insert("package_trust".into(), to_s(serde_json::json!(self.package_trust)));
216        m.insert("node_tier".into(), to_s(serde_json::json!(self.node_tier)));
217        m.insert("network".into(), to_s(serde_json::json!(self.network)));
218        m.insert("secrets".into(), self.secrets.to_string());
219        m.insert("emulated".into(), self.emulated.to_string());
220        m.insert(
221            "accelerator".into(),
222            self.accelerator.clone().unwrap_or_default(),
223        );
224        m.insert("resource_cost".into(), self.resource_cost.to_string());
225        m
226    }
227}
228
229impl WorkloadRequest {
230    /// Parse and validate the `workload` object from a gate context.
231    ///
232    /// Any top-level `effect` object is ignored on purpose: the vector is
233    /// always derived from these fields.
234    pub fn from_context(context: &serde_json::Value) -> Result<Self, String> {
235        let obj = context
236            .get("workload")
237            .ok_or_else(|| "missing 'workload' object in gate context".to_owned())?;
238        if !obj.is_object() {
239            return Err("'workload' must be a JSON object".into());
240        }
241        let effect: WorkloadEffect =
242            serde_json::from_value(obj.clone()).map_err(|e| format!("invalid workload effect: {e}"))?;
243        let refs: WorkloadRefs =
244            serde_json::from_value(obj.clone()).map_err(|e| format!("invalid workload refs: {e}"))?;
245        effect.validate()?;
246        if refs.signer_keys.len() > MAX_REFS || refs.artifact_hashes.len() > MAX_REFS {
247            return Err(format!("at most {MAX_REFS} signer keys and artifact hashes"));
248        }
249        use crate::revocation::RevocationKind;
250        if let Some(p) = &refs.package_id {
251            RevocationKind::Package.normalize(p).map_err(|e| e.to_string())?;
252        }
253        for k in &refs.signer_keys {
254            RevocationKind::SignerKey.normalize(k).map_err(|e| e.to_string())?;
255        }
256        for h in &refs.artifact_hashes {
257            RevocationKind::ArtifactHash.normalize(h).map_err(|e| e.to_string())?;
258        }
259        Ok(Self { effect, refs })
260    }
261}