The law: services behind traits, no new globals
The owner set this law on 2026-11-07, for Gunnar and Holger alike. It holds for every change from that day on. Holger has the same law in its docs/v2/14-code-shape.md.
- No new process-wide state. Nobody adds a
staticthat holds state, aOnceLock,OnceCell,LazyLock,lazy_static!orthread_local!that holds state, or astatic mut, unless it is on the allow listxtask/globals-allow.txt. Aconstis fine, and so is an immutablestatictable with no interior mutability. - Every outside service is reached through a trait. When a module depends on a service from outside it (a store, the clock, a network fetch, the job queue, a scanner, a mailer, a cloud API, an identity), it reaches that service through a trait. Production passes the real implementation, and a test passes a fake. New code follows this at once. Old code moves when a change touches it.
- Each test level uses what it is meant to use. The levels, smallest first:
| level | plain name | what it tests | the outside services |
| atoms | unit tests | one function or one struct | none, or fakes |
| molecules | unit tests | one module | faked through its traits |
| the server object | unit tests | the whole server built in memory and driven through its real API, with no ports, no disk and no internet | all faked: store, clock, network fetch, scanners, cloud, mail |
| integration tests | integration tests | the real binary, or real servers in process, on real ports | real |
| system | UpCloud mockup, KVM gate | the estate on real or emulated machines | real; these stay as they are |
UI tests stay where they are, in the UI repository.
The goal is the server object. Today 73 process-wide globals in Gunnar's server crates are wired in by hand, so a test that needs the server has to start a whole server. The goal is a Gunnar that a test can build in memory, with every outside service faked, and drive through its real API. Every global that moves behind a trait brings it closer. The server object is not built yet.
The guard
cargo xtask globals-law checks the law. It is a step of the quick CI set (cargo xtask ci --quick) and of the full set, and the xtask test no_new_process_wide_globals_in_the_server_crates runs the same check (cargo test -p xtask --lib globals_law). It builds nothing and takes about a second.
It lexes every .rs file git tracks in the server crates: the server binary gunnar, gunnar-server and the crates it links (common, core, store, wire, crypt, policy, lfs, heimdall, ssh, http, proto, grpc, repair, pairing-door), and gunnar-front. It leaves out tests/, benches/, examples/ and code under #[cfg(test)]. It counts a static mut, every static inside thread_local! or lazy_static!, a static whose type names an interior-mutable type (Mutex, RwLock, Cell, OnceLock, LazyLock, an Atomic* and the like), and a static of a named type that is built by a call or a struct literal. It then compares what it found with xtask/globals-allow.txt:
- A global that is not on the list fails the check. The message names its
file:lineand this chapter. - A row whose global is gone fails too. The row must be removed.
- The list may not hold more rows than the ceiling in
xtask/globals_law.rs, and when a row goes, the ceiling goes down with it. So the list only shrinks.
With F the keys found (<file>::<NAME>, #2 for a second one of a name in one file), A the keys on the list and c the ceiling, the check is two set differences and a count:
git ls-files (scope) ─► lexer ─► F
xtask/globals-allow.txt ───────► A (kind, reason) ceiling c (xtask/globals_law.rs)
new = F \ A gone = A \ F pass ⇔ new = ∅ ∧ gone = ∅ ∧ |A| = c
For example, a lane adds static CACHE: Mutex<Vec<u8>> at crates/gunnar-server/src/x.rs:12. Then new = {crates/gunnar-server/src/x.rs::CACHE}, and the check fails with:
crates/gunnar-server/src/x.rs:12: a new process-wide global `CACHE` (static (interior-mutable)) breaks the law in docs/v2/code-shape.md. Pass the service in through a trait instead; a const or an immutable table is fine.
The fix is a field on the value that owns it, reached through a trait. Later a lane moves the twin's FOLLOWING flag into the follower value. Then gone = {crates/gunnar-server/src/twin.rs::FOLLOWING}, and the lane removes that row and lowers the ceiling from 73 to 72.
Each row has a kind and a one-line reason:
| kind | what it means | rows on 2026-11-07 |
| per-process | one per process by nature: the log queue and tap, a counter, a lock, a thread-local that lasts one call, a cache keyed by store root, a value computed once | 38 |
| per-server | belongs to the one server on the box (the fed clock, the boot log, readiness, the monetize key in force, the tunnel, the repair loop, the LFS ledgers, the front's hub); right while one process runs one server | 30 |
| should-move | one per process, but a test process runs a main and a twin: the twin's host key KEY, GIT_BOUND_AS_TWIN, FOLLOWING, STOP_FOLLOWING and REPOINTED in twin.rs |
5 |
That is 73 rows in all. No global was moved when the law came in. Moving them is later work, one global at a time, each behind a trait and each with a test that uses a fake.