Map: exarch / cards
The exarch-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 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/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. In Rust it is aReadout—value/max?/unit?, the magnitude with no name of its own — plus the label this position has to supply.fields— the matrix mark: an aligned(label, value)table in one shared label column (Bertin’s selective alignment). A value nests atextmark or a bareReadout: the row carries the label, so the nested position cannot hold a second one, and alabelwritten into a nested`measureis dropped on decode like any other unread field. The columns are the card’s, not the mark’s: a card of severalfieldsmarks —/limits, one section per account — aligns as one table, since a selective alignment that restarts per section selects nothing.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 (card/decode.rs) is the card decoder, reading marks off the
first-order FOValue the surface sink carries, as encode_card writes them; decode_surface (shell-eval) tries the observation, edit,
and notice shapes first (io-surface), then the card, then done. 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 its
first line before (old) and after (new) the edit, and a rows list of {tag, text} records; a missing hunks
lifts to empty so a bare diff still renders. A worker’s cards
arrive in its settled batch and decode here unchanged
(surface-delivers-itself).
Encode — encode_card
encode_card (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
exarch-pins `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
exarch-pins `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 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; a worker that ended on an exit or a stop (`exited,
`stopped on the card) reads “exited n” or the stop’s cause, roled bad. 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, bus/post.rs) — 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.
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 — Chrome::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.
Core’s ready-boundary housekeeping (ral_core::types::Notice, decoded by
Notice::from_surface in decode_surface) has no card. A reaped worker and a pruned binding are each something nobody observed
for a long while — housekeeping, not news — so they are recorded as
Forensic::Reap and Forensic::Prune, written by shell_eval::surface’s
record — breadcrumbs no fold projects
(engine-protocol, agent).
Render — one interpreter, one binding table
render_card_unframed(&Card, width, level) (tui/line.rs) walks the marks at
the width the block is shown at: render_mark hands that width to every arm, so
a text folds to it, a fields mark seats its rows in the card’s columns, and
a diff measures its line-number gutter against it and wraps each body into
what is left. Every such column is one primitive, Col (tui/palette.rs,
beside the widths it belongs with): a width in display cells, and the padding
that seats a cell flush left (words) or flush right (figures, whose digits must
end level, so the bars beside them start level). A column is declared
(Col::wide, where the rows arrive one at a time and could only align with
themselves — an act’s verb, a picker’s labels, the status line’s state slot) or
measured (Col::of/seeing, where they arrive together — field labels,
measure readouts, a diff’s line numbers, the fleet matrix’s four); the two
differ in provenance alone, and no gutter in the TUI pads by hand. Pure
indentation — the rail margin, a card’s inset — is not a column: it holds
nothing. Cols::of(marks) measures a card’s label and readout columns once,
over every row of every mark, exhaustively; a legend or a fault readout,
standing alone, measures its own. A card is never laid out at a fixed budget and wrapped a second time.
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 readout_spans,
diff calls the patch body (diff_body, the one subject with a module of its own, tui/diff.rs), and fields plus render_readout
both feed the shared render_field_rows matrix primitive — so
render_readout is one internal caller of the fields path, not a duplicate
label-column. The diff header label reads diff. What render_readout lays
out it does not compose: the headline and fields come from
record::fault::Readout (provider faults),
which the headless printer renders too, so the two surfaces differ in
presentation and in nothing else.
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 boxes the width it is given — the reading measure is the
pane’s, capped once by Scrollback::render_window, never re-applied per
builder. It 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 (2, prose’s
column, so the text inside lands level with a call’s effect rows) for the
transcript, and none at all for the session card, which starts — like the
wordmark above it, and like the human’s own first typed line — in the column
the rail margin every row carries already opens, rather than in a pad baked
into data/banner.txt. The opening is one rail-free Chrome::Opening
(banner::opening): the session card fills the wordmark-and-eagle’s measured
width, so its two edges form one block. 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, framed, dial } (tui/block.rs) carries the render
document, a framed flag (a surfaced denial is framed; a lone effect is not),
and its Dial, the rung it is read at.
Disclosure is derived, not named: BlockKind::card gives a card a Dial
exactly when it holds a diff (Card::has_diff()), and such a card reads as
its header alone at Tally, its first DIFF_PEEK_ROWS rows at Summary —
the rung it opens at — or the complete diff at Full; a card of only
text/fields/measure/raw is inert, rendered whole. The rail shape is ▎
for a file mutation — a run of changes or a kit’s diff card — and none for a
framed surfaced card; an observation card folds onto the call above it in its
group rather than carrying its own rail. magnitude() is the summed diff
magnitude, feeding the rail’s value-step; lines_changed() exposes the same
total as the matrix’s write footprint, distinct from prose volume.
A file change is not a card but a fact, card/change.rs’s Change { path, outcome, diff } (a-file-change-is-one-fact),
its Diff cut at the source by Diff::between (card/diff.rs) to 2000
rows with exact added/removed counts. The mirror holds a run of them as
BlockKind::Changes: a change joins the run standing at the tail
(Block::admit_change, Scrollback::absorb) or opens one, and
tui/diff.rs’s changes_body draws it — each file once in first-touch order,
its changes stacked under one header, every header at every rung, Summary
sharing DIFF_PEEK_ROWS across the run and Full ending a source-cut diff in
⋮ N more changed lines. Headless and synod draw one change at a time through
change_card. Every other card is its own block: the scrollback reads a
Display::Card’s card once, as the fold opens it, and pushes it as a barrier;
an observation effect is instead a Member::Effect folded onto the call that
issued it.
Machine log
There is no independent operational trace: record.jsonl, written
through record::Emitter at the seam, is the one durable log. A deliberate
Display::Card records the rendered card itself — the mark tree is the fact
here — while every other Display/Forensic commit carries only the
structured fact a card renders from, rebuilt fresh by whoever draws it. 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 tasks-decode, its inverse over the
same shape. It leads with a strong text heading — the same mark
exarch-goal `set 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 exarch-tasks `list, computes the
new list, and writes it back through
tasks-sync — one write point wrapping exarch-pins `set/`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 has no surfacing constructors: 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 takes its own diff at the edit,
where both texts are already in hand, and surfaces it as a Change; a redirect
write becomes the same Change in exarch’s decoder, diffed between core’s two
snapshots. Both are cut at the source and dialled at display. The read
redirect and exec cards are composed from core’s I/O events. agent.ral 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 exarch-pins `read` answers from),
map: exarch.