Skip to main content

lean_ctx/core/
work_graph_delegation.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Control boundary for host-started Work Graph nodes.
3//!
4//! The host launches the child agent; LeanCTX binds that launch to one claimed
5//! node incarnation (fence), verifies the handed-over context capsule and, when
6//! the host is configured as delivery issuer, issues the child's narrow,
7//! certificate-bound delivery profile. Stopping the node revokes delivery.
8
9use 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;
20/// Default lease window of a host-started node.
21pub const DEFAULT_START_WINDOW_MS: u64 = 300_000;
22
23/// Validate before acquiring a claim or provisioning delivery authority.
24pub 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
33/// Node exclusivity across processes, but never across unrelated project stores.
34pub 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
47/// Project-bound lease of one node path claim (`path:<project-hash>/<rel>` as
48/// stored by delegate, or a plain `path:<rel>`). It is the resource a
49/// `ctx_agent` lease of the same file names.
50pub 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        // A claim canonicalized for another project root must never lease
58        // a path of this one.
59        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
70/// One execution incarnation, shared by delivery certificates and receipts.
71/// Length-delimited serialization prevents graph/node separator collisions.
72pub 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
89/// Use the authoritative persisted store scope, including its project identity.
90pub(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
104/// Delivery task identity of one attempt within an execution incarnation.
105pub fn attempt_task_id(execution_key: &str, attempt: u8) -> String {
106    format!("{execution_key}:attempt-{attempt}")
107}
108
109/// The claimed node a host is about to launch, with its authority lineage.
110#[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    /// Verify the handed-over capsule: root signature, node lineage and budget.
145    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/// Host-issued delivery identity of one child attempt. Contains identifiers
180/// and a host-signed certificate, never key material.
181#[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
189/// Fail-closed child value: an explicit empty object disables delegated
190/// delivery, whereas an absent variable would let a child inherit the parent's.
191pub const NO_CHILD_DELIVERY: &str = "{}";
192pub const CHILD_DELIVERY_ENV: &str = "LEAN_CTX_DELIVERY_PROFILE";
193/// The parent's issuer configuration must never reach a child.
194pub const ISSUER_ENV: &str = "LEAN_CTX_DELIVERY_ISSUER_PROFILE";
195
196impl ChildDeliveryProfileV1 {
197    /// Serialized value for the child's `LEAN_CTX_DELIVERY_PROFILE`, or `None`
198    /// when the profile no longer binds this task, project and child key.
199    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;