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 ┘
- the base facett code never changes — it is facett's, frozen and append-only;
- the mount prefix is the consumer's, applied via
FacetError::with_consumer_prefix; - an app's OWN backend errors keep the app's own codes (
korp-<subsystem>-<n>,nornir-…,dwarves-…,holger-…) — the two vocabularies compose, they never collide. In korp both land in ONE ring and are distinguished by whether the entry carries afacet_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:
Mount—korp/cases·korp/analysis·korp/infra, tied tokorp::mode::Mode.record(mount, &FacetError) -> Reaction— the ONE front door. It namespaces the code, records the mounted code into korp's EXISTING typed diagnostics ring ($KORP_STATE.diagnostics, not a twin), and returns the canonical reaction.diag_code_for(reaction)— maps a reaction onto korp's typedDiagCode, so a facett UI error is classified beside every other korp error:empty_state → NotFound(an absence, not a crash),retry/reconnect → Unavailable,fallback → FailedPrecondition(degrade, don't alarm),diagnose → Internal.korp::diag::DiagEntrygained an additivefacet_code: Option<String>.matrix()/state_json()— korp's rows of the shared table, published as data.
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:
- resolvable — the mounted code resolves to a canonical reaction;
- mount-independent — the same base code at every other mount reacts identically;
- round-trip — base code + mount recoverable from the mounted code;
- korp reacts —
recordreturns exactly the canonical reaction; - recorded — the MOUNTED code lands in
$KORP_STATE.diagnostics; - classified — with korp's typed
DiagCode, and the human message survives; - 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:
- facett-demo (
Errorstab) reads the compiledREGISTRYdirectly and renders the catalog slide (+ status + reaction + who-reacts columns) and the reaction-matrix slide. - facett-demo-python (
🩷 Errorstab,panes/errcode_pane.py) readspy/facett_demo_python/errcodes.json, generated from the same registry bycargo run -p facett-core --bin errcode_registry_json. facett-core/tests/registry_md_fresh.rsfails if EITHER export drifts, so the two demos can never show different catalogs.
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
-
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::DecoratedGraphViewnow raisesfacet-graphview-1on an empty scene (onlyMetroViewdid) — this is what unblockeddwarves/funnel;facett_map::layer::MapFacetnow raisesfacet-map-2when the view draws no features (onlyMapViewdid) — this unblocked korp's sixfacet-map-*rows. Both also gained the matchingseverity()override (the Robot-UI gate signal AND the consumer's cheap per-frame gate).
-
Five FICTIONAL rows, removed. The registry claimed
korp/inframountedsyschartandgraphview; korp has no dependency onfacett-syschartorfacett-graphviewat all — not inCargo.toml, not insrc/. Those five rows are gone from theconsumercolumn (35 → 30). They belong back only when korp really mounts those components. -
korp had no anti-fiction guard. Its mounts are a bare
Mountenum with nositeanchor, so the check the other three carry never applied — which is exactly how (2) survived.every_component_korps_matrix_claims_is_really_a_korp_dependencyis 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
- korp integrates facett errors into the typed
DiagCodediagnostics ring it already had — no twin surface. - nornir / dwarves / holger use the shared
facett_core::errcode::FacetRecorder, so "how a consumer reacts" is implemented ONCE rather than three times. Each app supplies only its mounts and foldsstate_jsoninto its own observable state.
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:
- a facett UI failure → facett's frozen code + the consumer's mount prefix (
nornir/viz:facet-graph3d-1); - the app's own failure → the app's code.