The register is read/write: a pin stays a card, and pin-read reads it back
surface-pins-state gave a kit
somewhere to put what is currently true, and made it write-only. A drafted
revision — `pin carries a datum, a view in a typed register handle
renders it — bought static state/view agreement at the price of the register’s
generality: every pin needed a handle, so pinning a stray fact, a warning, a
note acquired ceremony, and the noticeboard stopped being generic. This ADR
keeps the wire exactly as it is — `pin [key, body] where body is a
card, so anything the rail can show is pinnable with no apparatus — and adds
the read side: pin-read returns the card stored under a key in canonical
value form, pin-list names the occupied keys, and pin-set/pin-clear are
thin ral wrappers over the unchanged wire, completing one model-facing
pin-* family. The register becomes a read/write noticeboard whose default
mutator is the model, which reads cards natively; a kit that owns a key
treats its card as a serialization — read, destructure against its own
schema, mutate, set again. The tasks and goal kits become pure preludes over
the family: no Rust knows what a task or a goal is, and the task kit’s
value-threading dies not because state moved out of the card, but because the
card became readable.
Context
What the register is today
surface `` pin [key, body] “ decodes through value_to_pin
(exarch/src/bus/card/decode.rs:40) into Kind::Pin { key, card }, which the
viewport keeps in an ordered key → Card map (exarch/src/tui/viewport.rs:401)
and the session mirrors as PinDigests — an Arc<Mutex<BTreeMap<String, PinDigest>>> whose PinDigest already holds the full decoded Card
(exarch/src/shell_eval.rs:89), written by SurfaceApplier::live
(exarch/src/fleet/desk.rs:754). The mirror exists so the boundary nudge can
name what is pinned (exarch/src/agent.rs:135,
exarch/src/agent/nudge.rs:137 via summary_line,
exarch/src/bus/card.rs:229), and it is already per agent: a sub-agent’s
register is its own. Nothing reads a pin back.
The defect: write-only forces threading
Because the register cannot be read, the task kit keeps its list in a binding
and pins a rollup from inside each mutator (surface-progress,
exarch/data/agent.ral:104). The value and its picture travel by different
mechanisms and separate whenever the model skips the rebind:
add-task $exarch-tasks "x"withoutlet exarch-tasks =pins a gauge the binding contradicts.- A block discards its shell-state changes (SPEC §10), so
add-taskinside any function,ifarm, orwithinpins the new gauge and throws the list away when the block closes. - A fork inherits a copy of the list and pins to the same key, so parent and child overwrite each other’s rollup.
$exarch-tasksis a naming convention the checker cannot see.
set-goal has none of these, because the goal is write-only into the register
with no value form to drift from. The drafted revision generalized that by
moving the datum into the register and deriving the card from a view; this ADR
generalizes it the other way, by making the card readable so it is the
datum.
Why the card can be the datum
The card grammar is a value grammar. Marks are variants over records —
`fields [rows: […]] preserves span boundaries per row, `measure
carries label/value/max, spans carry role and text
(exarch/src/bus/card.rs:154, decode at decode.rs:67) — so a kit that
authored a card can destructure it with ordinary ral pattern matching, no
text-scraping. And the other reader needs no schema at all: the model reads
chrome natively; a rendered card is legible state to it. Two readers, one
store, and the store is the thing the rail shows.
The bound this sets is real and is accepted deliberately: only what the card
renders can be read back. A kit’s state must live in the decoder’s image,
and its encoding must be injective — two states that render identically are
the same state. done and blocked must reach distinguishable spans; a field
the rollup never shows does not survive the register. This is the WYSIWYG
invariant, and it is a feature: the register can never again hold a truth the
rail does not show. It has one immediate consequence for the task kit: tags
and notes, which today’s pinned rollup omits, must join the card or leave
the schema — the plan below renders them.
Decision
1. The wire is unchanged; the commands are a pin-* family
`pin [key, body] with a card body, `unpin [key], one keyspace, the
protected-key rule for services — all exactly as
surface-pins-state left them. On top
of that wire sit four model-facing commands:
pin-set <key> <card>— overwrite the slot; a ral wrapper oversurface ``pin “.pin-clear <key>— empty the slot; a ral wrapper oversurface ``unpin “.pin-read <key>— the stored card, canonically encoded, or().pin-list— the occupied keys.
Only the read pair are Rust builtins; the writes are prelude wrappers, since
the surface channel already carries them. The spellings follow the exarch
builtin convention of hyphenated compounds (view-text, edit-replace,
skill-list, service-handle, exarch/src/shell_eval/builtins.rs:915), and
deliberately avoid bare pin/unpin/pinned as command names — short bare
words are the likeliest to collide with a binary on some user’s PATH, while
`pin as a wire variant label is beyond PATH’s reach. “set” says a pin
overwrites, “clear” says the slot empties; “add”/“remove” were rejected
because a pin appends nothing and a clear removes no element.
2. pin-read returns the canonical card
pin-read is an enquiry, answered from the agent’s own mirror: the stored
card re-encoded as a ral value, or () when the slot is empty. Because the
mirror holds the decoded Card, the read returns the canonical form, not
the authored bytes: sugar the decoder lifts (a bare string span, a bare mark)
comes back as its record form, and an unknown mark comes back as the plain
text it degraded to. The contract is a host-side encoder inverse to the
decoder on its image: value_to_card ∘ encode_card = id on every Card the
decoder can produce.
Canonical, not verbatim, is load-bearing. Storing and returning the authored value would let a kit smuggle state through a payload the decoder degrades — an unknown mark whose record rides along invisibly — and the register would again hold something the rail does not show. Reading the canonical card makes storage and display one thing by construction.
pin-list is the same enquiry’s other face: the keys currently occupied in
the agent’s register, so the model can survey the board before reading a
slot. It returns keys only; the cards stay behind pin-read.
Read-after-write inside a single run is sound because the identity binding’s
IdentityDesk::enquire drains queued surface frames before handling the
request (core/src/protocol.rs), and frame order does the same under the
wire: a pin written earlier in the script is
already applied when the read is answered.
3. The model is the register’s default mutator
The whole family is model-facing. The model can list the board, read a slot, judge it, and set the revision — the register as its noticeboard, with no kit in the loop. This is what a generic card earns: the model can pin and revise anything, with zero apparatus, because a card needs no schema to be read by the reader that matters most.
4. Tasks and goals are preludes over the family
The host’s whole contribution is the four commands; nothing in Rust knows
what a task or a goal is. The kits in exarch/data/agent.ral are ordinary
prelude code that happens to own a key:
let add-task = { |desc|
let ts = decode-tasks !{pin-read "tasks"} # canonical card → task records
sync-tasks [...$ts, !{mk-task !{next-id $ts} $desc}]
}decode-tasks and tasks-card are the kit’s serialization pair (specified in
the plan, §D); sync-tasks is the one write point, clearing the slot when no
work remains — the list empty or every task done — so a finished board never
lingers to be named by the boundary nudge. All the map/filter/fold
survives; the threading dies. The model-facing surface takes no list —
add-task "fix the parser", transition 3 `` doing “ — and
set-goal/clear-goal collapse to one-line preludes over
pin-set/pin-clear.
A card under the kit’s key that its decoder does not recognize — the model
scribbled on it, exercising §3 — fails the kit call with a didactic fail
naming the expected shape. That collision is the price of one keyspace shared
between a schemaless mutator and a schema’d one, named rather than hidden;
the error text is the schema’s documentation.
5. Residency stays per agent
The register belongs to the agent that pins it, which is what makes
read-mutate-set safe: no other agent holds the key, so there is no lost
update to lock against. Within one agent, concurrent spawn bodies can still
race a read against a write; the rule is last-write-wins, stated in the kit
docs — mutate pinned state from the foreground. (pin-read/pin-list are
enquiries, so inside spawn { … } they error like every desk-answered
builtin already does.)
Implementation plan
Ordered stages; each compiles and passes its own tests before the next
begins. Existing behavior to preserve throughout: the protected-services
guard (reject_protected_pin, exarch/src/shell_eval.rs:154), the nudge
digest (summary_line over the same mirror), and the viewport’s rendering
path, none of which change.
A. Encoder: encode_card
exarch/src/bus/card/encode.rs, sibling to decode.rs, exporting
pub(crate) fn encode_card(card: &Card) -> ral_core::Value. It emits the
exact shapes decode.rs accepts, choosing one canonical spelling wherever
the decoder accepts several:
- The card:
`card [ <mark>, … ]— always the list form, even for one mark. Mark::Text→`text [spans: [<span>, …]]; a span is[role: "<role>", text: "<text>"], with therolekey omitted whenNone(decode_spanreads an absent role asNone; never emitrole: "").Mark::Measure→`measure [label: …, value: …]plusmax/unitkeys only when present.Mark::Fields→`fields [rows: [[label: …, value: <v>], …]]where<v>is`text [spans: …]forFieldVal::Inlineand`measure […]forFieldVal::Measure— always the tagged forms, never the bare-string sugar.Mark::Diff→`diff [path: …, hunks: [[start: …, rows: [[tag: "del"|"add"|"context", segs: [[text: …] …]], …]], …]], withemph: trueon a seg only when set.Mark::Raw→`raw [bytes: <Bytes>].Mark::Listingis host-composed and outside the decoder’s image; encode it as`rawof its bytes. The inverse law is stated on the decoder’s image only, and this is its one deliberate collapse.
Tests, in encode.rs: a round-trip covering every mark variant and every
optional field both present and absent (mirror decodes_every_mark,
decode.rs:260), asserting value_to_card(&encode_card(&card))
reconstructs the card structurally; plus a sugar-normalization case —
decode a card written with bare-string spans and a bare mark, encode, decode
again, and assert the second decode equals the first.
B. Desk: the mirror reaches the enquiry arms
- Add
pub pins: Option<shell_eval::PinDigests>toHostServices(exarch/src/fleet/desk.rs:29). Thread it at every construction site (rg 'HostServices \{'): the agent clones its ownpinsArc (exarch/src/agent.rs:135) into the services it assembles; sites with no mirror passNone. - Two arms in
ExarchDesk::handle(exarch/src/fleet/desk.rs:237), following the didactic-refusal style of their neighbors:"pin-read": payload[<key: String>]viapayload_list. Look up inpins; on a hit,encode_cardthe stored card and convert withFOValue::try_from(a card value is always first-order); on a miss or an absent mirror,FOValue::Unit."pin-list": no payload. The mirror’s keys asFOValue::Listof strings —BTreeMaporder, i.e. lexicographic; absent mirror → empty list.
- No drain work: the identity binding’s
IdentityDesk::enquire(core/src/protocol.rs) already applies queued surface frames before handling, which is exactly the read-after-write ordering §2 relies on.
Tests, in desk.rs’s test module: pin through SurfaceApplier::live, then
handle a pin-read and assert the canonical card comes back; unpin, assert
Unit; pin-list tracks set and clear; a pin-read of "services"
answers (reads are not writes, the protected guard does not apply).
C. Builtins: pin-read and pin-list
In exarch/src/shell_eval/builtins/harness.rs, extending
HARNESS_BUILTINS_ARR (:552) from 8 to 10 — these are desk-answered, so
they belong with the harness set, not EXARCH_BUILTINS_ARR:
builtin_pin_read: thebuiltin_unscheduleshape (:391) — enquire`pin-read [<key>], returnValue::from(answer). Schemepin-read :: ∀α. String → F α(thefrom-jsonprecedent —TyTemplate::Any,core/src/typecheck/builtins.rs:391— trusted, not checked; build it likescheme_replyat:545with the var in the return).builtin_pin_list: thebuiltin_schedulesshape (:380) — enquire, expectFOValue::List, error didactically on any other shape. Schemepin-list :: F [String](thescheme_agentsshape,:464).
Doc strings (model-facing; per the standing rule they describe data and placement, never rendering, and cross-reference nothing):
pin-read <key> — the card currently pinned under KEY on your register, as a ``card “ value you can destructure, or () if the slot is empty. Reads your own register only. Answered only on the run that calls it: inside spawn { … } this errors.`
pin-list — the keys currently occupied on your pin register, as [String]. Read one back with pin-read. Answered only on the run that calls it: inside spawn { … } this errors.
Tests: the scripted-provider round-trip pattern
(reply_full_stack_round_trip…, harness.rs:1075) — a script that
pin-sets, pin-reads in the same run, and replies with the readback;
assert the parent receives the canonical card. A second script reads an
absent key and replies ().
D. The kit: tasks and goals as preludes
Rewrite the task and goal sections of exarch/data/agent.ral. The wrappers
come first:
let pin-set = { |key card| surface `pin [key: $key, body: $card] }
let pin-clear = { |key| surface `unpin [key: $key] }The tasks card, normatively. Marks: a `text heading, a
`measure gauge, then a `fields matrix —
[text [spans: role: “strong”, text: “tasks”],
measure [label: "completed", value: <done>, max: <total>], fields [rows: …]]`. One row per task:
label:"#<id>".value:`textof exactly four spans, positionally decoded:- status — text is the status word (
open/doing/blocked/done), which alone carries the datum; role is chrome by the existing mapping (done→ok,doing→warn,blocked→bad,open→muted). - description — text is
" " + desc, the two-space separator stripped on decode; desc itself is unconstrained. - tags —
""when empty, else" [" + intercalate "," tags + "]". Injectivity demands tags contain no,or]:add-tagandretag-taskvalidate andfaildidactically, thevalid-statusesprecedent. - notes —
""when empty, else" -- " + notes; the final span, so notes are unconstrained.
- status — text is the status word (
This renders tags and notes, which the old rollup dropped — WYSIWYG
requires it (Context §3).
The heading is the same mark set-goal writes, and for the same reason: a
leading one-line `text mark is what the framed renderer lifts into the
top rule, so the two pins read alike — a titled box — instead of the gauge’s
label standing in for a title on one of them (amended, 2026-08-06).
The serialization pair and the write point:
tasks-card ts— the list to the card above.decode-tasks v—()→[]; a card of the exact shape above → the task records; anything else →fail "tasks: the card under the 'tasks' pin is not task-shaped — …", spelling out the expected three marks and the row form.sync-tasks ts—pin-clear "tasks"whentsis empty or every task is`done, elsepin-set "tasks" !{tasks-card $ts}. Every mutator ends in it. This keeps today’ssurface-progressergonomics: no outstanding work, no pin, and the nudge goes quiet on its own. The cost is accepted deliberately and documented in the kit: transition-ing the last open task to`donediscards the records — a reopen afterwards finds an empty register and starts a fresh list (decode-tasksof()is[]).
Mutators, all reading the register, none taking a list:
add-task <desc>, remove-task <id>, transition <id> <status>,
tag-task <id> <tag>, untag-task <id> <tag>, note-task <id> <note>,
retag-task <id> <tags>, clear-tasks (replacing empty-tasks),
render-tasks, find-task <id>, by-status <status>,
save-tasks <path> (readback → to-json), load-tasks <path>
(from-json → sync-tasks). The pure value helpers — mk-task, the
per-field setters, next-id, render-task — survive unchanged.
task-to-marks and surface-progress are deleted into tasks-card.
Goal:
let set-goal = { |text|
pin-set "goal" `card [`text [spans: [[role: "strong", text: "goal"]]],
`text [spans: [[text: $text]]]]
}
let clear-goal = { pin-clear "goal" }E. Docs
agent_library_docs(exarch/src/shell_eval/builtins.rs:74): every entry loses$exarch-tasks;empty-tasksbecomesclear-tasks; the ghoststatus-countsentry (advertised, never defined) is dropped;pin-setandpin-cleargain entries as library helpers. The builtin index picks uppin-read/pin-listautomatically from their entries.exarch/data/tasks.md: rewritten against the new surface — no bindings, no rebinds; the workflow isadd-task,transition,render-tasks,pin-readfor anything bespoke.- Wiki:
map/exarch/builtins.md(two new builtins, two library wrappers),map/exarch/cards.md(the encoder and the canonical-form rule),design/pins.md(rewritten against this ADR),map/exarch/shell-eval.mdif it namesPinDigest’s shape.
F. Whole-system tests
Beyond the per-stage tests above:
- Kit round-trip (wherever
agent.ralis exercised today —rg 'add-task' exarchfor the harness):add-task,transition,tag-task, thenrender-tasksand a directpin-read "tasks"; assert the decoded list holds every field, including tags and notes. - Block survival:
add-taskinside a function body, then read the list from the top level — the defect this ADR exists to close. - Isolation: a sub-agent pins
"tasks"; the parent’spin-readstill answers the parent’s card. - Protection: a model
pin-set "services" …is refused (existing guard), andpin-read "services"still answers. - All-done clears:
transitionthe last open task to`done—pin-read "tasks"answers(), and a subsequentadd-taskstarts a fresh list at id 1. - Schema collision:
pin-set "tasks"a plain text card, thenadd-task— the didacticfailfires, naming the expected shape.
Alternatives considered
- State-carrying pins with typed register handles —
`pincarries a first-order datum, a[key, view]record renders it host-side at pin time, and the read verb returns the handle’sα. This ADR was drafted in that shape, and it is the strongest alternative: the checker relates state to view, and the read is statically typed. Rejected because the handle is mandatory — there is no viewless pin, so the generic act, the model pinning some card, pays for machinery only kit plumbing uses. It also grows three typed builtins, a paired(state, card)register, and in-run view application, where this ADR grows one enquiry class and an encoder. The type dividend returns if kit-level runtime decode failures ever hurt in practice; the drift defect it targeted is closed here by identity instead — there is only one thing to drift. - Verbatim read-back: store the authored value beside the decoded card. Rejected. It spares the encoder and returns byte-faithful values, but an authored value can carry what the decoder degrades, so the register would hold state the rail does not display — the hidden-truth defect in a new coat. Canonical read-back forecloses it structurally.
- A
stateslot beside the card, written per pin. Rejected: a kit could author a card that misdescribes its datum, and nothing relates the two. The WYSIWYG identity is strictly stronger. - Typed handles as an optional layer over bare pins. Rejected: two pin forms, two register semantics, every reader handling both. The didactic decode error is the cheaper answer to the same risk.
- Bare verbs —
pin/unpin/pinned/recall— as the command names. Rejected on two grounds.recallsquats on fleet vocabulary (recalling a sub-agent is a plausible future verb) and says nothing about pins. The bare pin family reads well, but short bare words are the likeliestPATHcollisions, and exarch’s builtin register is uniformly hyphenated compounds;pin-*keeps the family greppable and self-locating. pin-add/pin-removeas the write spellings. Rejected: “add” implies appending to a collection when a pin overwrites one slot, and “remove” implies deleting an element when a clear empties the slot.- Host-owned task state:
add-task/transitionas Rust builtins. Rejected: it buys atomic mutation, which per-agent residency already gives, and pays a Rust task module serving exactly one kit — the opposite of §4’s “kits are preludes”. The list logic is better ral than Rust.
Consequences
- The register is a read/write noticeboard: the model can list the board,
read any pin, and revise it; a kit can round-trip its own.
pin-readandpin-listare the only new builtins;pin-setandpin-clearare ral wrappers over the existing wire. - Tasks and goals are pure preludes over the family; the host’s vocabulary ends at the four commands, and any future kit gets the same substrate for free.
- Storage and display are one value. Pin and picture cannot drift because they are identical, and the register can never hold what the rail does not show — which obliges the task card to start rendering tags and notes.
- Task state survives blocks,
within, function bodies, and forks — the read comes from the register, so SPEC §10’s discard rule no longer eats updates, and$exarch-tasksthreading is deleted rather than repaired. - The typing is dynamic where the drafted design was static:
pin-readtypes as∀α. String → F αon thefrom-jsonprecedent, and a kit’s decode failures surface at runtime as didacticfails, not at check time. Accepted as the cost of a handle-free generic pin. - Expressiveness is bounded by the card vocabulary: a kit needing state the rail should not show must keep it out of the register.
- No outstanding work means no pin, as today: the slot clears when the list empties or every task is done, so the boundary nudge stops naming a finished board. The accepted cost, named: since the register is now the store, marking the last open task done discards the task records, and a later reopen starts a fresh list.
- exarch grows one enquiry class (two arms), one encoder, and two builtins. No core change; no new wire format; no new concurrency invariant.
Open questions
- Should a child’s final register reach its parent? A returning agent’s pinned cards die with it. Surfacing them is plausibly the matrix’s job; left out of scope.
- Snapshot persistence. Still ephemeral, and now readable: a register
snapshot would let
/clearand replay restore a task list. Deferred, as before, but the cost of deferring has gone up. - Reading a foreign key.
pin-readreads the agent’s own register only. Cross-agent reads, if ever wanted, want a distinct verb and an authority rule, not a widenedpin-read.
See also
surface-pins-state (the register this
completes — wire format and taxonomy kept, write-only-ness retired),
pins (the design page to rewrite against this),
surface-carries-control (dispatch
by class — pin-read joins the enquiry desk the same way),
surface-carries-documents (the
card vocabulary that now doubles as the register’s storage grammar),
transcript-as-graphic
(encode-don’t-stream, which canonical read-back extends to reads),
cards, shell-eval,
agent (the nudge that names pins from the same cards
pin-read returns), and exarch/data/agent.ral (the client that loses its
threading).