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