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

nordisk/facett

.nornir/facett-error-reactions-phase2-design.md

facett error codes — Phase 2: consumer REACTIONS + the reaction test matrix

Status: Phase 2 COMPLETE — all 30 (code × mount) rows have a production consumer call site (see §8 for the bar, and for the correction of an earlier over-claim of 35). Phase 1 (the facet-<component>-<n> scheme) is merged; see facett-error-codes-design.md.

The ask (Rickard): "then implement in the test matrix robot UI tests … this would mean like 150 robot UI tests that MUST react if an error is raised."

1. The analysis — what consumers do TODAY

Before this phase, no consumer reacted to a facett UI failure at all. A facett pane that had no data or failed to load simply rendered nothing: the host showed a blank pane, or at best a generic string that no code could branch on. korp already had a good typed-error story for its OWN backend errors (korp::diag::DiagCode — a closed, gRPC-mapped enum in $KORP_STATE.diagnostics), but a facett UI error never entered it: the two worlds did not meet.

That is the gap Phase 2 closes.

2. The reaction vocabulary (facett owns it)

facett_core::errcode::Reaction — the shared vocabulary every consumer agrees on, so a failure means the same thing everywhere:

reaction what the consumer MUST do
retry re-attempt the load (with backoff) + show a retry affordance
empty_state show an honest empty state — never a blank pane
reconnect surface the disconnected state + a reconnect action
fallback note the degrade in diagnostics; keep working on the slow path
diagnose record a diagnostic with the code + surface the fault to the operator

facett declares the canonical reaction per code in errcode::REGISTRY, so the contract is shared rather than re-litigated in each app. errcode::reaction_for(code) resolves a bare (facet-map-2) or mounted (korp/cases:facet-map-2) code — the reaction is a property of the code; the mount only says where it happened.

3. Mount namespacing — how facett codes and APP codes compose

A component can be mounted many times in one app, so the consumer prefixes the facett code with its own mount:

korp/cases:facet-map-2      korp/infra:facet-map-2      nornir/viz:facet-graph3d-1
└── consumer mount ──┘└ base facett code ┘

4. The consumer mounts (measured, not assumed)

app facett components mounted mount
korp map, map3d, geomap, graph3d, korp panes korp/cases, korp/analysis, korp/infra
nornir graph3d, graphview (nornir-demo panel, autonom facett probe) nornir/viz
dwarves graphview (nornir-funnel-embed Funnel DAG) dwarves/funnel
holger syschart (mannequin PKI/OIDC/Nexus system map) holger/mannequin

This yields 30 (code × mount) pairs — the reaction matrix. See the generated LIST (facett-error-codes-registry.md) for every row. (It was 35 until §8's audit: korp was credited with graphview + syschart mounts it does not have — no dependency on either crate — and those five rows were removed.)

5. korp's implementation (the reference consumer)

korp::facet_diag is korp's half of the contract:

6. The test matrix — TABLE-DRIVEN, not 150 copies

korp/tests/facet_reaction_matrix.rs generates the suite from ONE data table (errcode::reaction_matrix() filtered to korp mounts). Adding a facett code — or a mount — adds rows automatically; nobody hand-writes near-identical tests, and nobody can add a code that silently has no reaction.

Every row runs the full check set:

  1. resolvable — the mounted code resolves to a canonical reaction;
  2. mount-independent — the same base code at every other mount reacts identically;
  3. round-trip — base code + mount recoverable from the mounted code;
  4. korp reacts — record returns exactly the canonical reaction;
  5. recorded — the MOUNTED code lands in $KORP_STATE.diagnostics;
  6. classified — with korp's typed DiagCode, and the human message survives;
  7. behaviour class — an empty state is never a hard fault, a GPU-absent code never alarms, a dead backend is always surfaced as unavailable.

Plus whole-matrix gates: a coverage report, every_code_is_accounted_for (the registry consumer column and korp's implemented matrix must AGREE — no silent gaps either way), the published state_json surface, and "an unregistered code is diagnosed, never ignored".

Tests assert the REACTION and the MOUNTED CODE — never a matched string.

7. Both demos, in lockstep (LAW #2)

The reaction matrix is a slide in both demos, from ONE source of truth:

8. Status — all 30 (code × mount) rows consumer-wired

Correction of record (2026-07-31). An earlier revision claimed "all 35 rows are consumer-wired". Measured against a real bar that was wrong in all four apps: each had a reaction MODULE and a table-driven suite, but the reaction function had no caller outside its own tests. A registration with no caller is not a wiring — the claim reproduced, one level up, the fiction this phase set out to kill.

The bar used below is: a production (non-test) call site on a path the app really runs, plus a test that drives that path with a component in a real failure state.

The matrix is 30 rows, not 35: five were fiction (below).

app rows production call site (the wiring)
korp 19 Korp::react_to_facet_panes in shell_ui (every frame) → facet_diag::react_to_pane_state
nornir 6 autonom::facett_probe::record → react_to_pane_state, on the live headless drive (probe_facett_components)
dwarves 3 graph::FunnelGraphPane::{set_dag, ui} → facet_react::react_to_pane_state
holger 2 gui::MannequinGui::refresh_chart → facet_react::react_to_pane_state (every frame)

One shape, four hosts

Read the code the pane published (state_json.error_code — the seam the whole scheme exists for), resolve mount ownership from errcode::mounts_for (so a mount cannot be fiction in either direction), record the MOUNTED code, act on the canonical reaction, publish the result as data in the app's own state. The hot path is gated on the pane's structural Severity, so a healthy pane never pays for the state_json build.

Three real gaps this uncovered — and closed

  1. Silent raise sites. A code was published by one type of a component family while the type hosts actually mount stayed silent, so the consumer had nothing to react to. Fixed in facett, in the same crate as the frozen code:

    • facett_graphview::DecoratedGraphView now raises facet-graphview-1 on an empty scene (only MetroView did) — this is what unblocked dwarves/funnel;
    • facett_map::layer::MapFacet now raises facet-map-2 when the view draws no features (only MapView did) — this unblocked korp's six facet-map-* rows. Both also gained the matching severity() override (the Robot-UI gate signal AND the consumer's cheap per-frame gate).
  2. Five FICTIONAL rows, removed. The registry claimed korp/infra mounted syschart and graphview; korp has no dependency on facett-syschart or facett-graphview at all — not in Cargo.toml, not in src/. Those five rows are gone from the consumer column (35 → 30). They belong back only when korp really mounts those components.

  3. korp had no anti-fiction guard. Its mounts are a bare Mount enum with no site anchor, so the check the other three carry never applied — which is exactly how (2) survived. every_component_korps_matrix_claims_is_really_a_korp_dependency is korp's equivalent: every claimed component must be a real dependency AND be referenced in korp's sources. And each app's site guard now also asserts the declared file calls the seam, not merely that it names the component — the check that would have caught the original over-claim.

Reserved codes

11 of the 30 rows carry codes the registry still marks reserved (GPU-fallback, non-convergence, source-load paths that have no explicit failure branch yet). Their mount is wired the same way — the seam is registry-driven, so it reacts to them the moment a pane publishes one — and each is proven through the production seam by the per-app every_*_matrix_row_is_handled_by_the_production_seam test. What is not yet proven end-to-end is the raise, and that is the facett-side work those rows wait on.

Two implementation shapes, one contract

The ANTI-FICTION guard (the dwarves lesson)

A registry that is 62% fiction is worse than none. Every ConsumerMount declares the site file that really mounts the component, and each app's suite fails if that file does not reference facett_<component> — so a DECLARED mount can never be fiction. Paired with the registry↔matrix agreement test in each app (the consumer column and the implemented matrix must agree in BOTH directions), the facett side cannot drift the way dwarves' registry did.

Composition with app-level codes

Every consumer now also owns its own frozen app registry (nornir-<subsystem>-<n>, holger-<subsystem>-<n>, dwarves-<subsystem>-<n>, korp's DiagCode). The two vocabularies compose, never collide, and each app has a test proving neither registry shadows the other: