Map: exarch / cards
The surface builtin carries a render document — a `card, an ordered
stack of Bertin marks a kit composes entirely in ral. exarch decodes it once
into a closed Rust model and draws it through one generic interpreter. The set
of cards is open (compose marks, zero Rust per card — a surfaced file write, a
build summary, a test matrix, anything); the set of marks stays closed and
small, so the renderer is total and width-reflow, click-to-disclose, and patch
aggregation all keep working. This is
the transcript-as-graphic
discipline extended from the frontend’s own chrome to the kit’s content: the
kit declares data and its level of measurement, never appearance; exarch
owns the binding to visual variables. See
surface-carries-documents.
The marks
A `card is a List of marks rendered top-to-bottom on one scrollback
block. Five marks, closed (exarch/src/bus/card.rs):
text— the qualitative mark: a run of spans. A span carries an optional nominalRole(path/code/ok/warn/bad/muted/strong) mapped to a hue, never a magnitude. A heading is astrongspan.measure[label, value, max?, unit?]— the quantitative mark, two ordered variables (size + value/lightness). Bounded (maxpresent) → a proportional fill bar; unbounded → alog2size bar.fields— the matrix mark: an aligned(label, value)table in one shared label column (Bertin’s selective alignment). A value nests atextormeasuremark.diff[path, hunks]— the dense composite, binding four variables exarch already computes: size (magnitude bar), grain (add/del texture), value (rail lightness), shape (▎). The one mark that earns graded disclosure and cross-emit aggregation.raw— un-encoded ink: pre-formed bytes appended verbatim, for output outside the grammar. Honest about being an image, not an encoding.
Composability is one rule at three scales: the plane stacks marks (card),
fields nests marks in its value column, text nests roles in its spans.
Decode — value_to_card
value_to_card (bus/card/decode.rs) is the card decoder, reading marks off the runtime
Value; decode_surface (shell-eval) tries the pin,
io, and notice shapes first (io-surface). The wire
shape is Variant{label:"card", payload: List<mark>}; each mark is
Variant{label, payload: Map}. A bare known mark surfaced unwrapped
(`diff [...]) is lifted into a one-mark card for convenience; any other
top-level value returns None and is dropped. Decoding never fails within a
recognised card: an unknown mark label or role degrades to plain text,
because a card is a deliberate user-facing act, not a sentinel that might be
malformed. The diff mark reads a path and a hunks list — each hunk a
start line and a rows list of {tag, text} records; a missing hunks
lifts to empty so a bare diff still renders. Detached workers buffer their
surface calls and replay them on await, so a card replays for free.
Encode — encode_card
encode_card (bus/card/encode.rs) is value_to_card’s inverse on the
decoder’s image: value_to_card(&encode_card(&card)) == card for every Card
the decoder can produce. It exists because
register-is-read-write’s
pin-read hands the model back a stored pin as a value it can destructure,
and that value must be canonical, not the bytes a kit happened to author:
a bare-string span or a bare mark — sugar the decoder accepts — comes back
through the tagged form (`text [spans: […]]), and an unknown mark comes
back as the plain text it already degraded to on the way in. Canonical
read-back is what makes storage and display one thing: a kit cannot smuggle
state through a payload the decoder discards, because the only card
pin-read can ever return is the one the rail already showed.
Host-composed one-liners — done, notices
Some of what the marks describe is composed in Rust, never decoded from a
kit-authored `card. settled_spans words a detached worker’s completion outcome — the `done
event core appends to every deferred surface batch, decoded by value_to_done
into a DoneOutcome and recorded as Display::Done { outcome } — as one
sentence, background block settled (exit n), with a failure’s message
appended. The outcome alone carries a level, roled ok/bad exactly as the
$ cmd → status exec row roles an exit code, which is what a settled block’s
is: a non-settled ╳ rail marks a turn error (provider, stall, or forensic
fault) or a cancelled turn, while a nonzero worker exit remains the settled
outcome rather than a turn error.
settled_text flattens the spans for the two sinks with
no ink to spend — the headless tee and the model’s wake-up notice
(surface_notice, agent) — so none of the three can drift;
only record::view’s ledger keeps its own [done: …], the bracketed register
every fact wears there. It names no worker, because there is nothing to name:
core spells a spawn’s cmd <block> and prelude.ral’s defer is a
spawn.
A settlement is announced, not bounded, so it is no card at all. Exarch’s
transcript seats those spans as a chrome line on the rail — ChromeKind::Settled
lifts to the ↘ of RailKind::Subagent, since background work landing in
root’s scrollback turns after the run that spawned it is the same event as an
agent’s answer arriving, whatever produced it — and synod’s fold drops
Display::Done unnarrated, a worker thread being exarch’s own bookkeeping
rather than anything the window’s reader has business with.
notice_card is done’s sibling for core’s ready-boundary housekeeping (value_to_notice → a Notice recorded as Display::Notice { notice }): a Notice::Reap renders
through reap_card as a warn span plus the worker’s cmd and which lease
fired (“idle 1h unobserved” / “24h backstop”), with prune and large-binding
notices rendered by its per-kind siblings
(engine-protocol). All are fixed-position
value marks, never an animation, and all stay inside the existing text mark
vocabulary, so none widens the closed mark set above.
A notice’s raw fact is what reaches the record log — Display::Notice { notice } — exactly as a structural observation records only its raw wire
form (Display::Observation/ObservationGroup, io-surface):
the card is a rendering, built fresh by whoever draws, and is never itself
recorded (agent).
Render — one interpreter, one binding table
render_card_unframed(&Card, level) (tui/line.rs) walks the marks; role_style(Role)
(over the palette constants in tui/palette.rs) is the single place hue
lives for kit content, so the kit can name a role but never a colour, and
magnitude can never land on hue — the encoding is correct by construction. The quantitative encoders are reused, not duplicated: measure
calls the generalised size_bar/progress_bar through measure_value_spans,
diff calls the patch body (diff_body), and fields plus provider_error
both feed the shared render_field_rows/push_field matrix primitive — so
provider_error is one internal caller of the fields path, not a duplicate
label-column. The diff header label reads diff.
render_card_unframed opens with the single leading blank every block wears; the
data-encoding rail span is prepended by the block to the
first content row. A diff-less card the model surfaced deliberately renders as
a box with its heading lifted into the top rule, no rail glyph (the frame is its
mark, see frontend).
render_card_framed takes the box’s left indent rather than owning one, so where a card
sits is a property of the placement that asks for it: CARD_INDENT (4) for the
transcript, banner::OPENING_INDENT for the session card, which shares the
constant with the wordmark so the two align by construction rather than by a pad
baked into data/banner.txt. The opening uses render_filled_card at the
wordmark-and-eagle’s measured width, so its two edges form one block; both
opening pieces use rail-free ChromeKind::Opening. render_pin is the third
placement, framing in its agent’s hue at the register’s own margin.
Block — derived disclosure and aggregation
BlockKind::Card{card, origin} (tui/block.rs) carries the render document and
a CardOrigin (Observation/Write/Surfaced) telling the coalescing
projection whether the card is a foldable effect or a barrier. Disclosure is
derived, not named: a card holding a diff is dialable (dialable() →
Card::has_diff()) and renders L1 header / L2 first-hunk / L3 full; a card of
only text/fields/measure/raw is chrome-level (L3-only, inert). The rail
shape is ▎ for a file mutation — a diff card or a write card alike — and
none for a framed surfaced card; an observation card folds into its ral group
rather than carrying its own rail. magnitude() is the summed diff
magnitude, feeding the rail’s value-step and the agent matrix’s size readout;
lines_changed() exposes the same diff total as the matrix’s write footprint,
distinct from prose volume.
A single-diff card joins the patch-grouping buffer in record/commit.rs
(Card::into_single_diff → SurfaceBuffer::absorb_patch): consecutive
same-(id, path) diff cards merge their hunks into one diff <path> block, the
way a unified diff presents one file. Every richer card is its own block, pushed
via Viewport::push_card (tui/viewport.rs); grouped observation effects land
via push_observation_card, a write card via push_write_card.
Machine log
There is no independent operational trace any more: record.jsonl, written
through record::Emitter at the seam, is the one durable log, and its
Display/Forensic commits carry the structured facts a card renders from
— never the rendered card itself, which the view fold rebuilds on resume. The
headless stderr condenser (card_stderr, headless.rs) walks marks
generically off the live bus.
Kit side
The kit declares data and its level of measurement; the host binds it to visual
variables, and with no host surface is the identity, so a kit stays runnable in
a bare ral REPL. The tasks library holds the small constructor so the mark
grammar lives in one ral place: tasks-card in exarch/data/agent.ral (the
tasks section; the kit owns the status→role mapping, since the host knows
only the closed role set), paired with decode-tasks, its inverse over the
same shape. It leads with a strong text heading — the same mark set-goal
writes — so the framed renderer lifts tasks into the top rule and the gauge
below it counts what is completed, rather than the label doing double duty as
a title. Every mutator reads the register through decode-tasks !{pin-read "tasks"}, computes the new list, and writes it back through
sync-tasks — one write point wrapping pin-set/pin-clear
(builtins;
register-is-read-write). The card
is the kit’s only state; there is no bound list threaded alongside it to drift
from what’s pinned.
The agent library’s surfacing constructors are gone: view-text, grep-files,
and edit-hash/edit-replace are Rust host builtins
(io-surface), their file I/O sunk below the redirect
frame so each is one logical surface. An edit builds its own whole-file diff
card (one canonical original-vs-final diff grouped into hunks by similar) at
the edit, where both texts are already in hand; a committed > reads what
landed against the empty side instead, an all-adds diff rather than a shape of
its own. Both cards retain every hunk; disclosure belongs to the renderer, so
L1 is the header, L2 the first hunk, and L3 the complete diff. The read
redirect and exec cards are likewise composed from core’s I/O events. agent.ral now carries
only the -around readers, the tasks kit, and the goal pins
(builtins).
One ral constraint shapes the wire format: lists and records are statically
homogeneous. Heterogeneous variant lists are fine (ral unifies them into a
sum, so `card [text, measure, diff] typechecks), but within a homogeneous
list every record needs the same fields — so every text span carries a role
(use "" for plain ink), and fields rows are records [label:, value:], not
positional [label, value] pairs (which would force label and value to one
type). exarch/data/surface.md teaches the model the marks, the roles, and
this constraint.
See also
surface-carries-documents (the
decision), tui-transcript-as-graphic
(the chrome-side Bertin encoding this extends), frontend
(the bus / block / line arm this re-grounds), shell-eval
(the host sink that decodes the card), io-surface (the
core I/O events the sibling decoder turns into cards),
register-is-read-write (the encoder
this page’s cards feed, and the canonical-form rule it answers to),
pins (the register pin-read answers from),
map: exarch.