funnel-core — Plan-DAG ↔ Component-DAG wiring
The funnel knows what was planned. Mímir knows what the code is. Nothing joined them, so the question "which code does this plan touch, and what is the blast radius?" had no answer. This is funnel-core's half of that join: the wiring module — a bipartite plan_node ↔ component layer, the traversal that walks it into the component graph, and the report that names every plan node the wiring does not cover.
Implements the funnel-core side of workspace_nordisk/.nornir/funnel-dag-component-wiring-design.md (P1). Additive and migration-safe: one new module, one new Event variant, one new Funnel field, one new PlanDag method. No existing type's shape or function's signature changes; every pre-existing log row replays byte-identically.
1. The three graphs
| graph | nodes | edges | owner |
| Plan-DAG | funnel cards (i-NNN mother idea, n-NNN task node) |
Mother (decomposition) + Dep (task waits on task) |
funnel-core (PlanDag) |
| Component-DAG | crates / modules / symbols ("funnel-core", "nornir-viz") |
Dep (dep_graph_edges) + Call (call_edges) |
Mímir / nornir |
| Wiring layer | — | bipartite plan_node → component |
this module |
2. Edge direction — the one thing to get right
A ComponentEdge { from, to } means from depends on to: the Cargo / dep_graph_edges DSM reading, where the dependent is the source (from_repo → to_repo). So deps_of walks forward (what a component needs) and dependents_of walks backward (who needs it). blast_radius is the transitive-dependents closure — change this component and these break — the pure twin of Mimir.AffectedByChange.
This is the opposite orientation to PlanDagEdge, where from → to means "from must finish before to" (dependency first). The two graphs come from different worlds and the seam refuses to paper over it: a nornir adapter feeding dep_graph_edges rows in must orient them dependent → dependency, and the direction is locked by a test that asserts the dependency closure of a root is a different set from its blast radius — reverse the traversal and it goes RED.
Both closures are BFS with a visited set, so a cyclic component graph (call edges routinely are — mutual recursion) terminates instead of spinning.
3. The seam: ComponentGraph
funnel-core is pure (FC-7): no RPC, no storage, no clock. So it never fetches the component graph — a host implements a trait and gets the traversal for free:
pub trait ComponentGraph {
fn has_component(&self, id: &ComponentId) -> bool;
fn deps_of(&self, id: &ComponentId) -> Vec<ComponentId>;
fn dependents_of(&self, id: &ComponentId) -> Vec<ComponentId>;
fn components(&self) -> Option<Vec<ComponentId>> { None } // provided
fn blast_radius(&self, id: &ComponentId) -> Vec<ComponentId> { … } // provided
fn dependency_closure(&self, id: &ComponentId) -> Vec<ComponentId> { … } // provided
}
Two implementations are expected: nornir's, over live Mimir.DepsOf/DependentsOf, and the in-memory ComponentDag shipped here for the offline/test/fixture path. ComponentDag is not a second source of truth — it is the materialised form of Mímir rows a caller builds when it wants the whole graph in hand.
components() defaulting to None is load-bearing, not laziness: a lazy RPC-backed graph cannot enumerate its nodes, and None says unknown, which is honestly distinct from Some(vec![]) ("the graph is empty ⇒ every component is claimed"). It flows straight through into WiringReport::unplanned: Option<Vec<ComponentId>>.
A ComponentId is deliberately an opaque String, not a funnel id_newtype: the component namespace belongs to Mímir (repo/member names — "funnel-core", "nornir"), so funnel-core neither mints these nor validates their shape.
4. Wires are events, and they fail closed
One additive variant, last-write-wins per (plan_node, component):
Event::PlanComponentWired { plan_node, component, origin, status, confidence, why, ts }
origin (drawn | inferred | declared) and status (proposed | confirmed | rejected) are free-form wire tokens, exactly like IdeaModeSet::mode, so a new provenance or lifecycle state never needs a schema bump. They are read typed via Wire::origin_kind / Wire::status_kind, which fail closed:
- an explicit status token always wins;
- an empty status falls back to the origin's natural status —
drawn ⇒ confirmed(a human dragging a wire is the confirmation), everything else⇒ proposed; - an unknown origin falls back to
proposed.
So no wire this build cannot fully understand ever binds by accident. Only Confirmed binds — a suggestion is not a mapping.
There is no un-wire event: the log is append-only, so a wire is retracted by appending a later row for the same pair with status = "rejected". That is not bookkeeping pedantry — it preserves the fact that a suggestion was considered and thrown away, which is what stops infer_declared re-proposing it forever.
Validation asymmetry, on purpose. apply rejects a wire whose plan_node is not a real card (an idea or a task node — the same card_exists check CardAssigned uses), because a wire to a step that does not exist would silently inflate a plan's wiring coverage. It does not validate the component: the funnel does not own that namespace and holds no component graph, and a plan must be wireable ahead of the code. An id the graph turns out not to hold surfaces as a dangling wire in the report instead.
5. The traversal
plan node ──▶ bound components ──▶ transitive dependents
(wiring) (touched) (affected)
Wiring::blast_radius(plan_node, &graph)— one node.Wiring::cone_blast_radius(&plan_dag, plan_node, &graph)— the node plus every Plan-DAG node downstream of it (PlanDag::descendants, added for this): "if I start here, what code does the rest of this plan touch, and what ripples out of it?"
PlanDag::descendants is a plain BFS rather than the topological fold bottlenecks uses, so it still answers on a malformed cyclic edge set (where topo_order fails and bottlenecks returns nothing) instead of silently reporting an empty cone.
A component that is directly touched is never also listed as affected — no double-counting. A missing (dangling) component is reported but never followed: a closure over a phantom id would be fiction.
6. The honesty contract (the point of the whole thing)
A plan node with no component binding must never silently succeed. Two mechanisms:
BlastRadius::unmapped_nodes. An empty affected list is ambiguous — it can mean "nothing downstream" or "we have no idea". The traversal therefore reports every unbound node in the cone, and is_complete() is the one-call reading. Two tests lock the distinction: an unwired node yields affected = [] and is_complete() == false; a wired leaf yields affected = [] and is_complete() == true. Collapse the two and one of them goes RED.
WiringReport. Every node of the Plan-DAG lands in exactly one of mapped / unmapped — nothing is dropped, which is what makes the unmapped list trustworthy. Each unmapped node carries a reason, ordered by how much attention it needs:
| reason | means | fix |
dangling |
confirmed wire → a component the graph does not hold | a stale/mistyped id. The loudest, because it looks mapped |
only_proposed |
only unconfirmed suggestions exist | confirm or reject them |
only_rejected |
every candidate was thrown away | needs a new wire |
no_wire |
nothing was ever drawn, inferred or declared | wire it |
Plus the roll-ups: is_fully_mapped() (the gate), coverage(), unmapped_ids(), unmapped_tasks() (task nodes only, for callers that treat an idea card as a container), and done_but_unmapped() — the loud row: a task node that is Done yet never wired means work landed in code the plan cannot point at. That one filters to task nodes on purpose: a mother idea card reads Done as soon as it is decomposed (its Planned lane), which is not finished work, so including ideas would drown the signal.
The other direction is unplanned — components no plan node claims. A component with churn and no plan wire is unplanned work.
7. The pure inference (declared)
The funnel already records file-glob targets on every NodeAdded, and a ComponentNode carries an optional path. Wiring::infer_declared(&funnel, &component_dag, ts) matches the two and returns the PlanComponentWired events to apply+append. Three rules, in order: the glob matches the component's path; the target names a file inside the component's path (funnel-core/src owns funnel-core/src/wiring.rs); the glob matches the component's name. That double rule matters because nornir's targets field is in practice overloaded — planner.rs::plan_from_component writes a component name, decompose.rs writes a file path — and this matches both.
It emits proposals, never bindings, and skips any pair that already has a wire — including a rejected one — so re-running is idempotent and never resurrects a suggestion a human threw away. Mímir's symbol-level inference is the impure half and arrives over the same seam as inferred wires.
ts is a parameter because funnel-core reads no clock (FC-7).
8. Fan-out
None. Every function here is a single-pass BTree-ordered projection over data the caller already holds — the cold render/report path, not a hot loop, and the graphs are hundreds of nodes, not millions. If a future consumer ever needs the wiring report fanned out across many plans, it is znippy gatling — never rayon, never thread::spawn, never a hand-rolled thread::scope. funnel-core itself stays dependency-free of all three.
HANDOFF — the nornir-side change (for the nornir agent)
funnel-core owns the types, the fold and the traversal. Three things must land on the nornir side, none of which funnel-core can do (they touch storage, RPC and the workspace graph). funnel-core is already green and pushed; nothing below blocks it.
Component ids are the workspace repo/member names (WorkspaceGraph::component_names() — nornir, funnel-core, facett, …), which is exactly what ComponentId carries. No translation layer is needed.
H0 — Event is now an OPEN enum (do this first)
This spec should have predicted the break it caused, and did not. Recorded here so the next variant cannot repeat it.
Adding PlanComponentWired broke nornir (lib) with E0004 at nornir/src/warehouse/funnel.rs:310 (build_batch) and :780 (event_kind_str), and through it dwarves --features embed-nornir. The cause was not the variant — it was that funnel_core::Event was a closed enum while the crate's own charter promises "new event kinds append at the bottom". Every future variant would have done the same.
Event is therefore now #[non_exhaustive]. Every match on an Event outside funnel-core needs a wildcard arm, including matches that list every variant that exists today. The two nornir sites above need one each, and what the arm does is not a detail:
| path | arm | why |
build_batch (event → row) |
_ => bail!("funnel-core Event variant {} is newer than this build's funnel_events schema", ev.kind_str()) |
Never drop it. A silently unwritten event is data loss the append-only log can never recover. Fail loud, fix the schema. |
parse_batch (row → event) |
unknown kind string ⇒ warn! + skip the row, keep parsing |
The row came from a newer writer. A hard error makes an old reader unable to open a new log at all. |
Better: stop matching. event_kind_str in warehouse/funnel.rs:779 and the eleven KIND_* consts at :76-86 can be deleted outright and replaced with funnel_core::Event::kind_str(). It is a total accessor that returns byte-identical tokens (they are serde's #[serde(tag = "kind")] variant names — the same PascalCase strings the KIND_* consts already hold, verified against them), it is locked by a test that asserts kind_str() == the serde tag for every variant, and it never needs touching again. funnel_core::Event::KINDS is the full token set for validating a stored column. Do this and H1 below is the only work a new variant ever costs nornir.
Other enums that became open in the same change — check nornir for exhaustive matches on these too, they need wildcards for the same reason: ItemKind, CardType, FunnelMode, and the new ComponentEdgeKind / WireOrigin / UnmappedReason. Deliberately left closed, because a new variant there is a real semantic break that should stop your build: NodeStatus, PlanStatus, TriageDecision, RunOutcome, WireStatus, DagStatus, EdgeKind, Lane, HistoryStatus, Sort.
H1 — persist the new event
Event::PlanComponentWired { plan_node: String, component: String, origin: String, status: String, confidence: Option<f64>, why: Option<String>, ts }.
-
nornir/src/warehouse/iceberg_schema.rs→pub fn funnel_events() -> Result<Schema>: append four optional fields after the existing keystone columns (mode,member,role,from_lane,to_lane) —plan_node: string,component: string,wire_origin: string,wire_status: string,confidence: double. Optional ⇒ migration-safe; the table auto-evolves forward on the first write viaiceberg::ensure_table_schema, the same route field 28 (item_kind) took. (whyreuses the existingwhycolumn;plan_nodecannot reusenode_id, which is a typedNodeIdcolumn — a wire may address ani-NNNidea card.) -
nornir/src/warehouse/funnel.rs:- the kind token is
"PlanComponentWired"— PascalCase, matching serde's variant name and the existingKIND_*consts. (An earlier draft of this spec saidplan_component_wired; that was wrong and would have made the row unreadable byparse_batch.) Better still, take H0's advice and delete theKIND_*consts in favour ofEvent::kind_str(), and this line stops being a decision at all; - add the new
COL_*index constants (append only — the comment there is explicit that the indices matchfunnel_eventsfield order) and the write arm infn build_batch(table, events) -> Result<RecordBatch>+ the read arm infn parse_batch(batch) -> Result<Vec<Event>>. parse_batchmust read a missing/NULLwire_origin/wire_statusback as""— the empty token is meaningful (it resolves throughWireOrigin::default_status), so it must not be coerced to"proposed"on the way in.
- the kind token is
-
nornir/src/warehouse/access_scan.rs— no change: the pair("append_event", funnel_events, Write)already covers it.
No change is needed in funnel/store.rs: Store::record already routes any Event through funnel.apply → append_event, and apply's new arm does the validation.
Rule for the next variant (this is the part the spec was missing): a new funnel_core::Event variant costs a consumer nothing if it uses Event::kind_str() and a wildcard arm, and costs it a build outage otherwise. Any future funnel-core change that adds a variant must land with this section re-read, and any consumer-side match ev must be written open from the start.
H2 — implement ComponentGraph over the workspace graph
New file nornir/src/funnel/component_graph.rs (or a mimir.rs impl block):
pub struct WorkspaceComponentGraph<'a>(pub &'a nornir_depgraph::WorkspaceGraph);
impl funnel_core::ComponentGraph for WorkspaceComponentGraph<'_> {
fn has_component(&self, id: &ComponentId) -> bool { self.0.has_component(id.as_str()) }
fn deps_of(&self, id: &ComponentId) -> Vec<ComponentId> { /* edges from == id → to */ }
fn dependents_of(&self, id: &ComponentId) -> Vec<ComponentId> { /* edges to == id → from */ }
fn components(&self) -> Option<Vec<ComponentId>> {
Some(self.0.component_names().into_iter().map(ComponentId::new).collect())
}
}
Direction check (do not skip): CrossRepoEdge { from, to } in edda/crates/nornir-depgraph must be read as from depends on to. If it is the other way round, swap the two bodies above — the whole blast radius inverts otherwise. Assert it with one test over a known pair (nornir depends on funnel-core, so dependents_of("funnel-core") must contain "nornir", and deps_of("funnel-core") must not).
Do not re-derive the closure: blast_radius / dependency_closure are provided methods and already cycle-safe. Overriding blast_radius with a call to WorkspaceGraph::affected_by_change is allowed only if it returns the same set — if it does, prefer it (one implementation of impact in the workspace) and lock the agreement with a test.
H3 — surface it
- CLI (
nornir/src/bin/nornir.rs,enum FunnelOp):Wire { node, component },Unwire { node, component }(⇒funnel_core::reject_wire), andWiring { plan }printingWiringReport::state_json().Wirebuildsfunnel_core::draw_wire(..)and hands it toStore::record. - gRPC (
nornir/proto/nornir.proto,service Funnel):rpc Wiring (PlanQuery) returns (JsonResponse)whose body isWiringReport::state_json(), plusrpc WireNode (WireRequest) returns (Empty). Keep the JSON blob shape — it is what the pane reads. - Viz (
nornir/src/viz/funnel_plandag.rs/funnel_tab.rs): the pane already callsPlanDag::from_funnel(&store.funnel, sel_plan)infn load_plan_dag. Add a siblingfn load_wiring(&self) -> Option<funnel_core::Wiring>(Wiring::from_funnel(&store.funnel)) and renderWiringReport::of(&dag, &wiring, &graph). Renderunmappedvisibly — a plan node with no binding must look different from a wired one, or the honesty in the data dies at the pixel layer. - Auto-propose:
nornir funnel wire --infer⇒Wiring::infer_declared(&funnel, &dag, Utc::now())→Store::persist_events. Idempotent; safe to run on every plan.
The dwarves/brokkr wiring canvas (plan_component_edges persistence in the P1 design doc) should persist through this event, not a private table — otherwise the funnel log and the canvas disagree about what is wired.