-- 028_relationship_blast_direction.up.sql -- Make blast_radius answer the question it is named after. -- -- blast_radius walked source_id -> target_id for every relationship type. But -- which end of an edge is the DEPENDENT differs per type: -- -- machine --hosts--> container if the machine dies, the container dies -- -> dependent is the TARGET (forward) -- service --depends-on--> service if the target dies, the SOURCE breaks -- -> dependent is the SOURCE (backward) -- ingress --routes-to--> service if the service dies, the route 502s -- -> dependent is the SOURCE (backward) -- document --documents--> entity neither breaks the other -- -> no runtime dependency at all -- -- Walking everything forwards meant the answer was right for `hosts` and -- `provides` and wrong for every backward edge, while `documents`, `involves` -- and `targets` (2,800+ edges of pure bookkeeping) polluted the result with -- tasks and executions that cannot "break". -- -- Direction is therefore a property of the relationship type, declared in -- seeds/ontology.yaml — the same shape as the `monitoring:` declaration on -- entity types. -- -- forward : if the SOURCE fails, the TARGET is affected -- backward : if the TARGET fails, the SOURCE is affected -- none : no runtime dependency (default — bookkeeping and documentation) -- -- Defaulting to 'none' is deliberate: an undeclared edge contributes nothing -- rather than silently producing a wrong answer, which is how the old -- everything-is-forward behaviour went unnoticed. ALTER TABLE relationship_types ADD COLUMN IF NOT EXISTS blast_direction TEXT NOT NULL DEFAULT 'none' CHECK (blast_direction IN ('forward', 'backward', 'none')); COMMENT ON COLUMN relationship_types.blast_direction IS 'Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().'; -- Walk the dependency graph in the direction each edge type declares. -- -- Returns everything that is affected when start_id fails, with the number of -- hops. Cycles are guarded by the path array, as before. CREATE OR REPLACE FUNCTION blast_radius(start_id UUID, max_depth INT DEFAULT 3, rel_types TEXT[] DEFAULT NULL) RETURNS TABLE(entity_id UUID, depth INT) AS $$ WITH RECURSIVE walk AS ( SELECT start_id AS entity_id, 0 AS depth, ARRAY[start_id] AS path UNION ALL SELECT next_id, w.depth + 1, w.path || next_id FROM walk w JOIN LATERAL ( -- forward: this entity is the source, so the target depends on it SELECT r.target_id AS next_id FROM relationships r JOIN relationship_types rt ON rt.name = r.type WHERE r.source_id = w.entity_id AND r.valid_to IS NULL AND rt.blast_direction = 'forward' AND (rel_types IS NULL OR r.type = ANY(rel_types)) UNION ALL -- backward: this entity is the target, so the source depends on it SELECT r.source_id AS next_id FROM relationships r JOIN relationship_types rt ON rt.name = r.type WHERE r.target_id = w.entity_id AND r.valid_to IS NULL AND rt.blast_direction = 'backward' AND (rel_types IS NULL OR r.type = ANY(rel_types)) ) nxt ON TRUE WHERE w.depth < LEAST(max_depth, 5) AND NOT nxt.next_id = ANY(w.path) ) SELECT entity_id, MIN(depth) FROM walk GROUP BY entity_id; $$ LANGUAGE sql STABLE;