// 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)) ] 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) *Historical decision record.* Entries retain their original source references and states. For the current filesystem and transport interface, use `docs/fs.md`; FUSE and the proof-of-concept examples are no longer supported. #context { let es = query().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` 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. ] #note("built", "2026-08-27")[ Built, and the carry-over entry turned out to be unnecessary. u9fs needs one because `readdir(3)` has already consumed the entry it could not fit; `acmefs` re-stages the whole listing from an index on every call and says why (`src/acmefs.zig:1013-1016`), so an entry that does not fit is simply not counted and the next read asks for it by index. The server keeps a per-fid PAIR — a byte cursor for the client's rule and an entry index for the core's — advanced together. That removes ≈300 bytes per fid and a class of staleness bug. The ≈40-line budget held. ] ] #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-`, 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.] #note("unblocked", "2026-08-27")[ *Reopen this.* The deferral rested on one objection and steps 4 and 5 dissolved it without meaning to. The objection was that a proxied `Twalk` cannot be answered until the remote `Rwalk` arrives, so the router needs per-tag continuations, tag remapping, flush forwarding and fid invalidation — machinery a core that must not block has nowhere to put. Three of those four now exist as shipped primitives: #ev("src/9p.zig")[`Server.retry()` re-offers a parked request oldest-first and `reply()` RE-PARKS it when the answer is still `.again`. The park table is therefore a continuation store that already survives across frames, and it is the one the FUSE mount has used all along.] #ev("src/9p.zig")[`Client` is sans-io: `submit()` hands back a tag and never waits, `take()` returns a completed operation or null. Its tag table is indexed BY the tag, so attributing an out-of-order reply is one bounds check — which is the remapping the objection was about.] So a proxy is now a loop, not a subsystem: `Server.next()` gives a request, `Client.submit()` forwards it, the answer is `.again`, and each frame `Server.retry()` offers it back until `Client.take()` completes and the real reply goes out. Nothing blocks, nothing is added to the core, and the daemon's poll set grows by one descriptor per upstream. What is still genuinely missing is fid invalidation on a connection that dies mid-walk, and a decision about whether `Tflush` forwards or is answered locally. Both are small and neither is architectural. Re-cost before building: the "several hundred lines" the draft claimed was wrong in the other direction too. Measure it against the ≈200 lines this loop looks like, and against `src/fs9_client.zig`'s 622, which already does the connect-and-pump half. ] ] #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 (`board9p.Header`). 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/builtins.zig`, `Board` namespace). 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 (`builtins.Board.readWord`, `builtins.Board.gpio`, `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.] #note("built", "2026-08-27")[ *The RAM figure above is wrong and the built one is 21,776 B, not 8,832.* Measured from the real structs: `Fid` is 64 B × 32 = 2,048, `Slot` is 208 B × 32 = 6,656, and the whole `Server` is 9,488 B before buffers; a 4,096 msize adds `in` 4,096 + `out` 8,192. Three reasons, all of them things the estimate did not know. `out` is TWO msize — one message being written, one being built — which is what makes every reply infallible and removes "can I write yet" from the whole file. A fid entry is 64 B and not 16, because `Rstat` carries a NAME that a node id does not, plus the open handle and the two-coordinate cursor. And the estimate did not cost the park table at all, which is 6,656 B of the total. It still fits with room: 6.5% of the board's ≈336 KB free heap, and about 11 KB in total at the 512-byte msize Plan 9 accepts. A test bounds `Fid` and `Slot` so that a change to either shows up as a diff in the board's budget rather than as a surprise on the die. ] ] #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?] #note("built", "2026-08-27")[ *Answered, and most of the list is moot.* `new/` creates a pane on WALK (`src/acmefs.zig:901-919`), so the capability exists and is not spelled `Tcreate`. The server answers `Rerror` to both `Tcreate` and `Tremove`, which takes the create-permission-masking, create-`..`, remove-on-close and write-then-remove bugs off the table entirely — four of `ad`'s twelve. `Tremove` still clunks the fid first, because remove(5) says the fid is invalid even if the remove fails. Of the rest, the partial-walk rule and the flush tracking are implemented and tested by name; the root qid's name is `"/"`; the fid cache is cleared on clunk and the release the core is owed is issued there. ] ] #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 `, 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//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 (`builtins.Board.gpio`), 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. ] ] #entry("9P-24", "A dead descriptor left in a poll set is a whole core, forever", state: "landed", tags: ("bug", "lesson"))[ Found by an adversarial pass over step 1 after it was committed and after the happy path had been demonstrated with the project's own example clients. It is recorded because the shape recurs, not because the fix was hard. Step 1 put `/dev/fuse` in the daemon's `poll(2)` set whenever the mount existed, and gave `Source.fuse` an empty arm on the grounds that being in the set was the whole point. #ev("linux/fs/fuse/dev.c")[`fuse_dev_poll` answers `EPOLLERR` once the connection is gone — and POSIX reports `POLLERR` whatever the `events` mask asked for, so an empty arm cannot decline it.] #ev("src/fuse.zig")[`pollLoop`, the mount's own poll thread, has carried `if (revents & (ERR|HUP|NVAL) != 0) return;` all along. That is why the tty and SDL shells never showed this and the daemon did: they never poll the descriptor themselves.] So an external `fusermount3 -u`, a sysfs abort, or systemd taking `/run/user/$UID` away at final logout — *exactly the moment a detached session is supposed to keep running* — made `poll(2)` return instantly and forever. Measured on the committed change: 0 CPU ticks over 10 s idle, then 1000 ticks over the next 10 s. After the fix, 0 ticks over 8 s in the same scenario, with the process alive and in state `S`. #verdict[ Gate the insertion on `!f.dead` and let the arm consume `POLLERR`/`POLLHUP`/ `POLLNVAL` by marking the mount dead. Both, not either: the gate is what ends the spin, and the arm is what saves the one spinning round before `Fs.next`'s first failed read would have set the flag anyway. The general rule, which is the reason this entry exists: *a descriptor added to a shared poll set needs an error arm even when it needs no data arm.* An empty arm is a decision about `POLLIN` only, and the kernel does not ask permission before reporting `POLLERR`. ] #note("review", "2026-08-27")[ Worth noticing how it was caught. The feature was demonstrated working — `pardesctl panes`, `new`, `send`, `body`, `del` against a live daemon — and the bug was nowhere near the happy path. It took an adversary told to *assume the happy path works and look elsewhere*, who then went and measured `/proc//stat` before and after an unmount. Four of that pass's other seven findings were false comments rather than false code, which in this codebase is the same severity: the comments are how the next change is made. ] ] #pagebreak() = After the build Five steps shipped, and the shape of what is left is not the shape the note predicted. These entries are written from the tree as built, not from the plan. #entry("9P-25", "The tree behind the server is pluggable, and that was not planned", state: "decided", tags: ("architecture", "windfall"))[ `Server` is `pub fn Server(comptime fs: type) type`, duck-typed on exactly `fs.Req`, `fs.Reply` and `fs.Reply.Attr`. That shape was chosen for a boring reason — importing `acmefs.zig` drags `pardes.zig` into `zig test` — and it turned out to be the most useful thing in the file. #ev("src/acmefs.zig")[filesystem one: the acme control tree, 9 ops.] #ev("src/board9p.zig")[filesystem two: 867 lines that re-declare the same `Op`, `Status`, `Req`, `Reply` and `handle`, and are served by the same `Server` with no translation layer at all.] So "serve X over 9P" is no longer a protocol question. It is: write a `handle()` over nine operations, and get a wire, a fid table, directory cursors, `Tflush`, error strings and both freestanding targets for free. #verdict[ Treat `Server(fs)` as the extension point it accidentally became, and say so where someone will look. Candidates that are now cheap and were never on a list: the LSP surface as a tree, a session dump as a tree, the config as a tree. None of them needs a line of 9P. The discipline that keeps this from becoming a plugin system: nine operations and no tenth. A filesystem that wants a tenth wants an API. ] ] #entry("9P-26", "What is cheap now, ranked, and step 6 is not first", state: "open", tags: ("sequencing", "next"))[ Step 6 — a remote display serving `screen` and `input` while the core is its client — is cheaper than it was, because its one hard prerequisite is done: `Client` exists, is 152 bytes, and blocks nowhere. And `board9p` proved that writing a second filesystem behind `Server(fs)` is a day's work, which is exactly what a `host` tree would be. But four things are now cheaper than step 6 AND serve the stated goals more directly. Ranked by value over cost: / A serial transport for the client: #[the board image exists and speaks 9P on UART0; `src/fs9_client.zig` speaks unix sockets. One transport away from the motivating case being real, and it is the smallest item here.] / TCP: #[`fs9_service` and `fs9_client` are `AF_UNIX` only. "Integrating over the network" was one of the four stated goals and it is currently a socket family, not a design problem.] / macOS and the browser: #[step 4's entire portability argument — that a 9P server needs no kernel — is UNEXERCISED. Nobody has run `9pfuse` against the socket on macOS, and the web build has no client. This is the payoff that justified the growth in `9P-20`, and it is closer to zero code than anything else on the list.] / Aggregation: #[unblocked, see `9P-10`'s note. Serves "chaining a pardes to another", which is the goal `9P-22` named as the whole point.] #q[Step 6 also changed SHAPE, and the note should be rewritten before it is built. It was sold as "the board stops being a shrunken pardes and becomes a terminal for a full core". What got built is the INVERSE: the board is a 9P server of its own devices and the desktop is the client. Both are useful and they are different images. Which one is step 6 — and is a board that shows a remote core's screen worth an image, now that a board that exposes its pins is already flashed?] #note("scope", "2026-08-27")[ Unchanged by any of this: `9P-23`'s measurement. Local hosts keep the direct vtable call, because a tree costs tty and gui about +204 lines each and the web shell +142 Zig plus ≈500 JavaScript. Step 6 is remote displays only, and the moment it is argued for the window in front of you, that number is the answer. ] ] #entry("9P-27", "What three adversaries found in steps 3, 4 and 5", state: "landed", tags: ("bug", "lesson", "review"))[ Steps 1 and 2 were reviewed and the pass found a 100%-CPU spin in the smallest change of the chain (`9P-24`). Steps 3, 4 and 5 — 1,005, 9,158 and 3,535 line diffs — were verified on the happy path and shipped unreviewed. Three agents, one per step, told to assume the happy path works and look elsewhere. Eight defects. Six fixed, five reproduced with measurements before and after. / Remote crash of the whole daemon: #[`Server.push` takes nothing once `startFrame` gives up on the framing, and `fs9_service.fill` asserted it took everything. One `size[4]` of zero plus one later byte reached `unreachable` — every pane, every frontend and the FUSE mount gone. The same stuck buffer separately made `room == 0` return without reading while poll reported ready forever: *99.7% of a core*, in one `write(2)`, from any process with the uid.] / A 177-second freeze of the editor: #[`fs9_client.connect` ran `connect(2)` on a still-BLOCKING socket, before `setNonblock` and before the deadline existed. On AF_UNIX a full accept backlog waits in `unix_wait_for_peer` for `sk_sndtimeo`, which is forever, and the core is single-threaded. Measured at *177.3 s*, ending only because the peer was killed. Now 2.03 s, the budget.] / A 64 KiB pty read wiping the queue: #[`queue_cap` is 65536 and a record must fit in `queue_cap - 4`, but every host reads a pty master with a 64 KiB buffer and a single read really returns 65536 on Linux. The eviction loop then emptied the queue and dropped the new record too, silently. Deterministic, not a race.] / Four silent sockets denying `--fs9` forever: #[nothing took a slot back, and there are four. `version(5)` requires `Tversion` first and `msize == 0` already meant "has not versioned", so the frontend transport's own five-second rule applied unchanged.] / EMFILE spinning a core: #[`accept` treated every failure as EAGAIN, but EMFILE leaves the connection in the backlog and poll is level triggered. *99.8% of one core.*] / `max_fids = 32` refuting the step's own acceptance clause: #[a mounting client keeps a fid per cached inode, and `docs/9p.typ` §12.4 makes `9pfuse` the proof. At 32, `find` produced *57 consecutive `Rerror`s*. Now 256 for a host and `board_fids` 32 for the board.] #verdict[ Three of the six are the SAME defect as `9P-24`: a descriptor left in a level-triggered poll set with no error arm, or a wait with no deadline. That is now four instances across five steps, every one of them a whole core or a frozen editor, and every one written by someone who had just read the previous one. So make it a rule rather than a lesson: *every descriptor this project adds to a poll set needs an error arm, and every wait needs a deadline set before the thing it is waiting on can begin.* Both are checkable by reading, and both were missed by authors who knew the rule. ] #note("method", "2026-08-27")[ Two observations about the reviewing, worth more than the bugs. The instruction that worked was *"assume the happy path works — it has been demonstrated live — and look everywhere else."* Every finding came from somewhere the demonstration could not reach: a peer that stalls, a mount that is torn down, a buffer at exactly its limit. The demonstrations were all real and all passed, and none of them would ever have found any of this. And the reviewers were told to *measure*. Every serious finding arrived with `/proc//stat` before and after, or a wall-clock figure, or a count of consecutive errors. A report saying "this could spin" would have been argued with; "998 jiffies per 10 s against a 0-jiffy baseline, here is the script" could not be. The cost of asking for that was a reviewer that took forty minutes instead of ten. ] #q[Two findings are NOT fixed and should be. A parked `pty/data` read whose client is gone keeps consuming the queue destructively — measured as 34% of a pane's output going nowhere on a long-running daemon, with the trigger not isolated in 23 attempts. And a `Tclunk` does not sweep park slots on the fid it retires, so a `close(2)` with a read in flight strands the tag forever; 32 of those and the connection can never serve a blocking read again. Both are reachable by ordinary client behaviour.] ]