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

nordisk/facett

.nornir/fc2-fc9-rollout-schedule-history.md

facett — FC-2 / FC-9 Compliance Matrix & Planned Rollout Schedule

Provenance: authored 2026-07-03 (audit by agent, commissioned per component-contract.md §Reconciliation, which states *"FC-2 + FC-9 (full Elm Msg

  • pure view) is an architectural decision across ~16 components … FC-2/FC-9 land on a planned schedule, not as a silent relaxation of the others."* This document is that planned schedule. It is additive: it modifies no component source and no requirement in component-contract.md. Where the two disagree, the contract governs; this doc only sequences the work.

0. Scope, method, honesty note

Method. Static source audit only — no cargo build, no egui_kittest run (per the audit brief). Verdicts that the contract says must be verified at runtime (FC-4 AccessKit retrieval, FC-5 id stability under reorder, FC-6 offscreen render, FC-7 frame-equality) are therefore marked provisional (🟠) wherever a static read cannot prove them, with the reason stated. FC-2 and FC-9 — the subject of this schedule — are structurally decidable from source (does a Msg enum exist? is view a &self -> Vec<Msg> function?) and are graded firmly.

Population. The contract's "~16 components" is now an undercount. The workspace currently holds 32 crates that impl Facet at the component level (≈40 distinct impl Facet blocks — facett-plotters alone ships 5: boxplot/candlestick/heatmap/ treemap/violin; facett-graphview ships 2; facett-graph and facett-table ship 2 each). Excluded as non-components (no impl Facet at lib level): facett-core (the trait + FacetDeck), facett-app (shell), facett-demo (demo binary), facett-arrow (columnar adapter), facett-osm (tile/data provider), facett-trace (diagnostics), facett-menu (overlay widget — impl Facet only in an example), facett-form (widget builder), facett-map3d-flight (a support crate for facett-map3d; its enum Msg at facett-map3d-flight/src/tiles.rs:43 is an internal tile-streaming message, not a component input surface).

1. The three-tier reality (the headline)

Today's Facet trait is fn ui(&mut self, ui: &mut Ui) — it mutates self and paints in one pass. Against that baseline, every component falls into exactly one of three tiers for FC-2/FC-9:

Tier Meaning Members
T0 — reference (✅/✅) Genuine Elm split already in place: pure render(&self)->Vec<Msg>, update(&mut self, Msg)->Vec<Effect>, state(&self)->&Model. facett-security (1)
T1 — partial (🟠/🟠) A real Msg enum + update(&mut self, Msg) exist, but update is called inline from the &mut self ui() pass, so the view is not pure; update returns bool (dirty), not Vec<Effect>; no state() accessor; model not fully serde. Half the pattern is done. facett-helix, facett-plandag, facett-timeline (3)
T2 — missing (❌/❌) No Msg enum. ui() mutates self directly on click/drag. FC-2 and FC-9 both absent; the input vocabulary must be invented from scratch. the other 28 components

facett-security is the worked reference. It hand-rolls exactly the bridge the whole library needs (facett-security/src/lib.rs:632-639):

fn ui(&mut self, ui: &mut egui::Ui) {
    // FC-9: the view is pure (paints + emits Msgs); FC-2: mutation only via `update`.
    let msgs = self.render(ui);          // render(&self, ui) -> Vec<Msg>   [lib.rs:475]
    for m in msgs { self.update(m); }    // update(&mut self, Msg) -> Vec<Effect> [lib.rs:423]
}

with state(&self) -> &SecurityState (lib.rs:397) and an intentionally uninhabited enum Effect {} (lib.rs:372) — the honest statement that this component does no I/O. Lifting this bridge into facett-core is the shared-infra prerequisite (§4).

2. Compliance matrix (component × FC-1 … FC-9)

Legend: ✅ met · 🟠 partial / provisional-pending-runtime-verify · ❌ absent. Columns FC-2 and FC-9 are graded firmly; the rest carry the static-audit caveat from §0.

Cross-cutting findings (apply to every row unless noted):

component FC-1 FC-2 FC-3 FC-4 FC-5 FC-6 FC-7 FC-8 FC-9 tier
facett-security ✅ ✅ ✅ ✅ 🟠 ✅ ✅ ✅ ✅ T0
facett-helix ✅ 🟠 🟠 🟠 🟠 🟠 ✅ ✅ 🟠 T1
facett-plandag ✅ 🟠 🟠 🟠 🟠 ✅ ✅ ✅ 🟠 T1
facett-timeline ✅ 🟠 🟠 🟠 🟠 ✅ ✅ ✅ 🟠 T1
facett-pop ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-stat ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-card ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-log ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-docview ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-matrix ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-plot ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-plotters (×5) ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-syschart ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-tabbar ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T2
facett-console ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-table ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-grid ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-warehousedeck ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-icebergview ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-jobview ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-filedialog ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-pipeline ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-graph ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-graphpan ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-graphview ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T3
facett-git ✅ ❌ 🟠 🟠 🟠 ✅ ✅ ✅ ❌ T3
facett-map ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T4
facett-geomap ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T4
facett-map3d ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T4
facett-graph3d ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T4
facett-model3d ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T4
facett-video ✅ ❌ 🟠 🟠 🟠 🟠 ✅ ✅ ❌ T4

Count summary

✅ full 🟠 partial ❌ missing
FC-2 (writable Msg surface) 1 (security) 3 (helix, plandag, timeline) 28
FC-9 (pure view) 1 (security) 3 (helix, plandag, timeline) 28

FC-2 and FC-9 track each other exactly — unsurprising, since the same architectural move (extract view(&self)->Vec<Msg>, route all mutation through update) satisfies both at once. Any component that lands one lands the other.

3. Per-tier delta (what specifically must change)

T0 — facett-security — done. Optional polish: rename private render → view and make it (or a thin wrapper) pub so it matches the canonical signature in component-contract.md §3 verbatim, and so callers can drive it headlessly without going through Facet::ui. Zero behavioral change.

T1 — helix, plandag, timeline — the extract-pure-view delta (small–medium each):

T2 — display components (pop, stat, card, log, docview, matrix, plot, plotters×5, syschart, tabbar) — the mechanical-wrap delta (small each): tiny input surfaces (mostly hover/select/scale). Introduce a small Msg enum for the handful of inputs, move the field writes into update, collect from view. Once the core bridge (§4) exists, each is a near-boilerplate change. tabbar's Msg is essentially SelectTab(id).

T3 — interactive data components (console, table, grid, warehousedeck, icebergview, jobview, filedialog, pipeline, graph, graphpan, graphview, git) — the rich-Msg delta (medium each): real interaction — row/cell select, sort, column resize, expand/collapse, navigate, filter. The Msg enum is substantial and every mutation path must be routed through it (FC-2's "no input reachable only by pointer simulation"). FC-5 matters here: sort/reorder must preserve element ids off stable domain keys. graphpan/graphview compose an inner GraphView (note graphpan's existing view() at lib.rs:564 is a getter returning &GraphView, not an FC-9 view — do not confuse the two).

T4 — wgpu / 3D / video (map, geomap, map3d, graph3d, model3d, video) — the big-lift delta (large each): continuous camera input (pan/zoom/orbit) forces a design decision on Msg granularity (discrete Msg::Orbit(delta) per frame vs. a camera-state Msg); the custom render path must be proven FC-6-offscreen; and FC-4 for painted/3D value elements is genuinely hard. These are the last to convert and warrant their own mini-design each. (None currently do async I/O, so at least FC-8 stays trivial.)

4. Shared infrastructure — land this first

Before converting any component, lift facett-security's hand-rolled bridge into facett-core as the canonical, reusable scaffold. Recommended shape (illustrative):

// facett-core: the Elm contract, with a blanket Facet bridge.
pub trait Elm {
    type Model: Serialize + DeserializeOwned + Clone + PartialEq;
    type Msg:   Clone + std::fmt::Debug;
    type Effect;
    fn state(&self) -> &Self::Model;                         // FC-3
    fn update(&mut self, msg: Self::Msg) -> Vec<Self::Effect>; // FC-2, FC-8
    fn view(&self, ui: &mut egui::Ui) -> Vec<Self::Msg>;      // FC-9, FC-4
    fn title(&self) -> &str;
}

// One bridge, written once, gives every Elm component its Facet impl for free:
impl<T: Elm> Facet for T {
    fn title(&self) -> &str { Elm::title(self) }
    fn ui(&mut self, ui: &mut egui::Ui) {
        for m in self.view(ui) { let _ = self.update(m); } // effects drained by host
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::to_value(self.state()).unwrap_or(serde_json::Value::Null)
    }
}

Why first: it collapses FC-2 + FC-3 + FC-8 + FC-9 into a single per-component impl, removes the per-component ui() bridge boilerplate (security writes it by hand today), and gives the nornir gate a uniform state()/view() seam to drive headlessly. orphan-rule caveat: a blanket impl<T: Elm> Facet for T may collide with existing hand-written impl Facet blocks during migration — expect to convert component-by-component (remove the hand impl Facet, add impl Elm) rather than flip a switch. A helper macro (elm_facet!{ … }) is the fallback if the blanket impl proves too coarse.

Second infra item: a headless driver in facett-core::harness that, given an Elm component, applies a Vec<Msg> and snapshots state() + AccessKit — this is what turns FC-2 into a tested property (proptest over update, per contract §5.1).

5. Proposed rollout order

Phase 0 (infra, blocking): land the Elm trait + blanket/macro Facet bridge + harness driver in facett-core (§4). Re-express facett-security on top of it as the first consumer / migration proof (it already matches the shape).

Phase 1 — first 3 components (T1, Msg already exists):

  1. facett-helix — smallest T1; controls already route through update; extract pure view, adopt injected clock (already present), derive serde.
  2. facett-timeline — same shape; the canvas-pick → Msg work is the added bit.
  3. facett-plandag — same shape; more controls + a composed DepGraphView.

Phase 2 (T2 display): pop → stat → card → log → docview → matrix → tabbar → plot → plotters(×5) → syschart. Mechanical once Phase 0 exists.

Phase 3 (T3 interactive data): console → jobview → filedialog → pipeline → table → grid → icebergview → warehousedeck → graph → graphpan → graphview → git.

Phase 4 — last 3 (T4, big lift), hardest last: … → model3d → graph3d → facett-map3d → facett-geomap → facett-video (final three). Each gets its own camera/render-path design note before conversion.

First 3 components: helix, timeline, plandag. Last 3 components: map3d, geomap, video.

6. What this schedule deliberately does not do