Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/funnel-core

.nornir/dag-wiring-design.md

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:

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)

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 }.

  1. 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 via iceberg::ensure_table_schema, the same route field 28 (item_kind) took. (why reuses the existing why column; plan_node cannot reuse node_id, which is a typed NodeId column — a wire may address an i-NNN idea card.)

  2. nornir/src/warehouse/funnel.rs:

    • the kind token is "PlanComponentWired" — PascalCase, matching serde's variant name and the existing KIND_* consts. (An earlier draft of this spec said plan_component_wired; that was wrong and would have made the row unreadable by parse_batch.) Better still, take H0's advice and delete the KIND_* consts in favour of Event::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 match funnel_events field order) and the write arm in fn build_batch(table, events) -> Result<RecordBatch> + the read arm in fn parse_batch(batch) -> Result<Vec<Event>>.
    • parse_batch must read a missing/NULL wire_origin/wire_status back as "" — the empty token is meaningful (it resolves through WireOrigin::default_status), so it must not be coerced to "proposed" on the way in.
  3. 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

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.