The datamodel explorer pane (sui-t36.7): one component, two data
sources. Authoring mode (build_authoring/3) merges the mode-
independent tiers StatifierUI.DatamodelExplorer.Scope builds - document
<data id> declarations (tier 1), the SCXML 5.10.1 system variables
(tier 2a), the predicator provider functions in scope (tier 2b) - with
one named fixture scenario (tier 3), the one tier that switches source
between modes (ADR-0003:82-88). Live mode's build_live/2 is
sui-t36.7 Phase 3's addition to this same module.
This pane is a projection, not an editor. There is no write path in either mode: authoring-mode tier-1 entries hold their declared source text for display but are never evaluated (predicator is non-evaluative, ADR-0004 upstream, adopted by ADR-0002), and live datamodel editing waits for a recordable-channel design (statifier ADR-0029).
The editing guard (sui-8hg, ADR-0012)
When a write path is eventually built - here or in whatever widget wraps
this pane - ADR-0012's flow-through clause forbids offering it over a
projected stream. edit_disabled_reason/1 and edit_disabled_reason/2
are that clause in code, and editable?/1 and editable?/2 are the
boolean form. They answer may a value-editing affordance be offered,
not does one exist: today none does, and the guard is here so the
constraint is consulted rather than rediscovered.
Two reasons close the door. The pane is projected - build_live/2
carries session.start's projection header onto the struct, and an
editor over a stream whose values the pane cannot see has nothing to seed
from and nothing meaningful to write over. The entry is redacted - its
value is the :redacted atom StatifierUI.Value decodes
{"$redacted": true} to, and an editor seeded from it would write the
sentinel back or silently write nothing. Both reasons name the profile or
the slot, so a host can say why rather than greying a control out
silently.
(sui-t36.8 used to be named here as the owner of the widget and the
write affordance. It closed 2026-08-22 having shipped the inspector
assembly and the demo notebook only, with no write path, which left this
constraint ownerless; sui-8hg picked it up.)
Summary
Types
Which data source fed this pane: the merged fixture scope, or a live session.
Options shared by both constructors; Scope.opt() is forwarded as-is.
session.start's projection header, verbatim, when the stream this pane
folded carried one (ADR-0012). nil for an unprojected stream and for
authoring mode, which has no stream at all.
Functions
Builds an authoring-mode pane from a compiled machine and an optional
fixture bundle.
Builds a live-mode pane from messages, a session's captured effect
stream (in any order - this is a pure fold, not a subscription).
This pane's diagnostics, bundle diagnostics ahead of any the pane's own build produced.
Why a value-editing affordance must not be offered over this pane, or
nil when nothing forbids one.
Why a value-editing affordance must not be offered for entry on this
pane, or nil when nothing forbids one.
Whether a value-editing affordance may be offered over this pane at all -
edit_disabled_reason/1 returning nothing.
Whether a value-editing affordance may be offered for entry -
edit_disabled_reason/2 returning nothing.
This pane's entries, in tier order (:data, :system, :function,
:scenario in authoring mode).
This pane's entries belonging to one tier, in the same relative order
entries/1 returns them.
Whether this pane folded a projected stream (ADR-0012).
The projection profile's name, or nil when the pane is not projected.
Types
@type mode() :: :authoring | :live
Which data source fed this pane: the merged fixture scope, or a live session.
@type opt() :: StatifierUI.DatamodelExplorer.Scope.opt() | {:scenario, String.t()}
Options shared by both constructors; Scope.opt() is forwarded as-is.
session.start's projection header, verbatim, when the stream this pane
folded carried one (ADR-0012). nil for an unprojected stream and for
authoring mode, which has no stream at all.
@type t() :: %StatifierUI.DatamodelExplorer{ diagnostics: [StatifierUI.Fixtures.diagnostic()], entries: [StatifierUI.DatamodelExplorer.Entry.t()], macrostep: non_neg_integer() | nil, mode: mode(), projection: projection() | nil, scenario: String.t() | nil, scenario_names: [String.t()], session: String.t() | nil, truncated?: boolean() }
Functions
@spec build_authoring(Statifier.Machine.t(), StatifierUI.Fixtures.t() | nil, [opt()]) :: {:ok, t()} | {:error, term()}
Builds an authoring-mode pane from a compiled machine and an optional
fixture bundle.
- Builds the mode-independent tiers with
Scope.build/2, propagating its error. - Selects a scenario: the
:scenariooption when given, else the first ofFixtures.scenario_names/1(sorted), elsenilwhenfixturesisnilor holds no scenarios. A named:scenariothe bundle does not hold is{:error, {:unknown_scenario, name}}- a typo in a host's switcher should say so rather than silently show the first scenario. - Merges the selected scenario's datamodel onto the scope: a scenario
value naming an existing tier-1 entry replaces that entry's
value,shapeandlabeland leaves itstier:dataand itsd_indexintact; a scenario value naming nothing declared becomes a newtier: :scenarioentry. Scenario values are already decoded Elixir terms (the sidecar decodes at load,sidecar.ex:171-179), so this path never callsStatifierUI.Value.decode/1- doing so would re-interpret an already-decodedDateas a$-tagged map.
entries/1 on the result concatenates :data, :system, :function,
then :scenario, each group in the order its builder produced. Bundle
diagnostics are carried onto the pane ahead of the scope's own, so a
sidecar that already had complaints does not lose them here.
macrostep is nil, truncated? is false, and session is nil -
none of those are meaningful outside live mode.
@spec build_live([StatifierUI.Trace.Message.t()], [opt()]) :: {:ok, t()} | {:error, {:mixed_sessions, [String.t()]}}
Builds a live-mode pane from messages, a session's captured effect
stream (in any order - this is a pure fold, not a subscription).
- Refuses a
messageslist naming more than one session, the sameEventLog.build/1rule and error shape (docs/wire-format.md:96-102). An empty list returns an empty:livepane withsession: nil. truncated?istruewhen the lowestseqpresent is greater than0- the head of the stream was dropped, theEventLogprecedent.- Seeds the name set from
session.datamodel'sdatamodelpayload map, decoding each value withStatifierUI.Value.decode/1before inferring its shape - the whole point, since every<data>element reads{"$undefined": true}there. A decode failure yields an entry holding:undefinedplus one:undecodable_datamodel_valuediagnostic, never a failed build. - Classifies each seeded name against
session.start'sdatatable (:data, carrying that element'sd_index) and the fixed system variable names spec 5.10 declares (:system); anything else is:runtime. - Applies every
effect.datamodel_changein the producer's stamped{macrostep, microstep, seq}order (ADR-0011: never re-sorted bylocation_path, byd_index, or by name).new_value/prior_valueabsence reads as:undefined, matchingnormalizer.ex:558-565's rule on the consumer side.location_path's head names the root entry (a name the snapshot never carried is added astier: :runtime); the remaining segments are applied into the root's decoded value, materializing a map for a string segment and a list for an integer segment when the container is missing or:undefined. A non-binary path head, an out-of-range index, or a segment that contradicts an existing container becomes an:unresolvable_location_pathdiagnostic and the write is skipped rather than crashing.location_sourceis kept from the most recent write applied to that entry. macrostepon the pane is the highest macrostep any applied write carried (nilwhen none did). An entry ischanged?: truewhen at least one write stamped at that macrostep carried a decodedprior_valuedifferent from its decodednew_value- so a first binding, which has noprior_valueat all, counts as a change from:undefined. This is macrostep granularity, not round - chosen wheneffect.datamodel_changestill carried anilroundenvelope. sui-67d has since stampedroundon everyeffect.*message, so round-granular marking is now a possible refinement here rather than an upstream wire change; this pane deliberately stays at macrostep granularity, and never reads theroundfield at all.shapeandlabelare computed once, from each entry's final decoded value, so an entry written more than once in one macrostep is labelled from its end state.projectionissession.start'sprojectionheader verbatim, ornilwhen the stream carried none. It is read, never derived: a stream that happens to hold no redacted value is still projected if the header says so, and that is whatedit_disabled_reason/1keys on (ADR-0012,sui-8hg).
Tier 2b (provider functions) is appended unchanged from the same source
Scope reads - Statifier.Evaluator.Functions.base_context().functions -
since it is a compile-time constant that needs no session; both modes
therefore show the same function list. entries/1 on the result returns
:data/:runtime, then :system, then :function, each group sorted
by name; there is no tier 3 in live mode.
@spec diagnostics(t()) :: [StatifierUI.Fixtures.diagnostic()]
This pane's diagnostics, bundle diagnostics ahead of any the pane's own build produced.
Why a value-editing affordance must not be offered over this pane, or
nil when nothing forbids one.
ADR-0012's flow-through clause in code. A projected pane always returns a reason: an editor over values the pane cannot see has nothing to seed from and nothing meaningful to write over. The returned string names the profile and is written to be shown to a user, not logged.
There is no write path in this pane today (see the moduledoc). This function exists so that whatever builds one asks first.
@spec edit_disabled_reason(t(), StatifierUI.DatamodelExplorer.Entry.t()) :: String.t() | nil
Why a value-editing affordance must not be offered for entry on this
pane, or nil when nothing forbids one.
The pane-level reason wins when the pane is projected. Otherwise a
redacted entry - one whose value is the :redacted atom
StatifierUI.Value decodes {"$redacted": true} to - carries its own
reason, because an editor seeded from the sentinel would write the
sentinel back or silently write nothing. A redacted slot is therefore
never presented as editable, whichever way it arrived.
Whether a value-editing affordance may be offered over this pane at all -
edit_disabled_reason/1 returning nothing.
@spec editable?(t(), StatifierUI.DatamodelExplorer.Entry.t()) :: boolean()
Whether a value-editing affordance may be offered for entry -
edit_disabled_reason/2 returning nothing.
@spec entries(t()) :: [StatifierUI.DatamodelExplorer.Entry.t()]
This pane's entries, in tier order (:data, :system, :function,
:scenario in authoring mode).
@spec entries(t(), StatifierUI.DatamodelExplorer.Entry.tier()) :: [ StatifierUI.DatamodelExplorer.Entry.t() ]
This pane's entries belonging to one tier, in the same relative order
entries/1 returns them.
Whether this pane folded a projected stream (ADR-0012).
True exactly when session.start carried a projection header. Authoring
mode is never projected - it has no stream.
iex> {:ok, pane} = StatifierUI.DatamodelExplorer.build_live([])
iex> StatifierUI.DatamodelExplorer.projected?(pane)
false
The projection profile's name, or nil when the pane is not projected.
This is the string ADR-0012 asks a host to surface alongside the mode, so a user asking "why can't I see this" has something to quote.