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 nominal Role (path/code/ok/warn/bad/muted/strong) mapped to a hue, never a magnitude. A heading is a strong span.
  • measure [label, value, max?, unit?] — the quantitative mark, two ordered variables (size + value/lightness). Bounded (max present) → a proportional fill bar; unbounded → a log2 size bar. In Rust it is a Readout — 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 a text mark or a bare Readout: the row carries the label, so the nested position cannot hold a second one, and a label written into a nested `measure is dropped on decode like any other unread field. The columns are the card’s, not the mark’s: a card of several fields marks — /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.