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