1use 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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(tag = "op", rename_all = "snake_case")]
53pub enum WorkflowPatchOperation {
54 InsertNode {
59 node_id: String,
60 #[serde(default)]
61 node: WorkflowPatchNodeBody,
62 },
63 AddEdge {
66 from: String,
67 to: String,
68 #[serde(default)]
69 branch: Option<String>,
70 #[serde(default)]
71 label: Option<String>,
72 },
73 UpsertPromptCapsule {
78 capsule_id: String,
79 capsule: WorkflowPatchPromptCapsuleBody,
80 },
81 UpdateNodePolicy {
85 node_id: String,
86 policy: WorkflowPatchNodePolicyBody,
87 },
88 UpdateBundlePolicy {
91 policy: WorkflowPatchBundlePolicyBody,
92 },
93}
94
95#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
240pub struct CapabilityCeilingViolation {
241 pub kind: String,
242 pub detail: String,
243}
244
245pub 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
280pub 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
323pub 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 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;