Repository: klothoplatform/klotho
Pull request #884 (2024-01-17 17:27:20)

add resource rule vertices
adds the ability to add additional rules into resource templates.

creates resource rule vertices that can operate as expected in op eval.

slight refactoring to the dependencies call so it can be unit tested

### Standard checks

- **Unit tests**: Any special considerations?
- **Docs**: Do we need to update any docs, internal or public?
- **Backwards compatibility**: Will this break existing apps? If so, what would be the extra work required to keep them working?



Patch:
diff --git a/README.md b/README.md
index fec34d9d..cacfe089 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ It's a superset of monoliths, microservices and serverless architectures, combin
 
 ## Infrastructure-from-Code
 
-Klotho is part of a new generation of cloud tools that implements Infrastructure-from-Code (IfC), a process to automatically create, configure and manage cloud resources from the existing software application's source code without having describe it explicitly.
+Klotho is part of a new generation of cloud tools that implements Infrastructure-from-Code (IfC), a process to automatically create, configure and manage cloud resources from the existing software application's source code without having to describe it explicitly.
 
 By annotating the clients, SDKs or language constructs used in the code with Klotho capabilities, they are automatically created, updated and wired into the application.
 
diff --git a/pkg/engine2/operational_eval/dependency_capture.go b/pkg/engine2/operational_eval/dependency_capture.go
index f603d162..c9a2b54f 100644
--- a/pkg/engine2/operational_eval/dependency_capture.go
+++ b/pkg/engine2/operational_eval/dependency_capture.go
@@ -11,14 +11,27 @@ import (
 	knowledgebase "github.com/klothoplatform/klotho/pkg/knowledge_base2"
 )
 
-// fauxConfigContext acts like a [knowledgebase.DynamicValueContext] but replaces the [FieldValue] function
-// with one that just returns the zero value of the property type and records the property reference.
-type fauxConfigContext struct {
-	propRef construct.PropertyRef
-	inner   knowledgebase.DynamicValueContext
-	changes graphChanges
-	src     Key
-}
+//go:generate 	mockgen -source=./dependency_capture.go --destination=./dependency_capture_mock_test.go --package=operational_eval
+
+type (
+	dependencyCapturer interface {
+		ExecuteOpRule(data knowledgebase.DynamicValueData, rule knowledgebase.OperationalRule) error
+		ExecutePropertyRule(data knowledgebase.DynamicValueData, rule knowledgebase.PropertyRule) error
+		DAG() construct.Graph
+		KB() knowledgebase.TemplateKB
+		ExecuteDecode(tmpl string, data knowledgebase.DynamicValueData, value interface{}) error
+		GetChanges() graphChanges
+	}
+
+	// fauxConfigContext acts like a [knowledgebase.DynamicValueContext] but replaces the [FieldValue] function
+	// with one that just returns the zero value of the property type and records the property reference.
+	fauxConfigContext struct {
+		propRef construct.PropertyRef
+		inner   knowledgebase.DynamicValueContext
+		changes graphChanges
+		src     Key
+	}
+)
 
 func newDepCapture(inner knowledgebase.DynamicValueContext, changes graphChanges, src Key) *fauxConfigContext {
 	return &fauxConfigContext{
@@ -46,6 +59,10 @@ func (ctx *fauxConfigContext) KB() knowledgebase.TemplateKB {
 	return ctx.inner.KB()
 }
 
+func (ctx *fauxConfigContext) GetChanges() graphChanges {
+	return ctx.changes
+}
+
 func (ctx *fauxConfigContext) ExecuteDecode(tmpl string, data knowledgebase.DynamicValueData, value interface{}) error {
 	t, err := template.New("config").Funcs(ctx.TemplateFunctions()).Parse(tmpl)
 	if err != nil {
diff --git a/pkg/engine2/operational_eval/evaluator.go b/pkg/engine2/operational_eval/evaluator.go
index f84e0ea9..45d3a1a0 100644
--- a/pkg/engine2/operational_eval/evaluator.go
+++ b/pkg/engine2/operational_eval/evaluator.go
@@ -31,6 +31,7 @@ type (
 
 	Key struct {
 		Ref               construct.PropertyRef
+		RuleHash          string
 		Edge              construct.SimpleEdge
 		GraphState        graphStateRepr
 		PathSatisfication knowledgebase.EdgePathSatisfaction
@@ -40,7 +41,7 @@ type (
 		Key() Key
 		Evaluate(eval *Evaluator) error
 		UpdateFrom(other Vertex)
-		Dependencies(eval *Evaluator) (graphChanges, error)
+		Dependencies(eval *Evaluator, propCtx dependencyCapturer) error
 	}
 
 	conditionalVertex interface {
@@ -372,11 +373,15 @@ func (changes graphChanges) addEdges(source Key, targets set.Set[Key]) {
 
 func (changes graphChanges) AddVertexAndDeps(eval *Evaluator, v Vertex) error {
 	changes.nodes[v.Key()] = v
-	deps, err := v.Dependencies(eval)
+
+	depCaptureChanges := newChanges()
+	propCtx := newDepCapture(solution_context.DynamicCtx(eval.Solution), depCaptureChanges, v.Key())
+
+	err := v.Dependencies(eval, propCtx)
 	if err != nil {
 		return fmt.Errorf("could not get dependencies for %s: %w", v.Key(), err)
 	}
-	changes.Merge(deps)
+	changes.Merge(depCaptureChanges)
 	return nil
 }
 
diff --git a/pkg/engine2/operational_eval/graph.go b/pkg/engine2/operational_eval/graph.go
index 66798ef5..4a37e4f4 100644
--- a/pkg/engine2/operational_eval/graph.go
+++ b/pkg/engine2/operational_eval/graph.go
@@ -155,6 +155,21 @@ func (eval *Evaluator) resourceVertices(
 		return nil
 	}
 	errs = errors.Join(errs, tmpl.LoopProperties(res, addProp))
+
+	for _, rule := range tmpl.AdditionalRules {
+		hash, err := rule.Hash()
+		if err != nil {
+			errs = errors.Join(errs, fmt.Errorf("could not hash rule for resource id %s: %w", res.ID, err))
+			continue
+		}
+		vertex := &resourceRuleVertex{
+			Resource: res.ID,
+			Rule:     rule,
+			hash:     hash,
+		}
+		errs = errors.Join(errs, changes.AddVertexAndDeps(eval, vertex))
+	}
+
 	return changes, errs
 }
 
diff --git a/pkg/engine2/operational_eval/vertex_edge.go b/pkg/engine2/operational_eval/vertex_edge.go
index 13b7f59b..84ca9fe3 100644
--- a/pkg/engine2/operational_eval/vertex_edge.go
+++ b/pkg/engine2/operational_eval/vertex_edge.go
@@ -3,9 +3,7 @@ package operational_eval
 import (
 	"errors"
 	"fmt"
-	"strings"
 
-	"github.com/dominikbraun/graph"
 	construct "github.com/klothoplatform/klotho/pkg/construct2"
 	"github.com/klothoplatform/klotho/pkg/engine2/operational_rule"
 	"github.com/klothoplatform/klotho/pkg/engine2/solution_context"
@@ -22,12 +20,7 @@ func (ev edgeVertex) Key() Key {
 	return Key{Edge: ev.Edge}
 }
 
-func (ev *edgeVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
-	cfgCtx := solution_context.DynamicCtx(eval.Solution)
-
-	changes := newChanges()
-	propCtx := newDepCapture(cfgCtx, changes, ev.Key())
-
+func (ev *edgeVertex) Dependencies(eval *Evaluator, propCtx dependencyCapturer) error {
 	data := knowledgebase.DynamicValueData{
 		Edge: &construct.Edge{Source: ev.Edge.Source, Target: ev.Edge.Target},
 	}
@@ -37,7 +30,7 @@ func (ev *edgeVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
 		errs = errors.Join(errs, propCtx.ExecuteOpRule(data, rule))
 	}
 	if errs != nil {
-		return changes, fmt.Errorf(
+		return fmt.Errorf(
 			"could not execute dependencies for edge %s -> %s: %w",
 			ev.Edge.Source, ev.Edge.Target, errs,
 		)
@@ -54,19 +47,20 @@ func (ev *edgeVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
 	//
 	pred, err := eval.graph.PredecessorMap()
 	if err != nil {
-		return changes, err
+		return err
 	}
-	for src := range changes.edges {
+	propChanges := propCtx.GetChanges()
+	for src := range propChanges.edges {
 		isEvaluated, err := eval.isEvaluated(src)
 		if err == nil && isEvaluated && len(pred[src]) == 0 {
 			// this is okay, since it has no dependencies then changing it during evaluation
 			// won't impact anything. Remove the dependency, since we'll handle it in this vertex's
 			// Evaluate
-			delete(changes.edges, src)
+			delete(propChanges.edges, src)
 		}
 	}
 
-	return changes, nil
+	return nil
 }
 
 func (ev *edgeVertex) UpdateFrom(other Vertex) {
@@ -84,7 +78,6 @@ func (ev *edgeVertex) UpdateFrom(other Vertex) {
 }
 
 func (ev *edgeVertex) Evaluate(eval *Evaluator) error {
-	log := eval.Log().With("op", "eval")
 	edge := &construct.Edge{Source: ev.Edge.Source, Target: ev.Edge.Target}
 
 	cfgCtx := solution_context.DynamicCtx(eval.Solution)
@@ -95,11 +88,6 @@ func (ev *edgeVertex) Evaluate(eval *Evaluator) error {
 		},
 	}
 
-	pred, err := eval.graph.PredecessorMap()
-	if err != nil {
-		return err
-	}
-
 	var errs error
 	for _, rule := range ev.Rules {
 		configRules := rule.ConfigurationRules
@@ -116,61 +104,20 @@ func (ev *edgeVertex) Evaluate(eval *Evaluator) error {
 			}
 		}
 
-		configuration := make(map[construct.ResourceId][]knowledgebase.ConfigurationRule)
-		for _, config := range configRules {
-			var ref construct.PropertyRef
-			err := cfgCtx.ExecuteDecode(config.Resource, opCtx.Data, &ref.Resource)
-			if err != nil {
-				errs = errors.Join(errs, fmt.Errorf(
-					"could not apply edge %s -> %s configuration rule: %w",
-					ev.Edge.Source, ev.Edge.Target, err,
-				))
-			}
-			err = cfgCtx.ExecuteDecode(config.Config.Field, opCtx.Data, &ref.Property)
-			if err != nil {
-				continue
-			}
-			key := Key{Ref: ref}
-			vertex, err := eval.graph.Vertex(key)
-			if err != nil {
-				errs = errors.Join(errs, fmt.Errorf("could not attempt to get existing vertex for %s: %w", ref, err))
-				continue
-			}
-			_, unevalErr := eval.unevaluated.Vertex(key)
-			if errors.Is(unevalErr, graph.ErrVertexNotFound) {
-				var evalDeps []string
-				for dep := range pred[key] {
-					depEvaled, err := eval.isEvaluated(dep)
-					if err != nil {
-						return fmt.Errorf("could not check if %s is evaluated: %w", dep, err)
-					}
-					if depEvaled {
-						evalDeps = append(evalDeps, `"`+dep.String()+`"`)
-					}
-				}
-				if len(evalDeps) == 0 {
-					configuration[ref.Resource] = append(configuration[ref.Resource], config)
-					log.Debugf("Allowing config on %s to be evaluated due to no dependents", key)
-				} else {
-					errs = errors.Join(errs, fmt.Errorf(
-						"cannot add rules to evaluated node %s for %s: evaluated dependents: %s",
-						ref, ev.Edge, strings.Join(evalDeps, ", "),
-					))
-				}
-				continue
-			} else if err != nil {
-				errs = errors.Join(errs, fmt.Errorf("could not get existing unevaluated vertex for %s: %w", ref, err))
-				continue
-			}
-			if v, ok := vertex.(*propertyVertex); ok {
-				v.EdgeRules[ev.Edge] = append(v.EdgeRules[ev.Edge], knowledgebase.OperationalRule{
-					If:                 rule.If,
-					ConfigurationRules: []knowledgebase.ConfigurationRule{config},
-				})
-			} else {
-				errs = errors.Join(errs, fmt.Errorf("existing vertex for %s is not a property vertex", ref))
-				continue
-			}
+		// the configurations that are returned can be executed out of band from the property vertex
+		// since the property vertex has already been evaluated. This is a hack to get around improper dep ordering
+		configuration, err := addConfigurationRuleToPropertyVertex(
+			knowledgebase.OperationalRule{
+				If:                 rule.If,
+				ConfigurationRules: configRules,
+			},
+			ev, cfgCtx, opCtx.Data, eval)
+
+		if err != nil {
+			errs = errors.Join(errs, fmt.Errorf(
+				"could not apply edge %s configuration rule: %w",
+				ev.Edge, err,
+			))
 		}
 
 		rule.Steps = nil
diff --git a/pkg/engine2/operational_eval/vertex_graph_state.go b/pkg/engine2/operational_eval/vertex_graph_state.go
index 740b3c0d..439ca164 100644
--- a/pkg/engine2/operational_eval/vertex_graph_state.go
+++ b/pkg/engine2/operational_eval/vertex_graph_state.go
@@ -17,8 +17,8 @@ func (gv graphStateVertex) Key() Key {
 	return Key{GraphState: gv.repr}
 }
 
-func (gv *graphStateVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
-	return graphChanges{}, nil
+func (gv *graphStateVertex) Dependencies(eval *Evaluator, propCtx dependencyCapturer) error {
+	return nil
 }
 
 func (gv *graphStateVertex) UpdateFrom(other Vertex) {
diff --git a/pkg/engine2/operational_eval/vertex_path_expand.go b/pkg/engine2/operational_eval/vertex_path_expand.go
index f0e054ec..4d7cbe7b 100644
--- a/pkg/engine2/operational_eval/vertex_path_expand.go
+++ b/pkg/engine2/operational_eval/vertex_path_expand.go
@@ -468,8 +468,9 @@ func (v *pathExpandVertex) addDepsFromEdge(
 	return errs
 }
 
-func (v *pathExpandVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
-	changes := newChanges()
+func (v *pathExpandVertex) Dependencies(eval *Evaluator, propCtx dependencyCapturer) error {
+	changes := propCtx.GetChanges()
+
 	srcKey := v.Key()
 
 	changes.addEdges(srcKey, getDepsForPropertyRef(eval.Solution, v.Edge.Source, v.Satisfication.Source.PropertyReference))
@@ -480,34 +481,34 @@ func (v *pathExpandVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
 	// This has to be run after we analyze the refs used in path expansion to make sure the operational rules
 	// dont create other resources that need to be operated on in the path expand vertex
 	if v.TempGraph == nil {
-		return changes, nil
+		return nil
 	}
 
 	var errs error
 	srcDeps, err := construct.AllDownstreamDependencies(v.TempGraph, v.Edge.Source)
 	if err != nil {
-		return changes, err
+		return err
 	}
 	errs = errors.Join(errs, v.addDepsFromProps(eval, changes, v.Edge.Source, srcDeps))
 
 	targetDeps, err := construct.AllUpstreamDependencies(v.TempGraph, v.Edge.Target)
 	if err != nil {
-		return changes, err
+		return err
 	}
 	errs = errors.Join(errs, v.addDepsFromProps(eval, changes, v.Edge.Target, targetDeps))
 	if errs != nil {
-		return changes, errs
+		return errs
 	}
 
 	edges, err := v.TempGraph.Edges()
 	if err != nil {
-		return changes, err
+		return err
 	}
 	for _, edge := range edges {
 		errs = errors.Join(errs, v.addDepsFromEdge(eval, changes, edge))
 	}
 
-	return changes, errs
+	return errs
 }
 
 // getDepsForPropertyRef takes a property reference and recurses down until the property is not filled in on the resource
diff --git a/pkg/engine2/operational_eval/vertex_property.go b/pkg/engine2/operational_eval/vertex_property.go
index ec7d52ec..7101182d 100644
--- a/pkg/engine2/operational_eval/vertex_property.go
+++ b/pkg/engine2/operational_eval/vertex_property.go
@@ -27,12 +27,7 @@ func (prop propertyVertex) Key() Key {
 	return Key{Ref: prop.Ref}
 }
 
-func (prop *propertyVertex) Dependencies(eval *Evaluator) (graphChanges, error) {
-	changes := newChanges()
-
-	propCtx := newDepCapture(solution_context.DynamicCtx(eval.Solution), changes, prop.Key())
-
-	kb := eval.Solution.KnowledgeBase()
+func (prop *propertyVertex) Dependencies(eval *Evaluator, propCtx dependencyCapturer) error {
 	resData := knowledgebase.DynamicValueData{Resource: prop.Ref.Resource}
 
 	// Template can be nil when checking for dependencies from a propertyVertex when adding an edge template
@@ -41,45 +36,24 @@ func (prop *propertyVertex) Dependencies(eval *Evaluator) (graphChanges, error)
 		details := prop.Template.Details()
 		if opRule := details.OperationalRule; opRule != nil {
 			if err := propCtx.ExecutePropertyRule(resData, *opRule); err != nil {
-				return changes, fmt.Errorf("could not execute resource operational rule for %s: %w", prop.Ref, err)
-			}
-		}
-
-		if !details.Namespace {
-			tmpl, err := kb.GetResourceTemplate(prop.Ref.Resource)
-			if err != nil {
-				return changes, fmt.Errorf("could not get resource template for %s: %w", prop.Ref.Resource, err)
-			}
-			for propKey, propTmpl := range tmpl.Properties {
-				if propTmpl.Details().Namespace {
-					nsRef := construct.PropertyRef{Resource: prop.Ref.Resource, Property: propKey}
-					propCtx.addRef(nsRef)
-				}
+				return fmt.Errorf("could not execute resource operational rule for %s: %w", prop.Ref, err)
 			}
 		}
 	}
 
-	for edge, rules := range prop.EdgeRules {
-		var errs error
+	for edge, rule := range prop.EdgeRules {
 		edgeData := knowledgebase.DynamicValueData{
 			Resource: prop.Ref.Resource,
 			Edge:     &construct.Edge{Source: edge.Source, Target: edge.Target},
 		}
-		for _, rule := range rules {
-			errs = errors.Join(errs, propCtx.ExecuteOpRule(edgeData, rule))
-		}
-		if errs != nil {
-			return changes, fmt.Errorf("could not execute %s for edge %s: %w", prop.Ref, edge, errs)
-		}
-
-		edgeKey := Key{Edge: edge}
-		_, err := eval.graph.Vertex(edgeKey)
-		if err == nil {
-			changes.addEdge(prop.Key(), edgeKey)
+		for _, opRule := range rule {
+			if err := propCtx.ExecuteOpRule(edgeData, opRule); err != nil {
+				return fmt.Errorf("could not execute edge operational rule for %s: %w", prop.Ref, err)
+			}
 		}
 	}
 
-	return changes, nil
+	return nil
 }
 
 func (prop *propertyVertex) UpdateFrom(otherV Vertex) {
@@ -165,7 +139,10 @@ func (v *propertyVertex) Evaluate(eval *Evaluator) error {
 		}
 		// If we have modified a list or set we want to re add the resource to be evaluated
 		// so the nested fields are ensured to be set if required
-		return eval.AddResources(res)
+		err = eval.AddResources(res)
+		if err != nil {
+			return fmt.Errorf("could not add resource %s to be re-evaluated: %w", res.ID, err)
+		}
 	}
 
 	// Now that the vertex is evaluated, we will check it for validity and record our decision
@@ -352,3 +329,99 @@ func (v *propertyVertex) Ready(eval *Evaluator) (ReadyPriority, error) {
 	}
 	return NotReadyMid, nil
 }
+
+// addConfigurationRuleToPropertyVertex adds a configuration rule to a property vertex
+// if the vertex parameter is a edgeVertex or resourceRuleVertex, it will add the rule to the
+// appropriate property vertex and field on the property vertex.
+//
+// The method returns a map of rules which can be evaluated immediately, and an error if any
+func addConfigurationRuleToPropertyVertex(
+	rule knowledgebase.OperationalRule,
+	v Vertex,
+	cfgCtx knowledgebase.DynamicValueContext,
+	data knowledgebase.DynamicValueData,
+	eval *Evaluator,
+) (map[construct.ResourceId][]knowledgebase.ConfigurationRule, error) {
+	configuration := make(map[construct.ResourceId][]knowledgebase.ConfigurationRule)
+
+	log := eval.Log().With("op", "eval")
+	pred, err := eval.graph.PredecessorMap()
+	if err != nil {
+		return configuration, err
+	}
+
+	var errs error
+
+	for _, config := range rule.ConfigurationRules {
+
+		var ref construct.PropertyRef
+		err := cfgCtx.ExecuteDecode(config.Resource, data, &ref.Resource)
+		if err != nil {
+			errs = errors.Join(errs, fmt.Errorf(
+				"could not decode resource for %s: %w",
+				config.Resource, err,
+			))
+			continue
+		}
+		err = cfgCtx.ExecuteDecode(config.Config.Field, data, &ref.Property)
+		if err != nil {
+			errs = errors.Join(errs, fmt.Errorf(
+				"could not decode property for %s: %w",
+				config.Config.Field, err,
+			))
+			continue
+		}
+		key := Key{Ref: ref}
+		vertex, err := eval.graph.Vertex(key)
+		if err != nil {
+			errs = errors.Join(errs, fmt.Errorf("could not attempt to get existing vertex for %s: %w", ref, err))
+			continue
+		}
+		_, unevalErr := eval.unevaluated.Vertex(key)
+		if errors.Is(unevalErr, graph.ErrVertexNotFound) {
+			var evalDeps []string
+			for dep := range pred[key] {
+				depEvaled, err := eval.isEvaluated(dep)
+				if err != nil {
+					errs = errors.Join(errs, fmt.Errorf("could not check if %s is evaluated: %w", dep, err))
+					continue
+				}
+				if depEvaled {
+					evalDeps = append(evalDeps, `"`+dep.String()+`"`)
+				}
+			}
+			if len(evalDeps) == 0 {
+				configuration[ref.Resource] = append(configuration[ref.Resource], config)
+				log.Debugf("Allowing config on %s to be evaluated due to no dependents", key)
+			} else {
+				errs = errors.Join(errs, fmt.Errorf(
+					"cannot add rules to evaluated node %s: evaluated dependents: %s",
+					ref, strings.Join(evalDeps, ", "),
+				))
+			}
+			continue
+		} else if err != nil {
+			errs = errors.Join(errs, fmt.Errorf("could not get existing unevaluated vertex for %s: %w", ref, err))
+			continue
+		}
+		pv, ok := vertex.(*propertyVertex)
+		if !ok {
+			errs = errors.Join(errs,
+				fmt.Errorf("existing vertex for %s is not a property vertex", ref),
+			)
+		}
+
+		switch v := v.(type) {
+		case *edgeVertex:
+			pv.EdgeRules[v.Edge] = append(pv.EdgeRules[v.Edge], knowledgebase.OperationalRule{
+				If:                 rule.If,
+				ConfigurationRules: []knowledgebase.ConfigurationRule{config},
+			})
+		default:
+			errs = errors.Join(errs,
+				fmt.Errorf("existing vertex for %s is not able to add configuration rules to property vertex", ref),
+			)
+		}
+	}
+	return configuration, errs
+}
diff --git a/pkg/engine2/operational_eval/vertex_resource_rule.go b/pkg/engine2/operational_eval/vertex_resource_rule.go
new file mode 100644
index 00000000..9c4f5c6a
--- /dev/null
+++ b/pkg/engine2/operational_eval/vertex_resource_rule.go
@@ -0,0 +1,87 @@
+package operational_eval
+
+import (
+	"errors"
+	"fmt"
+
+	construct "github.com/klothoplatform/klotho/pkg/construct2"
+	"github.com/klothoplatform/klotho/pkg/engine2/operational_rule"
+	knowledgebase "github.com/klothoplatform/klotho/pkg/knowledge_base2"
+)
+
+type (
+	resourceRuleVertex struct {
+		Resource construct.ResourceId
+		Rule     knowledgebase.AdditionalRule
+		hash     string
+	}
+)
+
+func (v resourceRuleVertex) Key() Key {
+	return Key{Ref: construct.PropertyRef{Resource: v.Resource}, RuleHash: v.hash}
+}
+
+func (v *resourceRuleVertex) Dependencies(eval *Evaluator, propCtx dependencyCapturer) error {
+	resData := knowledgebase.DynamicValueData{Resource: v.Resource}
+	var errs error
+	errs = errors.Join(errs, propCtx.ExecuteOpRule(resData, knowledgebase.OperationalRule{
+		If:    v.Rule.If,
+		Steps: v.Rule.Steps,
+	}))
+	if errs != nil {
+		return fmt.Errorf("could not execute %s: %w", v.Key(), errs)
+	}
+	return nil
+}
+
+func (v *resourceRuleVertex) UpdateFrom(other Vertex) {
+	if v == other {
+		return
+	}
+	otherRule, ok := other.(*resourceRuleVertex)
+	if !ok {
+		panic(fmt.Sprintf("cannot merge edge with non-edge vertex: %T", other))
+	}
+	if v.Resource != otherRule.Resource {
+		panic(fmt.Sprintf("cannot merge resource rule with different refs: %s != %s", v.Resource, otherRule.Resource))
+	}
+	v.Rule = otherRule.Rule
+}
+
+func (v *resourceRuleVertex) Evaluate(eval *Evaluator) error {
+	sol := eval.Solution.With("resource", v.Resource)
+	opCtx := operational_rule.OperationalRuleContext{
+		Solution: sol,
+		Data:     knowledgebase.DynamicValueData{Resource: v.Resource},
+	}
+	if err := v.evaluateResourceRule(&opCtx, eval); err != nil {
+		return err
+	}
+
+	res, err := sol.RawView().Vertex(v.Resource)
+	if err != nil {
+		return fmt.Errorf("could not get resource to evaluate %s: %w", v.Resource, err)
+	}
+	if err := eval.UpdateId(v.Resource, res.ID); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (v *resourceRuleVertex) evaluateResourceRule(
+	opCtx operational_rule.OpRuleHandler,
+	eval *Evaluator,
+) error {
+	err := opCtx.HandleOperationalRule(knowledgebase.OperationalRule{
+		If:    v.Rule.If,
+		Steps: v.Rule.Steps,
+	})
+	if err != nil {
+		return fmt.Errorf(
+			"could not apply resource %s operational rule: %w",
+			v.Resource, err,
+		)
+	}
+
+	return nil
+}
diff --git a/pkg/engine2/reconciler/remove_edge.go b/pkg/engine2/reconciler/remove_edge.go
index 0a601905..64a2348e 100644
--- a/pkg/engine2/reconciler/remove_edge.go
+++ b/pkg/engine2/reconciler/remove_edge.go
@@ -47,7 +47,12 @@ func RemovePath(
 	// We will pass the explicit field as false so that explicitly added resources do not get deleted
 	for _, path := range paths {
 		for _, resource := range path {
-			errs = errors.Join(errs, RemoveResource(ctx, resource, false))
+			// by this point its possible the resource no longer exists due to being deleted by the removeSinglePath call
+			// since this is ensuring we dont orphan resources we can ignore the error if we do not find the resource
+			err := RemoveResource(ctx, resource, false)
+			if err != nil && !errors.Is(err, graph.ErrVertexNotFound) {
+				errs = errors.Join(errs, err)
+			}
 		}
 	}
 	return errs
@@ -161,6 +166,10 @@ func findEdgesUsedInOtherPathSelection(
 			continue
 		}
 		for _, path := range paths {
+			// if the source is in the path then the path is just a superset of the path we are trying to delete
+			if path.Contains(source) {
+				continue
+			}
 			for i, res := range path {
 				if i == 0 {
 					continue
@@ -197,6 +206,10 @@ func findEdgesUsedInOtherPathSelection(
 		}
 
 		for _, path := range paths {
+			// if the target is in the path then the path is just a superset of the path we are trying to delete
+			if path.Contains(target) {
+				continue
+			}
 			for i, res := range path {
 				if i == 0 {
 					continue
diff --git a/pkg/infra/iac3/templates/aws/secret/factory.ts b/pkg/infra/iac3/templates/aws/secret/factory.ts
index 7d9c209a..aabb40bc 100644
--- a/pkg/infra/iac3/templates/aws/secret/factory.ts
+++ b/pkg/infra/iac3/templates/aws/secret/factory.ts
@@ -20,5 +20,6 @@ function create(args: Args): aws.secretsmanager.Secret {
 function properties(object: aws.secretsmanager.Secret, args: Args) {
     return {
         Arn: object.arn,
+        Id: object.id,
     }
 }
diff --git a/pkg/knowledge_base2/graph.go b/pkg/knowledge_base2/graph.go
index 841d064c..5690a210 100644
--- a/pkg/knowledge_base2/graph.go
+++ b/pkg/knowledge_base2/graph.go
@@ -8,6 +8,7 @@ import (
 	"github.com/dominikbraun/graph"
 	construct "github.com/klothoplatform/klotho/pkg/construct2"
 	"github.com/klothoplatform/klotho/pkg/graph_addons"
+	"github.com/klothoplatform/klotho/pkg/set"
 )
 
 type (
@@ -40,7 +41,7 @@ func resourceLocal(
 	dag construct.Graph,
 	kb TemplateKB,
 	rid construct.ResourceId,
-	ids *[]construct.ResourceId,
+	ids set.Set[construct.ResourceId],
 ) graph_addons.WalkGraphFunc[construct.ResourceId] {
 	return func(path graph_addons.Path[construct.ResourceId], nerr error) error {
 		if len(path) <= 1 {
@@ -58,19 +59,19 @@ func resourceLocal(
 		if !sideEffect {
 			return graph_addons.SkipPath
 		}
-		(*ids) = append(*ids, last)
+		ids.Add(last)
 		return nil
 	}
 }
 
 func resourceDirect(
 	dag construct.Graph,
-	ids *[]construct.ResourceId,
+	ids set.Set[construct.ResourceId],
 ) graph_addons.WalkGraphFunc[construct.ResourceId] {
 	return func(path graph_addons.Path[construct.ResourceId], nerr error) error {
 		id := path[len(path)-1]
 		if ids != nil {
-			(*ids) = append(*ids, id)
+			ids.Add(id)
 		}
 		return graph_addons.SkipPath
 	}
@@ -78,13 +79,13 @@ func resourceDirect(
 
 func resourceGlue(
 	kb TemplateKB,
-	ids *[]construct.ResourceId,
+	ids set.Set[construct.ResourceId],
 ) graph_addons.WalkGraphFunc[construct.ResourceId] {
 	return func(path graph_addons.Path[construct.ResourceId], nerr error) error {
 		id := path[len(path)-1]
 		if GetFunctionality(kb, id) == Unknown {
 			if ids != nil {
-				(*ids) = append(*ids, id)
+				ids.Add(id)
 			}
 			return nil
 		}
@@ -94,12 +95,12 @@ func resourceGlue(
 
 func firstFunctional(
 	kb TemplateKB,
-	ids *[]construct.ResourceId,
+	ids set.Set[construct.ResourceId],
 ) graph_addons.WalkGraphFunc[construct.ResourceId] {
 	return func(path graph_addons.Path[construct.ResourceId], nerr error) error {
 		id := path[len(path)-1]
 		if ids != nil {
-			(*ids) = append(*ids, id)
+			ids.Add(id)
 		}
 		if GetFunctionality(kb, id) == Unknown {
 			return nil
@@ -109,12 +110,12 @@ func firstFunctional(
 }
 
 func allDeps(
-	ids *[]construct.ResourceId,
+	ids set.Set[construct.ResourceId],
 ) graph_addons.WalkGraphFunc[construct.ResourceId] {
 	return func(path graph_addons.Path[construct.ResourceId], nerr error) error {
 		id := path[len(path)-1]
 		if ids != nil {
-			(*ids) = append(*ids, id)
+			ids.Add(id)
 		}
 		return nil
 	}
@@ -162,11 +163,11 @@ func DependenciesSkipEdgeLayer(
 }
 
 func Downstream(dag construct.Graph, kb TemplateKB, rid construct.ResourceId, layer DependencyLayer) ([]construct.ResourceId, error) {
-	var result []construct.ResourceId
+	result := set.Set[construct.ResourceId]{}
 	var f graph_addons.WalkGraphFunc[construct.ResourceId]
 	switch layer {
 	case ResourceLocalLayer:
-		f = resourceLocal(dag, kb, rid, &result)
+		f = resourceLocal(dag, kb, rid, result)
 	case ResourceDirectLayer:
 		// use a more performant implementation for direct since we can use the edges directly.
 		edges, err := dag.Edges()
@@ -181,16 +182,16 @@ func Downstream(dag construct.Graph, kb TemplateKB, rid construct.ResourceId, la
 		}
 		return ids, nil
 	case ResourceGlueLayer:
-		f = resourceGlue(kb, &result)
+		f = resourceGlue(kb, result)
 	case FirstFunctionalLayer:
-		f = firstFunctional(kb, &result)
+		f = firstFunctional(kb, result)
 	case AllDepsLayer:
-		f = allDeps(&result)
+		f = allDeps(result)
 	default:
 		return nil, fmt.Errorf("unknown layer %s", layer)
 	}
 	err := graph_addons.WalkDown(dag, rid, f)
-	return result, err
+	return result.ToSlice(), err
 }
 
 func DownstreamFunctional(dag construct.Graph, kb TemplateKB, resource construct.ResourceId) ([]construct.ResourceId, error) {
@@ -207,11 +208,11 @@ func DownstreamFunctional(dag construct.Graph, kb TemplateKB, resource construct
 }
 
 func Upstream(dag construct.Graph, kb TemplateKB, rid construct.ResourceId, layer DependencyLayer) ([]construct.ResourceId, error) {
-	var result []construct.ResourceId
+	result := set.Set[construct.ResourceId]{}
 	var f graph_addons.WalkGraphFunc[construct.ResourceId]
 	switch layer {
 	case ResourceLocalLayer:
-		f = resourceLocal(dag, kb, rid, &result)
+		f = resourceLocal(dag, kb, rid, result)
 	case ResourceDirectLayer:
 		// use a more performant implementation for direct since we can use the edges directly.
 		edges, err := dag.Edges()
@@ -226,16 +227,16 @@ func Upstream(dag construct.Graph, kb TemplateKB, rid construct.ResourceId, laye
 		}
 		return ids, nil
 	case ResourceGlueLayer:
-		f = resourceGlue(kb, &result)
+		f = resourceGlue(kb, result)
 	case FirstFunctionalLayer:
-		f = firstFunctional(kb, &result)
+		f = firstFunctional(kb, result)
 	case AllDepsLayer:
-		f = allDeps(&result)
+		f = allDeps(result)
 	default:
 		return nil, fmt.Errorf("unknown layer %s", layer)
 	}
 	err := graph_addons.WalkUp(dag, rid, f)
-	return result, err
+	return result.ToSlice(), err
 }
 
 func layerWalkFunc(
@@ -243,19 +244,22 @@ func layerWalkFunc(
 	kb TemplateKB,
 	rid construct.ResourceId,
 	layer DependencyLayer,
-	result []construct.ResourceId,
+	result set.Set[construct.ResourceId],
 ) (graph_addons.WalkGraphFunc[construct.ResourceId], error) {
+	if result == nil {
+		result = set.Set[construct.ResourceId]{}
+	}
 	switch layer {
 	case ResourceLocalLayer:
-		return resourceLocal(dag, kb, rid, &result), nil
+		return resourceLocal(dag, kb, rid, result), nil
 	case ResourceDirectLayer:
-		return resourceDirect(dag, &result), nil
+		return resourceDirect(dag, result), nil
 	case ResourceGlueLayer:
-		return resourceGlue(kb, &result), nil
+		return resourceGlue(kb, result), nil
 	case FirstFunctionalLayer:
-		return firstFunctional(kb, &result), nil
+		return firstFunctional(kb, result), nil
 	case AllDepsLayer:
-		return allDeps(&result), nil
+		return allDeps(result), nil
 	default:
 		return nil, fmt.Errorf("unknown layer %s", layer)
 	}
diff --git a/pkg/knowledge_base2/kb.go b/pkg/knowledge_base2/kb.go
index 34db4448..1ab4c451 100644
--- a/pkg/knowledge_base2/kb.go
+++ b/pkg/knowledge_base2/kb.go
@@ -11,6 +11,8 @@ import (
 	"go.uber.org/zap"
 )
 
+//go:generate mockgen --source=./kb.go -destination=./template_kb_mock_test.go -package=knowledgebase2
+
 type (
 	TemplateKB interface {
 		ListResources() []*ResourceTemplate
diff --git a/pkg/knowledge_base2/operational_rule.go b/pkg/knowledge_base2/operational_rule.go
index 133e9d74..210ab730 100644
--- a/pkg/knowledge_base2/operational_rule.go
+++ b/pkg/knowledge_base2/operational_rule.go
@@ -1,6 +1,9 @@
 package knowledgebase2
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"reflect"
@@ -22,6 +25,11 @@ type (
 		ConfigurationRules []ConfigurationRule   `json:"configuration_rules" yaml:"configuration_rules"`
 	}
 
+	AdditionalRule struct {
+		If    string            `json:"if" yaml:"if"`
+		Steps []OperationalStep `json:"steps" yaml:"steps"`
+	}
+
 	PropertyRule struct {
 		If    string          `json:"if" yaml:"if"`
 		Step  OperationalStep `json:"step" yaml:"step"`
@@ -90,6 +98,24 @@ const (
 	ClosestSelectionOperator SelectionOperator = ""
 )
 
+func (rule AdditionalRule) Hash() (string, error) {
+	// Convert the struct to a byte slice.
+	// Note that the struct must be able to be converted to JSON,
+	// so all fields must be exported (i.e., start with a capital letter).
+	byteSlice, err := json.Marshal(rule)
+	if err != nil {
+		return "", err
+	}
+
+	// Hash the byte slice.
+	hash := sha256.Sum256(byteSlice)
+
+	// Convert the hash to a hexadecimal string.
+	hashString := hex.EncodeToString(hash[:])
+
+	return hashString, nil
+}
+
 func (d Direction) Edge(resource, dep construct.ResourceId) construct.SimpleEdge {
 	if d == DirectionUpstream {
 		return construct.SimpleEdge{Source: dep, Target: resource}
diff --git a/pkg/knowledge_base2/resource_template.go b/pkg/knowledge_base2/resource_template.go
index a894ae24..0471a5d3 100644
--- a/pkg/knowledge_base2/resource_template.go
+++ b/pkg/knowledge_base2/resource_template.go
@@ -25,6 +25,8 @@ type (
 		// Properties defines the properties that the resource has
 		Properties Properties `json:"properties" yaml:"properties"`
 
+		AdditionalRules []AdditionalRule `json:"additional_rules" yaml:"additional_rules"`
+
 		// Classification defines the classification of the resource
 		Classification Classification `json:"classification" yaml:"classification"`
 
diff --git a/pkg/templates/aws/resources/secret.yaml b/pkg/templates/aws/resources/secret.yaml
index f5712119..d11274fa 100644
--- a/pkg/templates/aws/resources/secret.yaml
+++ b/pkg/templates/aws/resources/secret.yaml
@@ -6,6 +6,10 @@ properties:
     type: string
     configuration_disabled: true
     deploy_time: true
+  Id:
+    type: string
+    configuration_disabled: true
+    deploy_time: true
 
 path_satisfaction:
   as_target:
@@ -17,6 +21,12 @@ classification:
     - secret
     - storage
 
+consumption:
+  emitted:
+    - model: EnvironmentVariables
+      value:
+        '{{ .Self.Name }}_ID': '{{ fieldRef "Id" .Self }}'
+
 delete_context:
   requires_no_upstream: true
   requires_no_downstream: true


Test patch:
diff --git a/pkg/engine2/operational_eval/dependency_capture_mock_test.go b/pkg/engine2/operational_eval/dependency_capture_mock_test.go
new file mode 100644
index 00000000..b8b9994b
--- /dev/null
+++ b/pkg/engine2/operational_eval/dependency_capture_mock_test.go
@@ -0,0 +1,125 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./dependency_capture.go
+//
+// Generated by this command:
+//
+//	mockgen -source=./dependency_capture.go --destination=./dependency_capture_mock_test.go --package=operational_eval
+//
+
+// Package operational_eval is a generated GoMock package.
+package operational_eval
+
+import (
+	reflect "reflect"
+
+	construct2 "github.com/klothoplatform/klotho/pkg/construct2"
+	knowledgebase2 "github.com/klothoplatform/klotho/pkg/knowledge_base2"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockdependencyCapturer is a mock of dependencyCapturer interface.
+type MockdependencyCapturer struct {
+	ctrl     *gomock.Controller
+	recorder *MockdependencyCapturerMockRecorder
+}
+
+// MockdependencyCapturerMockRecorder is the mock recorder for MockdependencyCapturer.
+type MockdependencyCapturerMockRecorder struct {
+	mock *MockdependencyCapturer
+}
+
+// NewMockdependencyCapturer creates a new mock instance.
+func NewMockdependencyCapturer(ctrl *gomock.Controller) *MockdependencyCapturer {
+	mock := &MockdependencyCapturer{ctrl: ctrl}
+	mock.recorder = &MockdependencyCapturerMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockdependencyCapturer) EXPECT() *MockdependencyCapturerMockRecorder {
+	return m.recorder
+}
+
+// DAG mocks base method.
+func (m *MockdependencyCapturer) DAG() construct2.Graph {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DAG")
+	ret0, _ := ret[0].(construct2.Graph)
+	return ret0
+}
+
+// DAG indicates an expected call of DAG.
+func (mr *MockdependencyCapturerMockRecorder) DAG() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DAG", reflect.TypeOf((*MockdependencyCapturer)(nil).DAG))
+}
+
+// ExecuteDecode mocks base method.
+func (m *MockdependencyCapturer) ExecuteDecode(tmpl string, data knowledgebase2.DynamicValueData, value any) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ExecuteDecode", tmpl, data, value)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// ExecuteDecode indicates an expected call of ExecuteDecode.
+func (mr *MockdependencyCapturerMockRecorder) ExecuteDecode(tmpl, data, value any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteDecode", reflect.TypeOf((*MockdependencyCapturer)(nil).ExecuteDecode), tmpl, data, value)
+}
+
+// ExecuteOpRule mocks base method.
+func (m *MockdependencyCapturer) ExecuteOpRule(data knowledgebase2.DynamicValueData, rule knowledgebase2.OperationalRule) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ExecuteOpRule", data, rule)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// ExecuteOpRule indicates an expected call of ExecuteOpRule.
+func (mr *MockdependencyCapturerMockRecorder) ExecuteOpRule(data, rule any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteOpRule", reflect.TypeOf((*MockdependencyCapturer)(nil).ExecuteOpRule), data, rule)
+}
+
+// ExecutePropertyRule mocks base method.
+func (m *MockdependencyCapturer) ExecutePropertyRule(data knowledgebase2.DynamicValueData, rule knowledgebase2.PropertyRule) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ExecutePropertyRule", data, rule)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// ExecutePropertyRule indicates an expected call of ExecutePropertyRule.
+func (mr *MockdependencyCapturerMockRecorder) ExecutePropertyRule(data, rule any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecutePropertyRule", reflect.TypeOf((*MockdependencyCapturer)(nil).ExecutePropertyRule), data, rule)
+}
+
+// GetChanges mocks base method.
+func (m *MockdependencyCapturer) GetChanges() graphChanges {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetChanges")
+	ret0, _ := ret[0].(graphChanges)
+	return ret0
+}
+
+// GetChanges indicates an expected call of GetChanges.
+func (mr *MockdependencyCapturerMockRecorder) GetChanges() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChanges", reflect.TypeOf((*MockdependencyCapturer)(nil).GetChanges))
+}
+
+// KB mocks base method.
+func (m *MockdependencyCapturer) KB() knowledgebase2.TemplateKB {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "KB")
+	ret0, _ := ret[0].(knowledgebase2.TemplateKB)
+	return ret0
+}
+
+// KB indicates an expected call of KB.
+func (mr *MockdependencyCapturerMockRecorder) KB() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KB", reflect.TypeOf((*MockdependencyCapturer)(nil).KB))
+}
diff --git a/pkg/engine2/operational_eval/vertex_property_test.go b/pkg/engine2/operational_eval/vertex_property_test.go
index 5e3b683c..19357ede 100644
--- a/pkg/engine2/operational_eval/vertex_property_test.go
+++ b/pkg/engine2/operational_eval/vertex_property_test.go
@@ -113,3 +113,78 @@ func Test_propertyVertex_evaluateEdgeOperational(t *testing.T) {
 		})
 	}
 }
+
+func Test_propertyVertex_Dependencies(t *testing.T) {
+	tests := []struct {
+		name    string
+		v       *propertyVertex
+		mocks   func(dcap *MockdependencyCapturer)
+		wantErr bool
+	}{
+		{
+			name: "property vertex with template",
+			v: &propertyVertex{
+				Ref: construct.PropertyRef{
+					Property: "test",
+					Resource: construct.ResourceId{Name: "test"},
+				},
+				Template: &properties.StringProperty{
+					PropertyDetails: knowledgebase.PropertyDetails{
+						OperationalRule: &knowledgebase.PropertyRule{
+							If: "test",
+						},
+					},
+				},
+			},
+			mocks: func(dcap *MockdependencyCapturer) {
+				dcap.EXPECT().ExecutePropertyRule(knowledgebase.DynamicValueData{
+					Resource: construct.ResourceId{Name: "test"},
+				}, knowledgebase.PropertyRule{
+					If: "test",
+				}).Return(nil)
+			},
+		},
+		{
+			name: "property vertex with edge rules",
+			v: &propertyVertex{
+				Ref: construct.PropertyRef{
+					Property: "test",
+					Resource: construct.ResourceId{Name: "test"},
+				},
+				EdgeRules: map[construct.SimpleEdge][]knowledgebase.OperationalRule{
+					{
+						Source: construct.ResourceId{Name: "test"},
+						Target: construct.ResourceId{Name: "test2"},
+					}: {
+						{
+							If: "testE",
+						},
+					},
+				},
+			},
+			mocks: func(dcap *MockdependencyCapturer) {
+				dcap.EXPECT().ExecuteOpRule(knowledgebase.DynamicValueData{
+					Resource: construct.ResourceId{Name: "test"},
+					Edge:     &graph.Edge[construct.ResourceId]{Source: construct.ResourceId{Name: "test"}, Target: construct.ResourceId{Name: "test2"}},
+				}, knowledgebase.OperationalRule{
+					If: "testE",
+				}).Return(nil)
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			dcap := NewMockdependencyCapturer(ctrl)
+			tt.mocks(dcap)
+			eval := &Evaluator{}
+			err := tt.v.Dependencies(eval, dcap)
+			if tt.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			ctrl.Finish()
+		})
+	}
+}
diff --git a/pkg/engine2/operational_eval/vertex_resource_rule_test.go b/pkg/engine2/operational_eval/vertex_resource_rule_test.go
new file mode 100644
index 00000000..5f1e5db5
--- /dev/null
+++ b/pkg/engine2/operational_eval/vertex_resource_rule_test.go
@@ -0,0 +1,186 @@
+package operational_eval
+
+import (
+	"fmt"
+	"testing"
+
+	construct "github.com/klothoplatform/klotho/pkg/construct2"
+	knowledgebase "github.com/klothoplatform/klotho/pkg/knowledge_base2"
+	"github.com/stretchr/testify/assert"
+	gomock "go.uber.org/mock/gomock"
+)
+
+func Test_resourceRuleVertex_Dependencies(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	dcap := NewMockdependencyCapturer(ctrl)
+
+	tests := []struct {
+		name     string
+		resource construct.ResourceId
+		rule     knowledgebase.AdditionalRule
+		mocks    func()
+		wantErr  bool
+	}{
+		{
+			name:     "resource rule vertex",
+			resource: construct.ResourceId{Name: "test"},
+			rule: knowledgebase.AdditionalRule{
+				If: "test",
+			},
+			mocks: func() {
+				dcap.EXPECT().ExecuteOpRule(knowledgebase.DynamicValueData{
+					Resource: construct.ResourceId{Name: "test"},
+				}, knowledgebase.OperationalRule{
+					If: "test",
+				}).Return(nil)
+			},
+		},
+		{
+			name:     "resource rule vertex",
+			resource: construct.ResourceId{Name: "test"},
+			rule: knowledgebase.AdditionalRule{
+				If: "test",
+				Steps: []knowledgebase.OperationalStep{
+					{
+						Resource: "string",
+					},
+				},
+			},
+			mocks: func() {
+				dcap.EXPECT().ExecuteOpRule(knowledgebase.DynamicValueData{
+					Resource: construct.ResourceId{Name: "test"},
+				}, knowledgebase.OperationalRule{
+					If: "test",
+					Steps: []knowledgebase.OperationalStep{
+						{
+							Resource: "string",
+						},
+					},
+				}).Return(nil)
+			},
+		},
+		{
+			name:     "resource rule vertex dependencies throws error",
+			resource: construct.ResourceId{Name: "test"},
+			rule: knowledgebase.AdditionalRule{
+				If: "test",
+			},
+			mocks: func() {
+				dcap.EXPECT().ExecuteOpRule(knowledgebase.DynamicValueData{
+					Resource: construct.ResourceId{Name: "test"},
+				}, knowledgebase.OperationalRule{
+					If: "test",
+				}).Return(fmt.Errorf("err"))
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			v := &resourceRuleVertex{
+				Resource: tt.resource,
+				Rule:     tt.rule,
+			}
+			tt.mocks()
+			eval := &Evaluator{}
+			err := v.Dependencies(eval, dcap)
+			if tt.wantErr {
+				assert.Error(err)
+				return
+			}
+			assert.NoError(err)
+			dcap.ctrl.Finish()
+		})
+	}
+}
+
+func Test_resourceRuleVertex_UpdateFrom(t *testing.T) {
+
+	tests := []struct {
+		name    string
+		initial *resourceRuleVertex
+		other   Vertex
+		want    resourceRuleVertex
+	}{
+		{
+			name: "empty resource rule vertex",
+			initial: &resourceRuleVertex{
+				Resource: construct.ResourceId{Name: "test"},
+			},
+			other: &resourceRuleVertex{
+				Resource: construct.ResourceId{Name: "test"},
+				Rule: knowledgebase.AdditionalRule{
+					If: "test",
+				},
+			},
+			want: resourceRuleVertex{
+				Resource: construct.ResourceId{Name: "test"},
+				Rule: knowledgebase.AdditionalRule{
+					If: "test",
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			tt.initial.UpdateFrom(tt.other)
+			assert.Equal(tt.want, *tt.initial)
+		})
+	}
+}
+
+func Test_resourceRuleVertex_evaluateResourceRule(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	opctx := NewMockOpRuleHandler(ctrl)
+	tests := []struct {
+		name    string
+		v       *resourceRuleVertex
+		mocks   func()
+		wantErr bool
+	}{
+		{
+			name: "resource rule vertex",
+			v: &resourceRuleVertex{
+				Resource: construct.ResourceId{Name: "test"},
+				Rule: knowledgebase.AdditionalRule{
+					If: "test",
+				},
+			},
+			mocks: func() {
+				opctx.EXPECT().HandleOperationalRule(knowledgebase.OperationalRule{
+					If: "test",
+				}).Return(nil)
+			},
+		},
+		{
+			name: "resource rule vertex throws error",
+			v: &resourceRuleVertex{
+				Resource: construct.ResourceId{Name: "test"},
+				Rule: knowledgebase.AdditionalRule{
+					If: "test",
+				},
+			},
+			mocks: func() {
+				opctx.EXPECT().HandleOperationalRule(knowledgebase.OperationalRule{
+					If: "test",
+				}).Return(fmt.Errorf("err"))
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			eval := &Evaluator{}
+			tt.mocks()
+			err := tt.v.evaluateResourceRule(opctx, eval)
+			if tt.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			opctx.ctrl.Finish()
+		})
+	}
+}
diff --git a/pkg/engine2/reconciler/remove_edge_test.go b/pkg/engine2/reconciler/remove_edge_test.go
index 25de518a..596834ec 100644
--- a/pkg/engine2/reconciler/remove_edge_test.go
+++ b/pkg/engine2/reconciler/remove_edge_test.go
@@ -139,6 +139,46 @@ func Test_findEdgesUsedInOtherPathSelection(t *testing.T) {
 				{Source: construct.ResourceId{Provider: "p", Type: "t", Name: "t"}, Target: construct.ResourceId{Provider: "p", Type: "t", Name: "b"}},
 			},
 		},
+		{
+			name:         "path is superset of deletion path",
+			source:       construct.ResourceId{Provider: "p", Type: "t", Name: "s"},
+			target:       construct.ResourceId{Provider: "p", Type: "t", Name: "b"},
+			initialState: []any{"p:t:s", "p:t:t", "p:t:t -> p:t:s", "p:t:a", "p:t:b", "p:t:a -> p:t:t", "p:t:s -> p:t:b", "p:t:l", "p:t:l -> p:t:b"},
+			want: []construct.SimpleEdge{
+				{Source: construct.ResourceId{Provider: "p", Type: "t", Name: "l"}, Target: construct.ResourceId{Provider: "p", Type: "t", Name: "b"}},
+			},
+			mockKB: []mock.Call{
+				{
+					Method:    "GetResourceTemplate",
+					Arguments: []any{mock.Anything},
+					ReturnArguments: []any{&knowledgebase.ResourceTemplate{
+						Classification: knowledgebase.Classification{Is: []string{string(knowledgebase.Compute)}},
+						PathSatisfaction: knowledgebase.PathSatisfaction{
+							AsSource: []knowledgebase.PathSatisfactionRoute{
+								{Classification: ""},
+							},
+							AsTarget: []knowledgebase.PathSatisfactionRoute{
+								{Classification: ""},
+							},
+						},
+					}, nil},
+				},
+				{
+					Method:          "GetEdgeTemplate",
+					Arguments:       []any{mock.Anything, mock.Anything},
+					ReturnArguments: []any{&knowledgebase.EdgeTemplate{}},
+				},
+				{
+					Method:    "GetPathSatisfactionsFromEdge",
+					Arguments: []any{mock.Anything, mock.Anything},
+					ReturnArguments: []any{
+						[]knowledgebase.EdgePathSatisfaction{
+							{Classification: ""},
+						}, nil,
+					},
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/engine2/testdata/delete_api_to_lambda_edge.dataflow-viz.yaml b/pkg/engine2/testdata/delete_api_to_lambda_edge.dataflow-viz.yaml
new file mode 100644
index 00000000..85412c89
--- /dev/null
+++ b/pkg/engine2/testdata/delete_api_to_lambda_edge.dataflow-viz.yaml
@@ -0,0 +1,22 @@
+provider: aws
+resources:
+  rest_api/rest_api_0:
+    children:
+        - aws:api_deployment:rest_api_0:api_deployment-0
+        - aws:api_integration:rest_api_0:rest_api_0_integration_0
+        - aws:api_method:rest_api_0:rest_api_0_integration_0_method
+        - aws:api_resource:rest_api_0:api_resource-0
+        - aws:api_stage:rest_api_0:api_stage-0
+    tag: parent
+
+  lambda_function/lambda_function_2:
+    children:
+        - aws:ecr_image:lambda_function_2-image
+        - aws:ecr_repo:ecr_repo-0
+        - aws:iam_role:lambda_function_2-ExecutionRole
+    tag: big
+
+  aws:api_integration:rest_api_0/rest_api_0_integration_0:
+    parent: rest_api/rest_api_0
+    tag: big
+
diff --git a/pkg/engine2/testdata/delete_api_to_lambda_edge.expect.yaml b/pkg/engine2/testdata/delete_api_to_lambda_edge.expect.yaml
new file mode 100644
index 00000000..37fb8ead
--- /dev/null
+++ b/pkg/engine2/testdata/delete_api_to_lambda_edge.expect.yaml
@@ -0,0 +1,82 @@
+resources:
+    aws:api_stage:rest_api_0:api_stage-0:
+        Deployment: aws:api_deployment:rest_api_0:api_deployment-0
+        RestApi: aws:rest_api:rest_api_0
+        StageName: stage
+    aws:lambda_function:lambda_function_2:
+        ExecutionRole: aws:iam_role:lambda_function_2-ExecutionRole
+        Image: aws:ecr_image:lambda_function_2-image
+        LogGroup: aws:log_group:lambda_function_2-log-group
+        MemorySize: 512
+        Timeout: 180
+    aws:api_deployment:rest_api_0:api_deployment-0:
+        RestApi: aws:rest_api:rest_api_0
+        Triggers:
+            rest_api_0_integration_0: rest_api_0_integration_0
+            rest_api_0_integration_0_method: rest_api_0_integration_0_method
+    aws:SERVICE_API:lambda_function_2-lambda_function_2-log-group:
+    aws:ecr_image:lambda_function_2-image:
+        Context: .
+        Dockerfile: lambda_function_2-image.Dockerfile
+        Repo: aws:ecr_repo:ecr_repo-0
+    aws:iam_role:lambda_function_2-ExecutionRole:
+        AssumeRolePolicyDoc:
+            Statement:
+                - Action:
+                    - sts:AssumeRole
+                  Effect: Allow
+                  Principal:
+                    Service:
+                        - lambda.amazonaws.com
+            Version: "2012-10-17"
+        ManagedPolicies:
+            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+    aws:rest_api:rest_api_0:
+        BinaryMediaTypes:
+            - application/octet-stream
+            - image/*
+    aws:ecr_repo:ecr_repo-0:
+        ForceDelete: true
+    aws:log_group:lambda_function_2-log-group:
+        LogGroupName: /aws/lambda/lambda_function_2
+        RetentionInDays: 5
+    aws:api_resource:rest_api_0:api_resource-0:
+        FullPath: /{proxy+}
+        PathPart: '{proxy+}'
+        RestApi: aws:rest_api:rest_api_0
+    aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+        Authorization: NONE
+        HttpMethod: ANY
+        RequestParameters:
+            method.request.path.proxy: true
+        Resource: aws:api_resource:rest_api_0:api_resource-0
+        RestApi: aws:rest_api:rest_api_0
+    aws:api_integration:rest_api_0:rest_api_0_integration_0:
+        IntegrationHttpMethod: POST
+        Method: aws:api_method:rest_api_0:rest_api_0_integration_0_method
+        RequestParameters:
+            integration.request.path.proxy: method.request.path.proxy
+        Resource: aws:api_resource:rest_api_0:api_resource-0
+        RestApi: aws:rest_api:rest_api_0
+        Route: /{proxy+}
+        Target: aws:lambda_function:lambda_function_2
+        Type: AWS_PROXY
+        Uri: aws:lambda_function:lambda_function_2#LambdaIntegrationUri
+edges:
+    aws:api_stage:rest_api_0:api_stage-0 -> aws:api_deployment:rest_api_0:api_deployment-0:
+    aws:api_stage:rest_api_0:api_stage-0 -> aws:rest_api:rest_api_0:
+    aws:lambda_function:lambda_function_2 -> aws:SERVICE_API:lambda_function_2-lambda_function_2-log-group:
+    aws:lambda_function:lambda_function_2 -> aws:ecr_image:lambda_function_2-image:
+    aws:lambda_function:lambda_function_2 -> aws:iam_role:lambda_function_2-ExecutionRole:
+    aws:api_deployment:rest_api_0:api_deployment-0 -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:api_deployment:rest_api_0:api_deployment-0 -> aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+    aws:api_deployment:rest_api_0:api_deployment-0 -> aws:rest_api:rest_api_0:
+    aws:SERVICE_API:lambda_function_2-lambda_function_2-log-group -> aws:log_group:lambda_function_2-log-group:
+    aws:ecr_image:lambda_function_2-image -> aws:ecr_repo:ecr_repo-0:
+    aws:iam_role:lambda_function_2-ExecutionRole -> aws:log_group:lambda_function_2-log-group:
+    aws:rest_api:rest_api_0 -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:rest_api:rest_api_0 -> aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+    aws:rest_api:rest_api_0 -> aws:api_resource:rest_api_0:api_resource-0:
+    aws:api_resource:rest_api_0:api_resource-0 -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:api_resource:rest_api_0:api_resource-0 -> aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+    aws:api_method:rest_api_0:rest_api_0_integration_0_method -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
diff --git a/pkg/engine2/testdata/delete_api_to_lambda_edge.iac-viz.yaml b/pkg/engine2/testdata/delete_api_to_lambda_edge.iac-viz.yaml
new file mode 100644
index 00000000..3700557e
--- /dev/null
+++ b/pkg/engine2/testdata/delete_api_to_lambda_edge.iac-viz.yaml
@@ -0,0 +1,41 @@
+provider: aws
+resources:
+  rest_api/rest_api_0:
+
+  ecr_repo/ecr_repo-0:
+
+  log_group/lambda_function_2-log-group:
+
+  aws:api_resource:rest_api_0/api_resource-0:
+
+  aws:api_resource:rest_api_0/api_resource-0 -> rest_api/rest_api_0:
+  ecr_image/lambda_function_2-image:
+
+  ecr_image/lambda_function_2-image -> ecr_repo/ecr_repo-0:
+  iam_role/lambda_function_2-executionrole:
+
+  iam_role/lambda_function_2-executionrole -> log_group/lambda_function_2-log-group:
+  aws:api_method:rest_api_0/rest_api_0_integration_0_method:
+
+  aws:api_method:rest_api_0/rest_api_0_integration_0_method -> aws:api_resource:rest_api_0/api_resource-0:
+  aws:api_method:rest_api_0/rest_api_0_integration_0_method -> rest_api/rest_api_0:
+  lambda_function/lambda_function_2:
+
+  lambda_function/lambda_function_2 -> ecr_image/lambda_function_2-image:
+  lambda_function/lambda_function_2 -> iam_role/lambda_function_2-executionrole:
+  lambda_function/lambda_function_2 -> log_group/lambda_function_2-log-group:
+  aws:api_integration:rest_api_0/rest_api_0_integration_0:
+
+  aws:api_integration:rest_api_0/rest_api_0_integration_0 -> aws:api_method:rest_api_0/rest_api_0_integration_0_method:
+  aws:api_integration:rest_api_0/rest_api_0_integration_0 -> aws:api_resource:rest_api_0/api_resource-0:
+  aws:api_integration:rest_api_0/rest_api_0_integration_0 -> lambda_function/lambda_function_2:
+  aws:api_integration:rest_api_0/rest_api_0_integration_0 -> rest_api/rest_api_0:
+  aws:api_deployment:rest_api_0/api_deployment-0:
+
+  aws:api_deployment:rest_api_0/api_deployment-0 -> aws:api_integration:rest_api_0/rest_api_0_integration_0:
+  aws:api_deployment:rest_api_0/api_deployment-0 -> aws:api_method:rest_api_0/rest_api_0_integration_0_method:
+  aws:api_deployment:rest_api_0/api_deployment-0 -> rest_api/rest_api_0:
+  aws:api_stage:rest_api_0/api_stage-0:
+
+  aws:api_stage:rest_api_0/api_stage-0 -> aws:api_deployment:rest_api_0/api_deployment-0:
+  aws:api_stage:rest_api_0/api_stage-0 -> rest_api/rest_api_0:
diff --git a/pkg/engine2/testdata/delete_api_to_lambda_edge.input.yaml b/pkg/engine2/testdata/delete_api_to_lambda_edge.input.yaml
new file mode 100644
index 00000000..bc1c3251
--- /dev/null
+++ b/pkg/engine2/testdata/delete_api_to_lambda_edge.input.yaml
@@ -0,0 +1,95 @@
+constraints:
+- operator: must_not_exist
+  scope: edge
+  target:
+    source: aws:api_integration:rest_api_0:rest_api_0_integration_0
+    target: aws:lambda_function:lambda_function_2
+resources:
+    aws:api_stage:rest_api_0:api_stage-0:
+        Deployment: aws:api_deployment:rest_api_0:api_deployment-0
+        RestApi: aws:rest_api:rest_api_0
+        StageName: stage
+    aws:api_deployment:rest_api_0:api_deployment-0:
+        RestApi: aws:rest_api:rest_api_0
+        Triggers:
+            rest_api_0_integration_0: rest_api_0_integration_0
+            rest_api_0_integration_0_method: rest_api_0_integration_0_method
+    aws:rest_api:rest_api_0:
+        BinaryMediaTypes:
+            - application/octet-stream
+            - image/*
+    aws:api_resource:rest_api_0:api_resource-0:
+        FullPath: /{proxy+}
+        PathPart: '{proxy+}'
+        RestApi: aws:rest_api:rest_api_0
+    aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+        Authorization: NONE
+        HttpMethod: ANY
+        RequestParameters:
+            method.request.path.proxy: true
+        Resource: aws:api_resource:rest_api_0:api_resource-0
+        RestApi: aws:rest_api:rest_api_0
+    aws:api_integration:rest_api_0:rest_api_0_integration_0:
+        IntegrationHttpMethod: POST
+        Method: aws:api_method:rest_api_0:rest_api_0_integration_0_method
+        RequestParameters:
+            integration.request.path.proxy: method.request.path.proxy
+        Resource: aws:api_resource:rest_api_0:api_resource-0
+        RestApi: aws:rest_api:rest_api_0
+        Route: /{proxy+}
+        Target: aws:lambda_function:lambda_function_2
+        Type: AWS_PROXY
+        Uri: aws:lambda_function:lambda_function_2#LambdaIntegrationUri
+    aws:lambda_permission:rest_api_0_integration_0-lambda_function_2:
+        Action: lambda:InvokeFunction
+        Function: aws:lambda_function:lambda_function_2
+        Principal: apigateway.amazonaws.com
+        Source: aws:rest_api:rest_api_0#ChildResources
+    aws:lambda_function:lambda_function_2:
+        ExecutionRole: aws:iam_role:lambda_function_2-ExecutionRole
+        Image: aws:ecr_image:lambda_function_2-image
+        LogGroup: aws:log_group:lambda_function_2-log-group
+        MemorySize: 512
+        Timeout: 180
+    aws:SERVICE_API:lambda_function_2-lambda_function_2-log-group:
+    aws:ecr_image:lambda_function_2-image:
+        Context: .
+        Dockerfile: lambda_function_2-image.Dockerfile
+        Repo: aws:ecr_repo:ecr_repo-0
+    aws:iam_role:lambda_function_2-ExecutionRole:
+        AssumeRolePolicyDoc:
+            Statement:
+                - Action:
+                    - sts:AssumeRole
+                  Effect: Allow
+                  Principal:
+                    Service:
+                        - lambda.amazonaws.com
+            Version: "2012-10-17"
+        ManagedPolicies:
+            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+    aws:ecr_repo:ecr_repo-0:
+        ForceDelete: true
+    aws:log_group:lambda_function_2-log-group:
+        LogGroupName: /aws/lambda/lambda_function_2
+        RetentionInDays: 5
+edges:
+    aws:api_stage:rest_api_0:api_stage-0 -> aws:api_deployment:rest_api_0:api_deployment-0:
+    aws:api_stage:rest_api_0:api_stage-0 -> aws:rest_api:rest_api_0:
+    aws:api_deployment:rest_api_0:api_deployment-0 -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:api_deployment:rest_api_0:api_deployment-0 -> aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+    aws:api_deployment:rest_api_0:api_deployment-0 -> aws:rest_api:rest_api_0:
+    aws:rest_api:rest_api_0 -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:rest_api:rest_api_0 -> aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+    aws:rest_api:rest_api_0 -> aws:api_resource:rest_api_0:api_resource-0:
+    aws:api_resource:rest_api_0:api_resource-0 -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:api_resource:rest_api_0:api_resource-0 -> aws:api_method:rest_api_0:rest_api_0_integration_0_method:
+    aws:api_method:rest_api_0:rest_api_0_integration_0_method -> aws:api_integration:rest_api_0:rest_api_0_integration_0:
+    aws:api_integration:rest_api_0:rest_api_0_integration_0 -> aws:lambda_permission:rest_api_0_integration_0-lambda_function_2:
+    aws:lambda_permission:rest_api_0_integration_0-lambda_function_2 -> aws:lambda_function:lambda_function_2:
+    aws:lambda_function:lambda_function_2 -> aws:SERVICE_API:lambda_function_2-lambda_function_2-log-group:
+    aws:lambda_function:lambda_function_2 -> aws:ecr_image:lambda_function_2-image:
+    aws:lambda_function:lambda_function_2 -> aws:iam_role:lambda_function_2-ExecutionRole:
+    aws:SERVICE_API:lambda_function_2-lambda_function_2-log-group -> aws:log_group:lambda_function_2-log-group:
+    aws:ecr_image:lambda_function_2-image -> aws:ecr_repo:ecr_repo-0:
+    aws:iam_role:lambda_function_2-ExecutionRole -> aws:log_group:lambda_function_2-log-group:
diff --git a/pkg/knowledge_base2/kbtesting/graph_test.go b/pkg/knowledge_base2/kbtesting/graph_test.go
index 2e3bf691..bee6b385 100644
--- a/pkg/knowledge_base2/kbtesting/graph_test.go
+++ b/pkg/knowledge_base2/kbtesting/graph_test.go
@@ -88,6 +88,7 @@ func TestDownstream(t *testing.T) {
 			layer: knowledgebase.ResourceLocalLayer,
 			want:  []string{"p:t:B", "p:t:C"},
 		},
+
 		// Direct
 		{
 			name:     "no downstream",
@@ -123,6 +124,32 @@ func TestDownstream(t *testing.T) {
 			layer: knowledgebase.AllDepsLayer,
 			want:  []string{"p:t:B", "p:t:C", "p:t:D", "p:t:X", "p:t:Y"},
 		},
+		{
+			name: "all downstream, simple",
+			graph: []any{
+				"a:a:a", "a:a:b", "a:a:c", "a:b:a", "a:b:b", "a:b:c",
+				"a:a:a -> a:a:b", "a:a:b -> a:a:c",
+			},
+			resource: "a:a:a",
+			layer:    knowledgebase.AllDepsLayer,
+			want: []string{
+				"a:a:b", "a:a:c",
+			},
+		},
+		{
+			name: "all downstream, multiple paths for same resources",
+			graph: []any{
+				"a:a:a", "a:a:b", "a:a:c", "a:b:a", "a:b:b", "a:b:c",
+				"a:a:a -> a:a:b", "a:a:b -> a:a:c",
+				"a:a:b -> a:b:b", "a:b:b -> a:b:c",
+				"a:a:c -> a:b:b", "a:b:b -> a:b:c",
+			},
+			resource: "a:a:a",
+			layer:    knowledgebase.AllDepsLayer,
+			want: []string{
+				"a:a:b", "a:a:c", "a:b:b", "a:b:c",
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/knowledge_base2/operational_rule_test.go b/pkg/knowledge_base2/operational_rule_test.go
index bcfd73f8..04a555c0 100644
--- a/pkg/knowledge_base2/operational_rule_test.go
+++ b/pkg/knowledge_base2/operational_rule_test.go
@@ -58,3 +58,44 @@ properties:
 	}
 
 }
+
+func TestAdditionalRule_Hash(t *testing.T) {
+	tests := []struct {
+		name string
+		rule AdditionalRule
+		want string
+	}{
+		{
+			name: "simple rule",
+			rule: AdditionalRule{
+				If: "string",
+				Steps: []OperationalStep{
+					{
+						Resource: "string",
+					},
+				},
+			},
+			want: "02c61dd3cd718a1cb28439705f2291018dbffe8aaa110f4e5eb72b67f0963b4f",
+		},
+		{
+			name: "simple rule 2",
+			rule: AdditionalRule{
+				If: "string",
+				Steps: []OperationalStep{
+					{
+						Resource: "string2",
+					},
+				},
+			},
+			want: "ec11f23efba8646d56563e96ddf8d7963d09cede7290b6242efe49bb68e91c40",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			got, err := tt.rule.Hash()
+			assert.NoError(err)
+			assert.Equal(tt.want, got)
+		})
+	}
+}
diff --git a/pkg/knowledge_base2/template_kb_mock_test.go b/pkg/knowledge_base2/template_kb_mock_test.go
new file mode 100644
index 00000000..1da049f1
--- /dev/null
+++ b/pkg/knowledge_base2/template_kb_mock_test.go
@@ -0,0 +1,257 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./kb.go
+//
+// Generated by this command:
+//
+//	mockgen --source=./kb.go -destination=./template_kb_mock_test.go -package=knowledgebase2
+//
+
+// Package knowledgebase2 is a generated GoMock package.
+package knowledgebase2
+
+import (
+	reflect "reflect"
+
+	graph "github.com/dominikbraun/graph"
+	construct2 "github.com/klothoplatform/klotho/pkg/construct2"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockTemplateKB is a mock of TemplateKB interface.
+type MockTemplateKB struct {
+	ctrl     *gomock.Controller
+	recorder *MockTemplateKBMockRecorder
+}
+
+// MockTemplateKBMockRecorder is the mock recorder for MockTemplateKB.
+type MockTemplateKBMockRecorder struct {
+	mock *MockTemplateKB
+}
+
+// NewMockTemplateKB creates a new mock instance.
+func NewMockTemplateKB(ctrl *gomock.Controller) *MockTemplateKB {
+	mock := &MockTemplateKB{ctrl: ctrl}
+	mock.recorder = &MockTemplateKBMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockTemplateKB) EXPECT() *MockTemplateKBMockRecorder {
+	return m.recorder
+}
+
+// AddEdgeTemplate mocks base method.
+func (m *MockTemplateKB) AddEdgeTemplate(template *EdgeTemplate) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AddEdgeTemplate", template)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// AddEdgeTemplate indicates an expected call of AddEdgeTemplate.
+func (mr *MockTemplateKBMockRecorder) AddEdgeTemplate(template any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddEdgeTemplate", reflect.TypeOf((*MockTemplateKB)(nil).AddEdgeTemplate), template)
+}
+
+// AddResourceTemplate mocks base method.
+func (m *MockTemplateKB) AddResourceTemplate(template *ResourceTemplate) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AddResourceTemplate", template)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// AddResourceTemplate indicates an expected call of AddResourceTemplate.
+func (mr *MockTemplateKBMockRecorder) AddResourceTemplate(template any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddResourceTemplate", reflect.TypeOf((*MockTemplateKB)(nil).AddResourceTemplate), template)
+}
+
+// AllPaths mocks base method.
+func (m *MockTemplateKB) AllPaths(from, to construct2.ResourceId) ([][]*ResourceTemplate, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AllPaths", from, to)
+	ret0, _ := ret[0].([][]*ResourceTemplate)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// AllPaths indicates an expected call of AllPaths.
+func (mr *MockTemplateKBMockRecorder) AllPaths(from, to any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllPaths", reflect.TypeOf((*MockTemplateKB)(nil).AllPaths), from, to)
+}
+
+// Edges mocks base method.
+func (m *MockTemplateKB) Edges() ([]graph.Edge[*ResourceTemplate], error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Edges")
+	ret0, _ := ret[0].([]graph.Edge[*ResourceTemplate])
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Edges indicates an expected call of Edges.
+func (mr *MockTemplateKBMockRecorder) Edges() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Edges", reflect.TypeOf((*MockTemplateKB)(nil).Edges))
+}
+
+// GetAllowedNamespacedResourceIds mocks base method.
+func (m *MockTemplateKB) GetAllowedNamespacedResourceIds(ctx DynamicValueContext, resourceId construct2.ResourceId) ([]construct2.ResourceId, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAllowedNamespacedResourceIds", ctx, resourceId)
+	ret0, _ := ret[0].([]construct2.ResourceId)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAllowedNamespacedResourceIds indicates an expected call of GetAllowedNamespacedResourceIds.
+func (mr *MockTemplateKBMockRecorder) GetAllowedNamespacedResourceIds(ctx, resourceId any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllowedNamespacedResourceIds", reflect.TypeOf((*MockTemplateKB)(nil).GetAllowedNamespacedResourceIds), ctx, resourceId)
+}
+
+// GetClassification mocks base method.
+func (m *MockTemplateKB) GetClassification(id construct2.ResourceId) Classification {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetClassification", id)
+	ret0, _ := ret[0].(Classification)
+	return ret0
+}
+
+// GetClassification indicates an expected call of GetClassification.
+func (mr *MockTemplateKBMockRecorder) GetClassification(id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClassification", reflect.TypeOf((*MockTemplateKB)(nil).GetClassification), id)
+}
+
+// GetEdgeTemplate mocks base method.
+func (m *MockTemplateKB) GetEdgeTemplate(from, to construct2.ResourceId) *EdgeTemplate {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetEdgeTemplate", from, to)
+	ret0, _ := ret[0].(*EdgeTemplate)
+	return ret0
+}
+
+// GetEdgeTemplate indicates an expected call of GetEdgeTemplate.
+func (mr *MockTemplateKBMockRecorder) GetEdgeTemplate(from, to any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEdgeTemplate", reflect.TypeOf((*MockTemplateKB)(nil).GetEdgeTemplate), from, to)
+}
+
+// GetModel mocks base method.
+func (m *MockTemplateKB) GetModel(model string) *Model {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetModel", model)
+	ret0, _ := ret[0].(*Model)
+	return ret0
+}
+
+// GetModel indicates an expected call of GetModel.
+func (mr *MockTemplateKBMockRecorder) GetModel(model any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetModel", reflect.TypeOf((*MockTemplateKB)(nil).GetModel), model)
+}
+
+// GetPathSatisfactionsFromEdge mocks base method.
+func (m *MockTemplateKB) GetPathSatisfactionsFromEdge(source, target construct2.ResourceId) ([]EdgePathSatisfaction, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetPathSatisfactionsFromEdge", source, target)
+	ret0, _ := ret[0].([]EdgePathSatisfaction)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetPathSatisfactionsFromEdge indicates an expected call of GetPathSatisfactionsFromEdge.
+func (mr *MockTemplateKBMockRecorder) GetPathSatisfactionsFromEdge(source, target any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPathSatisfactionsFromEdge", reflect.TypeOf((*MockTemplateKB)(nil).GetPathSatisfactionsFromEdge), source, target)
+}
+
+// GetResourcePropertyType mocks base method.
+func (m *MockTemplateKB) GetResourcePropertyType(resource construct2.ResourceId, propertyName string) string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetResourcePropertyType", resource, propertyName)
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetResourcePropertyType indicates an expected call of GetResourcePropertyType.
+func (mr *MockTemplateKBMockRecorder) GetResourcePropertyType(resource, propertyName any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourcePropertyType", reflect.TypeOf((*MockTemplateKB)(nil).GetResourcePropertyType), resource, propertyName)
+}
+
+// GetResourceTemplate mocks base method.
+func (m *MockTemplateKB) GetResourceTemplate(id construct2.ResourceId) (*ResourceTemplate, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetResourceTemplate", id)
+	ret0, _ := ret[0].(*ResourceTemplate)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetResourceTemplate indicates an expected call of GetResourceTemplate.
+func (mr *MockTemplateKBMockRecorder) GetResourceTemplate(id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourceTemplate", reflect.TypeOf((*MockTemplateKB)(nil).GetResourceTemplate), id)
+}
+
+// GetResourcesNamespaceResource mocks base method.
+func (m *MockTemplateKB) GetResourcesNamespaceResource(resource *construct2.Resource) (construct2.ResourceId, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetResourcesNamespaceResource", resource)
+	ret0, _ := ret[0].(construct2.ResourceId)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetResourcesNamespaceResource indicates an expected call of GetResourcesNamespaceResource.
+func (mr *MockTemplateKBMockRecorder) GetResourcesNamespaceResource(resource any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourcesNamespaceResource", reflect.TypeOf((*MockTemplateKB)(nil).GetResourcesNamespaceResource), resource)
+}
+
+// HasDirectPath mocks base method.
+func (m *MockTemplateKB) HasDirectPath(from, to construct2.ResourceId) bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "HasDirectPath", from, to)
+	ret0, _ := ret[0].(bool)
+	return ret0
+}
+
+// HasDirectPath indicates an expected call of HasDirectPath.
+func (mr *MockTemplateKBMockRecorder) HasDirectPath(from, to any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasDirectPath", reflect.TypeOf((*MockTemplateKB)(nil).HasDirectPath), from, to)
+}
+
+// HasFunctionalPath mocks base method.
+func (m *MockTemplateKB) HasFunctionalPath(from, to construct2.ResourceId) bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "HasFunctionalPath", from, to)
+	ret0, _ := ret[0].(bool)
+	return ret0
+}
+
+// HasFunctionalPath indicates an expected call of HasFunctionalPath.
+func (mr *MockTemplateKBMockRecorder) HasFunctionalPath(from, to any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasFunctionalPath", reflect.TypeOf((*MockTemplateKB)(nil).HasFunctionalPath), from, to)
+}
+
+// ListResources mocks base method.
+func (m *MockTemplateKB) ListResources() []*ResourceTemplate {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListResources")
+	ret0, _ := ret[0].([]*ResourceTemplate)
+	return ret0
+}
+
+// ListResources indicates an expected call of ListResources.
+func (mr *MockTemplateKBMockRecorder) ListResources() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListResources", reflect.TypeOf((*MockTemplateKB)(nil).ListResources))
+}