summaryrefslogtreecommitdiff
path: root/docs/registry.typ
blob: 597e930f952d84411ae94dbab2eef1cb88397a2c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
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.
  ]
]