a73x

86beb855

Remove the layer and folder-edge enforcement; keep the source bans

a73x   2026-08-31 15:12

Commit message
Remove the layer and folder-edge enforcement; keep the source bans

Connecting a client to a daemon is the one operation this product exists
to perform, and the daemon's tests hand-roll it at 175 sites because no
callable primitive exists where they can reach one. Every home for a
~40-line dial module was refused by the build rather than by the design:
term owns the attach encoders and is wasm-pinned, so it cannot open a
socket; layer 0 may import nothing, so sockpath cannot compose
dial+attach; imports had to point STRICTLY downward, so a module shared
with client forced renumbering the whole graph; and folder rule 2 walled
the daemon's tests off from client.Transport.open. We were costing out a
175-site test sweep to avoid writing 40 lines of product. A gate that
makes the product change harder than the test rewrite is optimizing the
wrong direction, and its failure mode is not a bad edge sneaking in but a
good edge never being drawn — which nothing reports and no diff shows.

Stripped: ModSpec.layer and every .layer entry, layerOf, the comptime
layer-direction check, folder rules 1-3, and with them the Folder enum,
folderOf, FolderExemption, folder_exemptions and folderExempt.

Kept, because none of it is module architecture: the module table itself,
still the one place structure is written down and still the only way a
table-module import can exist — an edge is now an ordinary diff. Name-exists
validation of imports/test_imports. The test_imports twin mechanism, whose
subject is shipping, not graph shape. The wasm-closure check, reworded as
the mechanical fact it is: a flagged row compiles against
wasm32-freestanding, so an import without a .wasm twin cannot compile
there, and failing at the edge beats the twin loop's later @panic. And
source bans 4, 5 and 6, which pin behavior the import graph could never
see — no VT authored outside src/tui/, every program exec'd as argv, one
posix.fork site. checkFolderRules becomes checkSourceBans; the rules keep
their numbers, because files across the repo cite them in their own
`folder rule N exemption:` lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

CLAUDE.md
Old New
@@ -61,32 +61,27 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
61 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` | 61 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` |
62 | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link | 62 | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link |
63 63
64 `build.zig`'s `checkFolderRules` enforces it: engine and client name no tui, 64 The grouping itself is a convention now, not a gate: the table wires the
65 server or cli module; server names no client and no terminal; tui imports 65 build and says who owns what, and an import edit is an ordinary diff. What
66 client and never the reverse; and no line outside a `test` block under 66 `build.zig` still ENFORCES is `checkSourceBans`, which reads the PRODUCTION
67 `src/engine/` or `src/client/` spells `termios`, `isatty`, `tcgetattr`, 67 lines of every file under `src/` for bytes no import graph can catch. Rule 4:
68 `tcsetattr` or an escape byte without a `// folder rule 4 exemption:` line 68 no line outside a `test` block under `src/engine/` or `src/client/` spells
69 saying why — matched against a lower-cased line, so `\X1B[` is the same 69 `termios`, `isatty`, `tcgetattr`, `tcsetattr` or an escape byte without a
70 needle as `\x1b[`. Rule 5 is the product-wide one: no file under `src/` at 70 `// folder rule 4 exemption:` line saying why — matched against a lower-cased
71 all may spell `"/bin/sh"` or `"-c"`, because every program mux runs is 71 line, so `\X1B[` is the same needle as `\x1b[`. Rule 5 is the product-wide
72 exec'd as argv and no shell of ours parses a line we built; its three 72 one: no file under `src/` at all may spell `"/bin/sh"` or `"-c"`, because
73 markers are `flags.zig`, `main.zig` and `server_test_session.zig`, and two 73 every program mux runs is exec'd as argv and no shell of ours parses a line
74 of the three cover prose and a fixture rather than a shell the product runs. 74 we built; its three markers are `flags.zig`, `main.zig` and
75 `folder_exemptions` is empty and kept so; rule 4's three remaining debts are 75 `server_test_session.zig`, and two of the three cover prose and a fixture
76 the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each of which 76 rather than a shell the product runs. Rule 6 is the one `posix.fork` site,
77 produces VT bytes by contract. `predict.zig` sits under `src/tui/` with the 77 and it names its file in `except` rather than in the file. The three keep
78 rest of the wall, so `term` structurally cannot see the overlay. `spawn` 78 their numbers because the in-file `folder rule N exemption:` lines cite them
79 lives under `src/cli/` because it asks the OS whether it has a terminal, 79 by number. Rule 4's three remaining debts are the markers in `engine.zig`,
80 which rule 4 forbids a client module. 80 `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract.
81 81 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay
82 Layers are enforced in the same module table (grep `.layer =` for the graph). 82 lives beside the code that paints it and nowhere `term` would find it.
83 83 `spawn` lives under `src/cli/` because it asks the OS whether it has a
84 | Layer | Modules | 84 terminal, which rule 4 forbids a client module.
85 |---|---|
86 | 0 | `term` `pty` `quic` `proxy` `xdg` `sockpath` `cliflags` `spawn` `testtmp` + `script` `rawmode` `delaypipe` |
87 | 1 | `daemon` `client` `agent` + `render` `ptyclient` `wsclient` |
88 | 2 | `wall` `webhub` |
89 | 3 | `mux` — the dispatcher, and the only executable |
90 85
91 ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, 86 ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon,
92 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no 87 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no
build.zig
Old New
@@ -74,7 +74,7 @@ fn linkQuic(b: *std.Build, c: *std.Build.Step.Compile, deps: anytype) void {
74 74
75 /// Fail at build-graph-construction time with one clean line. The hygiene 75 /// Fail at build-graph-construction time with one clean line. The hygiene
76 /// invariants below are not opinions a gate step samples: a violation has 76 /// invariants below are not opinions a gate step samples: a violation has
77 /// to stop every build, the way the comptime layer checks do, so the graph 77 /// to stop every build, the way the comptime table checks do, so the graph
78 /// simply refuses to be built. 78 /// simply refuses to be built.
79 fn fatal(comptime fmt: []const u8, args: anytype) noreturn { 79 fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
80 std.debug.print("build.zig: " ++ fmt ++ "\n", args); 80 std.debug.print("build.zig: " ++ fmt ++ "\n", args);
@@ -86,24 +86,21 @@ fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
86 /// table-module import can exist except through this loop — violations 86 /// table-module import can exist except through this loop — violations
87 /// are impossible, not detected. (Dependency edges — ghostty — and 87 /// are impossible, not detected. (Dependency edges — ghostty — and
88 /// build_options are explicitly outside the table's jurisdiction and 88 /// build_options are explicitly outside the table's jurisdiction and
89 /// stay wired by hand.) Layers are the topological strata of the production 89 /// stay wired by hand.) A row is one owned COMPONENT rather than one file
90 /// graph, computed 2026-08-14 and re-stratified 2026-08-30, when the table 90 /// (re-grouped 2026-08-30): the root names the module and re-exports its
91 /// became one row per owned COMPONENT rather than one per file: the strata 91 /// child files, so a second row claiming one of those files is a compile
92 /// are four deep because the components are, and a row's children no longer 92 /// error about a file in two modules.
93 /// buy it a layer. FROZEN either way — a new import that would flatten or
94 /// invert a stratum fails at comptime, and re-stratifying requires editing
95 /// this table, which is the point.
96 const ModSpec = struct { 93 const ModSpec = struct {
97 name: []const u8, 94 name: []const u8,
98 path: []const u8, 95 path: []const u8,
99 layer: u8, 96 /// Production imports: what every instance of this row may @import, the
100 /// Production imports: must point at a strictly lower layer. 97 /// exes built from it included.
101 imports: []const []const u8 = &.{}, 98 imports: []const []const u8 = &.{},
102 /// Test-only imports (the testtmp pattern): granted ONLY to the row's 99 /// Test-only imports (the testtmp pattern): granted ONLY to the row's
103 /// test twin — the second module instance `addTest` compiles — so the 100 /// test twin — the second module instance `addTest` compiles — so the
104 /// production instance every exe and every importer sees has no such 101 /// production instance every exe and every importer sees has no such
105 /// module available. "Test scaffolding never ships" is therefore a 102 /// module available. "Test scaffolding never ships" is therefore a
106 /// compile error, not a convention. Excluded from the strata computation. 103 /// compile error, not a convention.
107 test_imports: []const []const u8 = &.{}, 104 test_imports: []const []const u8 = &.{},
108 link_libc: bool = false, 105 link_libc: bool = false,
109 /// Also instantiated against wasm32 (the browser core's twins). 106 /// Also instantiated against wasm32 (the browser core's twins).
@@ -113,7 +110,7 @@ const ModSpec = struct {
113 }; 110 };
114 111
115 const mod_table = [_]ModSpec{ 112 const mod_table = [_]ModSpec{
116 // ---- layer 0: imports nothing internal in production ---- 113 // ---- the leaves: they import nothing internal in production ----
117 // The terminal component, one row over four files: the wire contract, 114 // The terminal component, one row over four files: the wire contract,
118 // the authoritative engine, the daemon-side delta minting and the one 115 // the authoritative engine, the daemon-side delta minting and the one
119 // replay core, reached as `term.protocol`, `.engine`, `.delta`, 116 // replay core, reached as `term.protocol`, `.engine`, `.delta`,
@@ -124,56 +121,56 @@ const mod_table = [_]ModSpec{
124 // internal, which is what lets the tracker and the replay core be 121 // internal, which is what lets the tracker and the replay core be
125 // driven by a test holding an engine and no socket; and the whole 122 // driven by a test holding an engine and no socket; and the whole
126 // component is platform-free, so `mux_core.wasm` compiles it. 123 // component is platform-free, so `mux_core.wasm` compiles it.
127 .{ .name = "term", .path = "src/engine/term.zig", .layer = 0, .wasm = true }, 124 .{ .name = "term", .path = "src/engine/term.zig", .wasm = true },
128 .{ .name = "pty", .path = "src/server/pty.zig", .layer = 0, .link_libc = true }, 125 .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true },
129 // The QUIC vocabulary both ends share: the one @cImport of the vendored 126 // The QUIC vocabulary both ends share: the one @cImport of the vendored
130 // stack, the key, the wire constants, the egress ring. It has to be ONE 127 // stack, the key, the wire constants, the egress ring. It has to be ONE
131 // module — two @cImport blocks over the same headers are two distinct 128 // module — two @cImport blocks over the same headers are two distinct
132 // type universes, and `ngtcp2_vec`s cross between listener and client. 129 // type universes, and `ngtcp2_vec`s cross between listener and client.
133 .{ .name = "quic", .path = "src/quic.zig", .layer = 0, .link_libc = true, .quic_tests = true }, 130 .{ .name = "quic", .path = "src/quic.zig", .link_libc = true, .quic_tests = true },
134 // Test-only: short temp paths for the tests that bind unix sockets. 131 // Test-only: short temp paths for the tests that bind unix sockets.
135 // Imported by every module that has such a test, which is why it is a 132 // Imported by every module that has such a test, which is why it is a
136 // module rather than three copies. 133 // module rather than three copies.
137 .{ .name = "testtmp", .path = "src/testtmp.zig", .layer = 0 }, 134 .{ .name = "testtmp", .path = "src/testtmp.zig" },
138 // What the two scripted fixtures share: the escape table and the exit 135 // What the two scripted fixtures share: the escape table and the exit
139 // codes. One copy, so ptyclient and wsclient cannot disagree about 136 // codes. One copy, so ptyclient and wsclient cannot disagree about
140 // what a scenario's heredoc sent. 137 // what a scenario's heredoc sent.
141 .{ .name = "script", .path = "test/script.zig", .layer = 0 }, 138 .{ .name = "script", .path = "test/script.zig" },
142 // Test helpers, built as real binaries because that is how the suite 139 // Test helpers, built as real binaries because that is how the suite
143 // uses them: rawmode is a deterministic stand-in for an editor (nvim's 140 // uses them: rawmode is a deterministic stand-in for an editor (nvim's
144 // redraw timing is its own business and it is not installed everywhere), 141 // redraw timing is its own business and it is not installed everywhere),
145 // and delaypipe makes a slow round trip out of a shell pipeline instead 142 // and delaypipe makes a slow round trip out of a shell pipeline instead
146 // of out of netem and root. 143 // of out of netem and root.
147 .{ .name = "rawmode", .path = "test/rawmode.zig", .layer = 0 }, 144 .{ .name = "rawmode", .path = "test/rawmode.zig" },
148 .{ .name = "delaypipe", .path = "test/delaypipe.zig", .layer = 0 }, 145 .{ .name = "delaypipe", .path = "test/delaypipe.zig" },
149 // XDG-derived paths (key file, daemon log), shared by both binaries. 146 // XDG-derived paths (key file, daemon log), shared by both binaries.
150 .{ .name = "xdg", .path = "src/xdg.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} }, 147 .{ .name = "xdg", .path = "src/xdg.zig", .link_libc = true, .test_imports = &.{"testtmp"} },
151 // The socket path's identity and the right to bind it: the stale-socket 148 // The socket path's identity and the right to bind it: the stale-socket
152 // claim and the dev+ino record teardown compares against. A leaf — it 149 // claim and the dev+ino record teardown compares against. A leaf — it
153 // takes a path and nothing else, and knows no Server exists. 150 // takes a path and nothing else, and knows no Server exists.
154 .{ .name = "sockpath", .path = "src/sockpath.zig", .layer = 0, .test_imports = &.{"testtmp"} }, 151 .{ .name = "sockpath", .path = "src/sockpath.zig", .test_imports = &.{"testtmp"} },
155 // No imports that teach it anything, deliberately: the proxy is a byte 152 // No imports that teach it anything, deliberately: the proxy is a byte
156 // pump that knows nothing about the protocol it carries. `testtmp` is 153 // pump that knows nothing about the protocol it carries. `testtmp` is
157 // the one exception and does not weaken that — it hands its tests a 154 // the one exception and does not weaken that — it hands its tests a
158 // short directory to put a socket in and knows nothing about the bytes. 155 // short directory to put a socket in and knows nothing about the bytes.
159 .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} }, 156 .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .test_imports = &.{"testtmp"} },
160 // Reflection over a caller's options struct, so it imports nothing: the 157 // Reflection over a caller's options struct, so it imports nothing: the
161 // struct is the flag table and the parser learns it at comptime. 158 // struct is the flag table and the parser learns it at comptime.
162 .{ .name = "cliflags", .path = "src/cli/flags.zig", .layer = 0 }, 159 .{ .name = "cliflags", .path = "src/cli/flags.zig" },
163 // This image, as a path something can exec. Under src/cli/ because it 160 // This image, as a path something can exec. Under src/cli/ because it
164 // asks the OS about the process it is in — a question no headless 161 // asks the OS about the process it is in — a question no headless
165 // client may spell. 162 // client may spell.
166 .{ .name = "spawn", .path = "src/cli/spawn.zig", .layer = 0, .link_libc = true }, 163 .{ .name = "spawn", .path = "src/cli/spawn.zig", .link_libc = true },
167 // ---- layer 1: single-hop over the leaves ---- 164 // ---- single-hop over the leaves ----
168 // Replays a captured client stdout stream and prints the final grid in 165 // Replays a captured client stdout stream and prints the final grid in
169 // `mux d dump`'s formats — the client half of the M11 render-vs-dump 166 // `mux d dump`'s formats — the client half of the M11 render-vs-dump
170 // convergence check. Imports term so both sides of the diff go through 167 // convergence check. Imports term so both sides of the diff go through
171 // the same ghostty-vt and the same formatter. 168 // the same ghostty-vt and the same formatter.
172 .{ .name = "render", .path = "test/render.zig", .layer = 1, .imports = &.{"term"} }, 169 .{ .name = "render", .path = "test/render.zig", .imports = &.{"term"} },
173 // The pty-driving e2e fixture: real client on a pty slave, scripted 170 // The pty-driving e2e fixture: real client on a pty slave, scripted
174 // from stdin (M12). Imports pty so the product's own module is the one 171 // from stdin (M12). Imports pty so the product's own module is the one
175 // under it. 172 // under it.
176 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, 173 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .link_libc = true, .imports = &.{ "pty", "script" } },
177 // The command state machine, shell integration, the upgrade vocabulary 174 // The command state machine, shell integration, the upgrade vocabulary
178 // and the QUIC listener are CHILD FILES of this root rather than modules 175 // and the QUIC listener are CHILD FILES of this root rather than modules
179 // of their own, so nothing outside src/server/ can name one: a second 176 // of their own, so nothing outside src/server/ can name one: a second
@@ -185,7 +182,7 @@ const mod_table = [_]ModSpec{
185 // daemon itself when nobody handed it a --key — and for the shim 182 // daemon itself when nobody handed it a --key — and for the shim
186 // directory shell integration writes under the same 0700 policy. 183 // directory shell integration writes under the same 0700 policy.
187 // `pty` stays a row of its own: the ptyclient fixture consumes it. 184 // `pty` stays a row of its own: the ptyclient fixture consumes it.
188 .{ .name = "daemon", .path = "src/server/server.zig", .layer = 1, .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "quic", "xdg", "proxy" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 185 .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "quic", "xdg", "proxy" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
189 // The agent-facing client. It speaks frames and owns no terminal, which 186 // The agent-facing client. It speaks frames and owns no terminal, which
190 // is the whole point — it attaches at 0x0 and never claims the grid. 187 // is the whole point — it attaches at 0x0 and never claims the grid.
191 // The transport modules are the CLI client's, minus everything that 188 // The transport modules are the CLI client's, minus everything that
@@ -194,8 +191,8 @@ const mod_table = [_]ModSpec{
194 // wire contract and nothing else — no engine and no replica, muxa having 191 // wire contract and nothing else — no engine and no replica, muxa having
195 // nothing to draw: a fact of muxa.zig itself, which the one-row component 192 // nothing to draw: a fact of muxa.zig itself, which the one-row component
196 // no longer refuses on its behalf. 193 // no longer refuses on its behalf.
197 .{ .name = "agent", .path = "src/cli/muxa.zig", .layer = 1, .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags" }, .quic_tests = true }, 194 .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags" }, .quic_tests = true },
198 .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "term", "script" } }, 195 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } },
199 // Dialling, and what a chord means. The client is the only thing that 196 // Dialling, and what a chord means. The client is the only thing that
200 // predicts — the overlay is a local display decision and never becomes 197 // predicts — the overlay is a local display decision and never becomes
201 // state anybody else can see — but the predicting itself is interact's 198 // state anybody else can see — but the predicting itself is interact's
@@ -209,8 +206,8 @@ const mod_table = [_]ModSpec{
209 // sits here rather than in either front so the CLI wall and the browser 206 // sits here rather than in either front so the CLI wall and the browser
210 // hub resolve a host line the same way. Nothing here WRITES that file — 207 // hub resolve a host line the same way. Nothing here WRITES that file —
211 // `wall_host.recordHost` and `webhub_main` do. 208 // `wall_host.recordHost` and `webhub_main` do.
212 .{ .name = "client", .path = "src/client/client.zig", .layer = 1, .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 209 .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
213 // ---- layer 2 ---- 210 // ---- the two fronts ----
214 // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, 211 // The browser hub's HTTP/WebSocket decisions: Origin gate, route table,
215 // WS endpoint naming. Assets are injected (the exe root @embedFiles 212 // WS endpoint naming. Assets are injected (the exe root @embedFiles
216 // them), so its tests build no artifacts. It stays a row rather than 213 // them), so its tests build no artifacts. It stays a row rather than
@@ -218,11 +215,11 @@ const mod_table = [_]ModSpec{
218 // module's root directory is the dirname of its root file, and 215 // module's root directory is the dirname of its root file, and
219 // `@import("../client/webhub.zig")` from src/cli is "import of file 216 // `@import("../client/webhub.zig")` from src/cli is "import of file
220 // outside module path" — a compiler rule, not a table choice. 217 // outside module path" — a compiler rule, not a table choice.
221 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 2, .imports = &.{ "term", "client" }, .quic_tests = true }, 218 .{ .name = "webhub", .path = "src/client/webhub.zig", .imports = &.{ "term", "client" }, .quic_tests = true },
222 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of 219 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
223 // which can be ZOOMED — promoted to the terminal's size and typed 220 // which can be ZOOMED — promoted to the terminal's size and typed
224 // through. Same layer as webhub for the same reason — both sit on 221 // through. It sits beside webhub for the same reason — both are fronts
225 // client's Transport and `client.hosts`' grammar; neither may import the 222 // on client's Transport and `client.hosts`' grammar; neither imports the
226 // other; both resolve a spelling through `client.Target.fromSpec`. 223 // other; both resolve a spelling through `client.Target.fromSpec`.
227 // The interaction loop, the painter, the selector and the prediction 224 // The interaction loop, the painter, the selector and the prediction
228 // overlay are CHILD FILES of this root rather than modules of their own, 225 // overlay are CHILD FILES of this root rather than modules of their own,
@@ -232,8 +229,8 @@ const mod_table = [_]ModSpec{
232 // loop's alone, which the root never spells, and the painter takes 229 // loop's alone, which the root never spells, and the painter takes
233 // `term.engine` and `term.protocol`; the decoder and the key table they 230 // `term.engine` and `term.protocol`; the decoder and the key table they
234 // also want reach them through `client`'s seams. 231 // also want reach them through `client`'s seams.
235 .{ .name = "wall", .path = "src/tui/wallview.zig", .layer = 2, .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 232 .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
236 // ---- layer 3: the one binary ---- 233 // ---- the one binary ----
237 // Four words, one image — and one row: the daemon's entrypoint, the 234 // Four words, one image — and one row: the daemon's entrypoint, the
238 // client's and the hub's are CHILD FILES of the dispatcher, so a second 235 // client's and the hub's are CHILD FILES of the dispatcher, so a second
239 // module claiming any of them is a file-in-multiple-modules compile 236 // module claiming any of them is a file-in-multiple-modules compile
@@ -251,7 +248,7 @@ const mod_table = [_]ModSpec{
251 // downstream must refuse) and `sockpath` for the sun_path bound every verb 248 // downstream must refuse) and `sockpath` for the sun_path bound every verb
252 // checks before acting on a path. `testtmp` is the keygen round-trip's: 249 // checks before acting on a path. `testtmp` is the keygen round-trip's:
253 // it needs a directory to generate into, which the daemon never touches. 250 // it needs a directory to generate into, which the daemon never touches.
254 .{ .name = "mux", .path = "src/cli/mux.zig", .layer = 3, .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 251 .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
255 }; 252 };
256 253
257 /// Comptime row lookup. Every hand-written module name in this file goes 254 /// Comptime row lookup. Every hand-written module name in this file goes
@@ -264,118 +261,47 @@ fn idxOf(comptime name: []const u8) usize {
264 @compileError("module table: unknown module '" ++ name ++ "'"); 261 @compileError("module table: unknown module '" ++ name ++ "'");
265 } 262 }
266 263
267 fn layerOf(comptime name: []const u8) u8 {
268 return mod_table[idxOf(name)].layer;
269 }
270
271 comptime { 264 comptime {
272 // Both checks are O(edges x rows) name comparisons; the default 1000 265 // Both checks are O(edges x rows) name comparisons; the default 1000
273 // backwards branches does not cover a 21-row, 45-edge table (38 266 // backwards branches does not cover a 21-row, 45-edge table (38
274 // production imports and 7 test-only ones). 267 // production imports and 7 test-only ones).
275 @setEvalBranchQuota(20_000); 268 @setEvalBranchQuota(20_000);
276 for (mod_table) |m| { 269 for (mod_table) |m| {
277 for (m.imports) |dep| { 270 // Every edge names a real row, production and test-only alike, so a
278 if (layerOf(dep) >= m.layer) @compileError(std.fmt.comptimePrint( 271 // typo or a name left behind by a rename is a compile error here
279 "layer violation: {s} (layer {d}) imports {s} (layer {d}) — " ++ 272 // rather than the wiring loop's runtime `unreachable`.
280 "production imports point strictly downward; re-stratifying " ++ 273 for (m.imports) |dep| _ = idxOf(dep);
281 "is a deliberate edit to mod_table, never an accident", 274 for (m.test_imports) |dep| _ = idxOf(dep);
282 .{ m.name, m.layer, dep, layerOf(dep) }, 275 // A flagged row is also compiled against wasm32-freestanding, and
283 )); 276 // having a `.wasm` twin is exactly what says a module compiles
284 } 277 // there — so an import without one cannot. Mechanical rather than
285 // Test-only imports skip the direction rule but must name real rows. 278 // architectural, and failing here names the offending edge, where
286 for (m.test_imports) |dep| _ = layerOf(dep); 279 // the twin loop's later @panic could only say a module was missing.
287 // The wasm set must be closed under production imports: a twin can
288 // only import twins, so a row that gains an import from outside the
289 // flagged set fails here rather than at the twin loop's @panic.
290 if (m.wasm) for (m.imports) |dep| { 280 if (m.wasm) for (m.imports) |dep| {
291 if (!mod_table[idxOf(dep)].wasm) @compileError(std.fmt.comptimePrint( 281 if (!mod_table[idxOf(dep)].wasm) @compileError(std.fmt.comptimePrint(
292 "wasm row {s} imports {s}, which has no .wasm twin — the " ++ 282 "wasm row {s} imports {s}, which has no .wasm twin — a twin " ++
293 "wasm set must be closed under production imports", 283 "can only import twins",
294 .{ m.name, dep }, 284 .{ m.name, dep },
295 )); 285 ));
296 }; 286 };
297 } 287 }
298 } 288 }
299 289
300 /// The domain folders under `src/`. A module lives in the folder of the thing 290 /// The folders the doc gate walks. Listed rather than globbed: a new folder
301 /// that owns it, and this is what "owns" costs: an app that links `term` and 291 /// under `src/` is a decision about who owns what, and a glob would let one
302 /// `client` to paint its own way must be able to do so without dragging a 292 /// appear — with every file in it ungated — as a side effect of a mkdir.
303 /// terminal in. `mux_core.wasm` already links that set with no tty anywhere,
304 /// which is the proof the rule generalises past this repo's own binaries.
305 ///
306 /// `root` is the shared layer-0 utilities both sides link; `foreign` is the
307 /// test fixtures under `test/`, which are nobody's domain and unconstrained.
308 const Folder = enum { root, engine, server, client, tui, cli, foreign };
309
310 fn folderOf(path: []const u8) Folder {
311 if (!std.mem.startsWith(u8, path, "src/")) return .foreign;
312 const rest = path["src/".len..];
313 const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return .root;
314 const dir = rest[0..slash];
315 inline for (@typeInfo(Folder).@"enum".fields) |f| {
316 if (std.mem.eql(u8, dir, f.name)) return @field(Folder, f.name);
317 }
318 fatal("folder rule: src/{s}/ is not a domain folder — add it to Folder " ++
319 "or put the module in one that exists", .{dir});
320 }
321
322 /// The folders the doc gate and the folder rules both walk. Listed rather
323 /// than globbed: a new domain folder is a decision about who owns what, and
324 /// it must be made here, in the enum above, and in the rules below together.
325 const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli" }; 293 const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli" };
326 294
327 /// A folder edge the rules forbid and this repo still has, signed with its 295 /// The source bans, read off the PRODUCTION lines of the files under `src/`.
328 /// reason. Not a waiver mechanism to reach for: an entry here is a stated 296 /// They catch what the import graph cannot: a module needs no import to
329 /// debt, and the rule's whole value is that removing one is a visible diff. 297 /// spell an escape byte, a shell path or a fork, so these read the sources
330 /// Empty, and kept so — an edge nobody could sign is one nobody adds quietly. 298 /// themselves. The rules keep the numbers 4, 5 and 6 they were given, because
331 const FolderExemption = struct { from: []const u8, to: []const u8, why: []const u8 }; 299 /// files across the repo cite them by number in their own
332 const folder_exemptions = [_]FolderExemption{}; 300 /// `folder rule N exemption:` lines.
333 301 fn checkSourceBans(b: *std.Build) void {
334 fn folderExempt(from: []const u8, to: []const u8) bool {
335 for (folder_exemptions) |e| {
336 if (std.mem.eql(u8, e.from, from) and std.mem.eql(u8, e.to, to)) return true;
337 }
338 return false;
339 }
340
341 /// The folder rules, checked over the same table the layers are. Layers say
342 /// which way an import may point; folders say which domain may know the other
343 /// exists — two different questions, and a legal layer edge can still be an
344 /// illegal domain edge (`client` importing `tui` points downward and is
345 /// exactly what must not compile into a headless app).
346 fn checkFolderRules(b: *std.Build) void {
347 for (&mod_table) |spec| {
348 const from = folderOf(spec.path);
349 for (spec.imports) |dep| {
350 const to = folderOf(mod_table[idx0(dep)].path);
351 if (folderExempt(spec.name, dep)) continue;
352 if (from == .client and to == .tui) fatal(
353 "folder rule 3 broken: {s} (src/client/) imports {s} (src/tui/) — " ++
354 "tui imports client, never the reverse",
355 .{ spec.name, dep },
356 );
357 if ((from == .engine or from == .client) and
358 (to == .tui or to == .server or to == .cli)) fatal(
359 "folder rule 1 broken: {s} (src/{s}/) imports {s} (src/{s}/) — " ++
360 "engine and client name no tui, server or cli module",
361 .{ spec.name, @tagName(from), dep, @tagName(to) },
362 );
363 if (from == .server and (to == .client or to == .tui)) fatal(
364 "folder rule 2 broken: {s} (src/server/) imports {s} (src/{s}/) — " ++
365 "the daemon knows of no client and no terminal",
366 .{ spec.name, dep, @tagName(to) },
367 );
368 }
369 }
370 for (source_bans) |ban| checkSourceBan(b, ban); 302 for (source_bans) |ban| checkSourceBan(b, ban);
371 } 303 }
372 304
373 /// Runtime twin of `idxOf`, for the loops above that read the table as data.
374 fn idx0(name: []const u8) usize {
375 for (&mod_table, 0..) |m, i| if (std.mem.eql(u8, m.name, name)) return i;
376 fatal("module table: unknown module '{s}'", .{name});
377 }
378
379 /// A byte a folder's PRODUCTION lines may not spell. The import graph 305 /// A byte a folder's PRODUCTION lines may not spell. The import graph
380 /// cannot catch a module that writes the bytes itself, so these read the 306 /// cannot catch a module that writes the bytes itself, so these read the
381 /// sources the way the doc gate does. 307 /// sources the way the doc gate does.
@@ -486,8 +412,8 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
486 412
487 /// Every grant the table hands out must be one the source asked for: for 413 /// Every grant the table hands out must be one the source asked for: for
488 /// each name in a row's `imports` / `test_imports`, that row's root source 414 /// each name in a row's `imports` / `test_imports`, that row's root source
489 /// file has to contain `@import("<name>")`. The layer rules cannot see a 415 /// file has to contain `@import("<name>")`. The table's own checks cannot
490 /// stale grant — an edge nobody uses violates no direction — so a grant 416 /// see a stale grant — an edge nobody uses still names a real row — so a grant
491 /// outlives the code that needed it silently, and the table stops being a 417 /// outlives the code that needed it silently, and the table stops being a
492 /// description of the program. (Exactly how `exe` kept `cmd` after main.zig 418 /// description of the program. (Exactly how `exe` kept `cmd` after main.zig
493 /// stopped importing it; found in review 2026-08-14, and this is the check 419 /// stopped importing it; found in review 2026-08-14, and this is the check
@@ -708,7 +634,7 @@ fn zigFilesIn(b: *std.Build, sub: []const u8, paths: *std.ArrayList([]const u8))
708 /// "comments say why, not how" and "code, comments, docs drift" since the 634 /// "comments say why, not how" and "code, comments, docs drift" since the
709 /// first commit; a week of drift showed that prose is instruction and only a 635 /// first commit; a week of drift showed that prose is instruction and only a
710 /// check that RUNS is codification — the same reasoning behind the comptime 636 /// check that RUNS is codification — the same reasoning behind the comptime
711 /// layer laws above. It gates REFERENCES, not length: a comment must still 637 /// table checks above. It gates REFERENCES, not length: a comment must still
712 /// name something real, and may take as many lines as saying so takes. 638 /// name something real, and may take as many lines as saying so takes.
713 /// 639 ///
714 /// The tool is a build tool, not part of the program, so it stays out of the 640 /// The tool is a build tool, not part of the program, so it stays out of the
@@ -770,7 +696,7 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
770 } 696 }
771 697
772 /// Test registration order — the order failures ARRIVE in, and deliberately 698 /// Test registration order — the order failures ARRIVE in, and deliberately
773 /// NOT the layers'. A suite that waits on a socket can wedge, and a wedged 699 /// NOT the table's. A suite that waits on a socket can wedge, and a wedged
774 /// step prints nothing at all; whatever runs before it is the only legible 700 /// step prints nothing at all; whatever runs before it is the only legible
775 /// catch. So the suites that CAN wedge go last: from `pty` on, every row 701 /// catch. So the suites that CAN wedge go last: from `pty` on, every row
776 /// either waits on a pty or imports `testtmp`, which is what a row asks for 702 /// either waits on a pty or imports `testtmp`, which is what a row asks for
@@ -797,7 +723,7 @@ comptime {
797 if (test_order.len != mod_table.len) 723 if (test_order.len != mod_table.len)
798 @compileError("test_order must cover every mod_table row exactly once"); 724 @compileError("test_order must cover every mod_table row exactly once");
799 for (test_order, 0..) |n, i| { 725 for (test_order, 0..) |n, i| {
800 _ = layerOf(n); 726 _ = idxOf(n);
801 for (test_order[i + 1 ..]) |n2| { 727 for (test_order[i + 1 ..]) |n2| {
802 if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n); 728 if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n);
803 } 729 }
@@ -835,8 +761,8 @@ pub fn build(b: *std.Build) void {
835 checkGrantsUsed(b); 761 checkGrantsUsed(b);
836 // ...and no domain of a split file's tests goes unreached (nor the reverse). 762 // ...and no domain of a split file's tests goes unreached (nor the reverse).
837 checkSiblingTestsReached(b); 763 checkSiblingTestsReached(b);
838 // ...and every module still sits in the folder of the thing that owns it. 764 // ...and no production line under src/ spells a byte a source ban forbids.
839 checkFolderRules(b); 765 checkSourceBans(b);
840 766
841 // Two instances per row that has test grants: `mods[i]` is production — 767 // Two instances per row that has test grants: `mods[i]` is production —
842 // what the exes are built from and what every importer sees — and 768 // what the exes are built from and what every importer sees — and
docs/decisions.md
Old New
@@ -7653,3 +7653,51 @@ gone, with their baselines and the `doc-report` step; tiers 1 and 2 stay.
7653 - **What is unguarded now**: nothing stops a comment growing into narration 7653 - **What is unguarded now**: nothing stops a comment growing into narration
7654 again. That is a reviewer's job, and the trade is deliberate — a reviewer 7654 again. That is a reviewer's job, and the trade is deliberate — a reviewer
7655 can tell an essay from an explanation and a byte count cannot. 7655 can tell an essay from an explanation and a byte count cannot.
7656
7657 ## 2026-08-31 — the module-architecture enforcement is removed
7658
7659 `build.zig` had two mechanisms enforcing SHAPE on the import graph: layers
7660 (`ModSpec.layer`, `layerOf`, and a comptime check that every production
7661 import point at a strictly lower stratum) and folder rules 1–3 (engine and
7662 client name no tui/server/cli module; server names no client and no terminal;
7663 client never names tui), with a signed-exemption table for edges the rules
7664 forbid. All of that is gone, along with the `Folder` enum, `folderOf`,
7665 `FolderExemption`, `folder_exemptions` and `folderExempt`;
7666 `checkFolderRules` becomes `checkSourceBans`.
7667
7668 - **The trigger was a product primitive with nowhere legal to live.**
7669 Connecting a client to a daemon is the one operation this product exists to
7670 perform, and the daemon's own tests hand-roll it at 175 sites because no
7671 callable primitive exists where they can reach one. Every home for a
7672 ~40-line `dial` module was refused by the build, not by the design: `term`
7673 owns the attach encoders and is wasm-pinned, so it cannot open a socket;
7674 layer 0 may import nothing, so `sockpath` cannot compose dial+attach; an
7675 import must point STRICTLY downward, so a module `client` (layer 1) shares
7676 forces renumbering the whole graph; and folder rule 2 walls the daemon's
7677 tests off from `client.Transport.open`, the primitive the product does
7678 have. We were costing out a 175-site test sweep to avoid a 40-line module.
7679 - **A gate that makes the product change harder than the test rewrite is
7680 optimizing the wrong direction.** The layers described the graph honestly
7681 and then froze it; their failure mode is not a bad edge sneaking in but a
7682 good edge never being drawn, which nothing reports and no diff shows.
7683 - **The module table STAYS**, and is still where structure is written down:
7684 it wires every module and every import, so no table-module import can exist
7685 outside its loop. What changes is that adding an edge is now an ordinary
7686 diff instead of a re-stratification.
7687 - **Kept because they are not module architecture:** name-exists validation
7688 of `imports`/`test_imports` (a stale name is still a compile error); the
7689 `test_imports` twin mechanism, whose subject is SHIPPING — test scaffolding
7690 cannot reach a production instance — not graph shape; the wasm-closure
7691 check, reworded as the mechanical fact it is (a flagged row compiles
7692 against wasm32-freestanding, so an import without a `.wasm` twin cannot
7693 compile there, and failing at the edge beats the twin loop's later
7694 `@panic`); and source bans 4, 5 and 6, which pin BEHAVIOR — no VT authored
7695 outside `src/tui/`, every program exec'd as argv, one `posix.fork` site —
7696 by reading production lines the import graph could never see. The three
7697 keep their numbers, because files across the repo cite them in their own
7698 `folder rule N exemption:` lines.
7699 - **What is unguarded now**: a headless-app import (`client` reaching into
7700 `src/tui/`) is a reviewer's catch rather than a build failure. The stated
7701 invariants in CLAUDE.md are unchanged and `mux_core.wasm` still links
7702 `term` + the client core with no tty anywhere, which is the check that
7703 actually exercises the separation.
src/cli/flags.zig
Old New
@@ -186,8 +186,8 @@ pub fn version(prog: []const u8, ver: []const u8) u8 {
186 } 186 }
187 187
188 fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 { 188 fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 {
189 // Both strings are the caller's: cliflags is layer 0 and cannot see 189 // Both strings are the caller's: cliflags imports nothing at all, and
190 // build_options, where the version it prints lives. 190 // that includes build_options, where the version it prints lives.
191 var buf: [64]u8 = undefined; 191 var buf: [64]u8 = undefined;
192 writeTo(fd, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable); 192 writeTo(fd, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable);
193 return 0; 193 return 0;
src/engine/engine.zig
Old New
@@ -486,7 +486,8 @@ pub const Engine = struct {
486 486
487 /// Tracking modes AND report formats: a report in a spelling the 487 /// Tracking modes AND report formats: a report in a spelling the
488 /// application did not ask for arrives as garbage in its input. Field 488 /// application did not ask for arrives as garbage in its input. Field
489 /// names, not `protocol`'s — layer 0 does not know the wire. 489 /// names, not `protocol`'s — the engine does not import the wire
490 /// contract.
490 pub const MouseModes = struct { 491 pub const MouseModes = struct {
491 x10: bool = false, 492 x10: bool = false,
492 normal: bool = false, 493 normal: bool = false,
src/engine/protocol.zig
Old New
@@ -792,8 +792,8 @@ pub const attach_len = 20;
792 /// fixed part, which is why the tail is a tail and not a versioned field. 792 /// fixed part, which is why the tail is a tail and not a versioned field.
793 /// One pattern, four verbs: attach, debug_dump, status_req, await_req. 793 /// One pattern, four verbs: attach, debug_dump, status_req, await_req.
794 pub const session_name_max = 32; 794 pub const session_name_max = 32;
795 /// The daemon's session cap, spelled here because `protocol` is layer 0 and 795 /// The daemon's session cap, spelled here because `protocol` imports no
796 /// cannot see `Server.max_sessions`. Not a second owner: server.zig asserts 796 /// daemon and cannot see `Server.max_sessions`. Not a second owner: server.zig asserts
797 /// the two are equal at comptime, so a daemon that raised its cap without 797 /// the two are equal at comptime, so a daemon that raised its cap without
798 /// this line does not build. 798 /// this line does not build.
799 pub const sessions_max = 32; 799 pub const sessions_max = 32;
src/server/server.zig
Old New
@@ -21,7 +21,8 @@ pub const upgrade = @import("upgrade.zig");
21 const proxy = @import("proxy"); 21 const proxy = @import("proxy");
22 const TmpDir = @import("testtmp").TmpDir; 22 const TmpDir = @import("testtmp").TmpDir;
23 // The agent relay is a sub-file of this module, not a row of its own: it is 23 // The agent relay is a sub-file of this module, not a row of its own: it is
24 // one cluster of Server's state, and a module row would claim it is a layer. 24 // one cluster of Server's state, and a module row would make it a seam
25 // anything in the build could name.
25 const agent_mod = @import("server_agent.zig"); 26 const agent_mod = @import("server_agent.zig");
26 const SessionTable = @import("server_sessions.zig").SessionTable; 27 const SessionTable = @import("server_sessions.zig").SessionTable;
27 const AgentRelay = agent_mod.AgentRelay; 28 const AgentRelay = agent_mod.AgentRelay;
src/tui/interact.zig
Old New
@@ -119,7 +119,7 @@ pub const PrefixFilter = struct {
119 // The banner buffer must hold a whole prompt line — the `": "` and the 119 // The banner buffer must hold a whole prompt line — the `": "` and the
120 // cursor `"_"` on top of the spelling — and a comment cannot fail a 120 // cursor `"_"` on top of the spelling — and a comment cannot fail a
121 // build. Asserted here because the import only runs this way: interact 121 // build. Asserted here because the import only runs this way: interact
122 // is layer 2 and paint is layer 1. 122 // imports paint, never the reverse.
123 comptime { 123 comptime {
124 std.debug.assert(prompt_max + ": ".len + "_".len <= paint_mod.banner_label_max); 124 std.debug.assert(prompt_max + ": ".len + "_".len <= paint_mod.banner_label_max);
125 } 125 }
src/tui/paint.zig
Old New
@@ -16,8 +16,8 @@ pub const sync_end = "\x1b[?25h\x1b[?2026l";
16 /// Inclusive grid columns of one row, painted inverted. 16 /// Inclusive grid columns of one row, painted inverted.
17 pub const Span = struct { from: u16, to: u16 }; 17 pub const Span = struct { from: u16, to: u16 };
18 18
19 /// A callback, not a shape: `select.zig` is layer 1 like this module, so 19 /// A callback, not a shape: `select.zig` is a sibling child file of the same
20 /// neither may import the other. 20 /// module root, and neither file imports the other.
21 pub const Highlight = struct { 21 pub const Highlight = struct {
22 ctx: ?*anyopaque = null, 22 ctx: ?*anyopaque = null,
23 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, 23 span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,