lean_ctx/core/
work_graph_delegation.rs1use std::path::{Path, PathBuf};
10
11use ed25519_dalek::VerifyingKey;
12
13use crate::core::context_capsule::{ContextCapsuleV1, SignedContextCapsuleV1};
14use crate::core::work_graph::WorkNode;
15
16mod delivery_issuer;
17pub use delivery_issuer::DeliveryIssuerConfig;
18
19const MAX_START_WINDOW_MS: u64 = 30 * 60 * 1_000;
20pub const DEFAULT_START_WINDOW_MS: u64 = 300_000;
22
23pub fn start_window_ms(timeout_ms: u64) -> Result<u64, String> {
25 if timeout_ms == 0 || timeout_ms > MAX_START_WINDOW_MS {
26 return Err(format!(
27 "timeout_ms must be between 1 and {MAX_START_WINDOW_MS}"
28 ));
29 }
30 Ok(timeout_ms)
31}
32
33pub fn node_lease_resource(
35 project_root: &str,
36 graph_id: &str,
37 node_id: &str,
38) -> Result<String, String> {
39 crate::core::work_graph_store::validate_id(graph_id, "graph_id")?;
40 crate::core::work_graph_store::validate_id(node_id, "node_id")?;
41 let scope = crate::core::project_hash::hash_project_root(project_root);
42 let bytes =
43 serde_json::to_vec(&(scope, graph_id, node_id)).map_err(|error| error.to_string())?;
44 Ok(format!("work-graph-node:{}", blake3::hash(&bytes).to_hex()))
45}
46
47pub fn path_lease_resource(project_root: &str, claim: &str) -> Result<String, String> {
51 let claimed = claim
52 .strip_prefix("path:")
53 .ok_or_else(|| "path claim must use path: scheme".to_string())?;
54 let scope = crate::core::project_hash::hash_project_root(project_root);
55 let relative = match claimed.split_once('/') {
56 Some((head, rest)) if head == scope => rest,
57 Some((head, _))
60 if head.len() == scope.len() && head.bytes().all(|b| b.is_ascii_hexdigit()) =>
61 {
62 return Err("path claim belongs to another project".into());
63 }
64 _ => claimed,
65 };
66 crate::core::agent_lease::project_path_resource(project_root, relative)
67 .map_err(|error| error.to_string())
68}
69
70pub fn execution_key(
73 project_root: &Path,
74 graph_id: &str,
75 node_id: &str,
76 fence: &str,
77) -> Result<String, String> {
78 let root = project_root
79 .to_str()
80 .ok_or_else(|| "project root must be valid UTF-8".to_string())?;
81 execution_key_for_scope(
82 &crate::core::project_hash::hash_project_root(root),
83 graph_id,
84 node_id,
85 fence,
86 )
87}
88
89pub(crate) fn execution_key_for_scope(
91 project_scope: &str,
92 graph_id: &str,
93 node_id: &str,
94 fence: &str,
95) -> Result<String, String> {
96 if graph_id.is_empty() || node_id.is_empty() || fence.is_empty() {
97 return Err("execution identity requires graph, node and fence".into());
98 }
99 let bytes = serde_json::to_vec(&(project_scope, graph_id, node_id, fence))
100 .map_err(|error| error.to_string())?;
101 Ok(format!("work-execution:{}", blake3::hash(&bytes).to_hex()))
102}
103
104pub fn attempt_task_id(execution_key: &str, attempt: u8) -> String {
106 format!("{execution_key}:attempt-{attempt}")
107}
108
109#[derive(Clone, Debug)]
111pub struct StartPlan {
112 pub graph_id: String,
113 pub node: WorkNode,
114 pub root_agent_id: String,
115 pub parent_capsule_ref: Option<String>,
116 pub project_root: PathBuf,
117 pub window_ms: u64,
118}
119
120impl StartPlan {
121 pub fn validate(&self) -> Result<&str, String> {
122 if self.graph_id.is_empty() || self.graph_id.len() > 128 {
123 return Err("invalid graph_id".into());
124 }
125 start_window_ms(self.window_ms)?;
126 let fence = self
127 .node
128 .execution_fence
129 .as_deref()
130 .ok_or_else(|| "node must carry a live execution fence".to_string())?;
131 if self.node.task_ref.is_empty() || self.node.policy_ref.is_empty() {
132 return Err("node task/policy context is not configured".into());
133 }
134 let canonical_root = self
135 .project_root
136 .canonicalize()
137 .map_err(|error| format!("invalid project root: {error}"))?;
138 if !canonical_root.is_dir() {
139 return Err("project root is not a directory".into());
140 }
141 Ok(fence)
142 }
143
144 pub fn verify_capsule(
146 &self,
147 signed: &SignedContextCapsuleV1,
148 root_key: &VerifyingKey,
149 ) -> Result<(), String> {
150 signed
151 .verify(root_key)
152 .map_err(|error| format!("capsule verification failed: {error}"))?;
153 self.validate_capsule_scope(&signed.capsule)
154 }
155
156 fn validate_capsule_scope(&self, capsule: &ContextCapsuleV1) -> Result<(), String> {
157 if capsule.capsule_id != self.node.capsule_ref
158 || capsule.task_ref != self.node.task_ref
159 || capsule.policy_ref != self.node.policy_ref
160 || capsule.expected_outcome_ref != self.node.expected_outcome_ref
161 || capsule.chain.chain_id != format!("graph:{}", self.graph_id)
162 || capsule.chain.parent_capsule_ref != self.parent_capsule_ref
163 || capsule.chain.hop != self.node.depth
164 || capsule.chain.owner_agent_id != self.root_agent_id
165 || !capsule
166 .allowed_agent_ids
167 .iter()
168 .any(|agent| agent == &self.node.agent_id)
169 || capsule.budget.tokens_remaining < self.node.budget.tokens_allocated
170 || capsule.budget.cost_micros_remaining < self.node.budget.cost_micros_allocated
171 || capsule.budget.latency_ms_remaining < self.window_ms
172 {
173 return Err("capsule does not authorize this node or budget".into());
174 }
175 Ok(())
176 }
177}
178
179#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct ChildDeliveryProfileV1 {
184 pub task_id: String,
185 pub expires_at: chrono::DateTime<chrono::Utc>,
186 pub profile: crate::core::a2a::task::delivery_authority::DeliverySigningProfileV1,
187}
188
189pub const NO_CHILD_DELIVERY: &str = "{}";
192pub const CHILD_DELIVERY_ENV: &str = "LEAN_CTX_DELIVERY_PROFILE";
193pub const ISSUER_ENV: &str = "LEAN_CTX_DELIVERY_ISSUER_PROFILE";
195
196impl ChildDeliveryProfileV1 {
197 pub fn launch_value(&self, task_id: &str, working_dir: &Path) -> Option<String> {
200 if self.task_id != task_id
201 || self.expires_at <= chrono::Utc::now()
202 || self.profile.schema_version != 1
203 || !self.profile.project_root.is_absolute()
204 {
205 return None;
206 }
207 let root = self.profile.project_root.canonicalize().ok()?;
208 if self.profile.delegation.as_ref().is_some_and(|certificate| {
209 certificate.execution.task_id != task_id
210 || self.expires_at > certificate.expires_at
211 || certificate.schema_version != 1
212 || certificate.issued_at > chrono::Utc::now()
213 || certificate.project_root != root
214 || certificate.privacy != self.profile.privacy
215 || certificate.child_agent != self.profile.agent_id
216 || certificate.child_key_id != self.profile.key_id
217 || certificate.recipient != self.profile.recipient
218 || certificate.tenant_id != self.profile.tenant_id
219 || certificate.project_id != self.profile.project_id
220 || certificate.read_grant_id != self.profile.read_grant_id
221 || certificate
222 .write_grant_id
223 .as_ref()
224 .is_some_and(|grant| grant != &self.profile.write_grant_id)
225 || !crate::core::agent_identity::get_stored_public_key(&self.profile.agent_id)
226 .is_ok_and(|key| {
227 crate::core::agent_identity::hex_encode(key.as_bytes())
228 == certificate.child_public_key
229 })
230 }) {
231 return None;
232 }
233 if !root.is_dir() || root != working_dir.canonicalize().ok()? {
234 return None;
235 }
236 let raw = serde_json::to_string(&self.profile).ok()?;
237 (raw.len() <= 16_384).then_some(raw)
238 }
239}
240
241#[cfg(any(test, feature = "test-support"))]
242pub mod test_support;
243#[cfg(test)]
244pub(crate) mod tests;