summaryrefslogtreecommitdiff
path: root/docs/registry.typ
diff options
context:
space:
mode:
Diffstat (limited to 'docs/registry.typ')
-rw-r--r--docs/registry.typ986
1 files changed, 986 insertions, 0 deletions
diff --git a/docs/registry.typ b/docs/registry.typ
new file mode 100644
index 00000000..597e930f
--- /dev/null
+++ b/docs/registry.typ
@@ -0,0 +1,986 @@
+// THE DESIGN REGISTRY — the one place where an idea, a refactor or an open
+// question lives between "someone said it" and "the tree does it".
+//
+// WHY A FILE AND NOT AN ISSUE TRACKER. Every claim in here cites `file:line`
+// against this tree, and a tracker cannot be grepped from an editor pane, does
+// not diff, and is not there when the network is not. `docs/design.typ` says
+// what pardes IS; this file says what is being argued about. When an argument
+// settles, its conclusion moves into `design.typ` (or into the code) and the
+// entry here becomes `landed` or `rejected` — a tombstone with the reasoning
+// still attached, because the expensive part of a decision is the part that
+// says why the other option lost.
+//
+// NO PACKAGES. `design.typ` pins cetz because diagrams are genuinely painful to
+// hand-roll; this file has none, so it depends on nothing and builds offline
+// forever:
+//
+// typst compile docs/registry.typ docs/registry.pdf
+//
+// HOW TO ADD TO IT. Append an `#entry`. Argue inside it with `#note`. Cite with
+// `#ev`. Never delete a note — flip the entry's status and let the losing
+// argument stand. The dashboard on page one is generated from the entries, so
+// there is no index to keep in sync.
+
+#set page(paper: "a4", margin: (x: 2.2cm, y: 2cm), numbering: "1")
+#set text(font: "New Computer Modern", size: 9.6pt)
+#set par(justify: true, leading: 0.58em)
+#set heading(numbering: none)
+#show heading: set block(above: 1.4em, below: 0.7em)
+#show heading.where(level: 1): set text(size: 12pt)
+#show heading.where(level: 2): set text(size: 10.4pt)
+
+#show raw.where(block: true): it => block(
+ width: 100%,
+ fill: luma(246),
+ inset: (x: 0.5em, y: 0.45em),
+ radius: 1pt,
+ breakable: true,
+ text(size: 7.8pt, it),
+)
+#show raw.where(block: false): set text(size: 8.8pt)
+#set table(stroke: 0.4pt, inset: 0.4em)
+
+// ---------------------------------------------------------------------------
+// machinery
+// ---------------------------------------------------------------------------
+
+/// The lifecycle. `open` is a question nobody has taken; `investigating` has an
+/// agent or a human on it; `decided` has an answer but no code; `landed` and
+/// `rejected` are terminal and keep their argument; `deferred` is "correct, but
+/// not until X exists" and must say what X is; `blocked` waits on someone else.
+#let states = (
+ open: (rgb("#8a6d3b"), "OPEN"),
+ investigating: (rgb("#31708f"), "LOOKING"),
+ decided: (rgb("#2f6f4f"), "DECIDED"),
+ landed: (rgb("#3c763d"), "LANDED"),
+ rejected: (rgb("#a94442"), "REJECTED"),
+ deferred: (rgb("#6f5499"), "DEFERRED"),
+ blocked: (rgb("#777777"), "BLOCKED"),
+)
+
+#let chip(state) = {
+ let (c, label) = states.at(state)
+ box(
+ fill: c,
+ inset: (x: 4.5pt, y: 2.2pt),
+ outset: (y: 1.5pt),
+ radius: 2pt,
+ text(size: 6.4pt, fill: white, weight: "bold", tracking: 0.4pt, label),
+ )
+}
+
+/// One argument. `id` is stable forever — notes and code comments cite it, so a
+/// renumber is a lie in every file that referenced the old number.
+#let entry(id, title, state: "open", tags: (), body) = {
+ [#metadata((id: id, title: title, state: state, tags: tags)) <reg>]
+ block(above: 1.5em, below: 0.5em, breakable: true)[
+ #block(
+ width: 100%,
+ fill: luma(242),
+ inset: (x: 0.6em, y: 0.45em),
+ radius: 2pt,
+ stroke: (left: 2pt + states.at(state).at(0)),
+ )[
+ #text(weight: "bold", size: 9.6pt)[#raw(id) #h(0.5em) #title]
+ #h(1fr)
+ #chip(state)
+ #if tags.len() > 0 [
+ #linebreak()
+ #text(size: 7.4pt, fill: luma(90))[#tags.join(" · ")]
+ ]
+ ]
+ #block(inset: (x: 0.2em, top: 0.5em))[#body]
+ ]
+}
+
+/// A comment. The thread IS the design discussion; keep them in time order and
+/// never edit one in place — reply to it instead.
+#let note(who, date, body) = block(
+ width: 100%,
+ inset: (left: 0.9em, y: 0.35em),
+ stroke: (left: 1.6pt + luma(205)),
+ {
+ text(size: 7.6pt, weight: "bold", fill: luma(70))[#who]
+ text(size: 7.6pt, fill: luma(140))[ · #date]
+ linebreak()
+ set text(size: 9.2pt)
+ body
+ },
+)
+
+/// Evidence. A claim with a path is a fact; a claim without one is an opinion,
+/// and this makes the difference visible at a glance.
+#let ev(where, body) = block(
+ width: 100%,
+ inset: (left: 0.9em, y: 0.25em),
+ {
+ text(size: 8.2pt, fill: rgb("#2f6f4f"))[▸ ]
+ raw(where)
+ text(size: 9.2pt)[ — #body]
+ },
+)
+
+/// A question with no owner yet. Rendered so it can be found by eye when
+/// scanning for something to pick up.
+#let q(body) = block(
+ width: 100%,
+ inset: (x: 0.7em, y: 0.4em),
+ fill: rgb("#fdf6e3"),
+ radius: 2pt,
+ { text(size: 7.6pt, weight: "bold", fill: rgb("#8a6d3b"))[OPEN QUESTION]; linebreak(); body },
+)
+
+/// What the entry concluded. One per entry at most, and only when the state is
+/// `decided`, `landed` or `rejected`.
+#let verdict(body) = block(
+ width: 100%,
+ inset: (x: 0.7em, y: 0.4em),
+ fill: rgb("#eef5ef"),
+ radius: 2pt,
+ stroke: 0.4pt + rgb("#2f6f4f"),
+ { text(size: 7.6pt, weight: "bold", fill: rgb("#2f6f4f"))[VERDICT]; linebreak(); body },
+)
+
+// ---------------------------------------------------------------------------
+
+#align(center)[
+ #text(size: 15pt, weight: "bold")[The Design Registry]
+ #v(0.3em)
+ #text(size: 9pt, style: "italic")[open arguments, their evidence, and how they were settled]
+ #v(0.2em)
+ #text(size: 8.5pt)[#datetime.today().display("[year]-[month]-[day]")]
+]
+
+#v(0.8em)
+
+#context {
+ let es = query(<reg>).map(e => e.value)
+ let order = ("open", "investigating", "blocked", "decided", "deferred", "landed", "rejected")
+ let counts = order
+ .map(s => (s, es.filter(e => e.state == s).len()))
+ .filter(p => p.at(1) > 0)
+ .map(p => [#chip(p.at(0)) #text(size: 8.4pt)[#p.at(1)]])
+ align(center, counts.join(h(0.9em)))
+ v(0.6em)
+ table(
+ columns: (auto, 1fr, auto),
+ align: (left + horizon, left + horizon, right + horizon),
+ table.header(
+ text(size: 8pt, weight: "bold")[ID],
+ text(size: 8pt, weight: "bold")[Argument],
+ text(size: 8pt, weight: "bold")[State],
+ ),
+ ..es
+ .sorted(key: e => order.position(s => s == e.state) * 1000)
+ .map(e => (raw(e.id), text(size: 8.8pt)[#e.title], chip(e.state)))
+ .flatten()
+ )
+}
+
+#pagebreak()
+
+= 9P
+
+Born from the draft note `docs/9p.typ`. The owner's instinct: replace FUSE with
+9P, replace the private unix-socket wire with 9P too, and put a small 9P server
+on the ESP32-P4 that pardes talks to as a client. The instinct is recorded here
+entry by entry so it can be argued with in pieces rather than accepted or
+rejected whole.
+
+#entry("9P-1", "Where does 9P go? Two layers, and 9P is the outward face of one of them", state: "decided", tags: ("architecture", "umbrella"))[
+ The umbrella. The draft conflated two things that share a socket and nothing
+ else.
+
+ #ev("src/acmefs.zig:63-78")[LAYER 1, the acme control tree: request/response, nine operations, and an ABI that already says "FUSE opcodes, 9P messages and a unit test all reduce to these".]
+ #ev("src/detached/wire.zig:187-232")[LAYER 2, the detached wire: 19 tags carrying RLE cell frames and input, server-push, N frontends on one screen. None of them is a file.]
+
+ #verdict[
+ 9P is a TRANSPORT FOR LAYER 1 and a CARRIER FOR LAYER 2 — never a
+ re-encoding of either.
+
+ Layer 1 gets 9P as a second transport beside FUSE, because the ABI was
+ built for it and because FUSE cannot leave the machine (`9P-13`).
+
+ Layer 2 keeps `encodeFrame` byte for byte. 9P may carry those bytes
+ (`9P-12`), but the moment a frame is re-expressed as a file's contents the
+ design has lost the thing that makes it fast, and there is a thirty-year
+ demonstration of both answers (`9P-12`, devdraw against `/dev/screen`).
+ ]
+
+ #note("review", "2026-08-27")[
+ The reason this is worth stating as its own entry: nearly every mistake in
+ the draft is one layer's property asserted about the other. "Offsets are
+ meaningless" is true of Layer 2 and false of Layer 1. "The protocol is
+ private and undocumented" is true of Layer 2 and false of Layer 1, which is
+ acme(4). "Adding a feature means adding a message" is true of Layer 2 —
+ measured at 7 lines per fact (`src/detached/wire.zig`, `set_clipboard`
+ occurring 8 times) — and false of Layer 1, where a feature is a file.
+ ]
+]
+
+#entry("9P-2", "Make the transport seam explicit before writing any 9P", state: "decided", tags: ("refactor", "cheap"))[
+ `fs_service` already touches its transport through exactly three methods.
+ Turning `*fuse.Fs` into a ctx+vtable at three call sites is a no-behaviour
+ change that makes a second transport possible without deciding anything else.
+
+ #ev("src/fs_service.zig:158-200")[`drain`/`step` call only `retry()`, `next()` and `reply()`.]
+ #ev("src/tty/tty.zig:1216")[call site 1.]
+ #ev("src/gui/gui.zig:3738")[call site 2 — and `:3841` for the headless grid harness.]
+
+ #verdict[Do it first, independently of every other entry. ≈40 lines changed, 0 added, `fuse.Fs` is the first implementor. If 9P is never built, this costs nothing and documents the seam.]
+]
+
+#entry("9P-3", "Direct I/O is `qid.version = 0`, not `cache=none`", state: "decided", tags: ("protocol", "correction"))[
+ Under FUSE the server asserts `FOPEN_DIRECT_IO` per open and the client
+ cannot argue. The draft assumed 9P gives this up and compensated with advice
+ ("mount with `cache=none`"). It does not have to: Linux's client disables
+ both read and write caching for any file whose qid version is zero.
+
+ #ev("linux/fs/9p/fid.h:52-53")[`(fid->qid.version == 0) && !(s_flags & V9FS_IGNORE_QV)` sets `P9L_DIRECT` — "no read or write cache".]
+ #ev("linux/fs/9p/v9fs.h:82")[`CACHE_NONE = 0` is the default anyway.]
+ #ev("linux/fs/9p/v9fs.c:93")[`ignoreqv` is the only opt-out, and it is explicit.]
+
+ #verdict[A synthetic tree reports `qid.vers = 0` on every file. Server-enforced, near-parity with `FOPEN_DIRECT_IO`, and the mount advice in the draft becomes unnecessary. Plan 9 needs nothing: its cache is opt-in via `mount -c`.]
+]
+
+#entry("9P-4", "The error ABI: 9P2000 Rerror is a string", state: "open", tags: ("protocol", "ABI"))[
+ The core answers with numbers. 9P2000 answers with prose, and the Linux
+ client turns prose back into a number by exact string match. A miss is not
+ `EIO`; it is 526, which userspace prints as "Unknown error 526".
+
+ #ev("src/acmefs.zig:152-165")[`E.PERM..E.NOSYS`, nine numeric values.]
+ #ev("linux/net/9p/error.c:224-243")[`p9_errstr2errno` hash lookup; on a miss `errno = ESERVERFAULT`.]
+ #ev("principia-softwarica/editors/acme/xfid.c:19-24")[acme's own strings — `Ebadctl`, `Ebadaddr`, `Ebadevent` — are all misses in that table.]
+
+ Three ways out, and they are not equally good.
+
+ / A: #[Emit Linux `strerror` text verbatim so all nine round-trip. Cheap (≈20 lines), and makes English kernel strings this project's error ABI.]
+ / B: #[Serve 9P2000.u, whose `Rerror` carries a numeric errno alongside the string. Costs a second dialect in the codec; buys exact errnos and keeps a human-readable string for Plan 9 clients.]
+ / C: #[Emit acme's strings and accept 526 on Linux. Faithful to acme, hostile to `mount -t 9p`.]
+
+ #q[Which? Note that B also answers `9P-5`'s `statfs` gap for free, and that the draft deferred `.u`/`.L` without noticing either.]
+
+ #note("prior-art", "2026-08-27")[
+ `ad` — a Rust acme-like editor that already ships this — chose option C
+ without noticing. It emits nineteen lowercase prose strings: `"unknown
+ fid"`, `"permission denied"`, `"file not open"`, `"exclusive file already
+ open"`, `"invalid offset for read on directory"`
+ (`~/05-genizah/ad/crates/ninep/src/sansio/server.rs:25-43`). *None* of
+ them is in Linux's table, so under `mount -t 9p` every single error `ad`
+ can produce arrives as 526. It also serves `SUPPORTED_VERSION = "9P2000"`
+ and nothing else (`:46`), so there is no `.u` escape hatch in place.
+ Real implementations fall into this; it is not a theoretical trap.
+ ]
+]
+
+#entry("9P-5", "Directory reads need per-fid state the core does not have", state: "decided", tags: ("protocol", "cost"))[
+ This is the real offset discontinuity, and the draft missed it while
+ inventing a false one about `body`.
+
+ 9P requires a directory read at offset 0 or at exactly the byte offset where
+ the previous read ended, and the reply must contain whole `Dir` entries.
+ `acmefs` treats the offset as an *entry index* and re-stages the whole
+ listing each call, which is right for FUSE and wrong here.
+
+ #ev("principia-softwarica/lib_networking/lib9p/srv.c:473")[a dir read whose offset is neither 0 nor `fid->diroffset` is answered `Ebadoffset`.]
+ #ev("u9fs/u9fs.c:60-64")[the reference server keeps `diroffset`, a cached `dirent` and `direof` per fid.]
+ #ev("src/acmefs.zig:972")[`var skip = req.off;` — an entry index.]
+
+ #verdict[Transport-side, not core-side: the 9P transport keeps a per-fid byte cursor plus the one entry that did not fit, and calls the existing `readdir` with the entry index it has counted. `acmefs.zig` is untouched. Budget ≈40 lines.]
+
+ #note("prior-art", "2026-08-27")[
+ `ad` has no cookie at all: `read_dir` returns the entire `Vec<Stat>` on
+ every call and the server re-serialises all of it and byte-skips the offset
+ (`ninep/src/sync/server.rs:203`, `sansio/server.rs:377-401`). Its
+ `FidMeta` is `{qid, mode}` — no dir state (`:700-703`). So it is O(N) per
+ read, O(N²) per directory, and `E_INVALID_OFFSET` fires only when the
+ offset lands mid-entry (`:388-390`), which means an arbitrary offset on an
+ entry boundary is silently accepted and a changing directory tears.
+ The 40-line budget above buys correctness `ad` does not have.
+ ]
+]
+
+#entry("9P-6", "Is `addr` per-fid or per-window?", state: "open", tags: ("semantics", "divergence"))[
+ The draft claimed per-fid and attributed it to acme. acme does the opposite,
+ and so does pardes today.
+
+ #ev("principia-softwarica/editors/acme/dat.h:239")[`Range addr;` is a field of `struct Window`; every use in `xfid.c` is `w->addr`.]
+ #ev("src/acmefs.zig:1027-1031")[pardes copied that, deliberately, and records why: there is no fid table because FUSE puts the nodeid on every request.]
+
+ Per-fid genuinely is better — two scripts can address one window without
+ colliding — but it is a divergence from acme, not a restatement of it, and it
+ is the one place in the whole draft that asks the *core* to grow state. Under
+ FUSE there is no fid to hang it on at all.
+
+ #q[Take the divergence and pay for it (per-fid `addr` lives in the 9P transport, and the FUSE transport keeps one per mount), or keep acme's race and document it?]
+
+ #note("prior-art", "2026-08-27")[
+ Third independent source against the draft: `ad`'s address is per-BUFFER,
+ keyed by buffer id (`Req::SetBufferAddr{id, addr}`,
+ `ad/src/fsys/message.rs:77-80`). acme per-window, pardes per-pane, `ad`
+ per-buffer. Nobody has ever shipped it per-fid. That is not proof it is
+ wrong — it is proof it is a proposal, and it should be argued as one.
+ ]
+]
+
+#entry("9P-7", "One controller per window, or many?", state: "open", tags: ("semantics", "divergence"))[
+ The draft says `event` is exclusive-use. acme does not do this, and pardes
+ currently allows any number of readers.
+
+ #ev("principia-softwarica/editors/acme/xfid.c:603-609")[acme's exclusivity is an advisory `lock` ctl verb setting `w->ctlfid`, cleared on clunk.]
+ #ev("principia-softwarica/editors/acme/fsys.c:537-563")[`fsysopen` checks permission bits only; a second `event` reader is not refused.]
+ #ev("src/acmefs.zig")[`pf.readers +|= 1` on open — a count, and the count is what suppresses button actions.]
+
+ `DMEXCL` would make a second open fail with `EAGAIN` under Linux. That is a
+ tightening with a real cost: two cooperating scripts on one window stop
+ working, and nothing in `examples/acmefs/` was written expecting it.
+
+ #q[Leave it a count (status quo, acme-compatible), or make it exclusive and lose multi-reader?]
+
+ #note("prior-art", "2026-08-27")[
+ `ad` DOES do what the draft describes: `event` is `FileType::EXCLUSIVE`,
+ i.e. QTEXCL (`ninep/src/sansio/protocol.rs:503`, added in commit
+ `678fbf5`). Note how it is scoped, though — enforcement is per *ClientId*,
+ not per fid (`sansio/server.rs:762-764`), so one client may hold two fids
+ on `event` and two clients may not share one window. That is a third
+ position between "a count" and "one fid", and it is probably the right one:
+ it stops two unrelated scripts fighting without breaking a script that
+ opens the file twice.
+ ]
+]
+
+#entry("9P-8", "`pty/` files — the best idea in the draft, and unrelated to 9P", state: "open", tags: ("feature", "transport-independent"))[
+ A script today can write a terminal pane's `body` (it becomes a pty write)
+ and read its rendered scrollback. It cannot spawn a terminal, resize one, or
+ signal one. The draft's `pty/{data,ctl,status}` fixes that, and none of it
+ needs 9P — it is `ctl` verbs and two new `PaneFile` variants.
+
+ #ev("src/host.zig:80-82")[`push_spawn` and `push_pty_resize` already exist as effects, so `exec` and `winsize` are two existing effects with a name.]
+ #ev("src/acmefs.zig")[the `Verb` table has no pty verb; `PaneFile` has no pty entry.]
+
+ `sig INT` is the only genuinely new capability — there is no `kill` anywhere
+ in `host_io.zig`.
+
+ #verdict[Not blocked on anything. Build it in the FUSE tree now; a 9P transport inherits it for free. Sequencing it *after* 9P would be putting the transport before the feature.]
+
+ #note("prior-art", "2026-08-27")[
+ Worth knowing before building it: there is *no prior art anywhere*. acme
+ has no pty files; `ad` has none either — its tree is
+ `{ctl, minibuffer, scratch, log, buffers/…}` and contains no terminal
+ surface at all (`ad/src/fsys/mod.rs:17-32`). So `pty/` is a genuinely new
+ interface, which cuts both ways: nobody has made these mistakes for us, and
+ nobody's scripts already expect a particular spelling. Being first is a
+ reason to keep it small — `data`, `ctl`, `status`, and no more.
+ ]
+]
+
+#entry("9P-9", "Naming sessions: `aname` or a top-level directory", state: "open", tags: ("protocol", "naming"))[
+ Verified: `aname` works, and the draft's mount line is valid verbatim.
+
+ #ev("linux/fs/9p/v9fs.c:72-90")[`aname` parses to `Opt_remotename`; `version=9p2000` selects `p9_proto_legacy`.]
+ #ev("linux/fs/9p/vfs_super.c:340")[`port` defaults to 564, so the draft's command line needs no `port=`.]
+ #ev("u9fs/u9fs.c:409-420")[u9fs uses `aname` to pick a tree, so there is precedent for exactly this use.]
+
+ Against it: pardes has no multi-session concept in the core at all today —
+ `--detach=work` names a *socket*, not a tree. A top-level directory needs no
+ protocol feature and works with clients that ignore `aname`.
+
+ #q[Is this question even live before `9P-12` settles? A per-session socket already names a session; `aname` matters only if one listener serves many.]
+
+ #note("prior-art", "2026-08-27")[
+ `ad` does not use `aname` either: `Server::new` installs a single anonymous
+ root `""` (`ninep/src/sansio/server.rs:91-95`), and multi-session is the
+ SOCKET name, `ad-<pid>`, with discovery through a `list_open_sessions`
+ helper (`ad/src/fsys/mod.rs:158-159`). That is exactly pardes's
+ `--detach=work` shape. Two implementations independently reaching for a
+ named socket over `aname` is evidence about which one people actually
+ build.
+ ]
+]
+
+#entry("9P-10", "Aggregation: the prefix router", state: "deferred", tags: ("architecture", "premature"))[
+ The draft's longest technical section, and its own §11 concludes the
+ aggregate is not worth building before a second machine exists. That verdict
+ is correct and also covers the client half.
+
+ What the section understates: a proxied `Twalk` cannot be answered until the
+ remote `Rwalk` arrives, so "the fid table maps our fid to a pair" is not the
+ entire proxy — it needs per-tag continuations, remote↔local tag remapping,
+ `Tflush` forwarding and fid invalidation on connection death.
+
+ #ev("src/detached/wire.zig:64-70")[a round trip inside `update` is the one thing the transport must never do.]
+ #ev("src/detached/server.zig:238-241")[the daemon is one `poll(2)` over 50 slots, and `:565-569` records that it has no worker pool.]
+
+ #verdict[Deferred until a second machine exists AND `9P-12` has settled, because the proxy's shape depends entirely on which layer 9P occupies. Reopen with a named use case, not with an architecture.]
+]
+
+#entry("9P-11", "A 9P server on the ESP32-P4 — real, cheap, and a second firmware image", state: "decided", tags: ("board", "motivating-case"))[
+ The owner's motivating case, and the entry that changed the most under
+ measurement. Both the draft and the first review were wrong about it, in
+ opposite directions.
+
+ The draft was wrong about what the board IS: it describes a machine running
+ "a 9P server and nothing else", and today the P4 runs the whole editor
+ (`src/esp32p4.zig:264-300`, 2 of 21 vtable methods at `:930-934`).
+
+ The review was wrong about what the board CAN AFFORD, because it quoted a
+ stale number.
+
+ #ev("src/esp32p4.zig:470-473")[the "9,128 bytes free at 80×24" comment predates `direct_emit` (`:870`), which sized vaxis's two shadow grids to one cell.]
+ #ev("05-zig-p4/experiments/report.typ:848-849,877-880")[measured after that change: "the heap now reports 336 KB free at every geometry tried, including ones that used to fail outright… the heap has 336 KB spare while `.bss` runs out". The binding resource is the 240 KiB low L2MEM, not the 384 KiB heap.]
+
+ A 9P server's RAM, costed from real components: two msize buffers at 4,096
+ (u9fs uses three — `rxbuf`, `txbuf`, `databuf`, `u9fs.c:1814-1816` — a
+ minimal server needs two) = 8,192 B; a FIXED-ARRAY fid table of 32 entries ×
+ 16 B (`fid`, `qid.path`, `mode`, `diroffset`) = 512 B; codec scratch with
+ `P9_ERRMAX` = 128 B. *Total 8,832 B, or 2.5% of free heap.*
+
+ #ev("src/esp32p4/input_rescue.zig:52")[and a 4,096-byte reassembly buffer already exists on this exact UART, measured: "4,096 bytes in a single write arrive intact, and past that the loss is counted rather than silent".]
+ #ev("linux/net/9p/client.c:840-843,908-910")[the 4,096 floor is imposed by the LINUX KERNEL and by nothing else. Plan 9's devmnt, plan9port's `9p` and pardes's own client accept a 512-byte msize, which halves the buffers to 1 KiB.]
+
+ Flash: the editor image is 809,536 B of a 1,536,000 B partition
+ (`report.typ:375-376`), leaving 726,464 B. A 9P-only image is ≈23,870 B of
+ platform plus ≈15 KiB of server ≈ *39 KiB, 2.5% of the partition*.
+
+ #ev("build.zig:1182-1235")[a second board image is ALREADY expressible: `esp32p4-test` builds its own executable, `ImageStep`, `FlashStep` and run step in 54 lines, and deliberately links no pardes object. That is the shape.]
+ #ev("src/acmefs.zig")[and the semantics layer already compiles for riscv32: `llvm-nm` finds 21,548 B across 17 `acmefs.*` symbols in `zig-out/pardes-esp32p4.o`.]
+
+ #verdict[
+ Buildable, and much cheaper than anyone assumed. But it is a SECOND
+ FIRMWARE IMAGE, not a second role for this one: the editor owns UART0
+ bidirectionally (`esp32p4.zig:611`, `uart.zig:43-44,122-130`) and JP1
+ exposes no second P4 UART (`board_memory.zig:382-383`). The board is either
+ an editor or a filesystem at any one time. Say that plainly rather than
+ implying both.
+ ]
+
+ #note("review", "2026-08-27")[
+ The reframing the draft misses: the board already exposes `Peek`, `Poke`,
+ `Hexdump` and `Gpio` as acme words (`src/board_memory.zig:306-349,410-472`,
+ registered `src/builtins.zig:1009,1025,1038`). All four cap at 4,096 bytes
+ per command and the cap's stated reason is the 115200 console. So the whole
+ 2³² address space is already reachable — by *typing a word into a tag*,
+ with the answer landing in an output pane. Nothing is machine-readable and
+ nothing is remote. A 9P tree is that same capability with names instead of
+ verbs, and `mem/`, `gpio/pinout` and `prof` are backed by functions that
+ exist today (`board_memory.readWord:136`, `:368-390`,
+ `pardes_esp32p4_frame_prof` at `esp32p4.zig:985`, already exported).
+ Four more files need one new C-ABI extern each; four have no
+ implementation at all. That inventory belongs in the note, not a wishlist.
+ ]
+
+ #q[Should the parked second RISC-V core own the 9P server? `report.typ:552-563` says it needs four register writes plus a trampoline, shares one L1 D-cache so a lock-free ring needs only fences, and that giving core 1 the UART "eliminates the silent input loss". That is the one arrangement where the board serves 9P *and* keeps the editor. Uncosted.]
+]
+
+#entry("BOARD-1", "Raise UART0 to 921600 before quoting any board latency", state: "decided", tags: ("board", "cheap", "prerequisite"))[
+ Every board 9P latency figure is eight times worse than it needs to be, for
+ no reason but that the bootloader left the divider alone.
+
+ #ev("src/esp32p4/uart.zig:35-38")[the firmware never programs the divider; 115200 is inherited.]
+ #ev("05-zig-p4/src/hal/uart.zig:321-333")[`setBaudrate` and `divider` already exist and `reset()`'s refusal of instance 0 does not apply to them.]
+ #ev("05-zig-p4/experiments/report.typ:540-544")[921600 is one `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL — int 43, frag 6, +0.064% error. 2 Mbaud is representable but this CH340 is unreliable there, corroborated by the flasher at `build.zig:1136-1138`.]
+
+ #verdict[
+ One register write, 86.8 µs → 10.85 µs per byte. A warm
+ `cat /mnt/board/gpio/2/value` goes from 10.8 ms to 1.35 ms; a 4 KiB `Tread`
+ from 358 ms to 45 ms. Do it first, and re-measure everything after.
+ The hazard the same files record: writing the console UART's divider is
+ adjacent to what bricks the board, and the host must be reopened in step.
+ ]
+]
+#entry("9P-12", "Should 9P replace the private wire protocol? Half of it, and not the half you would guess", state: "decided", tags: ("architecture", "the-real-question"))[
+ Six layerings were costed. The finding that settles it is that layering is
+ not a cost decision at all.
+
+ #ev("src/detached/wire.zig")[1705 lines = 1092 code + 613 tests, of which only 303 are FRAME and 789 are TRANSPORT: bounds, tags, message structs, framing, codecs.]
+ #ev("src/detached/wire.zig:1401,1412")[a 56×14 full frame is 6,298 B; a one-keystroke diff is 56 B. Ratio over 100.]
+ #ev("src/detached/server.zig:1102-1110")[skip-slow is three lines: `if (c.out.items.len != 0) continue;` — skip the frame and leave the mirror alone, so the next frame diffs against what the client really has.]
+
+ A base-9P2000 codec (≈450), a dispatcher onto `acmefs.Op` (≈400) and a fid
+ table (≈120) come to ≈970 lines that are IDENTICAL in all six layerings.
+ Layering moves ±150. So the question is reach and semantics, not lines.
+
+ *Does 9P subsume the wire's job?* The transport half, yes, with shipped
+ precedent: clipboard becomes `snarf` (`rio/fsys.c:61`), input becomes
+ `mouse`/`cons` — and rio's `mouse` is exclusive-open with a blocking read and
+ a flush (`rio/xfid.c:167-173,638-660`), which is the `event` idiom exactly.
+ The frame half, no: 9P has no server-initiated message.
+
+ *Is "the frame is a file" a category error?* Only if you mean push. Two
+ shipped counter-forms exist and they disagree with each other, which is the
+ most useful thing in this entry:
+
+ #ev("plan9/rio/fsys.c:53, lens.c:281-282")[`/dev/screen` is an offset-addressed raster you POLL — `lens` seeks to a scanline and repaints on mouse events because it never learns the screen changed. Real, and precisely why nobody runs a remote rio through it.]
+ #ev("drawterm-9front/kern/devdraw.c:1574, include/draw.h:55")[`/dev/draw/N/data` is a PRIVATE BINARY COMMAND STREAM batched into 8,000-byte buffers and flushed as one `Twrite`. Real, and how remote display has actually shipped for thirty years.]
+ #ev("drawterm-9front/cpu.c:149,191,251,359")[and it inverts direction: the DISPLAY exports its devices and the APPLICATION mounts them. The frame travels as a client's `Twrite`, never as a server's push.]
+
+ pardes's codec is already devdraw's shape. `wire.zig`'s 5-byte length prefix
+ (`:145`, `framed()` `:564`) makes an `encodeFrame` message a legal 9P payload
+ with zero new framing.
+
+ #verdict[
+ *Superseded in part by `9P-19`.* The reasoning below stands; the conclusion
+ moved. O3's tunnel was chosen because it was the only option that reached
+ the board without sockets. `9P-19` shows the tunnel is unnecessary: the
+ link simply carries 9P, with the FRONTEND as the server and the core as the
+ client, and the frame travels as the client's `Twrite` to `screen`. That is
+ O1 done the way it has actually shipped for thirty years, and it dissolves
+ the objection recorded here.
+
+ What survives unchanged: *O4* (wire inside 9P) costs +34 B per frame, +61%
+ on a 56-byte diff. *O5* (a standalone listener) remains correct and is
+ independently worth building — it is the scripting face and it composes
+ with anything.
+
+ What was wrongly rejected: *O6*. "The frontend serves, the core is a
+ client" was rejected here for making the core write and match tags inside
+ `update` and for foreclosing the freestanding targets. Both are wrong. The
+ core need not wait for `Rwrite` — 9P permits many outstanding tags
+ (`linux/net/9p/client.c:194-199`, `plan9/devmnt.c:783-800`) with no
+ in-order reply requirement — and the freestanding targets have a byte
+ stream even though they have no sockets, which is all 9P asks for.
+ O6 is the destination. See `9P-19`.
+ ]
+
+ #note("review", "2026-08-27")[
+ One honest caveat against O3: `exportfs` carries 9P and nothing else — its
+ framing is 9P's own 4-byte size prefix (`exportfs.c:434,476`) — so
+ multiplexing a second protocol beside 9P on one link has *no* Plan 9
+ precedent. It is not hard, but we would be first, and "the transport
+ becomes someone else's problem" stops being true for that link.
+ ]
+]
+
+#entry("9P-13", "9P cannot replace FUSE on Linux; it can only join it", state: "decided", tags: ("portability", "privilege", "decisive"))[
+ The strongest argument for 9P was that it needs no kernel and no privileged
+ mount helper: 281 non-test lines of `fuse.zig` exist only to obtain a mount
+ an unprivileged user is not allowed to make.
+
+ #ev("src/fuse.zig:497-703")[207 lines: `_FUSE_COMMFD`, socketpair, `CMSG_*`/`SCM_RIGHTS`, fork/execve/waitpid of the setuid `fusermount3`, environ rebuild.]
+ #ev("src/fuse.zig:752-773")[22 more for the helper's unmount, `:933-978` 46 for the mount call, `:1718-1723` 6 for `clearCloexec`. 281 total, measured.]
+ #ev("src/fuse.zig:64")[and all of it is Linux-only, so macOS (14/21 vtable) and web (6/21) have no control filesystem at all.]
+
+ That argument is dead. The kernel grades mount privilege by a per-filesystem
+ flag, and the two filesystems are on opposite sides of it.
+
+ #ev("linux/fs/super.c:694-700")[`mount_capable`: without `FS_USERNS_MOUNT` the check is `capable(CAP_SYS_ADMIN)` — and `capable()` is `ns_capable(&init_user_ns, …)`, i.e. root in the INITIAL namespace, not the caller's.]
+ #ev("linux/fs/9p/vfs_super.c:362")[`.fs_flags = FS_RENAME_DOES_D_MOVE` — v9fs does not set `FS_USERNS_MOUNT`.]
+ #ev("linux/fs/fuse/inode.c:2004")[`.fs_flags = FS_HAS_SUBTYPE | FS_USERNS_MOUNT | FS_ALLOW_IDMAP` — FUSE does.]
+
+ So `mount -t 9p` costs real root, unconditionally: a fresh
+ `unshare(CLONE_NEWUSER|CLONE_NEWNS)` does not help, because `mount_capable`
+ falls back to the init namespace for exactly the filesystems that lack the
+ flag. `trans=fd` does not help either — it lets an unprivileged process own
+ the connected socket, but `mount(2)` is checked before the transport is ever
+ consulted (`linux/fs/namespace.c:3838`). And `9pfuse`, the usual escape,
+ is FUSE: it needs `fusermount` in `PATH` and reimports the whole setuid dance
+ in someone else's process.
+
+ #verdict[
+ The 281 lines are not overhead. They buy an *unprivileged pathname*, which
+ is a capability 9P has no way to provide on Linux. `fuse.zig` stays.
+
+ This does not weaken 9P; it relocates it. The two are complementary faces
+ of one `acmefs.handle`: FUSE is the PATHNAME face — local, unprivileged,
+ Linux, for `grep` and `make`. 9P is the NETWORK AND IPC face — remote,
+ unprivileged, every platform, for pardes's own client, for the board, for
+ the daemon, and for anyone with root who wants `mount -t 9p`.
+
+ Everything downstream follows from this split, and `9P-2` is what makes it
+ cost nothing.
+ ]
+
+ #note("review", "2026-08-27")[
+ Worth being precise about what survived. The draft's §6 has two halves and
+ only one of them died. "The client half needs no mount… no kernel
+ involvement, no privilege" is TRUE and remains the best paragraph in the
+ document. "The mount is for other people's programs… and it is sufficient"
+ is where the root requirement lands, and it is not sufficient — on Linux
+ that mount is strictly more privileged than the one we already have.
+ ]
+]
+
+#pagebreak()
+
+= Corrections
+
+Small, certain, and independent of every argument above.
+
+#entry("FIX-1", "`acmefs.zig` is wrong about 9P truncation", state: "decided", tags: ("comment", "one-line"))[
+ The comment justifying `setattr` says 9P has no truncate-on-open and that
+ nothing in acme answers a `Twstat` carrying a length. Both halves are wrong.
+
+ #ev("src/acmefs.zig:1097-1100")[the claim.]
+ #ev("u9fs/plan9.h:150")[`#define OTRUNC 16` — base 9P2000, used at `u9fs.c:1578` and `:1682`.]
+ #ev("u9fs/u9fs.c:1047")[`Twstat` with a length calls `truncate(2)`; that is how 9P truncates.]
+
+ #verdict[Rewrite the comment. The real reason `setattr` exists is that Linux strips `O_TRUNC` from the OPEN when `FUSE_ATOMIC_O_TRUNC` is not negotiated, which is a FUSE fact and stands on its own without the false claim about 9P. Under a 9P transport, `> body` arrives as `Topen` with `OTRUNC` or as `Twstat`, and maps onto the same `Req.truncate`.]
+]
+
+#entry("DOC-1", "`design.typ` says \"No 9P\"", state: "open", tags: ("docs", "consistency"))[
+ The project has already recorded a decision against this, under *What is
+ deliberately absent*, and the draft neither cites nor rebuts it.
+
+ #ev("docs/design.typ:1969-1971")[«No 9P. The library boundary is exactly where acme put the file server, and the FUSE mount is already that server with a Linux transport instead of a 9P one; a sixth shell could serve `Surface` and `Event` over 9P without touching the core.»]
+
+ Read closely, that paragraph is not hostile — it says *not built*, and its
+ second clause proposes something stronger than the draft does: serving
+ `Surface` and `Event`, i.e. `9P-12`'s option 1, which the project apparently
+ considered plausible enough to write down.
+
+ #q[When `9P-1` settles, this paragraph is rewritten rather than deleted — it should say which layer 9P occupies and why the other one was left alone.]
+]
+
+#entry("9P-14", "Do the daemon's filesystem first, with 16 lines and no 9P", state: "decided", tags: ("sequencing", "cheap", "do-first"))[
+ The strongest *stated* motive for putting 9P in the daemon is that a
+ detached session has no control filesystem. That is true, and it is not a
+ protocol problem.
+
+ #ev("src/detached/server.zig:563-570")[«no `push_fs_reply`, because this process mounted no /dev/fuse». The daemon simply never calls `fs_service.start`.]
+
+ The fix, counted: a `Source.fuse` variant (1 line), an `fs` field (1),
+ `fs_service.start` (1), `fsReply` copied verbatim from `tty.zig:1455-1458`
+ (4), a vtable entry (1), a pollfd and dispatch arm (6), `drain` (2).
+ *≈16 lines.* And it is *better* there than on the desktop hosts: the
+ daemon's own `poll(2)` covers `/dev/fuse` directly, so it needs no wake
+ thread at all (`src/fs_service.zig:127-131`).
+
+ #verdict[
+ Build this before deciding anything else. It costs sixteen lines, it
+ delivers the feature people actually want, and it removes a motive from the
+ 9P argument so that argument is decided on its merits. If it turns out to
+ be all anyone needed, that is a good outcome, not a wasted one.
+ ]
+]
+
+#entry("9P-15", "The honest cost of a 9P stack is ≈1,600-1,900 lines, not ≈770", state: "decided", tags: ("cost", "estimate"))[
+ Two independent implementations were measured rather than guessed, and they
+ agree closely.
+
+ #ev("~/05-genizah/ad/crates/ninep")[12,296 lines total; the irreducible 9P2000 SERVER core is 2,321 non-blank non-comment: codec 704, `Stat`/`Perm`/`Mode`/`WStat` 437, session+fid+flush 615, loop+handlers 474. Fid table alone is 57 lines; the server loop 141; the flush path ≈58; directory reads 50; tests 3,669, i.e. 31%.]
+ #ev("~/05-genizah/u9fs/u9fs.c")[1,838 lines — NOT the 6,149 the first review quoted, which counted a whole plan9-libc substitute. Its codec, `convM2S` + `convS2M`, is 805.]
+
+ Codec ≈700-800 and server logic ≈1,500-1,900, from both. The earlier
+ ≈770-line figure was the size of one component.
+
+ #verdict[
+ Budget ≈1,600-1,900 core lines plus ≈500 of tests, and say so out loud,
+ because it *breaks the house rule* (`docs/design.typ:1957-1960`: a change
+ leaves the file it touches no longer than it found it) unless something is
+ retired in exchange. `9P-13` says `fuse.zig` cannot be the thing retired.
+ So this is net growth, and it has to be justified by reach — the board, the
+ network, macOS, web — not by simplification.
+ ]
+
+ #note("review", "2026-08-27")[
+ Two mitigations that are real. First, ≈970 of those lines are identical in
+ every layering (`9P-12`), so none of it is at risk from the layering
+ decision. Second, 296 of ninep's 704 codec lines are macro-generated
+ (`protocol.rs:809-1154`); Zig `comptime` over an exhaustive message enum
+ should compress that at least as well, and the message set is the one part
+ of 9P that never changes. Against that: `ad` needed `UnsafeCell` plus
+ `unsafe impl Send/Sync` to get zero-allocation parsing
+ (`protocol.rs:67-76`), which Zig gives for free — so the *hard* part of
+ their codec is not a cost we inherit.
+ ]
+]
+
+#entry("9P-16", "Tflush is a park-table lookup, and this is where we beat the prior art", state: "decided", tags: ("protocol", "advantage"))[
+ The draft says `Tflush` is the most common omission in hand-written 9P
+ servers. It understates the problem: the common failure is having it and
+ still hanging.
+
+ #ev("~/05-genizah/ad/crates/ninep/src/sansio/server.rs:767-819")[`ad` gets the ORDERING right in 39 lines — an Rflush for an in-flight tag is chained and sent only after the original replies, matching `lib9p/srv.c:241-266`.]
+ #ev("~/05-genizah/ad/crates/ninep/src/sync/server.rs:163-164")[and then `Serve9p::flush` defaults to a no-op, which `ad` never overrides. A client flushing a blocked `event` read gets no Rflush until an unrelated editor event happens to arrive. Interrupts and unmounts hang exactly as the draft warns, *despite* Tflush being "implemented".]
+
+ #verdict[
+ pardes is structurally better placed than either reference. The 32-slot
+ park table (`src/fuse.zig:789`) is already keyed per outstanding request, so
+ `Tflush` is a lookup, a reply to the original, and a reply to the flush —
+ the same path `FUSE_INTERRUPT` already takes, which is tested
+ (`src/fuse.zig`: "interrupt answers the original with EINTR and drops it").
+ Implement it against the table, not against a filesystem callback, and the
+ class of bug `ad` shipped is unrepresentable.
+ ]
+]
+
+#entry("9P-17", "Clamp Rread to the client's count, or Linux hard-fails the read", state: "decided", tags: ("protocol", "correctness", "trap"))[
+ A trap with no analogue in FUSE, which is why nobody looks for it.
+
+ #ev("linux/net/9p/client.c:1475-1479")[`if (rsize < received) { pr_err("bogus RREAD count"); *err = -EIO; }` — an Rread longer than the `count` asked for is a HARD ERROR, not a truncation.]
+ #ev("~/05-genizah/ad/crates/ninep/src/fsys/event.rs:116-122")[`ad` clamps replies to `msize` (`protocol.rs:1011-1018`) but never to `count`, and its `event` file concatenates every drained event and returns the lot. Under a kernel mount that is `-EIO`.]
+
+ #verdict[
+ Every reply path clamps to `min(count, msize - 11)`. This interacts with a
+ decision already made the other way: `acmefs` refuses a read smaller than
+ one event record with `EINVAL`, on the grounds that half a record silently
+ desynchronises a client (`docs/acme-fs.md`). That rule stays and is now
+ load-bearing for a second reason — it is what makes "clamp to count"
+ safe on `event`, because a record is either whole or refused.
+ ]
+]
+
+#entry("9P-18", "The spec-bug checklist, taken from someone else's git history", state: "open", tags: ("testing", "gift"))[
+ `ad` shipped and then fixed each of these. They are the test list for
+ `src/ninep.zig`, in roughly the order they will bite.
+
+ #ev("~/05-genizah/ad/crates/ninep/src/sansio/server.rs:335-338")[the scar they left in the source: «Spec: first element failure must be Rerror, not Rwalk with zero qids».]
+
+ Partial walks (`09334ce`); `Tcreate` of `".."` (`bcb05d9`); write-then-remove
+ (`1c8ae96`); create permission masking (`3ccc461`); open with truncate
+ (`f0c71be`); remove-on-close (`6471cd3`); the root qid's name being `"/"`
+ (`64f2f4b`); the default `aname` (`b3a2c8b`, `ce7ebc6`); exclusive files
+ (`135190c`); clearing the fid cache on clunk (`113b053`); fid open state
+ (`22041ab`); flush tracking (`7170e6a`, `5a6b5bf`).
+
+ #q[Most of these are `Tcreate`/`Tremove` bugs, and a synthetic tree could simply answer `Rerror` to both — the draft's §9 already argues our trees are invented and have no cases we did not choose. Does `new/` need real `Tcreate`, or is walk-to-create enough as it is under FUSE?]
+]
+
+#entry("FIX-2", "The board's free-heap comment is two refactors out of date", state: "decided", tags: ("comment", "board", "one-line"))[
+ #ev("src/esp32p4.zig:470-473")[says 9,128 bytes free at 80×24. That was measured before `direct_emit` (`:870`) sized vaxis's two shadow grids to one cell.]
+ #ev("05-zig-p4/experiments/report.typ:848-849,877-880")[the heap now reports ≈336 KB free, and `.bss` in the 240 KiB low L2MEM is the binding constraint instead.]
+
+ #verdict[
+ Fix the comment and name the new constraint, because the stale number was
+ load-bearing in an architecture review — it is the reason a 9P server on
+ the board looked impossible when it costs 2.5% of free heap. A stale
+ measurement in a comment is worse than none: it gets quoted.
+ ]
+
+ #q[The `report.typ` figure of "336 KB at every geometry tried" cannot be literally true across the 62,480-byte swing between 56×14 and 80×24 at 55 B/cell. One boot and one `MARK PARDES_HEAP` line closes it.]
+]
+
+#pagebreak()
+
+= Direction
+
+The four entries below were written after the owner rejected the first
+synthesis as directionless and stated the goal himself: *"make it overall
+simpler, and more transparent to scripting and integrating over the network,
+and that could unlock some interesting things like chaining a pardes to
+another — right now these kinds of things are complex and ad hoc."*
+
+Three of those four are achievable. One is not, and `9P-20` says which.
+
+#entry("9P-19", "Bidirectional 9P is one role per CONNECTION, not one role per message", state: "decided", tags: ("protocol", "mechanism", "correction"))[
+ The owner's objection to the first review: *"you said the server can't
+ initiate a message, but since the daemon and the app both could have a client
+ and a server, they could communicate bidirectionally."* He is right, and the
+ mechanism is better than either of us described.
+
+ The tempting reading is a double-role link: both ends run a server and a
+ client on one descriptor, demuxing on the message-type parity, which 9P makes
+ possible for free.
+
+ #ev("u9fs/fcall.h")[`Tversion = 100, Rversion, Tauth = 102, Rauth, …` — T is even, R is odd, so a receiver can always tell a request it must serve from a reply it is owed.]
+
+ That reading is available and *nobody has ever built it*.
+
+ #ev("drawterm-9front/exportfs/io.c:132-163")[`io()` is a pure server loop: read a T-message, dispatch through `fcalls[type]`, reply. It never emits a message that is not a reply. Plan 9's `exportfs` is the same (`exportfs.c:510-515`), u9fs is the same, and v9fs and `devmnt` are pure clients. No implementation anywhere demuxes two roles on one descriptor.]
+
+ What ships instead is better, because it needs no invention at all:
+
+ #ev("drawterm-9front/cpu.c:119-192")[drawterm DIALS OUT to the cpu server, writes a shell script, and then calls `exportfs(fd, fd)` — *the dialer becomes the server on the socket it dialed*.]
+ #ev("principia-softwarica/networking/misc/cpu.c:448,459-463")[the script the remote runs is `mount -nc /fd/0 /mnt/term`, then it opens `/mnt/term/dev/cons` as its stdin and stdout. The remote is the CLIENT.]
+ #ev("drawterm-9front/kern/devdraw.c:1323-1326")[so when the remote application draws, the bytes cross as *the application's `Twrite`* to `/dev/draw/N/data`, and the terminal — the machine with the screen — executes them as a server.]
+
+ One descriptor, roles fixed per side, data flowing both ways. There is no
+ server push because none is needed: *the frame producer is the client.*
+
+ #verdict[
+ The rule for pardes, and it is the whole architecture in three lines:
+
+ / display: #[the FRONTEND is the server (it owns the screen), the CORE is the client. The core `Twrite`s frames to `screen` and blocking-`Tread`s `input`. This is `cpu` verbatim.]
+ / session: #[the CORE is the server (it owns the windows), scripts and other instances are clients.]
+ / devices: #[the BOARD is the server (it owns the memory and the pads), the core is the client.]
+
+ Three connections, one role each, and the program contains both halves. Do
+ NOT build a double-role single-descriptor link: no precedent, a `Tversion`
+ ordering hazard where each side must answer the peer's version while
+ awaiting its own, and `trans=fd` wants separate read and write descriptors
+ anyway.
+ ]
+
+ #note("mechanism", "2026-08-27")[
+ Two numbers that make the display path credible, both of which the first
+ review got wrong by assuming a push. Chunking is a non-issue: payload per
+ message is `msize − P9_IOHDRSZ` = 131,072 B at Linux's default
+ (`client.h:23`, `9p.h:364`), so every real frame — 20 B, a 56 B keystroke
+ diff, a 6,298 B full board frame, a 27 KB desktop frame — is ONE `Twrite`,
+ and even the 1,703,936 B worst case is 13. And the core need not block:
+ tags are allocated per outstanding request with no in-order reply
+ requirement (`client.c:194-199`, `devmnt.c:783-800`, and `exportfs`
+ deliberately answers out of order via slave procs, `exportsrv.c:408-520`).
+ pardes needs no slave procs — `Status.again` plus the park table is the
+ same thing done single-threaded.
+ ]
+
+ #note("mechanism", "2026-08-27")[
+ Copy the file discipline rather than inventing one. `/dev/draw/N/data` is a
+ write-only batched binary command stream (`devdraw.c:1323-1326`) with an
+ exclusive `ctl` (`:1047-1051`); `/dev/mouse` is an exclusive single-reader
+ file whose read blocks until something happens (`devmouse.c:96-98,150-158`).
+ That is exactly `screen` and `input`, already designed, already debugged,
+ and already the shape `event` has in `acmefs`.
+ ]
+]
+
+#entry("9P-20", "\"Simpler\" is false in lines and true in concepts — argue reach instead", state: "decided", tags: ("cost", "honesty", "framing"))[
+ The owner's first criterion was that 9P make the codebase *overall simpler*.
+ Measured, it does not. This entry exists so that nobody argues otherwise
+ later, including us.
+
+ The baseline: *nine* IPC mechanisms, *four* framings, *three* discovery
+ schemes, *two* version-negotiation schemes and one mechanism with none.
+
+ #ev("src/nested.zig")[743 lines to deliver ONE line. By region: 255 transport, 158 ancestor discovery, 43 `sendLook`, 232 tests, 30 header — and the FEATURE is ≈12 lines (`:301-306` format, `:507` filter). Ratio feature-to-transport ≈ 1:24.]
+ #ev("src/detached/wire.zig")[contains ZERO syscalls — no `socket`, `bind`, `accept`, `poll`, `read` or `write` anywhere. It is a pure codec into caller buffers, so calling 789 of its lines "transport" was wrong: they are the definition of what may be said. 9P replaces its 5-byte length prefix with a 4-byte one.]
+
+ Six concepts are genuinely duplicated across the mechanisms, and the
+ duplication is real: endpoint-path derivation 82 lines across 6 copies;
+ directory create-and-vet 105 across 6; stale sweeping 112 across 3;
+ bind/listen/accept 213 across 3; "which instance?" 231 across 3; version
+ negotiation 56 across 2. *799 lines, of which ≈330 is recoverable.*
+
+ #ev("src/fs_service.zig:37-55")[the comment there is right on the facts and wrong on the conclusion: `parentFrom` and `nested.socketDir` differ ONLY by appending `/pardes` under `$XDG_RUNTIME_DIR` and coincide verbatim under the HOME fallback. And there are two liveness oracles for one question — `kill(0)`/ESRCH at `nested.zig:401` and `fuse.zig:742`, `connect`/ECONNREFUSED at `server.zig:1940-1942`.]
+
+ #verdict[
+ Could delete ≈850 lines (`nested.zig`'s transport and its socket tests,
+ 518; the net collapse of duplicated plumbing, ≈330). Could not delete
+ ≈7,700 — `fuse.zig` 2,709 (`9P-13`), `wire.zig` 1,705, the daemon's host
+ half, the 158-line ancestor walk that answers a question 9P has no message
+ for, and every kernel interface that was never a protocol choice (inotify,
+ pty, pipes, `mkstemp`).
+
+ Against a 1,600-1,900-line 9P stack (`9P-15`): *net growth of +750 to
+ +1,050 lines.*
+
+ So: strike "simpler" from every argument. What IS true, and is worth
+ saying, is that the CONCEPT count falls — four framings to two, three
+ discovery schemes to two, two version schemes to one, six duplicated
+ concepts to one copy each. Fewer ideas, more lines. Say exactly that.
+ ]
+
+ #note("contra", "2026-08-27")[
+ The strongest form of the objection, and it should stay on the record: the
+ three mechanisms do not share a problem. `nested`'s format is twelve lines
+ *because* it reuses a language pardes already speaks — the file says so at
+ `:14-16`, "no serialization, nothing to version". `wire`'s codec is 1,167
+ lines because it carries a 1.6 MiB worst-case RLE grid at one message per
+ frame. `acmefs` is 2,324 because it is acme's semantics. The general thing
+ costs ≈970 lines before saying anything specific to pardes, and each of the
+ three still needs its specific part afterwards.
+ ]
+]
+
+#entry("9P-21", "The lost compile-time invariant, which nothing else prices", state: "open", tags: ("cost", "correctness", "unpriced"))[
+ The best objection found against the whole direction, and no other entry
+ costs it.
+
+ #ev("src/detached/wire.zig:23-27")[«every union and every enum gets a tag chosen HERE … so reordering `Event` or `CellStyle.ul` cannot silently redefine the protocol. The mapping switches are exhaustive: adding a variant to the core is a compile error in this file, which is the point of them.»]
+ #ev("src/detached/wire.zig:1199-1265")[and a test enumerates the eleven legal client tags by name under the rule "A HUMAN DID IT", asserts the enum holds exactly those, and asserts the other 250 tag bytes answer `BadTag` — so a frontend built before a change cannot forge a pane's output into a session built after it.]
+
+ Under a `ctl`-file grammar, input arrives as `Twrite` payload bytes. 9P's
+ codec validates 9P; it cannot validate that a byte string is a legal `key`.
+ An exhaustive switch checked by the compiler and a 250-byte refusal test
+ become a runtime string parse with nothing to bind to.
+
+ #ev("src/host.zig:44-52")[a related loss: fourteen of twenty-one vtable methods are `push_` and return nothing, because "a push with an answer would have N answers and no way to pick one". 9P answers every T with an R, so each acquires a tag, a reply, and a matching obligation.]
+
+ #q[Three candidate answers, none costed yet. (a) Keep the typed wire for input and use 9P only for control and text — accepts two protocols forever. (b) Make the `ctl` grammar generated from the same enum, so the exhaustive switch survives as a parser generator and adding a variant is still a compile error. (c) Accept the loss and buy it back with a fuzz test over the grammar. (b) looks best and nobody has tried it.]
+]
+
+#entry("9P-22", "The real gap: pardes cannot ask another pardes anything", state: "decided", tags: ("direction", "motivating-case"))[
+ This is the entry the whole file was missing, and it is the owner's own
+ criterion stated precisely.
+
+ Two instances of pardes can do exactly two things to each other today.
+
+ / Shout: #[`nested.sendLook` formats `Look <path>`, connects, writes, returns `true`, and NEVER READS (`src/nested.zig:294-330`). The receiver filters for the one legal verb and closes (`:466-510`). The test at `:652-702` exercises the whole protocol and asserts that nothing comes back.]
+ / Become: #[`Attach` is not a connection, it is a replacement. Only on a successful handshake does the frontend reap its pane shells, close its watches, unmount `--fs`, *deinit the core*, and enter the thin loop (`src/detached/client.zig`, `docs/detached.md:207-213`). The local core is destroyed, not linked.]
+
+ Everything else is out of reach. Nothing in `src/` reads `PARDES_FS`; `Event`
+ has no variant for a peer; `Host.VTable`'s 21 methods have no peer method.
+ Reading another session's text is possible only by shelling out —
+ `Exec cat /run/user/1000/pardes/<other-pid>/1/body` — which is Linux-only,
+ opt-in behind `--fs`, requires already knowing the pid, and is mediated
+ entirely by `/bin/sh`. And it fails outright against a detached session,
+ which serves no filesystem at all (`src/detached/server.zig:566-570`).
+
+ #verdict[
+ *The direction is a reply.*
+
+ pardes has no request/response channel to anything that is not the kernel.
+ `nested` is one-way by construction. The detached wire forbids a round trip
+ inside `update` by design (`wire.zig:64-70`). `acmefs` has request/response
+ and cannot leave the machine (`fuse.zig:64`, `9P-13`).
+
+ 9P is a request/response protocol that crosses machines, and that single
+ property is what the other three cannot be extended to have. Every good
+ thing on the list — scripting from macOS, driving a session over a network,
+ reading the board's memory, chaining one instance to another without
+ destroying either — is the same feature: *being able to ask, and get an
+ answer back.*
+
+ That is the justification. Not simplification (`9P-20` forbids it), not
+ elegance, and not symmetry.
+ ]
+
+ #note("direction", "2026-08-27")[
+ The measure of success, so this can be checked rather than admired: after
+ the work, `Attach` should have a sibling that CONNECTS instead of
+ replacing — two live cores, each able to walk the other's tree — and the
+ twelve-line `Look` sentence should be a `Twrite` to the other instance's
+ `new/` on a connection that can also answer. If those two things are not
+ true at the end, the protocol was adopted for its own sake.
+ ]
+]
+
+#entry("9P-23", "The host vtable stays; 9P is one implementation of it, for remote hosts only", state: "decided", tags: ("architecture", "scope-limit"))[
+ The grand version of the direction was that `Host.VTable` becomes a tree the
+ core mounts, so that the downward seam and the outward seam are the same kind
+ of thing. Measured, that is wrong for every LOCAL host and right only for
+ remote ones.
+
+ #ev("src/host.zig:52-140")[the seam is 21 methods: 15 `push_` and 6 `pull_`, with the prefix compiler-enforced (`isPull`, `:265-274`) — a `push_` returning non-void does not build.]
+
+ The blocking objection turns out to be nearly empty. Of the six `pull_`
+ methods, three are ALREADY asynchronous — `pull_read_clipboard`, `pull_lsp`
+ and `pull_pipe` return `void` and their answers arrive as `Event.paste`,
+ `.lsp_resp`, `.pipe_resp` (`src/pardes.zig:6896-6907`). `pull_wait_input`
+ cannot become an event because it is how events arrive. That leaves two:
+ `pull_gpio_toggle`, whose answer is only used to set a message row
+ (`board_memory.zig:422-428`), so deferring it a frame is invisible; and
+ `pull_tty_taken`, which is reached from `takesCommandLine`
+ (`pardes.zig:6465-6469`) at four call sites, two of which loop over all 16
+ panes — 16 probes per call, collapsing to ONE read if `proc` is a single file
+ of sixteen lines rather than `proc/N/status`.
+
+ So the core could be a 9P client without blocking. It should not be, locally:
+
+ #ev("per-host measurement")[bodies replaced by an estimated 9P server, per host: tty 345 → ≈225 but *+204* with the tree and `ctl` grammars; gui *+204*; detached server *+182*; macos *+164*; web *+142 Zig plus ≈500 lines of JavaScript*; board *+59* and 8,832 B of RAM. *No host gets simpler.*]
+ #ev("src/web.zig:356-391")[and web gets actively worse than the count shows: `present` today writes a flat `WebCell` array that JavaScript reads *directly out of wasm memory* across four `extern` declarations (`:330-340`). Under 9P that zero-copy array becomes an RLE stream JS must decode, and wasm32 has no sockets, so JS must carry a 9P codec too.]
+ #ev("src/esp32p4.zig:127")[the board is worse still in kind: its "host" is the same process behind a C-ABI function pointer, so a `Twrite`/`Rwrite` pair per frame is pure protocol overhead against a direct call.]
+
+ #verdict[
+ `Host.VTable` is already the abstraction — `docs/design.typ:1964-1965` says
+ the surface IS the abstraction and refuses a render layer over the shells.
+ A tree would be that layer.
+
+ So: a 9P host is *one more implementation of the existing vtable*, chosen
+ when the display is on the other end of a wire, exactly as `9P-2` makes a
+ 9P transport one more implementation of the filesystem seam. Local hosts
+ keep the direct call and pay nothing. The two seams stay two seams; what
+ they gain is a second backend each.
+
+ That also settles `9P-19`'s scope: the core is a 9P client of its display
+ only when the display is REMOTE — a detached frontend, or a board acting as
+ a terminal for a full core elsewhere. Never for the window in front of you.
+ ]
+
+ #note("correction", "2026-08-27")[
+ Fact correction for anything quoting the earlier tally: `tty` and `gui`
+ fill *19* of 21, not 20 — `src/detached/server.zig:558-559` already says
+ "NINETEEN each". The 20 figure was wrong by one and is superseded here.
+ ]
+]