Skip to main content

harn_vm/orchestration/
workflow_patch.rs

1//! Workflow patch proposals: a bounded, auditable contract that lets an
2//! agent author propose changes to a portable [`WorkflowBundle`] without
3//! touching the live runtime.
4//!
5//! The shape is intentionally small. An agent emits a JSON document
6//! describing a sequence of [`WorkflowPatchOperation`]s; Harn applies
7//! them to a copy of the bundle, runs the existing bundle validator,
8//! computes a capability-ceiling delta against the parent execution
9//! policy (when one is supplied), and returns a single
10//! [`WorkflowPatchValidationReport`] the host can render.
11//!
12//! The patch surface deliberately mirrors what the issue calls out:
13//! insert agent / verifier / approval node, update prompt capsule,
14//! update model & tool policy, add edge. Anything not in this list
15//! goes through a normal bundle edit instead — the patch contract is
16//! the meta-programming layer, not a general bundle DSL.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use serde::{Deserialize, Serialize};
21
22use super::policy::operation_is_covered;
23use super::workflow::{WorkflowEdge, WorkflowNode};
24use super::workflow_bundle::{
25    preview_workflow_bundle, validate_workflow_bundle, WorkflowBundle, WorkflowBundleGraphExport,
26    WorkflowBundlePolicy, WorkflowBundleValidationReport,
27};
28use super::CapabilityPolicy;
29
30pub const WORKFLOW_PATCH_SCHEMA_VERSION: u32 = 1;
31
32/// A bounded, auditable proposal to mutate a workflow bundle.
33///
34/// Patches are flat lists of [`WorkflowPatchOperation`]s applied in
35/// order. An empty operations list is rejected by [`apply_workflow_patch`]
36/// — silent no-ops would let agents claim work they did not do.
37#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(default)]
39pub struct WorkflowPatch {
40    pub schema_version: u32,
41    pub id: String,
42    pub summary: Option<String>,
43    pub operations: Vec<WorkflowPatchOperation>,
44}
45
46/// Operations are tagged by an external `op` discriminator so handlers
47/// can dispatch on the literal string from JSON.
48///
49/// Only the operations called out in #1423 are exposed: insert node,
50/// add edge, upsert prompt capsule, update node policy, update bundle
51/// policy. New operations require a deliberate contract bump.
52#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(tag = "op", rename_all = "snake_case")]
54pub enum WorkflowPatchOperation {
55    /// Insert a new workflow node. Fails if `node_id` already exists or
56    /// is empty. The `node` body uses the same shape as
57    /// `workflow.nodes[k]` in a bundle JSON, with sensible defaults
58    /// applied for unspecified fields.
59    InsertNode {
60        node_id: String,
61        #[serde(default)]
62        node: WorkflowPatchNodeBody,
63    },
64    /// Append an edge to the workflow graph. Fails if either endpoint
65    /// references an unknown node, or if the edge already exists.
66    AddEdge {
67        from: String,
68        to: String,
69        #[serde(default)]
70        branch: Option<String>,
71        #[serde(default)]
72        label: Option<String>,
73    },
74    /// Insert or replace a prompt capsule. The capsule's `node_id` must
75    /// reference a node that exists after all prior patch operations
76    /// have been applied (so an `InsertNode` followed by
77    /// `UpsertPromptCapsule` works).
78    UpsertPromptCapsule {
79        capsule_id: String,
80        capsule: WorkflowPatchPromptCapsuleBody,
81    },
82    /// Merge per-node policy fields onto an existing node. Only the
83    /// fields named on [`WorkflowPatchNodePolicyBody`] can be set —
84    /// arbitrary node fields are intentionally not patchable.
85    UpdateNodePolicy {
86        node_id: String,
87        policy: WorkflowPatchNodePolicyBody,
88    },
89    /// Merge bundle-level policy fields. Mirrors the safe knobs in
90    /// [`WorkflowBundlePolicy`].
91    UpdateBundlePolicy {
92        policy: WorkflowPatchBundlePolicyBody,
93    },
94}
95
96/// Shape used by [`WorkflowPatchOperation::InsertNode`]. Mirrors the
97/// editable fields on [`WorkflowNode`] without exposing every tunable.
98#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(default)]
100pub struct WorkflowPatchNodeBody {
101    pub kind: Option<String>,
102    pub task_label: Option<String>,
103    pub prompt: Option<String>,
104    pub system: Option<String>,
105    pub tools: Option<serde_json::Value>,
106    pub model_policy: Option<serde_json::Value>,
107    pub capability_policy: Option<CapabilityPolicy>,
108    pub approval_policy: Option<serde_json::Value>,
109    pub metadata: BTreeMap<String, serde_json::Value>,
110}
111
112/// Shape used by [`WorkflowPatchOperation::UpdateNodePolicy`]. Each
113/// field maps to the same-named field on [`WorkflowNode`] and is
114/// merged in place (set when `Some`, left alone when `None`).
115#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(default)]
117pub struct WorkflowPatchNodePolicyBody {
118    pub task_label: Option<String>,
119    pub prompt: Option<String>,
120    pub system: Option<String>,
121    pub tools: Option<serde_json::Value>,
122    pub model_policy: Option<serde_json::Value>,
123    pub capability_policy: Option<CapabilityPolicy>,
124    pub approval_policy: Option<serde_json::Value>,
125}
126
127/// Shape used by [`WorkflowPatchOperation::UpsertPromptCapsule`]. The
128/// patch always sets `id` to match `capsule_id` so the bundle invariant
129/// (`capsule.id == map_key`) holds without callers needing to repeat it.
130#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(default)]
132pub struct WorkflowPatchPromptCapsuleBody {
133    pub node_id: String,
134    pub trigger_id: Option<String>,
135    pub prompt: String,
136    pub system: Option<String>,
137    pub context: BTreeMap<String, serde_json::Value>,
138}
139
140/// Shape used by [`WorkflowPatchOperation::UpdateBundlePolicy`]. Each
141/// field replaces the corresponding [`WorkflowBundlePolicy`] field when
142/// `Some`. The `tool_policy` and `approval_required` lists replace
143/// rather than merge to keep the contract obvious — agents that want a
144/// merge should compute and submit the full list.
145#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(default)]
147pub struct WorkflowPatchBundlePolicyBody {
148    pub autonomy_tier: Option<String>,
149    pub tool_policy: Option<BTreeMap<String, serde_json::Value>>,
150    pub approval_required: Option<Vec<String>>,
151    pub retry: Option<serde_json::Value>,
152    pub catchup: Option<serde_json::Value>,
153}
154
155/// What a host renders when it shows the result of validating a patch.
156#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
157pub struct WorkflowPatchValidationReport {
158    pub schema_version: u32,
159    pub patch_id: String,
160    pub bundle_id: String,
161    pub valid: bool,
162    pub apply_errors: Vec<WorkflowPatchDiagnostic>,
163    pub bundle_validation: WorkflowBundleValidationReport,
164    pub graph_diff: WorkflowPatchGraphDiff,
165    pub capability_delta: WorkflowPatchCapabilityDelta,
166    pub graph_export: WorkflowBundleGraphExport,
167}
168
169/// One structured failure from applying a patch. The `op_index` lets
170/// the host highlight the offending operation in its review surface.
171#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
172pub struct WorkflowPatchDiagnostic {
173    pub severity: String,
174    pub op_index: Option<usize>,
175    pub op: Option<String>,
176    pub path: String,
177    pub message: String,
178    pub node_id: Option<String>,
179}
180
181/// Structural diff between the original and patched workflow graph.
182/// Used by host UIs and the patch-authoring skill so a model that
183/// proposes a patch can tell at a glance whether its edit produced the
184/// shape it intended.
185#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
186pub struct WorkflowPatchGraphDiff {
187    pub added_nodes: Vec<String>,
188    pub added_edges: Vec<WorkflowPatchEdgeRef>,
189    pub updated_nodes: Vec<String>,
190    pub updated_capsules: Vec<String>,
191    pub policy_fields_changed: Vec<String>,
192}
193
194#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
195pub struct WorkflowPatchEdgeRef {
196    pub from: String,
197    pub to: String,
198    pub branch: Option<String>,
199    pub label: Option<String>,
200}
201
202/// Capability ceiling delta between the bundle before and after the
203/// patch, optionally compared against a parent execution policy.
204///
205/// `widening` collects every dimension where the patched bundle asks
206/// for *more* than the parent ceiling (or the original bundle, when no
207/// parent is supplied). The patch is rejected when this list is
208/// non-empty — agents must not be able to expand permissions.
209#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
210pub struct WorkflowPatchCapabilityDelta {
211    pub before: CapabilityPolicy,
212    pub after: CapabilityPolicy,
213    pub parent: Option<CapabilityPolicy>,
214    pub added_tools: Vec<String>,
215    pub added_capabilities: BTreeMap<String, Vec<String>>,
216    pub raised_side_effect_level: Option<RaisedSideEffectLevel>,
217    pub added_workspace_roots: Vec<String>,
218    #[serde(default)]
219    pub added_read_only_roots: Vec<String>,
220    pub added_connector_scopes: BTreeMap<String, Vec<String>>,
221    pub added_command_gates: Vec<String>,
222    pub raised_autonomy_tier: Option<RaisedAutonomyTier>,
223    pub widening: Vec<CapabilityCeilingViolation>,
224}
225
226#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
227pub struct RaisedSideEffectLevel {
228    pub from: String,
229    pub to: String,
230}
231
232#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
233pub struct RaisedAutonomyTier {
234    pub from: String,
235    pub to: String,
236}
237
238/// One concrete way the patched bundle exceeds the parent ceiling.
239/// `kind` is a stable enum string so hosts can group/explain them.
240#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
241pub struct CapabilityCeilingViolation {
242    pub kind: String,
243    pub detail: String,
244}
245
246/// Apply a patch to a copy of the bundle and return the new bundle.
247/// Fails fast on the first structural error; callers that want richer
248/// diagnostics should prefer [`validate_workflow_patch`].
249pub fn apply_workflow_patch(
250    bundle: &WorkflowBundle,
251    patch: &WorkflowPatch,
252) -> Result<WorkflowBundle, Vec<WorkflowPatchDiagnostic>> {
253    let mut errors = Vec::new();
254    if patch.schema_version != WORKFLOW_PATCH_SCHEMA_VERSION {
255        errors.push(diagnostic_global(format!(
256            "unsupported patch schema_version {}; expected {}",
257            patch.schema_version, WORKFLOW_PATCH_SCHEMA_VERSION
258        )));
259    }
260    if patch.id.trim().is_empty() {
261        errors.push(diagnostic_global("patch id is required".to_string()));
262    }
263    if patch.operations.is_empty() {
264        errors.push(diagnostic_global(
265            "patch contains no operations; refusing to no-op".to_string(),
266        ));
267    }
268    if !errors.is_empty() {
269        return Err(errors);
270    }
271
272    let mut working = bundle.clone();
273    for (index, operation) in patch.operations.iter().enumerate() {
274        if let Err(diag) = apply_operation(&mut working, operation, index) {
275            return Err(vec![diag]);
276        }
277    }
278    Ok(working)
279}
280
281/// Apply + validate + diff + ceiling check, in one pass. The bundle
282/// validator is the source of truth for "is this still a valid bundle?";
283/// this function adds the patch-specific apply errors, the structural
284/// diff, and the capability delta on top.
285pub fn validate_workflow_patch(
286    bundle: &WorkflowBundle,
287    patch: &WorkflowPatch,
288    parent_ceiling: Option<&CapabilityPolicy>,
289) -> WorkflowPatchValidationReport {
290    let before_ceiling = bundle_capability_ceiling(bundle);
291
292    let (patched, apply_errors) = match apply_workflow_patch(bundle, patch) {
293        Ok(patched) => (patched, Vec::new()),
294        Err(errors) => (bundle.clone(), errors),
295    };
296
297    let bundle_validation = validate_workflow_bundle(&patched);
298    let graph_diff = diff_bundle_graph(bundle, &patched, patch);
299    let after_ceiling = bundle_capability_ceiling(&patched);
300    let capability_delta = compute_capability_delta(
301        bundle,
302        &patched,
303        before_ceiling,
304        after_ceiling,
305        parent_ceiling,
306    );
307    let graph_export = preview_workflow_bundle(&patched).graph;
308    let valid =
309        apply_errors.is_empty() && bundle_validation.valid && capability_delta.widening.is_empty();
310
311    WorkflowPatchValidationReport {
312        schema_version: WORKFLOW_PATCH_SCHEMA_VERSION,
313        patch_id: patch.id.clone(),
314        bundle_id: bundle.id.clone(),
315        valid,
316        apply_errors,
317        bundle_validation,
318        graph_diff,
319        capability_delta,
320        graph_export,
321    }
322}
323
324/// Project a bundle's effective capability ceiling. The bundle does
325/// not carry a single capability policy; we compose one from the
326/// per-node `capability_policy` declarations, the bundle-level
327/// `tool_policy` keys, the connector scopes, the autonomy tier, the
328/// `worktree_policy`, and the declared `command_gates`. The result is
329/// what a parent runtime needs to compare against to decide whether
330/// running this bundle would widen its own ceiling.
331pub fn bundle_capability_ceiling(bundle: &WorkflowBundle) -> CapabilityPolicy {
332    let mut tools: BTreeSet<String> = bundle.policy.tool_policy.keys().cloned().collect();
333    let mut capabilities: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
334    let mut workspace_roots: BTreeSet<String> = BTreeSet::new();
335    let mut read_only_roots: BTreeSet<String> = BTreeSet::new();
336    let mut max_side_effect: Option<&'static str> = None;
337
338    for node in bundle.workflow.nodes.values() {
339        for tool in node.capability_policy.allowed_tool_patterns() {
340            tools.insert(tool.to_string());
341        }
342        for (capability, ops) in node.capability_policy.allowed_capabilities() {
343            let entry = capabilities.entry(capability.to_string()).or_default();
344            for op in ops {
345                entry.insert(op.clone());
346            }
347        }
348        for root in &node.capability_policy.workspace_roots {
349            workspace_roots.insert(root.clone());
350        }
351        for root in &node.capability_policy.read_only_roots {
352            read_only_roots.insert(root.clone());
353        }
354        if let Some(level) = node.capability_policy.side_effect_level.as_deref() {
355            max_side_effect = match max_side_effect {
356                Some(current) if side_effect_rank(current) >= side_effect_rank(level) => {
357                    Some(current)
358                }
359                _ => Some(static_side_effect(level)),
360            };
361        }
362    }
363
364    let autonomy_floor = autonomy_side_effect_floor(&bundle.policy.autonomy_tier);
365    if let Some(floor) = autonomy_floor {
366        max_side_effect = match max_side_effect {
367            Some(current) if side_effect_rank(current) >= side_effect_rank(floor) => Some(current),
368            _ => Some(floor),
369        };
370    }
371
372    if !bundle.connectors.is_empty() {
373        capabilities
374            .entry("connector".to_string())
375            .or_default()
376            .insert("call".to_string());
377    }
378    if !bundle.environment.command_gates.is_empty()
379        || bundle.environment.worktree_policy != "host_managed"
380    {
381        capabilities
382            .entry("process".to_string())
383            .or_default()
384            .insert("exec".to_string());
385        max_side_effect = match max_side_effect {
386            Some(current) if side_effect_rank(current) >= side_effect_rank("process_exec") => {
387                Some(current)
388            }
389            _ => Some("process_exec"),
390        };
391    }
392
393    CapabilityPolicy {
394        tools: tools.into_iter().collect(),
395        capabilities: capabilities
396            .into_iter()
397            .map(|(k, v)| (k, v.into_iter().collect()))
398            .collect(),
399        workspace_roots: workspace_roots.into_iter().collect(),
400        read_only_roots: read_only_roots.into_iter().collect(),
401        side_effect_level: max_side_effect.map(|level| level.to_string()),
402        recursion_limit: None,
403        tool_arg_constraints: Vec::new(),
404        tool_annotations: BTreeMap::new(),
405        sandbox_profile: crate::orchestration::SandboxProfile::default(),
406        process_sandbox: Default::default(),
407    }
408}
409
410fn apply_operation(
411    bundle: &mut WorkflowBundle,
412    operation: &WorkflowPatchOperation,
413    index: usize,
414) -> Result<(), WorkflowPatchDiagnostic> {
415    match operation {
416        WorkflowPatchOperation::InsertNode { node_id, node } => {
417            if node_id.trim().is_empty() {
418                return Err(diagnostic_op(
419                    index,
420                    "insert_node",
421                    "operations".to_string(),
422                    "insert_node node_id is required".to_string(),
423                    None,
424                ));
425            }
426            if bundle.workflow.nodes.contains_key(node_id) {
427                return Err(diagnostic_op(
428                    index,
429                    "insert_node",
430                    format!("workflow.nodes.{node_id}"),
431                    format!("workflow already contains node {node_id}"),
432                    Some(node_id.clone()),
433                ));
434            }
435            let workflow_node = node_body_into_workflow_node(node_id, node);
436            bundle.workflow.nodes.insert(node_id.clone(), workflow_node);
437            if bundle.workflow.entry.is_empty() {
438                bundle.workflow.entry = node_id.clone();
439            }
440            Ok(())
441        }
442        WorkflowPatchOperation::AddEdge {
443            from,
444            to,
445            branch,
446            label,
447        } => {
448            if !bundle.workflow.nodes.contains_key(from) {
449                return Err(diagnostic_op(
450                    index,
451                    "add_edge",
452                    "edges.from".to_string(),
453                    format!("edge.from references unknown node: {from}"),
454                    Some(from.clone()),
455                ));
456            }
457            if !bundle.workflow.nodes.contains_key(to) {
458                return Err(diagnostic_op(
459                    index,
460                    "add_edge",
461                    "edges.to".to_string(),
462                    format!("edge.to references unknown node: {to}"),
463                    Some(to.clone()),
464                ));
465            }
466            let candidate = WorkflowEdge {
467                from: from.clone(),
468                to: to.clone(),
469                branch: branch.clone(),
470                label: label.clone(),
471            };
472            if bundle.workflow.edges.iter().any(|edge| {
473                edge.from == candidate.from
474                    && edge.to == candidate.to
475                    && edge.branch == candidate.branch
476                    && edge.label == candidate.label
477            }) {
478                return Err(diagnostic_op(
479                    index,
480                    "add_edge",
481                    "edges".to_string(),
482                    format!("edge {from} -> {to} already exists"),
483                    Some(from.clone()),
484                ));
485            }
486            bundle.workflow.edges.push(candidate);
487            Ok(())
488        }
489        WorkflowPatchOperation::UpsertPromptCapsule {
490            capsule_id,
491            capsule,
492        } => {
493            if capsule_id.trim().is_empty() {
494                return Err(diagnostic_op(
495                    index,
496                    "upsert_prompt_capsule",
497                    "prompt_capsules".to_string(),
498                    "capsule_id is required".to_string(),
499                    None,
500                ));
501            }
502            if !bundle.workflow.nodes.contains_key(&capsule.node_id) {
503                return Err(diagnostic_op(
504                    index,
505                    "upsert_prompt_capsule",
506                    format!("prompt_capsules.{capsule_id}.node_id"),
507                    format!(
508                        "prompt capsule references unknown node: {}",
509                        capsule.node_id
510                    ),
511                    Some(capsule.node_id.clone()),
512                ));
513            }
514            let existing = bundle
515                .prompt_capsules
516                .values()
517                .find(|other| other.node_id == capsule.node_id && other.id != *capsule_id);
518            if let Some(other) = existing {
519                return Err(diagnostic_op(
520                    index,
521                    "upsert_prompt_capsule",
522                    format!("prompt_capsules.{capsule_id}.node_id"),
523                    format!(
524                        "prompt capsule {capsule_id} would target node {} but capsule {} already targets it",
525                        capsule.node_id, other.id
526                    ),
527                    Some(capsule.node_id.clone()),
528                ));
529            }
530            let capsule_value = super::workflow_bundle::PromptCapsule {
531                id: capsule_id.clone(),
532                node_id: capsule.node_id.clone(),
533                trigger_id: capsule.trigger_id.clone(),
534                prompt: capsule.prompt.clone(),
535                system: capsule.system.clone(),
536                context: capsule.context.clone(),
537            };
538            bundle
539                .prompt_capsules
540                .insert(capsule_id.clone(), capsule_value);
541            Ok(())
542        }
543        WorkflowPatchOperation::UpdateNodePolicy { node_id, policy } => {
544            let Some(node) = bundle.workflow.nodes.get_mut(node_id) else {
545                return Err(diagnostic_op(
546                    index,
547                    "update_node_policy",
548                    format!("workflow.nodes.{node_id}"),
549                    format!("workflow does not contain node {node_id}"),
550                    Some(node_id.clone()),
551                ));
552            };
553            apply_node_policy_body(node, policy).map_err(|message| {
554                diagnostic_op(
555                    index,
556                    "update_node_policy",
557                    format!("workflow.nodes.{node_id}"),
558                    message,
559                    Some(node_id.clone()),
560                )
561            })?;
562            Ok(())
563        }
564        WorkflowPatchOperation::UpdateBundlePolicy { policy } => {
565            apply_bundle_policy_body(&mut bundle.policy, policy).map_err(|message| {
566                diagnostic_op(
567                    index,
568                    "update_bundle_policy",
569                    "policy".to_string(),
570                    message,
571                    None,
572                )
573            })?;
574            Ok(())
575        }
576    }
577}
578
579fn node_body_into_workflow_node(node_id: &str, body: &WorkflowPatchNodeBody) -> WorkflowNode {
580    let mut node = WorkflowNode {
581        id: Some(node_id.to_string()),
582        kind: body
583            .kind
584            .clone()
585            .filter(|kind| !kind.trim().is_empty())
586            .unwrap_or_else(|| "stage".to_string()),
587        ..WorkflowNode::default()
588    };
589    node.task_label = body.task_label.clone();
590    node.prompt = body.prompt.clone();
591    node.system = body.system.clone();
592    if let Some(tools) = &body.tools {
593        node.tools = tools.clone();
594    }
595    if let Some(model_policy) = &body.model_policy {
596        if let Ok(parsed) = serde_json::from_value(model_policy.clone()) {
597            node.model_policy = parsed;
598        }
599    }
600    if let Some(capability_policy) = &body.capability_policy {
601        node.capability_policy = capability_policy.clone();
602    }
603    if let Some(approval_policy) = &body.approval_policy {
604        if let Ok(parsed) = serde_json::from_value(approval_policy.clone()) {
605            node.approval_policy = parsed;
606        }
607    }
608    node.metadata = body.metadata.clone();
609    node
610}
611
612fn apply_node_policy_body(
613    node: &mut WorkflowNode,
614    body: &WorkflowPatchNodePolicyBody,
615) -> Result<(), String> {
616    if let Some(label) = &body.task_label {
617        node.task_label = Some(label.clone());
618    }
619    if let Some(prompt) = &body.prompt {
620        node.prompt = Some(prompt.clone());
621    }
622    if let Some(system) = &body.system {
623        node.system = Some(system.clone());
624    }
625    if let Some(tools) = &body.tools {
626        node.tools = tools.clone();
627    }
628    if let Some(model_policy) = &body.model_policy {
629        node.model_policy = serde_json::from_value(model_policy.clone())
630            .map_err(|error| format!("invalid model_policy: {error}"))?;
631    }
632    if let Some(capability_policy) = &body.capability_policy {
633        node.capability_policy = capability_policy.clone();
634    }
635    if let Some(approval_policy) = &body.approval_policy {
636        node.approval_policy = serde_json::from_value(approval_policy.clone())
637            .map_err(|error| format!("invalid approval_policy: {error}"))?;
638    }
639    Ok(())
640}
641
642fn apply_bundle_policy_body(
643    policy: &mut WorkflowBundlePolicy,
644    body: &WorkflowPatchBundlePolicyBody,
645) -> Result<(), String> {
646    if let Some(autonomy) = &body.autonomy_tier {
647        policy.autonomy_tier = autonomy.clone();
648    }
649    if let Some(tool_policy) = &body.tool_policy {
650        policy.tool_policy = tool_policy.clone();
651    }
652    if let Some(approval_required) = &body.approval_required {
653        policy.approval_required = approval_required.clone();
654    }
655    if let Some(retry) = &body.retry {
656        policy.retry = serde_json::from_value(retry.clone())
657            .map_err(|error| format!("invalid retry: {error}"))?;
658    }
659    if let Some(catchup) = &body.catchup {
660        policy.catchup = serde_json::from_value(catchup.clone())
661            .map_err(|error| format!("invalid catchup: {error}"))?;
662    }
663    Ok(())
664}
665
666fn diff_bundle_graph(
667    before: &WorkflowBundle,
668    after: &WorkflowBundle,
669    patch: &WorkflowPatch,
670) -> WorkflowPatchGraphDiff {
671    let mut diff = WorkflowPatchGraphDiff::default();
672    let before_node_ids: BTreeSet<&String> = before.workflow.nodes.keys().collect();
673    for node_id in after.workflow.nodes.keys() {
674        if !before_node_ids.contains(node_id) {
675            diff.added_nodes.push(node_id.clone());
676        }
677    }
678    let before_edges: BTreeSet<(String, String, Option<String>, Option<String>)> = before
679        .workflow
680        .edges
681        .iter()
682        .map(|edge| {
683            (
684                edge.from.clone(),
685                edge.to.clone(),
686                edge.branch.clone(),
687                edge.label.clone(),
688            )
689        })
690        .collect();
691    for edge in &after.workflow.edges {
692        let key = (
693            edge.from.clone(),
694            edge.to.clone(),
695            edge.branch.clone(),
696            edge.label.clone(),
697        );
698        if !before_edges.contains(&key) {
699            diff.added_edges.push(WorkflowPatchEdgeRef {
700                from: edge.from.clone(),
701                to: edge.to.clone(),
702                branch: edge.branch.clone(),
703                label: edge.label.clone(),
704            });
705        }
706    }
707    for operation in &patch.operations {
708        match operation {
709            WorkflowPatchOperation::UpdateNodePolicy { node_id, .. } => {
710                diff.updated_nodes.push(node_id.clone());
711            }
712            WorkflowPatchOperation::UpsertPromptCapsule { capsule_id, .. } => {
713                diff.updated_capsules.push(capsule_id.clone());
714            }
715            WorkflowPatchOperation::UpdateBundlePolicy { policy } => {
716                if policy.autonomy_tier.is_some() {
717                    diff.policy_fields_changed.push("autonomy_tier".to_string());
718                }
719                if policy.tool_policy.is_some() {
720                    diff.policy_fields_changed.push("tool_policy".to_string());
721                }
722                if policy.approval_required.is_some() {
723                    diff.policy_fields_changed
724                        .push("approval_required".to_string());
725                }
726                if policy.retry.is_some() {
727                    diff.policy_fields_changed.push("retry".to_string());
728                }
729                if policy.catchup.is_some() {
730                    diff.policy_fields_changed.push("catchup".to_string());
731                }
732            }
733            _ => {}
734        }
735    }
736    diff.added_nodes.sort();
737    diff.updated_nodes.sort();
738    diff.updated_nodes.dedup();
739    diff.updated_capsules.sort();
740    diff.updated_capsules.dedup();
741    diff.policy_fields_changed.sort();
742    diff.policy_fields_changed.dedup();
743    diff.added_edges
744        .sort_by(|left, right| (&left.from, &left.to).cmp(&(&right.from, &right.to)));
745    diff
746}
747
748fn compute_capability_delta(
749    before_bundle: &WorkflowBundle,
750    after_bundle: &WorkflowBundle,
751    before: CapabilityPolicy,
752    after: CapabilityPolicy,
753    parent: Option<&CapabilityPolicy>,
754) -> WorkflowPatchCapabilityDelta {
755    let added_tools: Vec<String> = after
756        .tools
757        .iter()
758        .filter(|tool| !before.tools.contains(tool))
759        .cloned()
760        .collect();
761
762    let mut added_capabilities: BTreeMap<String, Vec<String>> = BTreeMap::new();
763    for (capability, ops) in &after.capabilities {
764        let before_ops = before
765            .capabilities
766            .get(capability)
767            .cloned()
768            .unwrap_or_default();
769        let added: Vec<String> = ops
770            .iter()
771            .filter(|op| !before_ops.contains(op))
772            .cloned()
773            .collect();
774        if !added.is_empty() {
775            added_capabilities.insert(capability.clone(), added);
776        }
777    }
778
779    let raised_side_effect_level = match (
780        before.side_effect_level.as_deref(),
781        after.side_effect_level.as_deref(),
782    ) {
783        (Some(before_level), Some(after_level))
784            if side_effect_rank(after_level) > side_effect_rank(before_level) =>
785        {
786            Some(RaisedSideEffectLevel {
787                from: before_level.to_string(),
788                to: after_level.to_string(),
789            })
790        }
791        (None, Some(after_level)) => Some(RaisedSideEffectLevel {
792            from: "none".to_string(),
793            to: after_level.to_string(),
794        }),
795        _ => None,
796    };
797
798    let added_workspace_roots: Vec<String> = after
799        .workspace_roots
800        .iter()
801        .filter(|root| !before.workspace_roots.contains(root))
802        .cloned()
803        .collect();
804
805    let added_read_only_roots: Vec<String> = after
806        .read_only_roots
807        .iter()
808        .filter(|root| !before.read_only_roots.contains(root))
809        .cloned()
810        .collect();
811
812    let mut added_connector_scopes: BTreeMap<String, Vec<String>> = BTreeMap::new();
813    let before_scopes_by_id: BTreeMap<&str, BTreeSet<&str>> = before_bundle
814        .connectors
815        .iter()
816        .map(|connector| {
817            (
818                connector.id.as_str(),
819                connector.scopes.iter().map(String::as_str).collect(),
820            )
821        })
822        .collect();
823    for connector in &after_bundle.connectors {
824        let before_scopes = before_scopes_by_id
825            .get(connector.id.as_str())
826            .cloned()
827            .unwrap_or_default();
828        let added: Vec<String> = connector
829            .scopes
830            .iter()
831            .filter(|scope| !before_scopes.contains(scope.as_str()))
832            .cloned()
833            .collect();
834        if !added.is_empty() {
835            added_connector_scopes.insert(connector.id.clone(), added);
836        }
837    }
838
839    let added_command_gates: Vec<String> = after_bundle
840        .environment
841        .command_gates
842        .iter()
843        .filter(|gate| !before_bundle.environment.command_gates.contains(gate))
844        .cloned()
845        .collect();
846
847    let raised_autonomy_tier = match (
848        before_bundle.policy.autonomy_tier.as_str(),
849        after_bundle.policy.autonomy_tier.as_str(),
850    ) {
851        (before_tier, after_tier) if autonomy_rank(after_tier) > autonomy_rank(before_tier) => {
852            Some(RaisedAutonomyTier {
853                from: before_tier.to_string(),
854                to: after_tier.to_string(),
855            })
856        }
857        _ => None,
858    };
859
860    let widening = match parent {
861        Some(parent) => collect_ceiling_violations(
862            parent,
863            &after,
864            &added_connector_scopes,
865            &added_command_gates,
866            raised_autonomy_tier.as_ref(),
867        ),
868        None => Vec::new(),
869    };
870
871    WorkflowPatchCapabilityDelta {
872        before,
873        after,
874        parent: parent.cloned(),
875        added_tools,
876        added_capabilities,
877        raised_side_effect_level,
878        added_workspace_roots,
879        added_read_only_roots,
880        added_connector_scopes,
881        added_command_gates,
882        raised_autonomy_tier,
883        widening,
884    }
885}
886
887fn collect_ceiling_violations(
888    parent: &CapabilityPolicy,
889    requested: &CapabilityPolicy,
890    added_connector_scopes: &BTreeMap<String, Vec<String>>,
891    added_command_gates: &[String],
892    raised_autonomy_tier: Option<&RaisedAutonomyTier>,
893) -> Vec<CapabilityCeilingViolation> {
894    let mut violations = Vec::new();
895    if parent.tools_are_restricted() {
896        for tool in requested.allowed_tool_patterns() {
897            if !parent.tool_pattern_allows(tool) {
898                violations.push(CapabilityCeilingViolation {
899                    kind: "tool".to_string(),
900                    detail: format!("tool '{tool}' is not in parent tool ceiling"),
901                });
902            }
903        }
904    }
905    for (capability, ops) in requested.allowed_capabilities() {
906        match parent.capability_operations(capability) {
907            Some(parent_ops) => {
908                if ops.is_empty() && !parent_ops.is_empty() {
909                    violations.push(CapabilityCeilingViolation {
910                        kind: "capability".to_string(),
911                        detail: format!(
912                            "capability '{capability}' requests every operation beyond parent ceiling"
913                        ),
914                    });
915                    continue;
916                }
917                for op in ops {
918                    if !parent_ops.is_empty()
919                        && !parent_ops
920                            .iter()
921                            .any(|allowed| operation_is_covered(capability, allowed, op))
922                    {
923                        violations.push(CapabilityCeilingViolation {
924                            kind: "capability".to_string(),
925                            detail: format!(
926                                "capability '{capability}.{op}' exceeds parent ceiling"
927                            ),
928                        });
929                    }
930                }
931            }
932            None if parent.capabilities_are_restricted() => {
933                violations.push(CapabilityCeilingViolation {
934                    kind: "capability".to_string(),
935                    detail: format!("capability '{capability}' is not in parent ceiling"),
936                });
937            }
938            _ => {}
939        }
940    }
941    if let (Some(parent_level), Some(requested_level)) = (
942        parent.side_effect_level.as_deref(),
943        requested.side_effect_level.as_deref(),
944    ) {
945        if side_effect_rank(requested_level) > side_effect_rank(parent_level) {
946            violations.push(CapabilityCeilingViolation {
947                kind: "side_effect_level".to_string(),
948                detail: format!(
949                    "side_effect_level '{requested_level}' exceeds parent ceiling '{parent_level}'"
950                ),
951            });
952        }
953    }
954    if !parent.workspace_roots.is_empty() {
955        for root in &requested.workspace_roots {
956            if !parent.workspace_roots.contains(root) {
957                violations.push(CapabilityCeilingViolation {
958                    kind: "workspace_root".to_string(),
959                    detail: format!("workspace_root '{root}' exceeds parent allowlist"),
960                });
961            }
962        }
963    }
964    // A read-only root is within ceiling if the parent could read it —
965    // any of its writable or read-only roots.
966    if !parent.workspace_roots.is_empty() || !parent.read_only_roots.is_empty() {
967        for root in &requested.read_only_roots {
968            if !parent.workspace_roots.contains(root) && !parent.read_only_roots.contains(root) {
969                violations.push(CapabilityCeilingViolation {
970                    kind: "read_only_root".to_string(),
971                    detail: format!("read_only_root '{root}' exceeds parent allowlist"),
972                });
973            }
974        }
975    }
976    if !added_connector_scopes.is_empty() {
977        let parent_allows_connector_calls = parent
978            .capabilities
979            .get("connector")
980            .is_some_and(|ops| ops.iter().any(|op| op == "call"));
981        if !parent_allows_connector_calls && parent.capabilities_are_restricted() {
982            for (connector_id, scopes) in added_connector_scopes {
983                violations.push(CapabilityCeilingViolation {
984                    kind: "connector_scope".to_string(),
985                    detail: format!(
986                        "connector '{connector_id}' adds scopes {scopes:?} but parent ceiling does not include connector.call"
987                    ),
988                });
989            }
990        }
991    }
992    if !added_command_gates.is_empty() {
993        let parent_allows_exec = parent
994            .capabilities
995            .get("process")
996            .is_some_and(|ops| ops.iter().any(|op| op == "exec"));
997        if !parent_allows_exec && parent.capabilities_are_restricted() {
998            violations.push(CapabilityCeilingViolation {
999                kind: "command_gate".to_string(),
1000                detail: format!(
1001                    "patch adds command gates {added_command_gates:?} but parent ceiling does not include process.exec"
1002                ),
1003            });
1004        }
1005    }
1006    if let Some(raised) = raised_autonomy_tier {
1007        violations.push(CapabilityCeilingViolation {
1008            kind: "autonomy_tier".to_string(),
1009            detail: format!(
1010                "autonomy_tier raised from '{}' to '{}' — patches must not widen autonomy",
1011                raised.from, raised.to
1012            ),
1013        });
1014    }
1015    violations
1016}
1017
1018fn side_effect_rank(level: &str) -> usize {
1019    crate::tool_annotations::SideEffectLevel::rank_str(level)
1020}
1021
1022fn static_side_effect(level: &str) -> &'static str {
1023    // Canonical normalization (single source of truth). A previous local table
1024    // silently downgraded any level it didn't list (e.g. `desktop_control`) to
1025    // `none`; parsing through the ladder keeps every known level intact and maps
1026    // only a genuinely-unknown value to `none`.
1027    crate::tool_annotations::SideEffectLevel::parse(level).as_str()
1028}
1029
1030fn autonomy_rank(tier: &str) -> usize {
1031    match tier {
1032        "shadow" => 0,
1033        "suggest" => 1,
1034        "act_with_approval" => 2,
1035        "act_auto" => 3,
1036        _ => 0,
1037    }
1038}
1039
1040fn autonomy_side_effect_floor(tier: &str) -> Option<&'static str> {
1041    match tier {
1042        "act_auto" => Some("network"),
1043        "act_with_approval" => Some("process_exec"),
1044        "suggest" => Some("read_only"),
1045        _ => None,
1046    }
1047}
1048
1049fn diagnostic_op(
1050    index: usize,
1051    op: &str,
1052    path: String,
1053    message: String,
1054    node_id: Option<String>,
1055) -> WorkflowPatchDiagnostic {
1056    WorkflowPatchDiagnostic {
1057        severity: "error".to_string(),
1058        op_index: Some(index),
1059        op: Some(op.to_string()),
1060        path,
1061        message,
1062        node_id,
1063    }
1064}
1065
1066fn diagnostic_global(message: String) -> WorkflowPatchDiagnostic {
1067    WorkflowPatchDiagnostic {
1068        severity: "error".to_string(),
1069        op_index: None,
1070        op: None,
1071        path: "patch".to_string(),
1072        message,
1073        node_id: None,
1074    }
1075}
1076
1077#[cfg(test)]
1078#[path = "workflow_patch_tests.rs"]
1079mod workflow_patch_tests;