clawft_kernel/workload_governance/
effect.rs1use serde::{Deserialize, Serialize};
10
11use crate::governance::EffectVector;
12
13pub const MAX_KIND_LEN: usize = 64;
15pub const MAX_ACCEL_LEN: usize = 128;
17pub const MAX_REFS: usize = 64;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum PackageTrust {
27 Unsigned,
29 AdoptedUnverified,
34 ProjectCert,
39 SignedUnpinned,
41 OperatorAttested,
43 PinnedSigner,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum NodeTrustTier {
55 Discovered,
57 Paired,
59 Pinned,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum NetworkPolicy {
67 None,
69 Lan,
71 Egress,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct WorkloadEffect {
78 pub kind: String,
80 pub package_trust: PackageTrust,
82 pub node_tier: NodeTrustTier,
84 pub network: NetworkPolicy,
86 #[serde(default)]
88 pub secrets: bool,
89 #[serde(default)]
91 pub emulated: bool,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub accelerator: Option<String>,
95 #[serde(default)]
97 pub resource_cost: f64,
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub struct WorkloadRefs {
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub package_id: Option<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub signer_keys: Vec<String>,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub artifact_hashes: Vec<String>,
112}
113
114#[derive(Debug, Clone, PartialEq)]
120pub struct WorkloadRequest {
121 pub effect: WorkloadEffect,
123 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 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 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 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 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 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}