# Wolf Language Specification — 11: OS surface Status: normative, v1 (campaign c30-signals, sprints s114/s118). Anchors `[os.*]`. This chapter writes down the operating-system surface the builtin tier exposes. v1 covers **signal reception** (`[os.signal]`, wolf-lang#126) — the receive side of the process-signal world, the last upstream gate on wolf-wws's operator surface (`-s reload|quit|stop` and the zero-downtime upgrade path) — and **the random source** (`[os.random]`, wolf-lang#143): the operating-system entropy call every cryptographic construction stands on. The SEND side (`os_kill`, the process trio) and the environment/time/json tiers are `wolf_sema`/`wolf_rt` builtins landed earlier (s40/s107); their normative home is this chapter as it grows. Implemented by `wolf_rt::signal`/`wolf_rt::random` (native lanes) and `wolf_mem::ubcheck` (the checked lane); evidence: `corpus/os/signal_*`, `corpus/os/random_*`. The claims this chapter buys: a wolf program can HEAR an OS signal — as an ordinary event in task control flow — without any wolf code ever running in async-signal-handler context, with the surface portable by MEANING rather than by raw signal number; and a wolf program can obtain bytes the operating system asserts are unpredictable, through the ONE call that either succeeds or traps — never a userspace generator, never a silent fallback. --- ## §1 Signal reception `[os.signal]` - `[os.signal]` A **signal** is an asynchronous notification the OS (or another process) delivers to a running program. Wolf delivers signals as **events in normal task control flow**, never as async callbacks. A program REGISTERS interest explicitly, then RECEIVES a signal by parking a task on a blocking wait; there is no ambient global handler and no wolf code runs in a signal handler. The three builtins are `os_signal_listen`, `os_signal_wait`, `os_signal_raise`. - `[os.signal.model]` **Events, not callbacks (the safe design).** Async-signal-safety makes real handlers a minefield — a handler may call almost nothing (no allocation, no lock, no user code). The runtime therefore installs a minimal async-signal-safe trampoline that does exactly one async-signal-safe thing — write one byte naming the signal's MEANING to a self-pipe — and a dedicated drain reads those bytes in ordinary thread context and delivers them to parked waiters. A design that calls a wolf function value from a signal handler is non-conformant. (Self-pipe / signalfd on Linux; `kqueue` `EVFILT_SIGNAL` or a `sigwait` thread on macOS/BSD; see `[os.signal.platform]`.) - `[os.signal.set]` **By MEANING, not by number.** Interest and delivery are named by a **meaning bitmask**, mapped to platform signal numbers only at the runtime boundary — never raw signal numbers in portable code. The v1 set is the four wws needs: | meaning | bit | unix signal | wws use | |-------------|-----|-------------|---------------------------------| | `RELOAD` | 1 | `SIGHUP` | re-read config, drain old workers | | `TERMINATE` | 2 | `SIGTERM` | fast shutdown | | `QUIT` | 4 | `SIGQUIT` | graceful shutdown after drain | | `UPGRADE` | 8 | `SIGUSR2` | binary-swap / zero-downtime upgrade | s219 (wolf-lang#622) adds the five meanings an interactive program needs — the terminal's three characters and the two job-control stops, and a child's change of state (spellings: ruled A (#57), 2026-10-10, `[os.term.note]`): | meaning | bit | unix signal | sent when | |-------------|-----|-------------|---------------------------------| | `INTERRUPT` | 16 | `SIGINT` | the terminal's interrupt character (Ctrl-C) | | `SUSPEND` | 32 | `SIGTSTP` | the terminal's suspend character (Ctrl-Z) | | `BG_READ` | 64 | `SIGTTIN` | a background process group read the terminal | | `BG_WRITE` | 128 | `SIGTTOU` | a background process group wrote to, or set, the terminal | | `CHILD` | 256 | `SIGCHLD` | a child stopped or ended | s232 (wolf-lang#679, #423, #141) gives every other signal POSIX names a bit (spellings: ruled A (#70), 2026-10-12, `[os.signal.shell.note]` R1). Eleven more a program may catch: | meaning | bit | unix signal | sent when | |-----------------|--------|-------------|---------------------------------| | `USER` | 512 | `SIGUSR1` | another program chooses (`UPGRADE` is `SIGUSR2`) | | `ALARM` | 1024 | `SIGALRM` | a timer ran out | | `BROKEN_PIPE` | 2048 | `SIGPIPE` | a write to a pipe or socket nobody reads | | `ABORT` | 4096 | `SIGABRT` | the process was asked to abort | | `CONTINUE` | 8192 | `SIGCONT` | the process was continued after a stop | | `RESIZE` | 16384 | `SIGWINCH` | the terminal's window changed size | | `URGENT` | 32768 | `SIGURG` | out-of-band data on a socket | | `VIRTUAL_ALARM` | 65536 | `SIGVTALRM` | a user-time timer ran out | | `PROFILE_ALARM` | 131072 | `SIGPROF` | a profiling timer ran out | | `CPU_LIMIT` | 262144 | `SIGXCPU` | the CPU-time limit was passed | | `FILE_LIMIT` | 524288 | `SIGXFSZ` | the file-size limit was passed | and eight a program may only SEND (`[os.signal.send]`) and ask the number of (`[os.signal.number]`): `KILL` (1048576, `SIGKILL`) and `STOP` (2097152, `SIGSTOP`), which no process can catch or ignore, and the six fault signals `SEGMENT_FAULT` (`SIGSEGV`, 1 << 22), `BUS_ERROR` (`SIGBUS`, 1 << 23), `ARITHMETIC` (`SIGFPE`, 1 << 24), `ILLEGAL` (`SIGILL`, 1 << 25), `BREAKPOINT` (`SIGTRAP`, 1 << 26) and `BAD_SYSCALL` (`SIGSYS`, 1 << 27) — a trampoline that returns from a real fault runs the faulting instruction again, and the runtime's stack guard owns `SIGSEGV` and `SIGBUS`. The **listenable** meanings are bits 0..19 (1048575); the whole table is bits 0..27 (268435455). `SIGPOLL` (STREAMS, absent on macOS) has no bit. A *set* is the bitwise OR of meanings; `os_signal_wait` reports the single meaning that arrived. **One shape rule (s232):** every call that takes a set — `listen`, `wait`, `poll`, `ignore`, `default` — refuses an empty set, or one holding any bit that is not a listenable meaning, with its `io` row, and changes nothing; `raise` takes exactly one listenable meaning. (Before s232 a listen dropped such a bit and answered ok, and a wait or poll refused only a set with no listenable bit at all.) Real-time signals and `siginfo` payloads are out of scope for v1 (rapid repeats of one meaning may coalesce — standard edge-signal semantics). - `[os.signal.set.stable]` **A published bit is permanent.** Every meaning `[os.signal.set]` has published in a release keeps its bit forever: a released bit is never reassigned or reused, even if its meaning is retired. A new meaning takes only an unused bit, in order (the lowest bit no release has published). Portable code names a meaning by its prelude constant, never by a literal bit value or a host signal number; `os_signal_number` (`[os.signal.number]`) is the only way from a meaning to the host's number. (At wolf 0.2.29 the prelude declares no constant for any meaning: the names a program has are `std.signal`'s functions, nine of the twenty listenable at wolf-std `d7c2968`, and a meaning with no name yet is written as its bit in the table above.) Ruled by the maintainer, 2026-10-12. - `[os.signal.listen]` `os_signal_listen(set: int) -> () ! {io}` registers interest in a set of meanings and installs the trampoline for each. It is idempotent per meaning and explicit — no signal is received without a prior listen. The one failure row is `io` (a set the shape rule refuses — empty, or holding a bit that is not a listenable meaning, s232 — or a handler-install failure); registration is a value, never a trap. Under the D15 pay-for-what-you-use rule a program that never listens carries no signal machinery (no self-pipe, no drain thread). **An inherited mask does not silence a listen** (s188, wolf-lang#483): a process inherits its signal mask across `exec`, and a signal blocked on every thread never reaches its trampoline, so on unix `os_signal_listen` also unblocks the signal of each meaning it arms — on the drain thread, the thread the runtime keeps for the program's life — before it returns. It unblocks nothing else: every other signal keeps the mask the process inherited, on every thread. A signal sent while it was still blocked is pending, and arrives when the listen unblocks it. (nginx sets its own mask for the same reason.) Windows has no signal mask (`[os.signal.platform]`); its inheritable analog, the console's CTRL+C-ignore flag, is not cleared yet (wolf-lang#490). A listen for a meaning ends an ignore of it (`[os.signal.disp]`). - `[os.signal.disp]` **A meaning's disposition** (s219). Each meaning's signal does one of three things to this process: the host's default action (for `INTERRUPT`, `TERMINATE` and `QUIT`, the end of the process; for `SUSPEND`, `BG_READ` and `BG_WRITE`, a stop; for `CHILD`, nothing), nothing at all, or a delivery the program reads (a listen). **`os_signal_ignore(set: int) -> () ! {io, unsupported}`** sets every meaning in `set` to ignored and **`os_signal_default(set: int) -> () ! {io, unsupported}`** to the default; either ends a listen for those meanings, and `os_signal_listen` ends an ignore. The last call for a meaning wins. Meanings already delivered stay queued for `wait` and `poll`. An empty set, or one with a bit outside the nine meanings, is `io` and changes nothing; windows is `unsupported` for every set. **Across a spawn** a child starts as `exec` leaves it (XSH `exec`): a meaning this program ignores is ignored in the child, and a meaning it listens for is the default there — the trampoline does not survive `exec`. That is what makes a listening shell's `sleep` die of Ctrl-C while the shell lives. A shell that ignores a meaning for itself (dash -i ignores `QUIT` and `TERMINATE`, and with `-m` `SUSPEND` and `BG_WRITE`) and wants its children to start with the default names it in `os_spawn_job`'s `defaults` (`[os.proc.job]`) — dash's children, measured, start with nothing ignored. Ignoring `CHILD` is the host's: on unix a child of a process that ignores `SIGCHLD` is reaped by the host, so a later `os_wait` of it is `io`. **`BROKEN_PIPE` across a spawn** (s232, #423): the runtimes start children through Rust's `Command`, which sets `SIGPIPE` back to the default in every child it starts (Rust programs ignore it; a wolf program never did) — measured before s232, a native child of a program that ignored `SIGPIPE` died of it. A program that ignores `BROKEN_PIPE` now hands the ignore on, as the rule above says: the child sets it ignored again, last, before it runs (one `signal` call in the child, and only then — a spawn from a program that does not ignore it is unchanged). - `[os.signal.poll]` **`os_signal_poll(set: int) -> int ! {io}`** takes the oldest delivered meaning in `set` off the queue and returns it, or returns 0 when none has arrived — it never waits. A program that has never listened has nothing queued. A set the shape rule refuses is `io`, as for `wait`. This is how a program that blocks elsewhere — a shell reading its prompt line, a shell waiting for its foreground child — learns afterwards that an interrupt came. **A poll does not trail a delivery** (s232, wolf-lang#691). A signal is *delivered* when its action is taken (XBD 3) — here, when the trampoline has written the meaning to the self-pipe. Before s232 the queue a poll read was filled from that pipe by the drain thread, so a poll could answer 0 for a signal already delivered (2,000 of 2,000 polls straight after a raise, measured). Now a poll, and a wait's first look: (1) lets every signal of a listened meaning that is still *pending* for the process be delivered on the calling thread — it blocks the listened signals on that thread and unblocks them again, and POSIX `pthread_sigmask` delivers a pending unblocked signal before it returns (measured on linux: a call that leaves the mask unchanged delivers nothing, hence the two changes), the mask put back as found, repeated until a round delivers nothing; (2) waits for any trampoline running on another thread to finish its write; (3) reads the self-pipe itself, under the lock the drain thread also reads it under, so no meaning is ever between the pipe and the queue while a poll looks. **The guarantee:** a meaning whose signal was delivered before the poll began, or was pending for the process and is not blocked on every thread, is on the queue when the poll looks. `os_signal_raise` delivers before it returns (`[os.signal.raise]`), so a poll after a raise always sees it; a signal from another process is delivered on a thread that does not block it — a shell whose waiting thread does not block it takes it on that thread, before its wait returns. **What is left,** stated: a signal the host has taken off the pending set for another thread and not yet started that thread's trampoline is neither pending nor delivered, and no poll can see it; it is on the queue microseconds later. The cost: three `pthread_sigmask` calls and one read that finds the pipe empty, about a microsecond, per poll of a program that listens; nothing for one that does not. - `[os.signal.wait]` `os_signal_wait(set: int) -> int ! {io}` parks the calling task until one meaning in `set` is delivered, and returns that meaning. The wait parks a real thread with blocking compensation (the structured-concurrency model, `[conc.*]`); an empty or all-unmapped set is `io` at once (nothing could ever arrive — never a hang). It is a KILL teardown point (`[conc.proc.kill]`): a killed supervisor terminates at the wait rather than hanging on a signal that never comes. Plain cancellation keeps waiting (the `{io}` row vocabulary has no cancellation row — the net tier's kill-only posture). - `[os.signal.raise]` `os_signal_raise(sig: int) -> () ! {io}` delivers one meaning to THIS process — the send-to-self companion to `os_kill` (send-to-child). It is the deterministic loopback the conformance witness rides, and a real capability (a program triggering its own reload path). Anything but one listenable meaning is `io`, never the send of a wild signal number. **s232:** on unix it sends to the calling thread (`raise(3)`), which POSIX says does not return until the handler has — so the meaning is delivered when the call returns — unless that thread blocks the signal, when it sends to the process (`kill(getpid())`) and the drain thread, which a listen unblocked it on, takes it. - `[os.signal.ignored]` **What a signal's disposition is** (s232, wolf-lang#681). **`os_signal_ignored() -> int ! {unsupported}`** answers the set of listenable meanings whose signal this process ignores now, and changes nothing (`sigaction` with no new action). Read before the program's own first `ignore`, `default` or `listen` of a meaning, it is the disposition the process started with — what XCU `trap` needs ("signals that were ignored on entry to a non-interactive shell cannot be trapped or reset") and what makes `nohup` work. A listened meaning is not ignored. Windows has no dispositions: `unsupported`. - `[os.signal.send]` **A signal to any process** (s232, wolf-lang#141). **`os_signal_send(pid: int, meaning: int) -> () ! {denied, invalid, io, not_found, unsupported}`** sends one meaning's signal — any of the table's 28, `KILL` and `STOP` included — to the process `pid` (`kill(2)`). The order of refusal is the contract: (1) the SHAPE — **`invalid`** for a `pid` that is not above 0 or does not fit a process id, or a `meaning` that is neither 0 nor exactly one bit of the table; (2) the HOST — **`unsupported`** on windows, by name; (3) the TARGET — **`not_found`** when no process has that id (`ESRCH`), **`denied`** when this process may not signal it (`EPERM`), **`io`** otherwise. A `meaning` of 0 sends nothing and answers the same rows — XCU `kill`'s signal 0, "is there such a process, and may I signal it". A group (`kill`'s 0 and negative ids) is job control's, the next lane's (`[os.term.note]`), not this call's. A process id names whichever process holds it at the call: a child already reaped may have passed its id on — `os_kill` on a handle has no such hazard. Sending to this process's own id is a process-directed `raise`. - `[os.signal.number]` **A meaning's number on this host** (s232). **`os_signal_number(meaning: int) -> int ! {invalid, unsupported}`** is the signal number the host gives one meaning of the table (28 bits): what `kill -l` prints, what `trap … 13` names and what `128 + N` decodes, which differ between hosts (`USER` is 10 on linux and 30 on macOS). Anything but one bit of the table is `invalid`; windows is `unsupported`. It reads the host's numbering and nothing else, so it carries no capability (`os_error_text`'s shelf) and is refused at comptime (the compiling host's number is not the running one's). - `[os.signal.platform]` **The platform matrix (Windows is tier-1, with NAMED gaps).** The surface is portable by MEANING; the delivery mechanism is per platform. - **Linux / macOS:** `sigaction` trampoline → self-pipe → drain thread. Full set. Linux is the s114 implementation; macOS joined at s59 with the task-layer port this clause said it would follow (one platform seam: `pipe` + two `fcntl(FD_CLOEXEC)` where pipe2 does not exist). BSD delivery — the same POSIX shape, or `kqueue` `EVFILT_SIGNAL` / a `sigwait` thread — widens with s61. A NAMED stop for the hosts still outside, recorded here, not silence. - **Windows:** POSIX signals do not exist. The meaning abstraction maps to `SetConsoleCtrlHandler`: `CTRL_C` → `TERMINATE`, `CTRL_BREAK` → `QUIT`, `CTRL_CLOSE` → `TERMINATE`. `RELOAD` (`SIGHUP`) and `UPGRADE` (`SIGUSR2`) have **no Windows analog** — a NAMED platform gap: Windows reload/upgrade uses a control channel (wolf-wws ws04's ungated half), NOT a signal. The console-control delivery landed with the Windows task layer (s60b, 2026-09-02): the console handler runs on a system thread in normal context and enqueues the meaning directly (no self-pipe); a listened meaning is consumed, an unlistened one is left to the console's default disposition (process exit, as an unhandled `SIGTERM`). `os_signal_raise` on Windows is an **in-process** delivery — `GenerateConsoleCtrlEvent` is console-wide and has no `kill(getpid())` twin — so self-raise reaches a listener for every meaning (the loopback witness and a program's own reload path), an unlistened `TERMINATE`/`QUIT` self-raise ends the process as Ctrl+C would (`STATUS_CONTROL_C_EXIT`), and an unlistened `RELOAD`/`UPGRADE` self-raise is dropped (nothing to deliver to — the checked machine's posture). The mapping is normative: the surface is a meaning surface, not a Linux-signal surface pretending to be portable. s219: the five terminal and child meanings (`INTERRUPT` … `CHILD`) have no console event behind them; a listen for one is `io` on windows, and `os_signal_ignore`, `os_signal_default`, `os_spawn_job` with any option on, and every `[os.term]` call are `unsupported` there, by name. Ctrl+C on the console stays `TERMINATE`. s232: the eleven new catchable meanings have no console event either — a listen for one is `io`, and a raise of one `io`; `os_signal_ignored`, `os_signal_send` (after its shape), `os_signal_number` (after its shape) and `os_ppid` are `unsupported`; `os_pid` answers. - `[os.signal.det]` **Determinism (X12 — the replay rule).** A signal is external non-determinism. Under the deterministic test scheduler (`--schedules`/`--replay`) signal delivery is **excluded from the replay stream**: arrival emits no `sched-ev` record — it is not a `pick`, not a schedule point (`[sched.point.set]` gains no signal kind). The deterministic scheduler owns task and channel interleavings, not OS signal arrival; a server's real `SIGHUP` is not a thing you replay. Exclusion is by construction — the runtime's signal path never calls the schedule-point seam. A signal *wait* still parks through the ordinary blocking point (its block-enter/exit are schedule points like any park); only the external *arrival* is excluded. A program stays reproducible when it waits for exactly the signal it caused (the loopback witness): the OUTPUT is causally pinned even though arrival TIMING is not. Deterministic signal REPLAY (injecting arrivals as seeded schedule events) is deliberately not in v1; it is a contracted determinism follow-on if a future need escalates it. - `[os.signal.checked]` **The checked lane.** The checked machine (`wolf_mem::ubcheck`) runs in a threaded test host where touching real OS signals is unsound (the `env_set` overlay asymmetry). It therefore models signals as a PURE IN-MACHINE queue: `os_signal_listen` records the interest set, `os_signal_raise` enqueues a listened meaning, `os_signal_wait` dequeues the first matching meaning. The sequential loopback (listen→raise→wait) is fully modeled and deterministic. A `os_signal_wait` with no pending delivery lets the run's other tasks go on and looks again after each (`[exec.checked.task]`), so a sibling's raise reaches a parked waiter (`corpus/os/signal_supervisor.lu`, refused by name until s226); with no other task alive it is refused **by name** (the conservatism ledger) — nothing in the machine could deliver a signal later; the honest refusal, never a fake pass. **s219:** a listen also arms the host for real, on unix — a handler that sets the meaning's bit in an atomic pending set and does nothing else, and an unblock of the signal on the machine's one thread — so a real signal to a checked program that listens (Ctrl-C at its terminal) is a delivery `wait` and `poll` take, after the in-machine queue, and never the program's death. `os_signal_ignore` and `os_signal_default` are the host's own `sigaction`: a checked program that ignores `INTERRUPT` survives Ctrl-C, and its children inherit the ignore, as on every other machine. The settings outlive the run in a process that hosts more than one (a test binary): stated, not undone. **s232:** a listen, wait or poll refuses a set by the one shape rule; a poll or wait first lets a pending listened signal be delivered on the machine's thread (`[os.signal.poll]`'s step 1; the handler sets the pending bit itself, so there is no pipe to trail). A signal sent to the process is handed to some thread that does not block it, and the `wolf` process has many (the compiler's own, measured: seventeen beside the machine's); one of them would run the handler after the machine's poll had looked (measured before: 1 of 2,000 reaps missed, and every run missed once the caller alone blocked them). So `wolf conform-run --checked` blocks the twenty catchable signals before it makes any other thread — every thread it makes inherits the block — and the machine's own thread runs with the mask the process started with: it is the one thread a signal can be handed to, and what it spawns and execs starts as it would have. `os_signal_ignored` reads the host's dispositions, which are the `wolf` process's — except `BROKEN_PIPE`, which Rust's runtime sets to ignored in `wolf` before `main`: until the program sets that one itself, it is answered from the disposition `wolf` was started with, which `wolf` records in a start-up constructor before that runtime runs. `os_signal_send` and `os_signal_number` are the host's own; `os_pid` is the `wolf` process's id, the process the program runs as (`[os.proc.exec]`), and `os_ppid` its parent's. A child, and an exec, start with `BROKEN_PIPE` as the PROGRAM holds it (`[os.signal.disp]`), not as `wolf` does. - `[os.signal.shell.note]` **Design note: the shell's signals** (s232; wolf-lang#679, #681, #691, #141, #423; pelt's `trap`, `kill`, `$$`, `$PPID`). A POSIX shell's `trap` takes any signal XCU `kill` names, must know which it was started ignoring, must never miss one that came while it waited for a child, and must name and signal any process. What stays out, by rule: no wolf code in a handler (`[os.signal.model]`); no group send, stop reporting or `SIGCONT` to a job — job control's next lane (`[os.term.note]`); no real-time signals or `siginfo`. **Ruled (s232; #70, 2026-10-12), each as its recommendation, which is what is implemented:** - **R1, which signals get a bit, and their names.** (A) every signal POSIX names: the twenty a program may catch, listenable; `KILL`, `STOP` and the six fault signals send-only; names of what happened in the s114/s219 style (`USER`, `ALARM`, `BROKEN_PIPE`, `ABORT`, `CONTINUE`, `RESIZE`, `URGENT`, `VIRTUAL_ALARM`, `PROFILE_ALARM`, `CPU_LIMIT`, `FILE_LIMIT`, `KILL`, `STOP`, `SEGMENT_FAULT`, `BUS_ERROR`, `ARITHMETIC`, `ILLEGAL`, `BREAKPOINT`, `BAD_SYSCALL`) at bits 9..27; (B) the same bits named as POSIX names them (`USR1`, `ALRM`, …) — exact, but the table is by meaning on purpose and ruling #57 R1 took names of what happened; (C) only the six pelt's cases name (`USR1`, `ALRM`, `PIPE`, `ABRT`, `CONT`, `WINCH`) — leaves `kill -9` unsendable and the next shell script short. **Recommend A.** The fault signals are send-only because a trampoline that returns from a real fault re-runs it and the stack guard owns `SIGSEGV` and `SIGBUS`. - **R2, a bit outside the table.** (A) one shape rule for every set-taking call — empty or any bit that is not listenable is `io`, nothing changes; (B) fix `listen` alone and keep wait/poll's "all unmapped" rule — two rules for one family; (C) mask the bit off everywhere, as listen did — a typo listens for nothing and says ok (#679's finding). **Recommend A.** - **R3, reading a disposition (#681).** (A) `os_signal_ignored() -> int ! {unsupported}`, the ignored set now; (B) `os_signal_disposition(meaning) -> int` (0 default, 1 ignored, 2 listened) — one meaning a call and an int that means a state; (C) a snapshot the runtime takes at start — a constructor in every program, and a second idea of "now". **Recommend A**: read at start, it is the snapshot. - **R4, the poll that trails (#691).** (A) the poll and a wait's first look settle pending signals onto the calling thread, wait out a running trampoline and read the self-pipe themselves under the drain thread's lock; `raise` sends to the calling thread (`[os.signal.poll]`, `[os.signal.raise]`); (B) the clause says a poll may trail and names a barrier (raise one meaning and wait for it: pelt's `settle`) — every caller pays and must know; (C) `signalfd` or `kqueue` with every listened signal blocked on every thread — linux-only or BSD-only, and a mask every child would inherit. **Recommend A.** - **R5, this process's id and its parent's (#141).** (A) `os_pid() -> int`, `os_ppid() -> int ! {unsupported}`; (B) `os_proc_pid(-1)` and a sentinel for the parent — a handle that is not one; (C) `os_proc_self() -> List[int]` — a pair for two questions. **Recommend A.** (The contract offered `os_proc_pid`; ruling #57 gave that name to a child's id.) - **R6, a signal to any process (#141).** (A) `os_signal_send(pid, meaning)`, pid above 0, meaning 0 for "could I"; (B) `kill(2)`'s whole encoding, groups by 0 and negative ids — job control's surface, the next lane's; (C) `os_kill(h, meaning)` on handles only (lobo's ask) — cannot reach `$PPID` or a pid file's process. **Recommend A**; a handle-taking form can be built over it with `os_proc_pid`. - **R7, a meaning's number.** (A) `os_signal_number(meaning) -> int ! {invalid, unsupported}`; (B) none — a shell carries a table per host (pelt today orders `USR2` "last here on both, a named simplification"); (C) the whole table as a `List[int]` by bit — one call, but a list to keep in step with the bits. **Recommend A.** - **R8, capabilities (ruling #54's reading).** `os_signal_send` is `exec` (process control, with `os_kill`); `os_pid`, `os_ppid` and `os_signal_ignored` are `env` (process context read, with `os_pgid`); `os_signal_number` is uncharged (`os_error_text`'s shelf). **Recommend as listed.** - **R9, lupin.** lupin forbids `unsafe` and links no libc (#57 R8). (A) it serves `os_pid` and `os_ppid` (std's `process::id` and unix `parent_id`) and `os_signal_number` from the linux and macOS numberings, answers every shape row, and refuses `os_signal_send` and `os_signal_ignored` by name (std has no `kill(pid, sig)` and no `sigaction`); (B) the same through a safe syscall crate (rustix) — the maintainer's call, as at #57. **Recommend A.** --- ## §2 The random source `[os.random]` - `[os.random]` **`os_random(n: int) -> List[int]`** returns exactly `n` bytes — each element in `0..=255` — of **operating-system-provided unpredictable bytes**: the platform CSPRNG, the same pool that keys the host's own TLS stacks. One call, OS-sourced, always. There is no userspace generator behind it, no seeding API, no deterministic or "fast" variant, and none may be added: a seedable source would be used for keys the moment it existed. Deterministic testing of cryptographic code INJECTS bytes at the API boundary (explicit nonce/key parameters — the sc16–sc22 posture); it never seeds a global. (The prelude's `random_seed` remains what it declares: a PRNG-seed convenience, `() -> int` — it is NOT an entropy API and no cryptographic construction may consume it.) - `[os.random.fill]` **The call's contract.** `n == 0` answers the empty list — a valid request for no entropy, not an error. `n < 0` is a caller contract violation: the deterministic trap **`assert`** (`[conf.trap.map]`, the `[mem.str.repeat]` posture), on every lane — never an empty list, never a row. A successful call returns a list of length exactly `n`; a short answer is not a conforming outcome (the implementation loops the platform call over short reads and `EINTR` — the boundary is the runtime's, never the caller's). Comptime evaluation refuses the call categorically (D33, the `Random` sandbox category): same program + target must give bit-identical comptime results on every host. - `[os.random.trap]` **Failure is a TRAP, never a silent fallback** (the X3 temperament: no silent wrong answers). If the operating system cannot provide bytes — the platform call fails, or the platform has no backend (`[os.random.platform]`) — the call traps with kind **`assert`**, ruled here the way `[mem.str.repeat]` rules its trap. There is deliberately NO error row on this surface and no degraded path behind it: a recoverable row would invite exactly the `else`-arm fallback to predictable bytes this clause exists to make impossible, and a cryptographic caller that receives predictable bytes while believing them unpredictable is the worst failure this language could ship. On Linux the call BLOCKS until the kernel pool is initialized (the `getrandom(2)` contract — early boot is a wait, not a failure); after initialization it does not block again. - `[os.random.platform]` **The platform matrix (named, per the `[os.signal.platform]` discipline).** - **Linux:** `getrandom(2)`, flags 0 — no `/dev/urandom` file descriptor to exhaust, no chroot failure mode; `EINTR` retried, short reads continued. The running implementation on all three lanes. - **macOS / FreeBSD:** `getentropy(3)` in 256-byte chunks (the call's own per-request cap). Implemented and host-tested; COMPILED-program delivery reached macOS at s59 with the backend port (the debug tier runs the run-phase corpus there). FreeBSD keeps the named lane gap until s61 — the s114 precedent: the gap is the lane, not the call. - **Windows (tier-1):** `BCryptGenRandom` with the system-preferred RNG (`RtlGenRandom` is its deprecated alias). Implemented and host-tested; compiled-program delivery lands with the Windows native backend (none exists yet) — the same named lane gap. - **Anything else:** no backend — the call TRAPS (`[os.random.trap]`). Never a PRNG, never silence. - `[os.random.checked]` **The checked lane.** The checked machine is a host process, so REAL OS entropy is available wherever the compiler runs: `os_random` makes the same platform call the native runtime makes (`wolf_mem::ubcheck`'s hand mirror of `wolf_rt::random` — the locked crate graph, D15) and refuses nothing. This is deliberate and load-bearing: wolf-wws and std's crypto ladder run on the checked lane, and an entropy surface that only worked natively would push them back into the dark. No signal-style in-machine model arises — entropy has no delivery timing to virtualize — and the two draws of the conformance witness are compared to EACH OTHER, never to a pinned value. - `[os.random.sole]` **The sole source.** `os_random` is the ONLY sanctioned source of cryptographic material in wolf — TLS session keys, ephemeral ECDH secrets, ACME account keys, nonces, salts, and every future key-generation surface in std.crypto MUST bottom out in this call. Deriving key material from clocks, pids, file descriptors, counters, hashes of any of these, or any userspace generator is non-conforming for cryptographic use (wolf-wws's ws05 interim derivation is exactly the practice this clause retires). X12 determinism modes do not virtualize this call: a replayed schedule may not replay entropy — key material under `--replay` is still real key material, never a recorded byte stream. --- ## §3 Sockets `[os.net]` The net builtin tier is the s39 blocking-TCP surface the prelude has carried since v0.1 — `net_listen`/`net_port`/`net_accept`/`net_connect` over `"host:port"` addresses, `net_read`/`net_write` (text, `utf8` on decode), `net_read_bytes`/`net_write_bytes` (a `List[byte]`, no gate), `net_deadline`, `net_close` — with the D30 row vocabulary `{refused, timeout, closed, utf8, invalid, io}`. A wolf handle is a dense small `int` into the runtime's own socket table, never the OS fd. This section states the second address family (s136), the listener and stream options, readiness, the accept, the gathered write, and the posture every parking call takes (s141); the TCP family's base clause is the prelude's documentation at this pin. - `[os.net.unix]` **Unix-domain sockets.** **`net_listen_unix(path: str) -> int ! {unsupported, exists, not_found, denied, io}`** binds and listens an `AF_UNIX` stream socket at `path`; **`net_connect_unix(path: str) -> int ! {unsupported, refused, not_found, denied, io}`** dials one. The fd either answers is an ordinary net stream/listener: `net_accept`, `net_read`/`net_write`, the byte pair, `net_deadline` and `net_close` serve it call for call (`net_port` on it is `io` — a path has no port). The rows distinguish the two things a caller must tell apart (wolf-lang#227): **`unsupported`** is the host — the runtime does not serve the family there, refused BY NAME and never a bare `io`; every other row is the path. At bind: an existing path is **`exists`** (a stale socket file is the operator's to remove — a program that owns the path removes it first with `fs_remove`; the runtime never clobbers a path it did not bind), a missing directory **`not_found`**, a permission the caller lacks **`denied`**. At dial: no socket file is **`not_found`**, a file nobody listens on **`refused`**, `denied` as at bind. **Cleanup posture:** the runtime created the socket file, so the runtime removes it — `net_close` of a unix LISTENER unlinks its path; a stream's close never touches the path; a process that dies without closing leaves the file, which the next bind refuses as `exists` (the nginx/haproxy posture, stated rather than implied). Host posture at this pin: linux and macOS serve the family on every tier (native, release, checked); windows answers `unsupported` — `AF_UNIX` exists there since Win10 1803 and the toolchain measures the kernel's answer on the runner, but `std::net` has no unix-domain surface on that host and the runtime carries no winsock binding beyond `WSAPoll` (D15), so the serving rung is named for the host's next campaign, not claimed. The reference interpreter's half rides wolf-interp is36. Witness: `corpus/net/unix_echo.lu` (three lanes; the path's cleanup asserted after close). - `[os.net.listen.opts]` **Listener options.** **`net_listen_with(addr: str, reuse_port: bool, backlog: int) -> int ! {unsupported, exists, denied, io}`** is `net_listen` with the two options a prefork server needs. It is an ACQUISITION call and nothing else: the handle it answers is the ordinary net listener every other call in the family already serves — `net_port`, `net_accept`, `net_deadline`, `net_close` — so nothing else in the surface grows for it, and `net_listen_with(addr, false, 0)` is `net_listen(addr)` call for call. **`reuse_port`** sets `SO_REUSEPORT` **before** the bind (after it the option is too late, which is why this is a call rather than a post-bind setter): N listeners — in one process or across N — may then hold one address at once, every member of the group carrying the option. What the kernel DOES with the group is the host's, and the runtime states rather than promises it: **linux** distributes accepts across the group by a 4-tuple hash (every hand accepts); **macOS** hands every SYN to the newest bound socket and falls to a survivor when that socket closes — the port is shared and failover is gap-free, but accepts are not distributed (measured on the runner and pinned by the runtime's own test, not quoted from a manual); **windows** answers `unsupported`, BY NAME — `SO_REUSEADDR` there lets any process take a bound port, and the runner's own measurement says what that then delivers: every dial to the FIRST socket bound, none to the second, so a second bind succeeds and then serves nothing. It is refused rather than aliased to this option. Both hosts that serve it guarantee the same two things a caller can build on: every dial is accepted by SOME member, and the survivor takes every dial after the others close. **`backlog`** is the `listen(2)` queue hint; `<= 0` asks for the runtime's default (128, std's own number) and the host clamps any hint to its `somaxconn`. **Rows.** An address another socket already holds — without the option on both sides — is **`exists`**, the "already in use" answer wolf-lang#234 asked for, spelled in the vocabulary `[os.net.unix]` gave the family rather than a new `in_use` tag; a privileged port the caller may not bind is **`denied`**; the host that does not serve an option is **`unsupported`**, by name; the rest is `io`. (`net_listen`'s own row stays `{io}` — its lowering coarsens, so a program that wants the distinction calls this one.) Every tier serves it where the host does: native, release, and the checked machine, whose bind is a hand mirror of the runtime's. Witness: `corpus/net/reuse_port.lu`. - `[os.net.wait]` **Readiness over a set.** **`net_wait(fds: List[int], deadline_ms: int) -> List[int] ! {io}`** answers which of `fds` can be read WITHOUT blocking: a listener whose `net_accept` will not block, a stream whose `net_read` will answer bytes or `closed`. The answer is the SUBSET, in the caller's order. Every other blocking call in the family waits on ONE socket; this is the one that waits on many, and it is what a serving loop that spawns no tasks has instead of a scheduler (wolf-lang#127: without it a single-loop server must time-slice — a short deadline on the listener, another on every open connection, every idle pass — and spends its life waking up to learn that nothing happened). **An EMPTY answer is the deadline expiring with nothing ready. That is an answer, not a failure**, and a loop that treats it as one is the mistake this sentence exists to prevent. **`deadline_ms`**: a negative value waits until something is ready, `0` asks and returns at once, a positive value is milliseconds. Readiness is **level-triggered**: asking consumes nothing, so two waits with the same set answer the same until the program actually reads or accepts. A stream whose peer has closed is READY — the `net_read` that follows is what reports `closed`, which is how a loop learns to drop it. **`io`** is a forged or closed handle anywhere in the set (nothing is waited on), a set that is empty with an unbounded deadline (nothing could ever end that wait — a program that means to sleep says `time_sleep_ms`), or a failure of the poll itself. The call does not interact with the task layer: it neither parks a task nor disturbs the reactor's registrations, so a program may mix it with `spawn` freely. Host posture: every tier-1 host answers this question and every tier serves it — `poll(2)` on linux, macOS and freebsd, `WSAPoll` on windows, and the checked machine mirrors the runtime call for call. Alone in s137, this clause names no refusal. **Readiness is not exclusivity** (s138): on a listener that several processes share, every waiter learns `ready` for one connection and only one of them takes it — true of `poll(2)` everywhere and not a defect, but this clause invites exactly that arrangement, so what the others' `net_accept` then does is stated in `[os.net.accept]`. Witness: `corpus/net/wait_readiness.lu`. - `[os.net.accept]` **Taking a connection.** **`net_accept(fd: int) -> int ! {timeout, io}`** parks until a connection is pending on the listener — or its `net_deadline` budget fires, `timeout` — and then TAKES it; the handle it answers is an ordinary stream. The prelude has carried the call since v0.1; this clause states the part `[os.proc.inherit]` made load-bearing (wolf-lang#242): **a wake is not a connection.** N processes accepting on one inherited listener are all woken by one arrival and one of them takes it, so the runtime takes WITHOUT BLOCKING — a listener lives non-blocking under the runtime on every reactor host (linux, macOS, windows) — and a hand that finds nothing there **waits again against the same budget**, the one computed when the call began, never a fresh one and never in the kernel's accept queue. **What a lost race answers: nothing, because it carries nothing.** From the program's side it is indistinguishable from the connection never having arrived, and it is the same shape as a peer that aborted between the wake and the take, which a single hand meets too (a BSD-derived kernel drops such a connection from the queue and the take answers `would_block`; linux hands it over as `ECONNABORTED` and its manual says to retry). So it is not a row — a `net_accept(l)?` with no budget armed would fail on a stranger's reset, and `timeout` before any deadline would be a lie about a clock — not an empty handle (D30: rows, never sentinels), and never a trap. What a program CAN build on: **with a budget armed, `net_accept` returns within it** — `timeout` when every wake inside the budget was for a connection another hand took, which is the row the call already declares and the arm every serving loop already writes; without one it parks until a connection is its own, in the runtime's wait (a park kill teardown reaches) rather than the kernel's. A loop that multiplexes a SHARED listener with `net_wait` and must keep serving its other sockets arms a short budget on the listener: that budget is the most a lost race can cost it. The accepted stream enters the runtime's table under the posture `[os.net.io]` states — non-blocking under the runtime whatever the listener's mode (BSD-derived kernels and winsock hand the new socket the listener's flags, linux does not; the runtime sets it either way), the syscall first, a park only on would-block — so nothing about `net_read`/`net_write`'s rows changes. Host posture: the three reactor hosts, every native tier; a tier-2 host with no reactor keeps the blocking syscall and has no inherit call to share a listener with. The **checked machine** is the same posture by construction: its budgeted accept polls a non-blocking listener and retries `would_block` against the same budget, its unbudgeted accept is the blocking syscall, and no other hand can share its listener — it refuses an inherit set BY NAME. Measured on the clause's box (macOS arm64, three hands in a free-for-all on one inherited listener, `ab -n 6000 -c 32`, one connection per request): **about 30,000 req/s before and after**, because under load a lost race is unparked by the next arrival microseconds later; the difference is when traffic stops, which ws17 measured as a hand at 0.0% CPU answering nothing, ever — and which the turn it shipped to avoid cost it 11,622 req/s against 17,347. **What the take COSTS the kernel, and why it is in this clause** (s149, wolf-lang#290). The posture above — non-blocking, close-on-exec — used to be written onto the new descriptor AFTER the accept, one extra syscall per connection, because std's accept asks for `SOCK_CLOEXEC` and nothing else. Where the host has **`accept4`** the runtime asks for the whole posture in the accept itself (`SOCK_NONBLOCK | SOCK_CLOEXEC`), so a connection costs ONE call instead of two. This is a COST, not a row: the stream a program is handed is the same stream, under the same posture, with the same rows, and nothing a program can write down tells the two apart. **The per-host shape, stated.** linux has `accept4` and uses it. **macOS has no `accept4` at all**, so there the take is std's `accept` plus the two flag writes the kernel does not fold in (`ioctl(FIOCLEX)`, then `ioctl(FIONBIO)`) — three calls, measured on the clause's box by interposing libSystem, and no kqueue arrangement changes it, because the cost is in the ACQUISITION and not in the wait. windows is its own acquisition and unchanged; a tier-2 host without the call keeps the two-step. So the SAVING is linux's and the BEHAVIOUR is everyone's, which is the only honest way to state a syscall count in a language spec. Witness: `corpus/net/accept_race.lu` — two hands on one inherited listener, one connection, both hands return; the posture on the accepted descriptor, read back from the kernel on both unix hosts, is `wolf_rt::net`'s crate test, and the linux COUNT is `crates/wolf_rt/tests/syscall_shape.rs` (`accept4` exactly once, carrying both flags; no `ioctl(FIONBIO)` on the accepted fd, ever). What a program observes is `corpus/net/accept_posture.lu`: nothing. - `[os.net.io]` **The syscall goes first.** Every parking call on a stream or a listener — `net_accept`, `net_read`/`net_read_bytes`, `net_write`/`net_write_bytes`/`net_writev` — tries the syscall BEFORE it waits, and waits only when the kernel answers that it would block. A socket that is ready when the call is made — the one `net_wait` just reported, a connection already in the backlog, a freshly accepted stream whose send buffer is empty — is answered by the syscall alone: the runtime's reactor is not consulted, no waiter is registered, no other thread is woken. A socket that is not ready parks in the runtime's wait (the same park as before: kill teardown reaches it, a `net_deadline` budget bounds it) and the call is retried after the wake; every park a call makes goes against the budget computed when the call began, never a fresh one — `[os.net.accept]`'s rule, now every call's. **Nothing a program can observe in the rows moves**: the same calls answer the same rows on the same inputs; what moves is the cost. Measured where the cost was found (lobo ws22, `sample(1)`, macOS arm64, wolf-lang#257): the pre-syscall park was ~30 of the 63 µs a wolf HTTP server spent per keepalive request — three round-trips through the `wolf-reactor` thread for sockets `net_wait` had already reported ready, 37.8% of the serving thread parked in a condvar, another 8% in its own `kevent`, the syscalls themselves 12%. One consequence is stated because a program can build on it: **a write's budget covers the whole drain.** A body larger than the send buffer leaves in chunks with a park between them, each park against the call's budget, so a `net_deadline` armed on a stream now bounds a large `net_write` to a peer that stopped reading (before this clause the drain rode a blocking syscall past any budget); the row is `net_write`'s `io` (its `timeout` coarsened, as the call declares), and the bytes the kernel took before the budget fired have been sent. Host posture: every socket the runtime holds lives NON-BLOCKING on the three reactor hosts (linux, macOS, windows) — a listener since `[os.net.accept]`, a stream since this clause — and the runtime sets the flag on acquisition rather than trusting a kernel's inheritance rule (BSD-derived kernels hand it down, linux does not); a tier-2 host with no reactor keeps the blocking syscall, which parks in the kernel as v0 did. A socket the program did not get from the runtime is never touched. The **checked machine** answers the same rows through its own blocking sockets and their timeouts, and owes no scheduling; the reference interpreter polls its non-blocking sockets first and waits only on not-yet (the posture it held when `[os.net.accept]` cost it no source motion), so the differential is the proof. Witness: `corpus/net/syscall_first.lu` — a read on a socket `net_wait` reported answers; a budgeted large write to a peer that never reads returns at the budget with the row, not a park. - `[os.net.writev]` **The gathered write.** **`net_writev(fd: int, parts: List[List[byte]]) -> () ! {closed, io}`** sends every part in order as ONE write — `writev(2)` on unix, `WSASend` on windows: the bytes are gathered by the kernel and never copied by the runtime, so a head and a body leave in one syscall, and where two writes would have been two segments, in one. It is `net_write` in every other respect: the whole gather drains, the syscall first (`[os.net.io]`), a park on would-block against the budget computed at entry, the drain resumed from the byte the kernel stopped at in whichever part it stopped in. The rows are `net_write`'s exactly — `closed` for a peer that has gone, `io` for the rest, a fired budget included; `invalid` is `net_write_bytes`'s refusal of a list that is not a `List[byte]`, which a typed `List[List[byte]]` cannot present and this call does not declare. Empty parts are permitted and send nothing; a call whose parts are all empty is a completed write with no syscall. A listener or a forged handle is `io`. Host posture: every tier-1 host, every tier; the checked machine drives the same std vectored write to completion over its blocking sockets. Witness: `corpus/net/writev_gather.lu`. - `[os.net.writev.head]` **The gather with a `str` head.** **`net_writev_head(fd: int, head: str, parts: List[List[byte]]) -> () ! {closed, io}`** sends `head`'s bytes and then every part, in order, as ONE write — `[os.net.writev]` in every respect (the same drain, the same park, the same rows), with the head carried as the `{ptr, len}` pair a `str` already is instead of as a list. An empty head contributes no segment; a call with an empty head and empty parts is a completed write with no syscall. **Why the surface exists, and what it costs.** A response head is a `str`, and `[mem.str.view.lend]` rules that `s.bytes()` in a `let` — or pushed into a list, which is a callee that keeps it — MATERIALIZES a `List[byte]`. So entering `[os.net.writev]`'s vector cost a full copy of the head plus a list header, **per response**, for bytes that already sat in the arena and that `writev` would have read in place: 241 bytes and one list on the shape lobo measured at ws28, after which it was the only copy left on its plaintext path (wolf-lang#299). **The cost, stated, and it is a subtraction.** This call removes that copy and that allocation and adds nothing: one more `iovec` entry, which the syscall already takes, and no change to the number of syscalls, the drain, or the rows. It is not a faster `net_writev`; it is the same write reached without materializing an operand — which is also why it is a named call rather than an optimization: `[mem.str.view.lend]`'s copy is a rule of the language and not a compiler's choice, so the way out of it must be a spelling. Host posture: `[os.net.writev]`'s exactly — every tier-1 host, every tier. Witness: `corpus/net/writev_head_gather.lu`. - `[os.net.nodelay]` **Nagle's algorithm, and the option surface.** Every TCP stream the runtime hands a program — accepted or dialed — has **`TCP_NODELAY` set**: Nagle's algorithm is OFF by default. The reason is the one wolf-lang#254 measured: a server that answers with a head and then a body — two writes, the second small — stalls on linux behind the peer's delayed ACK, 40 ms per request, a 108x gap against nginx on the same runner for a program that did nothing wrong; the posture Go, nginx and node take is the one that makes a naive two-write server correct without knowing this clause exists. **`net_nodelay(fd: int, on: bool) -> () ! {io}`** sets the option either way — `false` puts Nagle back for the program that wants its small writes coalesced — on a TCP stream; a listener, a unix-domain stream (the option is TCP's), a forged or a closed handle is `io`. **What this surface is, and is not.** It is one option, named, taking effect after acquisition, idempotent, with a stated default — the shape a stream option must have to enter this section. It is NOT a generic `setsockopt`, and none is planned: an option reaches the language by clause, with its rows and its default stated, as `reuse_port` did in `[os.net.listen.opts]` and this one does here. What the shape admits without a new mechanism are options of that kind — a keepalive switch is the obvious next — and nothing whose semantics differ per host without a measurement to state (linger, buffer sizes). Host posture: every tier-1 host serves the option through the same call, on every tier — native, release, and the checked machine, whose streams enter its table under the same default. **When the default reaches the kernel** (s149, wolf-lang#290). The default is a promise about WRITES, and Nagle can only hold back a write that follows bytes of the same stream still in flight: the FIRST write on a connection has no unacknowledged data behind it and leaves at once, option or no option. So the runtime pays the `setsockopt` at the last moment it can matter — before the first write that finds bytes already in flight, including the continuation of a large drain, whose final short chunk is exactly the segment Nagle would sit on — and a connection whose one response is followed by a close pays it **never**. nginx reaches the same place by a different rule (it sets the option when a connection goes keepalive). **What a program can observe: nothing.** Every write leaves when it left before, in the order it left, with the rows it had; `net_nodelay` called by the PROGRAM is answered at once, either way, and `false` ends the deferral rather than postponing it — the runtime never sets an option the program has said it does not want. The one place the deferral is visible is from OUTSIDE the language: a `getsockopt` through a foreign call, or a tracer, sees the option unset on a stream that has written once. That is the measurement lobo makes, not a semantics a program can depend on. The one row it moves: a kernel that refuses the option is `io` at that write rather than `io` at the accept. Witnesses: `corpus/net/nodelay.lu` (the option's rows and the two-write shape the default exists for), `corpus/net/accept_posture.lu` (what a program sees of the deferral: nothing), and, off the corpus, `wolf_rt::net`'s crate test — which reads `TCP_NODELAY` BACK from the kernel at each step, so "unset after one write, set before the second" is measured on every unix host and not merely traced on linux. --- ## §4 Descriptor inheritance `[os.proc]` The process trio `os_spawn`/`os_wait`/`os_kill` has never handed a child anything but argv: every descriptor the runtime opens is close-on-exec, so a spawned child holds no socket of its parent's (wolf-lang#235 measured exactly that with `lsof`). A prefork server needs the one exception, named and bounded. - `[os.proc.spawn]` **A child's descriptors 0, 1 and 2.** A child started by `os_spawn(argv)` or `os_spawn_with(exe, args, inherit)` gets **descriptor 0 wired to the host's null device** — it reads immediate end-of-file and never the parent's input — and **descriptors 1 and 2 inherited**: the parent's own stdout and stderr, so what the child writes goes where the parent's writes go, interleaved at the byte level with no capture and no reordering by the runtime. The same on linux, macOS and windows, and on every tier. **The checked machine** shares the posture with one stated asymmetry: its `print` stream is a buffer the machine owns and the child's fd-level writes cannot enter, so under `conform-run --checked` the child's bytes reach the HOST stream ahead of the observation record rather than inside its `stdout_inline`. Capture is not this call's: a program that must read a child's output, feed its input or redirect any of its descriptors spawns with a descriptor map (`[os.proc.fds]`) and a pipe (`[os.proc.pipe]`), s215's answer to wolf-std F-0065 and wolf-lang#129's named ask; the posture here is what a child gets when nothing is mapped. **The cost, stated:** zero — no pipe, no thread, no copy; the child is handed the parent's descriptors at spawn and the null device is one open. (Ruled by s111's fix of wolf-lang#129, `21b129e`, and written here 2026-09-15 by s163 for wolf-lang#256: `[os.proc.inherit]` said "ordinary stdio" and wolf-std's `std.process` read it as three null-wired streams. Measured at wolf 0.2.14 on linux: `--native` and `--checked` carry a child's stdout and stderr through and hand it an empty stdin; **lupin 0.1.36 shows neither of the child's streams**, which parts from this clause and is the interpreter's to mirror. Witness: `crates/wolf_driver/tests/json_process_native.rs`, `spawned_child_stdout_writes_through`, on every host with a `/bin/sh`.) - `[os.proc.exe]` **`os_exe()` names the running executable, which is not always the program.** It answers the path of the binary the process is executing. A compiled wolf program is that binary, so a native tier's `os_exe` is the program and a self-spawn re-runs it. **Under an interpreter it is the interpreter**: the checked machine answers the `wolf` binary and lupin answers `lupin`, so a self-spawn there starts the interpreter with the program's arguments, which it does not understand (both exit 2 at 0.2.14 and 0.1.36). A self-spawning witness is therefore a native-tier witness, and says so. **The cost, stated:** one host query, the path read once per call. (Written 2026-09-15, s163 — wolf-lang#256's second half: sc39 spent a measurement learning it.) - `[os.proc.inherit]` **Handing descriptors to a child.** **`os_spawn_with(exe: str, args: List[str], inherit: List[int]) -> int ! {unsupported, not_found, denied, io}`** spawns `exe` with `args` — the program named apart from its arguments, unlike `os_spawn`'s single argv list — and hands the child the net handles in `inherit`. **The child receives them as descriptors 3, 4, … in the order given.** That numbering is the contract, and it is what makes the pair usable: the parent never learns an OS descriptor number, and the child does not need one told to it — "the listener is 3" is true by position. Descriptors 0, 1 and 2 are the spawn's stdio as `[os.proc.spawn]` wires it; nothing above `2 + len(inherit)` is open in the child that the runtime put there. An empty set is `os_spawn` with the program named apart. **`net_adopt_listener(fd: int) -> int ! {unsupported, io}`** is the child's half: an ACQUISITION call answering the ordinary net listener (`[os.net.listen.opts]`'s rule), so accept, the deadlines and the closer come free. **What is checked.** The runtime asks the KERNEL what the number is rather than trusting the caller — a stream socket (`SO_TYPE`) with no peer (`getpeername` answering `ENOTCONN`), and, where the kernel keeps a listening flag, that flag (`SO_ACCEPTCONN` on linux and freebsd; macOS answers `ENOPROTOOPT` to that option on every socket, so a bound socket nobody called `listen` on adopts there and its first accept is `io`). Anything else — not a socket, a connected stream, a family the table does not serve, a number this process already adopted, a negative or absurd number — is **`io`**: never a trap, never a wrapped stranger. **Ownership at close.** An adopted descriptor belongs to exactly one wolf handle, and `net_close` of that handle CLOSES it: the adopter owns the fd from the adopt on, and a second adopt of a still-live number is `io` rather than a double close waiting to happen (closing the handle releases the number, and a freshly handed one adopts again). What the adopter does NOT own is anything OUTSIDE the descriptor: an adopted `AF_UNIX` listener does not unlink its socket file at close — `[os.net.unix]`'s cleanup posture belongs to the process that BOUND the path, which for an inherited listener is the parent. An adopted descriptor is marked close-on-exec on adoption: this process's own children inherit only what it hands them in turn. **Bounds.** One spawn hands at most 64 descriptors; a larger set is `io` before any child exists, as is a forged or closed handle anywhere in the set — the mapping from handles to descriptors happens first, so a bad set spawns nothing. **Host posture at this pin.** linux and macOS serve both calls on the native and release tiers. windows answers **`unsupported`** to a non-empty inherit set and to `net_adopt_listener`, BY NAME: handle inheritance exists there (the runtime's windows test suite measures it on the runner — a listener marked `HANDLE_FLAG_INHERIT` is the same listener in a child), but a `SOCKET` is not a small stable number a parent can name by position, so the serving rung is named in `docs/platforms.md` rather than claimed. The **checked machine** refuses both BY NAME with the construct named (the records rule): it is the `wolf` binary interpreting a program, so a descriptor handed to "the program's child" would be handed to the compiler's child. Its `os_spawn_with` with an EMPTY set is served, like `os_spawn`. **The accept behind it** (s138): the shared-listener prefork this clause enables is where `[os.net.accept]`'s lost race is met first — N hands accepting on one handed-down listener are all woken by one connection and one takes it — and that clause is what makes the hands safe to run free-for-all: a hand that loses waits again against its own budget, never in the kernel. Witness: `corpus/net/inherit_listener.lu`; the race itself, `corpus/net/accept_race.lu`. **A child's descriptors, set up by the parent** (written 2026-10-06, s215, for pelt's H2 — the plumbing a shell is made of: a pipeline, a redirection, `2>&1`, `N&1` is the pair `(2, w)` written beside `(1, w)` by the shell that parsed it, not something the runtime reads. A pipe is the other half: two ordinary fs handles, which is what lets a map hand either end to a child and lets the parent read or write the other with the `fs_*` calls it already has. **Why `os_spawn_with`'s listener set is not folded into the map.** It would be the special case "targets 3, 4, … in order, sources the inherit list" — except that its sources are NET handles and the map's are FS handles, two numbering spaces the runtime keeps apart (a net handle 3 and an fs handle 3 are different objects), so one integer cannot name either without a tag on it. The prefork call keeps its narrower contract (positional, listeners only, adopted by `net_adopt_listener`), and a later lane that gives a map entry a source kind can retire it; nothing in this clause forecloses that. - `[os.proc.fds]` **The spawn with a descriptor map.** **`os_spawn_fds(exe: str, args: List[str], map: List[int]) -> int ! {denied, invalid, io, not_found, unsupported}`** spawns `exe` with `args` — the program named apart from its arguments, as `os_spawn_with` names it — and sets up the child's descriptors as `map` says. `map` is a flat list of PAIRS `[target, source, target, source, …]`: the child receives `source` AS descriptor `target`. A source is a handle of this process — **0, 1 and 2 its standard streams** (`[os.fs.std]`), **3 and up a handle of the fs table**: a file an open answered, a pipe end (`[os.proc.pipe]`) — or **`-1`, "closed in the child"**. Two targets may share a source (`1 ← w`, `2 ← w` is `2>&1` onto `w`), and a source may sit at a number the map also names as a target: every source is read before any target is written, so `(0, 1), (1, 0)` swaps. **What the map does not name** keeps `[os.proc.spawn]`'s posture: **0 the null device, 1 and 2 inherited, nothing else open** — every runtime descriptor is close-on-exec, so the only descriptors above 2 a child holds are the ones its map placed (witnessed by comparing a mapped child's `/dev/fd` with a control's). **The order of refusal is the contract**, so a caller can tell how far the call got, and nothing before the last step makes a child: (1) the map's SHAPE — **`invalid`** for an odd length, a target outside `0..255`, a target named twice, a source below `-1`, or more than 64 pairs; (2) the HOST — **`unsupported`**, by name, for a non-empty map on windows, where a `HANDLE` is not a small number a child can be handed at a position; (3) the SOURCES — **`io`** for a closed or forged handle; (4) the program — **`not_found`** (and for an empty `exe`), **`denied`**, **`io`**, as `os_spawn`. The parent keeps every source: the call reads the map and closes nothing of the parent's, so a pipeline's parent closes its own copies of the ends it handed down — or the last reader never sees the end of its input. An empty map is `os_spawn_with` with an empty set. **Mechanics (unix), stated because the order is observable:** each source is duplicated in the parent and held until the spawn returns (a handle another task closes meanwhile cannot have its number reused under the map); in the forked child every source is first staged above the highest target, then `dup2`'d onto its target (which clears close-on-exec on the target and only there), each stage closed, each `-1` target closed, and the null device opened onto 0 when the map leaves 0 out — only async-signal-safe calls, between `fork` and `exec`. **Machines.** native and release serve it on linux and macOS from `wolf_rt::os`. **The checked machine serves it too** — unlike `os_spawn_with`'s listener set, every source is a handle of the machine's own table or one of 0..2, so what the child receives is exactly what the program made — with `[os.proc.spawn]`'s asymmetry: 0..2 as SOURCES are the `wolf` process's streams, and the machine's `print` stream is a buffer no child enters. **lupin** serves targets 0, 1 and 2 and refuses BY NAME a target above 2 or a `-1` entry: it admits no `unsafe`, and stable Rust (1.97) has no safe call that places a descriptor above 2 in a child or closes one there. **The cost, stated:** one duplicate per source in the parent and, in the child, at most two `dup2`-class calls per pair plus one `open` — no thread, no copy of any byte. Witnesses: `corpus/os/spawn_fds_rows.lu` (the rows that make no child, every host); a two-stage pipeline, `2>&1` onto a pipe, an explicit descriptor 5, no leaks and a child in the moved directory — `crates/wolf_driver/tests/proc_fd_lanes.rs`, unix. - `[os.proc.pipe]` **A pipe.** **`os_pipe() -> (int, int) ! {io}`** answers a new pipe's READ end and WRITE end, in that order, as two handles of the fs table (3 and up, `[os.fs.std]`'s numbering). Everything the table serves serves them: `fs_write`/`fs_write_chunk` on the write end, `fs_read`/`fs_read_chunk` on the read end, which answer `eof` once every write end — in this process and in every child that holds one — is closed; `fs_fstat` says kind 2 (a fifo); `fs_seek`, `fs_tell` and `fs_read_at` are `unseekable`; `fs_close` ends either. **Both ends are close-on-exec**, as every runtime descriptor is: a child receives an end only through a map (`[os.proc.fds]`), never by accident. `io` is a host that cannot make one (the process's descriptor limit). A write to a pipe whose every read end is closed is the host's `EPIPE` (and, where the process has not ignored it, `SIGPIPE`); a program that writes into a pipeline closes ends in the order a shell would. **Host posture.** linux, macOS and windows all make it (Rust's `std::io::pipe`, which answers both ends close-on-exec on every host; an anonymous pipe on windows), on every machine; only handing an end to a CHILD is unix-only, which is `[os.proc.fds]`'s rule, not this one's. **The cost, stated:** one or two system calls (the pipe, and the close-on-exec flag where the host cannot set it in the same call) and two table slots. Witness: `corpus/os/pipe_round_trip.lu`. --- - `[os.proc.job]` **The spawn for a job** (s219, wolf-lang#622). **`os_spawn_job(exe: str, args: List[str], map: List[int], group: int, tty: int, defaults: int) -> int ! {denied, invalid, io, not_found, unsupported}`** is `os_spawn_fds` (`[os.proc.fds]`) with what a job-control shell does between `fork` and `exec`, done in the child in this order, before the map is placed: **`group`** -1 leaves the child in this process's group, 0 makes it the leader of a new one (its group id is its pid, `os_proc_pid`), and a positive id joins that group (`setpgid`); **`tty`** -1 hands nothing, and a handle naming a terminal makes the child's group that terminal's foreground (`tcsetpgrp`, with `SIGTTOU` blocked for the call); **`defaults`** is a set of meanings (`[os.signal.set]`) set back to the default in the child (`[os.signal.disp]`). The parent repeats the `setpgid` and the `tcsetpgrp` after the spawn — neither side races the other — and ignores their failure (the child may already have ended). The call is read in `os_spawn_fds`'s order: the SHAPE (`invalid`: a bad map, `group` or `tty` below -1, a `defaults` bit outside the meanings), the HOST (windows: `unsupported` unless every option is off and the map is empty), the `tty` (`io` unless it is an open terminal) and the map's sources (`io`), then the program; a failure in the child before `exec` is the spawn's own (`denied` for a group in another session, `io` otherwise), with no child left behind. With every option off this call is `os_spawn_fds`. - `[os.proc.status]` **A child's pid and how it ended** (s219). **`os_proc_pid(h: int) -> int ! {io}`** is the child's process id (`io` for a forged or reaped handle). **`os_wait_status(h: int) -> int ! {io}`** waits and reaps exactly as `os_wait` does, and answers the exit code (0..255) for a child that exited, or **-N** for a child ended by signal N — the platform's number, because a death by `SIGSEGV` or `SIGKILL` has no meaning in `[os.signal.set]` and a shell reports `128 + N`. The two ranges never meet, so no status is a fake code (`os_wait`'s `signal` row stays for the programs that only need to know). A child that stops (Ctrl-Z) is not an ending: the wait goes on waiting (stop reporting is job control's next step, named in `[os.term.note]`). - `[os.proc.self]` **This process's id, and its parent's** (s232, wolf-lang#141). **`os_pid() -> int`** is this process's id (`getpid`; every host has one, so there is no row) — what a shell's `$$` is, and what a pid file holds. **`os_ppid() -> int ! {unsupported}`** is its parent's id now (`getppid`): a process whose parent has ended answers the id of the process that adopted it, so a shell reads it once, at start, for `$PPID`. Windows has no such call: `unsupported`. `os_proc_pid(h)` (`[os.proc.status]`) stays a child's id by handle. **Replacing the program, and its environment** (written 2026-10-10, s225, for wolf-lang#534: boreutils' `env`, pelt's `exec` and `unset`). The design, before its two clauses: an exec is `execve(2)`, and its arguments are the whole of what the new program receives — its argv, its environment and its descriptors — so each is DATA the call takes, never state it reads behind the caller's back. The argv is complete (argv[0] included: `env -a` and a shell's `exec -a` name it); the environment is a list of `NAME=VALUE` entries, `env_vars()`'s own shape, so `env_vars()` passed through keeps the current one and a `List[str]()` is `env -i`; the descriptor map is `os_spawn_fds`'s. The signal-number half of wolf-lang#534 is `os_wait_status` (`[os.proc.status]`), and a meaning a shell wants defaulted in the new program is set by `os_signal_default` before the exec (`[os.signal.disp]`): an exec keeps an ignored signal ignored and resets a caught one, as POSIX says, so no option of the exec repeats those calls. - `[os.proc.exec]` **Replacing the running program.** **`os_exec(exe: str, argv: List[str], env: List[str], map: List[int]) -> () ! {denied, invalid, io, not_found, unsupported}`** replaces this process's program with `exe`: the process id stays, the image goes, and on success the call does not return. Its result type is unit, as `os_exit`'s is (`[os.host.sigs]`): a call that returns has failed, and its row says how. **What the new program receives.** `argv` exactly, argv[0] included; `env` exactly, as its whole environment (each entry `NAME=VALUE`; the value may be empty and may hold `=`); and this process's descriptors as `map` (`[os.proc.fds]`'s flat pairs) places them, with one difference from a spawn: **an unmapped 0, 1 and 2 stay this process's own** (an exec keeps its stdio; the spawn's null stdin does not apply). Every descriptor the runtime opened is close-on-exec, so nothing the program opened survives the exec unless the map placed it, and a `-1` entry closes that target in the new program. **Which program.** An `exe` holding `/` is used as written, relative to the working directory; a bare name is searched along the `PATH` entry of `env` — the environment the new program is HANDED (its first `PATH=` entry) — or `/usr/bin:/bin` when `env` names none; an empty component is the working directory. The first candidate that is a regular file with an execute bit is the program. The rule reads only the call's own arguments, so every machine applies it alike (`env -i NAME=1 ls` finds `ls`; `env PATH=/nowhere ls` does not). A file without the `#!` line the kernel needs is `io` (`ENOEXEC`): no machine re-runs it under `/bin/sh`. **The order of refusal is the contract**, so a caller can tell how far the call got, and nothing before the last step is irreversible: (1) the SHAPE — **`invalid`** for an empty `argv`, a NUL in `exe` or an argv word, an `env` entry with no `=`, an empty name or a NUL, and every map `[os.proc.fds]` calls `invalid`; (2) the HOST — **`unsupported`**, by name, on windows, which has no exec; (3) the SOURCES — **`io`** for a closed or forged handle in the map; (4) the PROGRAM — **`not_found`** (and for an empty `exe`) when no candidate exists, **`denied`** when a candidate is a file without an execute bit or the kernel says `EACCES`, **`io`** otherwise. **A failed exec leaves the process as it was:** every target the map moved is put back (open with its flags, or closed). **Mechanics (unix), stated because the order is observable:** each source is duplicated and held, as `os_spawn_fds` holds it; each target's current descriptor and its close-on-exec flag are saved above the highest target; every source is staged there and `dup2`'d onto its target (clearing close-on-exec on the target only); the `-1` targets are closed; then `execve`. **Machines.** native and release serve it on linux and macOS from `wolf_rt::exec`. **The checked machine** replaces the `wolf` process that is interpreting the program — the pid is the one `wolf` ran as — and does three things first, each undone if the exec fails: it writes the bytes the program printed (its buffered `print` and `eprint` streams) to descriptors 1 and 2, where a native program's bytes already are; it enters the machine-local working directory (`[os.fs.chdir]`); and it sets `SIGPIPE` back to the default, which the `wolf` binary ignores (as Rust programs do) and the program never asked for. Under `conform-run` a successful exec ends the observation with no record: what follows the program's own bytes on the stream is the new image's. **lupin** serves it through Rust's `CommandExt::exec` — safe code, so the interpreter can replace itself — for targets 0, 1 and 2, and refuses BY NAME a target above 2 or a `-1` entry, as it refuses them in `[os.proc.fds]` (no `unsafe`); its environment and working directory are its own machine-local ones. **The cost, stated:** one `execve`; before it, per map pair, one duplicate held, one save and one stage (`fcntl`) and one `dup2` — no thread, no copy of any byte. Witnesses: `corpus/os/exec_rows.lu` (the rows that replace nothing, every host); the image replaced with the pid unchanged, argv and the environment as given, a mapped descriptor present and an unmapped opened file closed, a bare name found along the handed `PATH`, and a failed exec that left descriptor 0 in place — `crates/wolf_driver/tests/exec_lanes.rs`, unix, on checked, native, release and lupin. - `[os.env.unset]` **Removing a variable.** **`env_unset(name: str) -> () ! {invalid}`** removes `name` from this process's environment: `env_get` answers `missing` after it, `env_vars` no longer lists it, and a child started after it (`os_spawn`, `os_spawn_with`, `os_spawn_fds`, `os_spawn_job`) does not receive it. Removing a variable that is not there succeeds, as POSIX `unsetenv` does; a name `env_set` refuses — empty, or holding `=` or NUL — is **`invalid`**. A later `env_set` brings the name back. **Machines.** native and release write the program's own real environment, as `env_set` does (and with `env_set`'s concurrency posture: a program that removes a variable while another task reads the environment owns that race). **The checked machine** keeps a removal as a tombstone in its machine-local overlay — `env_set`'s overlay, for `env_set`'s reason (it runs inside threaded hosts) — and since this clause **a child it spawns is handed the host environment with the overlay applied**, sets and removals both, as a native child is handed its parent's (before s225 a checked child saw neither an `env_set` nor, had it existed, a removal: a parting no witness had pinned). **lupin** removes the name from its machine-local environment (s40: an overlay that starts empty and never reads the host's), and its children are handed the host environment with that overlay applied, as the checked machine's are (they stay null-wired, s163's parting, so the unix witness asks a lupin child through its exit status). **The cost, stated:** one `unsetenv` natively; one map entry on the checked machine and lupin. Witnesses: `corpus/os/env_unset.lu` (every machine); a child that no longer sees a removed variable — `crates/wolf_driver/tests/exec_lanes.rs`, unix. ## §5 The machine's size `[os.cpus]` - `[os.cpus]` **The count of schedulable cores.** **`os_cpus() -> int ! {io}`** answers how many cores this process may actually be scheduled on, and it is always `>= 1` when it answers. **"Schedulable", not "installed"** is the whole content of the clause: on linux the number honours a cgroup cpu quota and a cpu affinity mask, so a container given two cpus of quota on a sixty-four-core host answers **2**, where counting `processor` rows in `/proc/cpuinfo` — what a program had to do before this call existed — answers 64 and starts sixty-two workers that will never get a core between them. macOS reads `hw.ncpu`, windows `GetSystemInfo`. **`io`** is a host that cannot answer, and it is a ROW rather than a silent 1 on purpose: a program resolving "`workers auto`" must be able to say it did not learn the number instead of quietly running one worker and looking healthy. The number is machine state, so it is not comptime-readable (the `env` capability, with `os_cwd` and `os_exe`) and a conformance witness pins the RELATION, never the value. Every tier answers from the same source, so the native lanes and the checked machine agree on one host — which matters, because this number decides how many processes a program starts. Witness: `corpus/os/cpus.lu`. ## §6 Files `[os.fs]` The `fs_*` family (s38, s90) is specified by its witnesses under `corpus/fs/` and by `wolf_rt::fs`'s per-call rows; the clauses below are the entries that have been ruled since, in the shape `[os.net]` uses — one call, its rows, its default, its host posture. - `[os.fs.path]` **A path is the host's, handed over verbatim.** Every `fs_*` call takes its `path: str` to the host's own call unchanged: no canonicalization, no component walk, no case folding, no separator rewriting. A relative path resolves against the process's working directory, on every tier and on every tier-1 host, and `..` and symlinks mean what the host's resolution makes them mean. **Confinement is never a row**: an implementation that does not serve a path answers the program `unsupported` (a scope decline, `[proto.record.unsupported]`), never `denied`, `invalid` or any other tag a program could not also meet on the host itself. `[os.fs.path.domain]` **The domain is every path the host lets the process reach** (ruled 2026-09-24, wolf-lang#386, s175's draft as written). An `fs_*` path is not filtered, walked or canonicalized by the language: absolute, relative, climbing (`..`) and run-time-built paths are all served, and what the host refuses comes back as the host's own row (`denied`, `not_found`, …). An implementation that serves the tier over a narrower set — an in-process walk that must not write outside its tree — declines the rest by name (`unsupported`, `[proto.record.unsupported]`), never by a row, and **the boundary it declines at is resolved, not lexical**: a path that stays inside the served tree after `..` is applied is inside it, and a relative path with no `..` that a symlink carries outside is not. Such a decline is a *scope* statement about that implementation, may narrow as it grows a sandbox, and is never a fact about the program; a portable program still names a relative path with no `..`, and every corpus witness does. The compiler serves the whole domain on both tiers (measured at 0.2.14, 0.2.15, 0.2.16 and trunk `a565d4b9`). lupin 0.1.38 confines to a relative path with no `..` component, judged lexically — so it refuses `target/sub/../x` by name, which the ruling says it must serve, and its `lupin conform-run` answered a write through a symlinked directory with a `not_found` row — not a symlink defect but that door's observation root, a private directory in place of the one it was invoked in (a plain file there failed the same way, and `lupin run` served both), fixed in lupin 0.1.40, whose `conform-run` resolves against the working directory. Both are lupin's, wave 47 (is55); the four ruled witnesses with their verdict per machine live under `wolf/sprints/compiler/88-the-rulings-prose/witnesses/` (`fs_path_absolute`, `fs_path_climbs_out`, `fs_path_symlink_out`, `fs_path_inside_after_dotdot`) plus the symlink-inside control (`fs_path_symlink_in`), and stay there rather than under `corpus/` because they name paths a corpus witness may not. `[os.net.unix]`'s socket path is the same object and takes the same answer. **The cost, stated:** zero — the path is the `str` view the program built, passed as a pointer and a length; the runtime adds no allocation and no system call to any path. (Written 2026-09-15, s163 — wolf-lang#365, item 1, measured on linux at wolf 0.2.14 and lupin 0.1.36; the domain ruled 2026-09-24, s182, re-measured on linux at trunk `a565d4b9` and lupin 0.1.38.) - `[os.fs.remove]` **`fs_remove` removes a file, never a directory.** **`fs_remove(path: str) -> () ! {not_found, denied, io}`** is the host's file removal (`unlink`; `DeleteFileW`), and on a directory it is **refused with a row and removes nothing**. WHICH row is the host's error through the family's mapping (a missing entry `not_found`, a permission refusal `denied`, anything else `io`), and the hosts differ, so it is stated per host: **linux `io`** (`EISDIR`), **macOS `denied`** (`EPERM`), **windows `denied`** (access denied). A portable program removes a directory with `fs_remove_dir` (empty) or `fs_remove_dir_all`, and one that must classify first asks `fs_is_dir`. Every tier and both machines take the same split, because each delegates to the host's call; the clause pins it so that one that stops delegating has a row to match. **The cost, stated:** one system call, the host's own — no stat precedes it, so a program pays for the directory question only if it asks one. Witnesses: `corpus/fs/remove_dir_refused.lu` (the refusal and the survival, on every host); the row per host is `wolf_rt::fs`'s crate test `remove_on_a_directory_is_the_hosts_row`. (Written 2026-09-15, s163 — wolf-lang#365, item 2: is48 measured macOS `denied` at 0.2.12; linux `io` on both tiers and on lupin 0.1.36, measured here.) - `[os.fs.open]` **The open, and the mode that cannot park.** **`fs_open(path: str) -> int ! {not_found, denied, io}`**, **`fs_create(path)`** and **`fs_open_mode(path: str, mode: int) -> int ! {not_found, denied, exists, invalid, io}`** are ONE call under three spellings: `fs_open` is mode **0** and `fs_create` is mode **1**, and the moded form takes the caller's own. The set is **0** read, **1** write (create, truncate), **2** append (create), **3** read-write (create, no truncate), **4** create-new (exclusive — the one atomicity every tier-1 target promises), and **5** **read, non-blocking**. A mode outside the set is `invalid`, decided BEFORE the filesystem is touched, so a bad mode is never a half-done open. **What mode 5 is for** (wolf-lang#289). A read open is `O_RDONLY`, and on unix that call PARKS on a fifo with no writer — the whole hand, until some writer appears. A web root is operator-controlled, so a fifo (or a device) under it is a realistic hang, and the only surface a program had to avoid it was to stat the PATH first and open only what the stat called a regular file — which, on the regular files that are every request that matters, is a walk of the name the open is about to walk again. nginx has never paid it: it opens with `O_NONBLOCK` and classifies with `fstat` on the fd it got. Mode 5 is that open. On unix it carries **`O_NONBLOCK`**; a **regular file is unaffected** — the flag changes nothing about its open, its reads or its writes, so mode 5 and mode 0 are the same handle over the same bytes — and a **fifo or device answers AT ONCE**: a handle, which `[os.fs.fstat]` then classifies as `kind` **2**, and whose read is `eof` where no writer exists and `io` where one exists and has said nothing. So the shape a static-file server wants is two calls, `fs_open_mode(p, 5)` then `fs_fstat(fd)`, and the path stat it was paying to be safe is gone. **What mode 5 is NOT.** It is not asynchronous io and it does not make `fs_read` non-blocking on a regular file — a regular file's read blocks on the disk on every unix, flag or no flag, and no `O_NONBLOCK` changes it. It is a guarantee about ONE call: the open answers. **Host posture.** linux, macOS and freebsd carry the flag; windows has no equivalent on this path and serves mode 5 as mode 0, BY NAME — a program that opens a windows named pipe through this family can still be parked by it, and the clause says so rather than promising a portability the host does not have. Every tier serves the mode, the checked machine through the same `OpenOptions` the runtime uses. Witness: `corpus/fs/open_nonblock.lu`; the fifo itself is the crate tests' (`wolf_rt::fs`, `#[cfg(unix)]` — the language has no `mkfifo`, so a corpus witness could not build one), and the linux CALL COUNT is `crates/wolf_rt/tests/syscall_shape.rs`, which reads `openat`'s flags out of a trace and asserts no path stat precedes it. - `[os.fs.fstat]` **The stat on an open handle.** **`fs_fstat(fd: int) -> List[int] ! {not_found, denied, io}`** answers **`[kind, size, modified_ms]`** from ONE metadata read on the handle `fs_open`/`fs_open_mode` returned: `kind` is **0** for a regular file, **1** for a directory, **2** for anything else (a fifo, a socket, a device); `size` is bytes; `modified_ms` is milliseconds from the Unix epoch, negative before it — `fs_size`'s and `fs_modified_ms`'s words, in their units, so the handle's answer and the path's compare without a conversion. The reason is wolf-lang#261: a static-file server was paying four path stats per request (`fs_is_dir`, `fs_is_file`, `fs_size`, `fs_modified_ms`) where nginx pays one `open` and one `fstat`, and the last two could not move into the program because nothing answered off the handle it was about to read anyway. The row set is the path stat's, so one handler serves both spellings; on a handle the hosts answer `io` for nearly everything, the entry being already resolved. A closed or forged handle is **`io`**, the family's rule; a size or a time outside `int` is `io`, `fs_size`'s rule. Host posture: every tier-1 host, every tier — the checked machine reads the same handle metadata the runtime does. **The one host difference is `fs_open`'s, and it is stated:** linux, macOS and freebsd open a directory read-only, so `kind` 1 is reachable there; windows refuses to open a directory as a file (`denied` from `fs_open`), so on windows every handle this call sees is a file or a device and a server classifies directories by path, as before. Witness: `corpus/fs/fstat.lu`; the directory case is the crate tests'. **Descriptors 0, 1 and 2** are the process's standard streams (`[os.fs.std]`), so `fs_fstat(0)` answers what standard input IS — `kind` **0** and its size for a regular file, `kind` **2** for a pipe, a socket or a terminal — and `fs_fstat(1)` what standard output is. (Before s199 the three numbers were indices into the implementation's own handle table and answered `io` whatever the descriptors were, wolf-lang#424.) - `[os.fs.std]` **The three descriptors a program did not open.** The handle numbers **0**, **1** and **2** name the process's standard input, output and error — the descriptors it was started with — and no open ever answers one of them: the **first handle an open answers is 3** on every machine and every tier, and later ones are larger (never reused, the family's rule). Eight calls serve them: `fs_fstat` (`[os.fs.fstat]`), `fs_seek`, `fs_tell` and `fs_read_at` (below), and — since s200, wolf-lang#405 — `fs_read`, `fs_read_chunk`, `fs_write` and `fs_write_chunk`, so a program reads and writes its standard streams **as bytes, through the descriptors themselves**; `fs_copy_chunk` (`[os.fs.copy]`) takes them too. On a standard stream those calls act on the descriptor itself — a seek of 0 moves the offset the process shares with whoever handed it standard input, a read of 0 starts where that offset stands and moves it, and a pipe, a socket or a terminal is read or written as what it is — and the implementation never closes it: `fs_close` on 0, 1 or 2 answers `io` as on any number no open answered. The rows are each call's own (`fs_read_chunk`'s `{eof, io}`, …), and the host's refusals arrive in them: a write to a closed descriptor is `io` (`EBADF`), never a silent success and never a write into whatever file took the number. A write to a pipe whose reader has gone meets the host's `SIGPIPE` first, whose default ends the process as it ends GNU's tools (a program cannot yet ask otherwise, wolf-lang#423); a process started with `SIGPIPE` ignored sees `io` (`EPIPE`). **Ordering.** A write to 1 or 2 lands in program order with `print` and `eprint` around it: the native tiers write the descriptor under the same lock `print` takes, after a flush. A byte read of 0 after `read_line` answers the bytes `read_line` read ahead first — the implementation holds them, and a byte read takes them before it reads the descriptor — so the two spellings never disagree about where input stands; the descriptor's own offset (what `fs_tell(0)` answers, and what a later process inherits) is past that buffer, and `fs_read_at` and `fs_fstat` are unaffected by it. **Why these names and not new ones** (#405 asked for `stdin_read_chunk(max)`/`stdout_write_chunk(bytes)`): s199 made 0, 1 and 2 the standard streams in the family's own handle space, so four more spellings would be a second handle space for the same descriptors; the chunk calls a program already writes for a file now serve standard input and output unchanged, and std's `File { fd }` wraps them with no new type. **The reopen this retires.** Before s200 the one route to bytes on 0 and 1 was reopening `/dev/stdin` and `/dev/stdout`, which on linux opens a FRESH description (the shell's offset lost), refuses a socket, turns `1<> file` into an append, and with descriptor 1 closed hands the number to the program's first input (#405's table). None of those survive a call that never opens anything. **Host posture.** linux, macOS and freebsd serve the four calls on all three descriptors. windows serves them through the process's standard handles on the compiler's machines; a standard handle the process does not have (a null one) is `io`, and the host's `GetFileType` decides what a handle IS: anything but `FILE_TYPE_DISK` is `kind` 2 for `fs_fstat` and `unseekable` for the offset calls (`[os.fs.seek]`). The metadata std reads there cannot decide it — it reports an anonymous pipe as a regular file whose size is the bytes waiting in it, a seek on it "succeeds" and a positional read consumes it (measured on the windows runner before the rule: `fstat0 kind=0 size=19` with standard input a pipe, wolf-lang CI run 37062798818, job 111023227162). lupin, whose source admits no `unsafe` and so no `GetFileType`, declines the four calls on 0, 1 and 2 on windows BY NAME (`[proto.record.unsupported]`) rather than answer from that metadata. The byte calls (s200) serve 0, 1 and 2 on every host, windows included — a read or write needs no classification — through the process's standard handles on the compiler's machines and through lupin's safe duplicate (`try_clone_to_owned`) there; a write to a console handle on windows is the console's bytes, not UTF-16, as for any handle. **The checked machine** serves the offset calls on the `wolf` process's own descriptors, which are the checked program's. Its byte calls read descriptor 0 from the input buffer a test hands the machine when one is handed (`read_line`'s, shared) and from the process's own descriptor 0 otherwise — every production caller hands none, so `conform-run --checked` reads the real stream — and write 1 and 2 into the machine's CAPTURE (the record's stdout, the `eprint` channel), which is decoded once at the end as the driver decodes a native child's stdout for the record, so the two tiers' records agree by construction. **`conform-run --native/--release` hands its child the null device on 0**, so a witness that sets standard input runs the binary `wolf build` made. **The cost, stated:** none — the descriptor is the number; no handle is minted and no descriptor is duplicated by the native tiers, and a byte read or write is one system call (a write to 1 or 2 also takes `print`'s lock, uncontended in a program that is not printing from two tasks at once). Witnesses: the fixtures under `crates/wolf_driver/tests/fixtures/fs_std/`, run with standard input a regular file and a pipe on four machines by `crates/wolf_driver/tests/fs_std_lanes.rs` (not corpus rows: the answer depends on what the harness wires to 0); the byte calls, the shared offset, a closed descriptor and `/dev/full`, `crates/wolf_driver/tests/byte_surface_lanes.rs` and its fixtures; the order of a byte write among `print`s, `corpus/fs/std_write_bytes.lu`; the first handle, `corpus/fs/seek_tell.lu`. (Written 2026-10-02, s199 — wolf-lang#424; the byte calls 2026-10-06, s200 — wolf-lang#405.) - `[os.fs.seek]` **Moving the offset.** **`fs_seek(fd: int, off: int, whence: int) -> int ! {invalid, io, unseekable}`** moves the handle's offset and answers the new one, counted from the start of the file. `whence` **0** counts `off` from the start, **1** from the current offset, **2** from the end — POSIX's `SEEK_SET`, `SEEK_CUR` and `SEEK_END`, one `lseek` on unix. A seek past the end is legal and moves nothing on disk; a read there is `eof`. **The rows.** A `whence` outside {0, 1, 2}, or an offset that would land below zero, is **`invalid`**, and the offset does not move. A handle with no offset — a pipe, a fifo, a socket, a terminal; `ESPIPE` on unix — is **`unseekable`**, a tag of its own because a program's response to it differs from its response to `io`: it reads forward instead, as `tail` does through a pipe. A closed or forged handle is **`io`** (the handle is checked before `whence`), and so is a host failure and an offset outside `int`. An append handle (mode 2) seeks, and its writes still go to the end (`[os.fs.open]`). **Host posture.** linux, macOS and freebsd answer from `lseek`. windows answers a disk file through `SetFilePointerEx`; any other handle is `unseekable` there, decided by asking `GetFileType` first, because windows does not refuse a seek on a pipe or a console the way `ESPIPE` does — the call succeeds and the file pointer means nothing (`[os.fs.std]` has the measurement). Every tier and both machines serve the call; the checked machine moves the same descriptor's offset the runtime would. **The cost, stated:** one system call (on windows, two: the type, then the seek). Witnesses: `corpus/fs/seek_tell.lu` (from each end, past the end, `invalid` twice, `io` twice); `unseekable` on a pipe is `fs_std_lanes.rs`'s and `wolf_rt::fs`'s crate tests'. (Written 2026-10-02, s199 — wolf-lang#426.) - `[os.fs.tell]` **Reading the offset.** **`fs_tell(fd: int) -> int ! {io, unseekable}`** answers the handle's offset from the start of the file without moving it — `lseek(fd, 0, SEEK_CUR)`. The rows are `fs_seek`'s less `invalid`, which a tell cannot earn: `unseekable` for a handle with no offset, `io` for a closed or forged one or a host failure. A handle fresh from an open answers 0; after reads of `a` and `b` bytes it answers `a + b`. Host posture and cost: `[os.fs.seek]`'s. Witnesses: `corpus/fs/seek_tell.lu`, `corpus/fs/read_at.lu`. (Written 2026-10-02, s199 — wolf-lang#426.) - `[os.fs.read_at]` **The positional read.** **`fs_read_at(fd: int, off: int, max: int) -> List[byte] ! {eof, invalid, io, unseekable}`** is `fs_read_chunk` at an offset: at most `max` bytes starting at byte `off`, and **the handle's own offset is left exactly where it was** — `pread` on unix. The order is the family's: the handle first (closed or forged is `io`), then the offset (below zero is `invalid`), then `max` (at or below zero answers the empty list without touching the host), the 1 MiB clamp `fs_read_chunk` carries, and zero bytes at a positive `max` is **`eof`** — an offset at or past the end. A handle with no offset is `unseekable`. It is the read a program makes when it knows where the bytes are — `tail -n 10` walks back from the size `fs_fstat` gave it, a block at a time — and because it does not move the cursor, two readers of one handle at different places need no seek between them. **Host posture.** unix answers from `pread`. windows reads with `ReadFile` at the offset (`seek_read`), which moves the file pointer, so the implementation puts it back before the call returns, under the lock that guards the handle table, so no other hand sees it moved; a handle that is not a disk file is `unseekable` there (`[os.fs.seek]`'s rule). **The cost, stated:** one system call on unix, four on windows (the type, the pointer read, the read, the pointer put back), and the list minted at exact capacity, as `fs_read_chunk` mints it. Witnesses: `corpus/fs/read_at.lu`; descriptor 0 a file and a pipe, `fs_std_lanes.rs`. (Written 2026-10-02, s199 — wolf-lang#426.) - `[os.fs.chdir]` **Changing the working directory.** **`os_chdir(path: str) -> () ! {denied, io, not_found}`** makes `path` the process's working directory — the write twin of `os_cwd`, wolf-std F-0067's ask. A relative `path` resolves against the current directory (`[os.fs.path]`), and from the call on every relative path an fs call takes resolves against the new one, `os_cwd` answers it, and a spawned child starts in it. **`not_found`** is a path that does not exist, **`denied`** a directory this process may not enter (search permission), **`io`** a path that names something other than a directory (the host's `ENOTDIR`) and every other host failure. The directory is the PROCESS's, shared by every task, and a program that changes it while another task resolves a relative path races itself; a shell changes it in the shell, between commands. **Machines.** native and release change the process's own directory (`chdir`). **The checked machine keeps a machine-local one**, for the reason it keeps `env_set`'s overlay: it runs inside threaded test hosts, where a real `chdir` would move every other thread's relative paths. Its directory is the host's canonical answer for the same change (`realpath`, which is what `getcwd` reports), and every path-taking fs call, the unix-domain socket calls, `os_cwd` and every spawn honour it — the relation a program can observe is the same. lupin moves the directory its fs tier already resolves against. **The cost, stated:** one system call (native); on the checked machine one canonicalization per change and one path join per relative path afterwards. Witnesses: `corpus/os/chdir_relative.lu`; a child in the moved directory, `proc_fd_lanes.rs`. - `[os.fs.isatty]` **Is a handle a terminal?** **`os_isatty(fd: int) -> bool ! {io}`** answers whether the handle names a terminal: 0, 1 and 2 the process's standard streams (`[os.fs.std]`), 3 and up the fs table. A closed or forged handle is **`io`**, the family's rule, never `false`: "not a terminal" and "not a handle" are different answers. It is the question an interactive program asks before it prompts — a shell reads commands with a prompt only when its input is a terminal. **Machines.** Every machine asks the HOST descriptor (`isatty` on unix; Rust's `IsTerminal` on windows) — on the checked machine and lupin, 0..2 are the interpreting process's own streams, which are the program's. **The cost, stated:** one system call. Witnesses: `corpus/os/pipe_round_trip.lu` (a pipe end is not a terminal; closed and forged are `io`); true for 0 and 2 under `script(1)` and false under a pipe, `proc_fd_lanes.rs`. - `[os.fs.copy]` **The copy the host makes.** **`fs_copy_chunk(src: int, dst: int, max: int) -> int ! {eof, io}`** is `fs_read_chunk` then `fs_write_chunk` fused: ONE transfer of at most `max` bytes from `src`'s offset to `dst`'s, both offsets advancing, answering how many bytes moved — and the bytes never pass through the program, and where the host can, never through user space at all. The order is the family's: both handles first (a closed or forged one is `io`; 0, 1 and 2 are the standard streams, `[os.fs.std]`), then `max` (at or below zero answers **0** without touching the host), and zero bytes moved at a positive `max` is **`eof`** — `src` is at its end. A transfer moves at least one byte and at most `max` (clamped to 1 GiB per call), and HOW MANY is the host's and the rung's: a program loops until `eof` and depends on the total, never on the split (a regular file may move whole in one call on linux and in 256 KiB steps on macOS). The rows are the chunk pair's, `eof` from the read and `io` from either side; there is no `invalid`, because no argument can be malformed past the handle check. **The rungs, by name.** linux tries `copy_file_range`, then `sendfile`, then `splice`, then the read/write loop: each kernel call is refused by the PAIRING before a byte moves when it cannot serve it (`EINVAL` for a pipe, a terminal or `/dev/null` under `copy_file_range`; `EXDEV` across filesystems; `EBADF` from `copy_file_range` and `EINVAL` from `sendfile` for an append handle; `ENOSYS`, `EOPNOTSUPP`), and the next rung is tried; the rung a pair settles on is remembered for the next call on the same pair, so a refusal costs one system call once, not per chunk. A kernel rung's zero is confirmed by a read before it is `eof`, because a pseudo-file (`/proc`, sysfs) reports a size of zero with bytes still to read. **macOS, freebsd and windows have the read/write loop only, by name**: one read of at most 256 KiB (bu01's measured knee) into a buffer the runtime reuses, then one write of all of it — macOS's `sendfile` writes only to a socket and `fcopyfile` copies whole files from their starts, neither of which is this call. The loop is also every host's answer once `read_line` has read ahead on 0 (its buffered bytes come first, `[os.fs.std]`). A write to 1 or 2 keeps `[os.fs.std]`'s ordering with `print`. **The checked machine and lupin** move the bytes through their own buffer: the same bytes and offsets, with a capture standing in for 1 and 2 on the checked machine. A table handle is DUPLICATED for the call (`dup`; `DuplicateHandle` — the duplicate shares the offset), so a copy that parks on a pipe never holds the lock every other handle needs. What it is for: `cat`, `cp`, `tee` and `dd` (#417 measured boreutils `cat` at 0.16x GNU to `/dev/null` on linux, every byte crossing the boundary twice), and a server's file body (lobo: 187 µs of CPU per MiB against nginx's 34 with `sendfile`). A server's send to a SOCKET is not this call — a socket is a `net_*` handle, a separate space — and stays #417's open half. **The cost, stated:** on linux one system call per transfer once a pair has settled (plus `dup` and `close` for a table handle), no buffer; elsewhere two system calls and one 256 KiB buffer per thread, allocated on first use and kept. Witnesses: `corpus/fs/copy_chunk.lu` (file to file, the total, `eof`, `max` 0, `io` closed and forged); standard input and output, a pipe and `/dev/null`, `byte_surface_lanes.rs`; the rung per pairing on linux, `wolf_rt::fs`'s crate tests. (Written 2026-10-06, s200 — wolf-lang#417.) - `[os.fs.error]` **The host's number, beside the row.** **`os_error() -> int`** answers the host's error number for the most recent FALLIBLE fs-family call the calling task made — `errno` on unix, `GetLastError` on windows — and **0** when that call succeeded or its failure was decided before the host was asked (a closed or forged handle, a mode outside the set, `eof`, `utf8`); **`os_error_text(code: int) -> str`** answers the host's message for a number (`strerror`'s text on unix, `FormatMessageW`'s on windows, as the Rust standard library renders it, without its ` (os error N)` suffix) and `""` for a code at or below zero. The family is `read_line` and every `fs_*` builtin with a row; the total predicates (`fs_exists`, `fs_is_file`, `fs_is_dir`) leave the number alone, and the net and os families do not set it at this pin (named; their rows are untouched). The word is the TASK's: a task holds its thread until it returns (`[conc.task.order]`), the runtime clears the word when a task starts, and no other task's call can move it — the race #407 named against a process-global `last_error` does not exist. A program reads it where it handles the row, before its next fs call: `fs_open(p) else |e| { eprint("{p}: {os_error_text(os_error())}") … }` prints `No such file or directory` where GNU prints it. **Why beside the row and not on it** (#407 preferred a payload): a row's tags are a closed set of RESPONSES (`[os.host.sigs]`: one tag per response), the number is a REASON for a reader, and the language's pact (`[mem.str.parse]`, W0603) makes a payload-carrying tag CapCase — so the payload reading is `io` renamed to `Io(OsError)` on every fs builtin, which refuses every `io` arm and every declared `io` row that meets one, for a word most handlers never read. s200 measured it: a throwaway compiler whose host rows carried `Io(int)` refused all 27 boreutils utilities, lobo, and 65 of wolf-std's 71 test rows over the host families, and the three repositories name the tag at 743 sites (wolf-lang PR evidence, `downstream-*` and the census). The number differs by host and the text by host and locale (`ENOENT` is 2 on linux and macOS, and windows' `ERROR_FILE_NOT_FOUND` is also 2; `ENOSPC` is 28 on both unixes, `ERROR_DISK_FULL` 112), so a program that BRANCHES on a reason still branches on the row, and prints the number or the text. **The checked machine** keeps one word per run (it declines structured concurrency by name, so a run is one task) and **lupin** one per task's machine, each from the same `io::Error` the host handed it, so every machine answers the same number for the same call on the same host. **The cost, stated:** one thread-local store on entry to each fallible fs call and one when the host refuses; a read is one thread-local load; the text is minted in the ambient region only when asked. Witnesses: `corpus/fs/os_error.lu` (a missing path is 2 on every tier-1 host and its text is not empty, a success is 0, a forged handle is 0, `os_error_text(0)` is empty); `/dev/full` (`ENOSPC`, "No space left on device"), a closed descriptor (`EBADF`), and eight tasks failing side by side under `taskset -c 0-3` with no task seeing another's number (`fixtures/byte_surface/os_error_tasks.lu`), `byte_surface_lanes.rs`. (Written 2026-10-06, s200 — wolf-lang#407.) - `[os.fs.stat]` **The full stat record, by path, following a link or not.** **`fs_stat(path: str) -> List[int] ! {denied, io, not_found}`** and **`fs_lstat(path: str) -> List[int] ! {denied, io, not_found}`** answer ONE record from ONE host call — `stat` and `lstat`, POSIX's pair: `fs_stat` follows a final symbolic link and describes what it names; `fs_lstat` describes the link itself, and on a path whose last component is not a link the two answer the same record. A dangling link is `not_found` to `fs_stat` and a record of kind 3 to `fs_lstat`. The rows are `fs_size`'s, a size or a modification time outside `int` is `io` (its rule), and the path is `[os.fs.path]`'s, verbatim. **The record is twenty `int` words**, numbered, whose first three are `fs_fstat`'s (`[os.fs.fstat]`) so the two read alike: | word | name | meaning | |---|---|---| | 0 | kind | **0** regular file, **1** directory, **2** anything else, **3** symbolic link, **4** fifo, **5** socket, **6** character device, **7** block device | | 1 | size | bytes (a link's: the length of its target) | | 2 | modified_ms | milliseconds from the Unix epoch, negative before it (`fs_modified_ms`'s word) | | 3 | have | bit *i* set when word *i* was answered by the host | | 4 | mode | the permission bits with set-user-id, set-group-id and sticky (`st_mode & 0o7777`) | | 5 | nlink | the hard-link count | | 6, 7 | uid, gid | the owner and group ids | | 8 | blocks | the space allocated, in 512-byte units (`st_blocks`) | | 9, 10 | dev, ino | the file's identity: the device and the inode | | 11 | rdev | a device file's own device number | | 12, 13 | atime | last access: seconds, nanoseconds | | 14, 15 | mtime | last modification: seconds, nanoseconds | | 16, 17 | ctime | last status change: seconds, nanoseconds | | 18, 19 | btime | creation (birth): seconds, nanoseconds | A time is POSIX's `timespec`: whole seconds from the Unix epoch, floored (negative before it), and nanoseconds in `0..999999999`, so `-1, 999999999` is one nanosecond before the epoch. `dev`, `ino`, `rdev`, `nlink` and `blocks` are the host's unsigned 64-bit words carried in an `int`'s 64 bits: an identity to compare for equality (two paths name one file when their `dev` and `ino` agree, wolf-lang#536), never to order. **A word the host does not answer is 0 with its `have` bit clear** — that is the refusal, BY NAME and per word, never a guessed value; words 0, 1, 2 and 3 are always answered. **Per host** (the Rust standard library's metadata is the host call on every tier): | host | answers | refuses (bit clear) | |---|---|---| | linux | every word; `btime` where `statx` reports one | `btime` on a filesystem or kernel that keeps none | | macOS | every word | — | | freebsd | every word (tier-2: a cross-check in CI, never run) | — | | windows | kind (0, 1, 3, else 2), size, modified_ms, atime, mtime, btime | mode, nlink, uid, gid, blocks, dev, ino, rdev, ctime | **Design note (s218, wolf-lang#625, #536).** The record is a `List[int]` and not a struct because no host builtin returns a struct (every one is typed in `[os.host.sigs]` from the prelude's types), and because the shape `fs_fstat` already has extends without a new vocabulary: std names the words (`std.fs.Stat`). Twenty words are one allocation of 160 bytes; a program that wants three asks `fs_size`/`fs_modified_ms` as before. The `have` word exists because windows answers a different subset and linux's birth time depends on the filesystem: a sentinel (`-1`) would collide with a real uid on some hosts and with a time before the epoch on all of them. **What was not done:** the handle form (the record from an open handle, for `cat`'s input-is-output check, #536's second half) stays `fs_fstat`'s three words; and a path is a `str` (`[os.fs.path]`), so an entry whose name is not UTF-8 is LISTED (`[os.fs.readdir]`) but cannot be named to `fs_stat`/`fs_lstat` — a byte-path spelling of the path family is a later lane's, named here. **Every machine.** The checked machine reads the same metadata the runtime does, word for word; lupin serves both calls inside its tree and refuses by name an `fs_stat` whose link resolves outside it, while `fs_lstat` of that link is served (it never leaves the tree). **The cost, stated:** one host call (`stat`/`lstat`; `statx` on linux) and one 20-word list; no second call for any word. Witnesses: `crates/wolf_driver/tests/fs_stat_lanes.rs` over `fixtures/fs_stat/` (a link against its target, a dangling link, mode, nlink, uid, gid, size, blocks, dev and ino against `stat(1)`, nanosecond times read back exactly), on checked, native, release and lupin; `wolf_rt::fs`'s crate tests. (Written 2026-10-08, s218 — wolf-lang#625, #536.) - `[os.fs.readlink]` **A link's target.** **`fs_read_link(path: str) -> List[byte] ! {denied, invalid, io, not_found}`** answers the target a symbolic link stores, as the host stored it: **bytes**, verbatim — a target is a name, and a name need not be UTF-8 (windows hands over WTF-8). The target is not resolved, not checked and not made absolute: `ln -s nope d` reads back `nope`. A path that exists and is not a link is **`invalid`** (POSIX's `EINVAL`; windows' `ERROR_NOT_A_REPARSE_POINT`); a missing path `not_found`. Every machine; lupin serves a link inside its tree, wherever its target points, since reading it follows nothing. **The cost, stated:** one `readlink`; one byte list at the target's length. Witnesses: the `fs_stat_lanes` fixtures. (Written 2026-10-08, s218 — wolf-lang#625.) - `[os.fs.readdir]` **A listing as the host keeps it.** **`fs_read_dir_entries(path: str) -> List[List[byte]] ! {denied, io, not_found}`** answers a directory's entries **in the order the host hands them over** — no sort — without `.` and `..`, each entry ONE `List[byte]`: **byte 0 is the entry's kind** (`[os.fs.stat]`'s codes, the entry's OWN, so a link is 3 whatever it names; **2** when the host gives no type) and **the rest are the name's bytes, verbatim**. A name that is not UTF-8 is listed like any other: it is data, never decoded, so the call has no `utf8` row and one odd name can no longer fail the listing (`fs_read_dir`'s `utf8`, wolf-lang#626). The kind is the directory entry's own type where the host carries one (linux and freebsd `d_type`, macOS likewise, windows the find data); where it carries none, the host's `lstat` of the entry answers it, and an entry gone between the read and that question is kind 2 rather than a failed listing. **Why one list per entry, kind first** (s218's design note): a pair of parallel lists can disagree in length and order, a struct cannot cross the builtin boundary, and the name must stay bytes, so the one byte in front is the smallest record that keeps the kind and the name together; `std.fs.entries` splits it. `fs_read_dir` keeps its sorted `List[str]` and its `utf8` row, for the program that wanted a sorted, decoded listing. **Order is the host's and is not portable**: two filesystems list one directory in two orders, so a test of this call compares sets, and `ls -U` prints exactly this order. **The cost, stated:** one directory read; no stat per entry where the host types it; one byte list per entry. Witnesses: the `fs_stat_lanes` fixtures (a non-UTF-8 name listed whole on linux, where the filesystem accepts one — macOS's refuse to create it; the creation order, which is not byte order, printed back unsorted). (Written 2026-10-08, s218 — wolf-lang#626.) - `[os.fs.mode]` **Setting a file's mode.** **`fs_set_mode(path: str, mode: int) -> () ! {denied, invalid, io, not_found, unsupported}`** is `chmod`: the file's permission bits, with set-user-id, set-group-id and sticky, become **exactly** `mode` — the same twelve bits `fs_stat` answers as word 4 (`[os.fs.stat]`), so what is read is what is set, and the umask plays no part. A mode is an `int` a program writes in octal, `0o755` (`[gram.lex]`'s `INT`; wolf-lang#688 made native and release lower the spelling the other machines already ran). **A final symbolic link is followed**: the call changes what the link names, never the link, and a dangling link is `not_found` — no tier-1 host changes a link's own mode through a portable call, and none reads one to decide access. **`invalid`** is a `mode` outside `0..=0o7777`, decided before the host is asked; **`denied`** a file this process does not own, or a path it may not search; **`not_found`** a missing path. **Host posture:** linux, macOS and freebsd serve it; **windows has no permission bits and answers `unsupported`, by name** — its one bit, read-only, is not a mode, and a call that set it from `mode & 0o200` would report success for a request it did not carry out. Every tier and both machines make the same one call. **The cost, stated:** one system call (`chmod`); no stat before or after. Witnesses: `corpus/fs/mode_symlink.lu` (every host, asserted on both sides), `corpus/fs/change_rows.lu` (`invalid`); under a known umask, the bits above `0o777`, a link followed and `denied`, `crates/wolf_driver/tests/fs_change_lanes.rs`. (Written 2026-10-11, s230 — wolf-lang#346.) - `[os.fs.create.mode]` **A mode at creation.** **`fs_open_perm(path: str, open: int, mode: int) -> int ! {denied, exists, invalid, io, not_found, unsupported}`** is `fs_open_mode(path, open)` (`[os.fs.open]`: the same six open modes, the same handle) with the permission bits a CREATION uses, and **`fs_create_dir_mode(path: str, mode: int) -> () ! {denied, exists, invalid, io, not_found, unsupported}`** is `fs_create_dir` with them. The file or directory, if the call creates it, has `mode` **less the process's umask** (`[os.fs.umask]`) from its first instant — there is no moment at which it exists wider than asked, which is the difference from a creation followed by `fs_set_mode` (a private key file readable between the two calls is wolf-lang#346's whole complaint). `fs_open`, `fs_create`, `fs_open_mode` and every whole-file write ask for `0o666`, and `fs_create_dir` and `fs_create_dir_all` for `0o777`, as they always have. An existing file keeps its mode whatever `mode` says, and an `open` that never creates (0 and 5) reads `mode` only to check it. **`invalid`** is an `open` outside the set, then a `mode` outside **`0..=0o777`**, both before the host is asked: the three bits above the nine are not part of a creation, because the hosts part on them (linux keeps set-id and sticky at `open` and `mkdir`, macOS drops them — measured), and a program that wants them sets them with `fs_set_mode` once the file is its own. **`exists`** is open mode 4 over a path that is there, and `fs_create_dir_mode` over any entry. **Host posture:** as `[os.fs.mode]` — windows answers `unsupported` by name and creates nothing; a program there uses the unmoded spellings. **The cost, stated:** the one `open` or `mkdir` the unmoded call makes, with a different third argument. Witnesses: `corpus/fs/mode_symlink.lu`, `corpus/fs/change_rows.lu`; each mode less a known mask, an existing file's mode kept, and the rows, `fs_change_lanes.rs`; `wolf_rt`'s `tests/umask.rs`. (Written 2026-10-11, s230 — wolf-lang#346.) - `[os.fs.symlink]` **Making a symbolic link.** **`fs_symlink(target: str, path: str) -> () ! {denied, exists, invalid, io, not_found, unsupported}`** makes `path` a symbolic link whose stored target is `target`, **verbatim**: not resolved, not checked, not made absolute — the bytes `fs_read_link(path)` then answers (`[os.fs.readlink]`). A relative target means what the host makes it mean when the link is followed (relative to the link's own directory), and a target that names nothing makes a link like any other. The arguments are in `ln -s`'s order and `symlink(2)`'s: what the link says, then where it goes. **`exists`** is any entry already at `path`, a dangling link included — the call never replaces; **`not_found`** a missing parent directory; **`invalid`** an EMPTY target, decided before the host is asked, because the hosts part on it (linux refuses one with `ENOENT`, macOS makes the link — measured) and a link that names nothing at all is no host's intention. **Host posture:** linux, macOS and freebsd; **windows answers `unsupported`, by name**: its symbolic links need a privilege or a developer setting most processes lack, and come in two kinds (file and directory) the caller must choose before the target need exist — neither is this call. **The cost, stated:** one system call (`symlink`); the target is never examined. Witnesses: `corpus/fs/mode_symlink.lu`, `corpus/fs/change_rows.lu`; the target verbatim, a dangling link, each `exists` and `denied`, `fs_change_lanes.rs`. (Written 2026-10-11, s230 — wolf-lang#698.) - `[os.fs.link]` **Making a hard link.** **`fs_link(existing: str, path: str) -> () ! {cross_device, denied, exists, io, not_found}`** makes `path` a second name of the file `existing` names: one file, one inode (`fs_stat`'s words 9 and 10 agree for both names), a link count one higher (word 5), and bytes written through either name read through the other; removing one name leaves the file under the other. **A final symbolic link in `existing` is not followed**: the new name is a second name of the LINK (kind 3 to `fs_lstat`), which is POSIX's `linkat` with no flag; a program that wants the target resolves it first. **`exists`** is any entry already at `path` — the call never replaces, which makes it the atomic publish `fs_rename` (which replaces) cannot be: write a temporary, `fs_link` it into place, and `exists` says someone was first. **`cross_device`** is two file systems (a copy is the answer, as for `fs_rename`); **`denied`** a directory (no tier-1 host gives a directory a second name), a file system with no hard links, or a path this process may not search or write; **`not_found`** a missing `existing` or a missing parent of `path`. **Host posture:** every tier-1 host — `linkat` on linux, macOS and freebsd, `CreateHardLinkW` on windows (NTFS). **The cost, stated:** one system call. Witnesses: `corpus/fs/hard_link.lu` (every host); one inode and two links, a link's own second name, a directory `denied`, `fs_change_lanes.rs`. (Written 2026-10-11, s230 — wolf-lang#699.) - `[os.fs.times]` **Setting a file's times.** **`fs_set_times(path: str, atime_s: int, atime_ns: int, mtime_s: int, mtime_ns: int, follow: bool) -> () ! {denied, invalid, io, not_found}`** is `utimensat`: it sets the access time and the modification time of `path` — the times `fs_stat` answers as words 12–13 and 14–15 (`[os.fs.stat]`), in the same units: whole seconds from the Unix epoch, floored (negative before it), and nanoseconds. Each time is one of three, chosen by its NANOSECOND word: | nanoseconds | the time becomes | |---|---| | `0..=999999999` | the seconds beside it and these nanoseconds | | **`-1`** | **now**, by the host's clock at the call (`UTIME_NOW`); the seconds are not read | | **`-2`** | **what it already is** (`UTIME_OMIT`); the seconds are not read | So one call sets both times, or sets one and keeps the other with no read in between (`touch -a`, `touch -m`), or stamps "now" (`touch`), and every other nanosecond word is **`invalid`**, decided before the host is asked. "Now" is the HOST's, not a clock the program read: it is the same instant for both times, and on unix it needs only write permission where an explicit time needs ownership (POSIX's rule for `utimensat`). **`follow`** true sets the times of what a final symbolic link names (a dangling link is `not_found`); **false** sets the link's own (`AT_SYMLINK_NOFOLLOW`, what `touch -h` asks for), and on a path that is not a link the two are the same call. The status-change time (words 16–17) is the host's and moves to now with any change; the birth time is not settable. A file system keeps what it can: one that stores a coarser time rounds down (windows keeps 100 ns steps, and some keep seconds), so a program compares what it reads back, not what it asked for. **Host posture:** `utimensat` on linux, macOS and freebsd; **windows** sets the same two times on a handle opened for attributes alone, a directory included, the link itself when `follow` is false, and reads "now" from its own clock once per call (it has no `UTIME_NOW`, and no ownership rule the difference could show through). **The cost, stated:** one system call on unix; on windows an open, the set and a close. Witnesses: `corpus/fs/set_times.lu` (every host: both, one kept, now, a directory, before the epoch, `not_found`), `corpus/fs/change_rows.lu` (`invalid`); a link's own times, `fs_change_lanes.rs`. (Written 2026-10-11, s230 — wolf-lang#700.) - `[os.fs.truncate]` **Setting a file's length.** **`fs_truncate(fd: int, len: int) -> () ! {invalid, io}`** is `ftruncate` on a handle of the fs table (`SetEndOfFile` on windows): the file becomes **exactly `len` bytes** — shorter, its tail gone, or longer, the new bytes zeros that no write stored (a hole, where the file system keeps holes). **The handle's offset does not move**: after ten bytes written and a truncation to four, the offset is still 10 and the next write lands there, leaving zeros between. **`invalid`** is a negative `len`, decided before anything else. **Every host refusal is `io`**, with the host's number in `os_error()` (`[os.fs.error]`): a handle not open for writing, a directory, a pipe or a terminal, a length past the file system's limit — the hosts spell these differently (`EINVAL` on linux and macOS, access denied on windows, for the same read-only handle) and a caller answers none of them differently. A closed or forged handle is `io` with no host number, the family's rule; 0, 1 and 2 are the standard streams (`[os.fs.std]`). The call is on a handle because the handle is what a program that just wrote the file holds, and it serves the path form: open, truncate, close. **The cost, stated:** one system call. Witnesses: `corpus/fs/truncate.lu` (every host), `corpus/fs/change_rows.lu`. (Written 2026-10-11, s230 — wolf-lang#702.) - `[os.fs.umask]` **The file mode creation mask.** **`os_umask(mask: int) -> int ! {invalid, unsupported}`** is `umask`: the process's mask becomes `mask`, and the answer is the mask it replaced. The mask is the set of permission bits a creation does NOT get: every file and directory the process creates has the mode its creation asked for (`[os.fs.create.mode]`) with the mask's bits removed. **Set-and-answer is the only form**, as it is POSIX's: a program READS the mask with two calls, `let m = os_umask(0o022)?` then `os_umask(m)?`. **`invalid`** is a `mask` outside `0..=0o777`, decided before the host is asked, and changes nothing. **The mask is the PROCESS's** — every task's, and every child's at its spawn (a shell's `umask` builtin is this call, and the commands after it inherit it). There is no per-task mask on any host, so a program that changes it while another task creates a file races itself, and the two-call read is such a change: a program sets its mask where a C program does, before it starts tasks, or keeps every creation in the changing task's hands. `fs_set_mode` after a creation is the way to a mode the mask would have taken (`mkdir -p` gives each parent it creates `u+wx` so it can create inside it, whatever the mask says). **Machines.** native and release set the process's mask. **The checked machine sets it too** — the real one, where it keeps the working directory and the environment to itself (`[os.fs.chdir]`): a machine-local mask could be honoured only by a second host call after each creation, with the file existing at the host's mask in between, which is the window the mask exists to close; so a host that embeds several checked runs in one process runs those that call `os_umask` one at a time. **Host posture:** linux, macOS and freebsd; **windows has no mask and answers `unsupported`, by name**. The call is outside `[os.fs.error]`'s family and leaves `os_error()` alone. **The cost, stated:** one system call. Witnesses: `corpus/os/umask.lu` (every host, with masks that keep the owner's bits), `corpus/fs/change_rows.lu` (`invalid`); a creation under each mask, a child's inherited mask and the parent-directory repair, `fs_change_lanes.rs` (a process of its own per run); `wolf_rt`'s `tests/umask.rs` (a test binary of its own, its tests under one lock). (Written 2026-10-11, s230 — wolf-lang#704.) - `[os.fs.change.note]` **Design note: the calls that change a file** (s230; wolf-lang#346, #698, #699, #700, #702, #704). s218 let a program read what `ls -l` shows; these seven calls and the mask let it write the same words, which is what `chmod`, `touch`, `ln`, `mkdir -m`, `cp -p` and a shell's `umask` are. **One order for every call:** the SHAPE of the arguments first (`invalid`, the same on every host), then the HOST (`unsupported`, by name, where it has no such operation), then the host's one call and its own error through the family's mapping — `os_exec`'s order (`[os.proc.exec]`), so a malformed call is `invalid` on windows too. **Per host:** | call | linux, macOS, freebsd | windows | |---|---|---| | `fs_set_mode` | `chmod` | `unsupported` | | `fs_open_perm`, `fs_create_dir_mode` | `open`, `mkdir` with the mode | `unsupported` | | `fs_symlink` | `symlink` | `unsupported` | | `fs_link` | `linkat`, no flag | `CreateHardLinkW` | | `fs_set_times` | `utimensat` | the two times on an attribute handle | | `fs_truncate` | `ftruncate` | `SetEndOfFile` | | `os_umask` | `umask` | `unsupported` | **Every machine.** native and release make the call through `wolf_rt`; the checked machine makes the same call, a path joined to its own working directory first (`[os.fs.chdir]`); the freestanding target refuses each by its name (`[abi.target.none]`). lupin, which forbids `unsafe` and links no libc, serves what the Rust standard library reaches — the mode, both creations, both links, the length, and the times of a path it can open, following a link, with "now" read from its own clock — and refuses by name what the library does not: `os_umask` whole, and `fs_set_times` on a link itself. None of the eight allocates: each answers `()` or one `int`, so no region rule reads them. **What was not done**, each left to its issue: owner changes (wolf-lang#701), holes and reflinks (#703), fifos and device nodes (#705), a rename that does not replace (#706), calls relative to an open directory (#707), a path that is not UTF-8 (#687), and the handle forms of the mode and the times (`fchmod`, `futimens`), which nothing has yet needed. **The spellings and shapes were ruled on 2026-10-11 (ruling #67): each as recommended below, which is what is implemented. The options are kept as the record of what was weighed:** - **R1, the mode change.** (A) `fs_set_mode(path, mode)`, following a final link — the fs family spells its operations out (`fs_read_link`, `fs_create_dir`), the record calls the word `mode`, and it pairs with `fs_set_times`; (B) `fs_chmod`, POSIX's name, as `os_chdir` and `os_umask` are — but those are the `os_` family's habit; (C) a `follow` argument, as `fs_set_times` has — linux has no call that changes a link's mode, so false would be `unsupported` on the commonest host; (D) the handle form instead (`fchmod`) — closes the race between a creation and its mode, which R2 closes at the creation. **Ruled A.** - **R2, a mode at creation.** (A) two new calls, `fs_open_perm(path, open, mode)` and `fs_create_dir_mode(path, mode)`, the bits `0..=0o777` less the umask; (B) a mode argument added to `fs_open_mode` and `fs_create_dir` — every existing call site changes arity for a word most never want; (C) the umask alone — set it around the creation: process-wide, so it races every other task; (D) wolf-lang#346's first sketch, `fs_create_mode(path, bits)`, exclusive creation only — `cp` creates or truncates. **Ruled A.** - **R3, the links.** (A) `fs_symlink(target, path)` and `fs_link(existing, path)`, in `ln`'s and the system calls' order, the target verbatim, an empty target `invalid`, `fs_link` never following `existing`; (B) a `follow` argument on `fs_link` (`ln -L`) — one more word on every call for the rare one, and `fs_read_link` already lets a program resolve first; (C) the target as `List[byte]`, as `fs_read_link` answers it — right for a name that is not UTF-8, and that is the byte-path lane's (#687), for every path at once. **Ruled A.** - **R4, the times.** (A) one call, the two times as seconds and nanoseconds, the special values in the nanosecond word (`-1` now, `-2` leave) where POSIX keeps them, and `follow`; (B) a flags word beside four plain numbers — a second place for the same two bits; (C) separate `fs_set_atime`, `fs_set_mtime` and `fs_touch` — three names, and "set both to different times at once" becomes two calls with the first one's change visible in between; (D) milliseconds, as `fs_modified_ms` reads — it cannot write back what `fs_stat` read. **Ruled A.** - **R5, the length.** (A) `fs_truncate(fd, len)` on a handle, `invalid` for a negative length and `io` for every host refusal; (B) the path form — one more open inside the runtime, and no way to truncate the file a program is writing; (C) both. **Ruled A.** - **R6, the mask.** (A) `os_umask(mask) -> int`, set-and-answer, the two-call read stated with its caveat; (B) also a read that changes nothing — linux can answer one from `/proc/self/status`, macOS and freebsd have no such call, so it would be `unsupported` on two tier-1 hosts or a set-and-restore in disguise; (C) `os_umask(-1)` as the read — the same disguise, with a magic number. **Ruled A.** - **R7, windows.** (A) `unsupported` by name for the mode, the moded creations, the symbolic link and the mask, and the hard link, the times and the length served; (B) `fs_set_mode` mapped onto the read-only attribute — success reported for a mode that was not set; (C) `fs_symlink` attempted, `denied` without the privilege — a call whose answer depends on a machine setting, and which must guess the link's kind. **Ruled A**, as `[os.fs.stat]` answers no mode there and `[os.proc.exec]` no exec. - **R8, capabilities (ruling #54's reasoning).** The seven `fs_*` calls are `fs`. `os_umask` is `env`: it changes process state every later creation reads, as `os_chdir` changes the directory every later path reads. **Ruled as listed.** - **R9, the checked machine's mask.** (A) the process's own, really set, with the embedding caveat `[os.fs.umask]` states; (B) a machine-local mask applied by swapping the real one around each creation under a lock — other threads' creations see the swapped mask for the length of a system call; (C) a machine-local mask repaired by a `chmod` after each creation — the window the mask exists to close. **Ruled A.** - **R10, lupin.** (A) what the Rust standard library reaches, the rest refused by name (`os_umask`; a link's own times); (B) a safe system-call crate, which would serve both exactly — ruling #57's R8 asked the same question of the terminal calls and answered A. **Ruled A**; B stays the question ruling #57's R8 left, and these two calls are now part of what it would buy. ## §7 The host builtin table `[os.host]` (Written 2026-09-15, s163 — wolf-lang#181. `[gram.expr.tagident]` makes a bare identifier in a handler a tag "exactly as wide as the declared row", and for a module callee that row is in its signature; for a builtin — `net_connect`, `os_wait`, `env_get`, `json_get` — no pinned document declared one, and each machine carried an implementation-local table probed off the other's behaviour.) - `[os.host.sigs]` **Every host builtin's signature, its row included, is the table below**, and a builtin call's declared row — the row `[gram.expr.tagident]` reads, the row a `match` over a caught value is exhaustive against, the row `?` propagates — is its row here and nothing wider or narrower. A line reads `name(parameter types) -> result`; a fallible result is `T ! {tags}`, the tags written in sorted order because a row is a set (`[gram.type.row.flatten]`), and no tag carries a payload (D30's payload-less v0 rows). A builtin the table does not list has no row: the output and sync surface (`print`, `print_raw`, `eprint`, `eprint_raw`, `List`, `Map`, `Pool`, `Mutex`, `channel`, `range`), the helpers `min` and `zip`, the comptime intrinsics (`reflect`, `typeinfo`, `typebuild`, `implements`, `size_of`) and `assert` are typed by their own clauses, and `acquire`, `release`, `build_config`, `build_batch`, `worker` and `sleeper` are the s02 corpus stand-ins, not language. WHICH tag a failing call raises is each call's own clause (`[os.fs.open]`, `[os.fs.remove]`, `[os.net.accept]`, …); this table fixes only the set. **The table is the compiler's, byte for byte**: `wolf_sema`'s `host_builtin_sig` renders these lines, and the crate test `tests/host_table_spec.rs` reds when a signature and this block part in either direction, so a builtin cannot gain, lose or retag a row without this clause moving in the same commit. **The cost, stated:** none — a table is a static fact; a row is checked where the call is typed and adds nothing to the call. **The interpreter against it** (lupin 0.1.36, measured by s163 on linux: each fallible builtin called with an argument that fails harmlessly, under a handler naming exactly this table's tags, so a tag missing from lupin's own row would fall to an earlier arm as a binder; 56 of the table's 57 fallible builtins, `os_signal_wait` left out because it parks). 45 of the 56 calls agree in verdict and stdout with wolf's native tier, 34 of them raising a tag the table lists; lupin raised no tag outside it. Eight part by scope — lupin does not resolve `fs_create_dir`, `fs_remove_dir`, `fs_write_chunk`, `net_writev_head`, `os_signal_listen`, `os_signal_raise` or `read_line`, and refuses `net_adopt_listener` by name — and three part inside the row, each the interpreter's to mirror: `fs_rename` of a missing file raises `cross_device` there and `not_found` here, and `net_read`/`net_read_bytes` of a forged handle for zero bytes succeed there and are `io` here. lupin's static rung checks no row (a handler missing a tag passes its `resolve`), so the measurement is dynamic, one raised tag per call; the table, not the probe, is what an implementation transcribes. **The count** is 117 lines at s232 (2026-10-11, wolf-lang#681, #141), which added `os_signal_ignored` (`[os.signal.ignored]`), `os_signal_send` (`[os.signal.send]`), `os_signal_number` (`[os.signal.number]`), `os_pid` and `os_ppid` (`[os.proc.self]`) to the 112 s230 left (2026-10-11, wolf-lang#346, #698, #699, #700, #702, #704), which added `fs_set_mode` (`[os.fs.mode]`), `fs_open_perm` and `fs_create_dir_mode` (`[os.fs.create.mode]`), `fs_symlink` (`[os.fs.symlink]`), `fs_link` (`[os.fs.link]`), `fs_set_times` (`[os.fs.times]`), `fs_truncate` (`[os.fs.truncate]`) and `os_umask` (`[os.fs.umask]`) to the 104 s218 left (2026-10-10, wolf-lang#625, #626), which added `fs_stat`, `fs_lstat` (`[os.fs.stat]`), `fs_read_link` (`[os.fs.readlink]`) and `fs_read_dir_entries` (`[os.fs.readdir]`) to the 100 s225 left; s225 (2026-10-10, wolf-lang#534) added `env_unset` (`[os.env.unset]`) and `os_exec` (`[os.proc.exec]`) to the 98 s219 left (2026-10-08, wolf-lang#622), which added `os_signal_ignore`, `os_signal_default`, `os_signal_poll` (`[os.signal.disp]`, `[os.signal.poll]`), `os_spawn_job`, `os_proc_pid`, `os_wait_status` (`[os.proc.job]`, `[os.proc.status]`), `os_pgid` and the four `os_term_*` (`[os.term]`, `[os.term.mode]`) to the 87 s215 left (r31's integration, 2026-10-08), which added `os_spawn_fds`, `os_pipe`, `os_chdir` and `os_isatty` to the 83 s200 left (2026-10-06, wolf-lang#405, #411, #417, #407: `fs_copy_chunk` (`[os.fs.copy]`), `os_error` and `os_error_text` (`[os.fs.error]`), and `bytes_find` and `bytes_count` (`[mem.list.bytes]`) joined the 78 s199 left; wolf-lang#426 added `fs_seek`, `fs_tell` and `fs_read_at` to 75); the crate test asserts the number, so a builtin cannot join or leave the table without this sentence moving. ```text host-table read_line() -> str ! {eof, io, utf8} read_text(str) -> str net_fetch(str) -> str env_var(str) -> str clock_ms() -> int random_seed() -> int fs_read_text(str) -> str ! {denied, io, not_found, utf8} fs_write_text(str, str) -> () ! {denied, io, not_found} fs_open(str) -> int ! {denied, io, not_found} fs_create(str) -> int ! {denied, io} fs_read(int, int) -> str ! {eof, io, utf8} fs_write(int, str) -> () ! {io} fs_close(int) -> () ! {io} fs_remove(str) -> () ! {denied, io, not_found} fs_exists(str) -> bool fs_open_mode(str, int) -> int ! {denied, exists, invalid, io, not_found} fs_read_bytes(str) -> List[byte] ! {denied, io, not_found} fs_write_bytes(str, List[byte]) -> () ! {denied, invalid, io, not_found} fs_read_chunk(int, int) -> List[byte] ! {eof, io} fs_write_chunk(int, List[byte]) -> () ! {invalid, io} fs_read_dir(str) -> List[str] ! {denied, io, not_found, utf8} fs_create_dir(str) -> () ! {denied, exists, io, not_found} fs_create_dir_all(str) -> () ! {denied, io} fs_remove_dir(str) -> () ! {denied, io, not_found} fs_remove_dir_all(str) -> () ! {denied, io, not_found} fs_rename(str, str) -> () ! {cross_device, denied, exists, io, not_found} fs_is_file(str) -> bool fs_is_dir(str) -> bool fs_size(str) -> int ! {denied, io, not_found} fs_modified_ms(str) -> int ! {denied, io, not_found} fs_fstat(int) -> List[int] ! {denied, io, not_found} fs_stat(str) -> List[int] ! {denied, io, not_found} fs_lstat(str) -> List[int] ! {denied, io, not_found} fs_read_link(str) -> List[byte] ! {denied, invalid, io, not_found} fs_read_dir_entries(str) -> List[List[byte]] ! {denied, io, not_found} fs_set_mode(str, int) -> () ! {denied, invalid, io, not_found, unsupported} fs_open_perm(str, int, int) -> int ! {denied, exists, invalid, io, not_found, unsupported} fs_create_dir_mode(str, int) -> () ! {denied, exists, invalid, io, not_found, unsupported} fs_symlink(str, str) -> () ! {denied, exists, invalid, io, not_found, unsupported} fs_link(str, str) -> () ! {cross_device, denied, exists, io, not_found} fs_set_times(str, int, int, int, int, bool) -> () ! {denied, invalid, io, not_found} fs_truncate(int, int) -> () ! {invalid, io} fs_seek(int, int, int) -> int ! {invalid, io, unseekable} fs_tell(int) -> int ! {io, unseekable} fs_read_at(int, int, int) -> List[byte] ! {eof, invalid, io, unseekable} fs_copy_chunk(int, int, int) -> int ! {eof, io} net_listen(str) -> int ! {io} net_port(int) -> int ! {io} net_accept(int) -> int ! {io, timeout} net_connect(str) -> int ! {io, refused, timeout} net_read(int, int) -> str ! {closed, io, timeout, utf8} net_write(int, str) -> () ! {closed, io} net_read_bytes(int, int) -> List[byte] ! {closed, io, timeout} net_write_bytes(int, List[byte]) -> () ! {closed, invalid, io} net_writev(int, List[List[byte]]) -> () ! {closed, io} net_writev_head(int, str, List[List[byte]]) -> () ! {closed, io} net_nodelay(int, bool) -> () ! {io} net_listen_unix(str) -> int ! {denied, exists, io, not_found, unsupported} net_connect_unix(str) -> int ! {denied, io, not_found, refused, unsupported} net_listen_with(str, bool, int) -> int ! {denied, exists, io, unsupported} net_adopt_listener(int) -> int ! {io, unsupported} net_wait(List[int], int) -> List[int] ! {io} net_close(int) -> () ! {io} net_deadline(int, int) -> () ! {io} env_args() -> List[str] env_get(str) -> str ! {missing, utf8} env_set(str, str) -> () ! {invalid} env_unset(str) -> () ! {invalid} env_vars() -> List[str] os_cwd() -> str ! {io} os_exe() -> str ! {io} os_cpus() -> int ! {io} os_exit(int) -> () os_spawn(List[str]) -> int ! {denied, io, not_found} os_spawn_with(str, List[str], List[int]) -> int ! {denied, io, not_found, unsupported} os_spawn_fds(str, List[str], List[int]) -> int ! {denied, invalid, io, not_found, unsupported} os_pipe() -> (int, int) ! {io} os_chdir(str) -> () ! {denied, io, not_found} os_isatty(int) -> bool ! {io} os_umask(int) -> int ! {invalid, unsupported} os_exec(str, List[str], List[str], List[int]) -> () ! {denied, invalid, io, not_found, unsupported} os_wait(int) -> int ! {io, signal} os_kill(int) -> () ! {io} os_signal_listen(int) -> () ! {io} os_signal_wait(int) -> int ! {io} os_signal_raise(int) -> () ! {io} os_signal_ignore(int) -> () ! {io, unsupported} os_signal_default(int) -> () ! {io, unsupported} os_signal_poll(int) -> int ! {io} os_spawn_job(str, List[str], List[int], int, int, int) -> int ! {denied, invalid, io, not_found, unsupported} os_proc_pid(int) -> int ! {io} os_wait_status(int) -> int ! {io} os_pgid() -> int ! {unsupported} os_term_foreground(int) -> int ! {io, unsupported} os_term_set_foreground(int, int) -> () ! {io, unsupported} os_term_mode(int) -> int ! {io, unsupported} os_term_set_mode(int, int) -> () ! {invalid, io, unsupported} os_signal_ignored() -> int ! {unsupported} os_signal_send(int, int) -> () ! {denied, invalid, io, not_found, unsupported} os_signal_number(int) -> int ! {invalid, unsupported} os_pid() -> int os_ppid() -> int ! {unsupported} os_random(int) -> List[int] os_error() -> int os_error_text(int) -> str time_now_ms() -> int time_unix_ms() -> int time_sleep_ms(int) -> () json_valid(str) -> bool json_get(str, str) -> str ! {missing, parse} json_type(str, str) -> str ! {missing, parse} json_len(str, str) -> int ! {kind, missing, parse} str_from_utf8(List[byte]) -> str ! {utf8} bytes_find(List[byte], byte, int) -> int ! {none} bytes_count(List[byte], byte) -> int region_bytes(region) -> int live_region_bytes() -> int ``` --- ## §8 The terminal `[os.term]` (Written 2026-10-08, s219 — wolf-lang#622, pelt's H3.) - `[os.term]` **The controlling terminal's foreground group and mode.** A session has one controlling terminal; one process group at a time is its **foreground** group — the group the terminal's interrupt, quit and suspend characters signal, and the only group that may read it — and the terminal has a **mode** (XBD 11). The calls take a handle as the fs family does (0..2 the standard streams, 3 and up a table handle such as `/dev/tty` opened with `fs_open_mode`) and act on the terminal it names. A handle that is closed, forged or not a terminal is `io`; every call is `unsupported` on windows, whose console has no process groups. - **`os_pgid() -> int ! {unsupported}`** — this process's group id. - **`os_term_foreground(fd: int) -> int ! {io, unsupported}`** — the terminal's foreground group (`tcgetpgrp`). - **`os_term_set_foreground(fd: int, pgid: int) -> () ! {io, unsupported}`** — make `pgid` the foreground group (`tcsetpgrp`); a group not in this session, or not positive, is `io`. A process outside the foreground group that does this is sent `SIGTTOU` (default: stop) unless it blocks or ignores it, and a shell taking the terminal back from a finished job is exactly that process, so the call blocks `SIGTTOU` on the calling thread for its duration and restores the mask after; nothing else's mask moves. - `[os.term.mode]` **The mode is one int.** **`os_term_mode(fd: int) -> int ! {io, unsupported}`** reads it and **`os_term_set_mode(fd: int, mode: int) -> () ! {invalid, io, unsupported}`** sets it: bit 0 `CANONICAL` (1, input a line at a time with the terminal's own editing; `ICANON`), bit 1 `ECHO` (2; `ECHO`), bit 2 `SIGNALS` (4, the interrupt, quit and suspend characters raise signals; `ISIG`), `VMIN` in bits 8..15 and `VTIME` (tenths of a second) in bits 16..23 — how a read waits when `CANONICAL` is off. Every other field of the terminal's settings is left as it is found, so a mode read and later set again restores exactly what was changed. Any other bit, or a negative mode, is `invalid` before the handle is read. The set waits for output already written to drain and keeps input already typed (`TCSADRAIN`), and blocks `SIGTTOU` as `os_term_set_foreground` does. The runtime restores nothing on exit: a program that changes the mode restores it (std's `term` module pairs the two). - `[os.term.note]` **Design note: Ctrl-C without a handler** (s219). The issue (#622) asked for a meaning for `SIGINT`; a shell needs four things, and each was measured against dash 0.5.13.4 black-box through a pseudo-terminal: (1) to **survive** Ctrl-C at its prompt and while its child runs — a listen does it (the trampoline is a real handler), and an ignore does it too but would be inherited by every child; (2) to **learn** afterwards that one came — `os_signal_poll`, because dash at the prompt prints nothing at Ctrl-C (the terminal echoes `^C`) and answers only when the next line is read, so a poll after the read is the whole of it; (3) to give a child the **defaults** the shell does not keep for itself — `os_spawn_job`'s `defaults`, since dash's children start with nothing ignored although dash ignores `QUIT` and `TERMINATE`; (4) to **report** `128 + N` — `os_wait_status`. Job control adds the group, the foreground and its take-back, and line editing adds the mode. What stays out, by rule: no wolf code in a handler (`[os.signal.model]`) — a disposition is default, ignore or listen, nothing else; no `siginfo`; no `SA_RESTART` choice (a delivered signal never tears a read, so the prompt case is a poll after the read, not an `EINTR`); no stop reporting (`WUNTRACED`), no `os_kill` of a group, no `SIGCONT` — the next job-control lane (`fg`, `bg`, Ctrl-Z) owns them. **Ruled A (#57), 2026-10-10 (s219), each with its recommendation, which is what is implemented:** - **R1, the five meanings' names and bits.** (A) `INTERRUPT`, `SUSPEND`, `BG_READ`, `BG_WRITE`, `CHILD` at 16..256 — names of what happened, in the v1 table's style (`RELOAD`, not `HUP`); (B) the POSIX signal names (`INT`, `TSTP`, `TTIN`, `TTOU`, `CHLD`) — exact, but the table is by meaning on purpose; (C) a separate `os_signal_*_raw` family by number — rejected by `[os.signal.set]`. **Recommend A.** - **R2, the disposition calls.** (A) two calls, `os_signal_ignore(set)` and `os_signal_default(set)`, beside `os_signal_listen(set)`; (B) one `os_signal_disposition(set, how)` with an int `how` — one name, but an int that means a verb; (C) a flag on `os_signal_listen` — conflates hearing with surviving. **Recommend A.** - **R3, learning without waiting.** (A) `os_signal_poll(set) -> int`, 0 for none; (B) `os_signal_wait` with a timeout argument — a new arity of an existing builtin, and a 0 timeout that means poll; (C) an `interrupted` row on the fs reads — every read site would have to handle it, and the trampoline does not tear reads. **Recommend A.** - **R4, the job spawn.** (A) `os_spawn_job(exe, args, map, group, tty, defaults)`, the descriptor map's sibling; (B) a builder value (a `Spawn` struct) — the builtin tier has no structs, std builds one over A; (C) separate `os_setpgid(h, g)` after the spawn — racy by construction (the child may exec and read before it lands). **Recommend A.** - **R5, a child's status.** (A) `os_wait_status(h) -> int`, the code or `-N`; (B) `128 + N` as the shell writes it — collides with a real exit code; (C) a `List[int]` pair (kind, number) — honest but clumsy for every caller. **Recommend A.** - **R6, the terminal calls.** (A) `os_pgid`, `os_term_foreground`, `os_term_set_foreground`, `os_term_mode`, `os_term_set_mode`, the mode one int as `[os.term.mode]` packs it; (B) the mode as four separate get/set calls — four round trips to save and restore; (C) a raw `termios` byte image — not portable between linux and macOS. **Recommend A.** - **R7, capabilities (with ruling #54's reasoning).** The dispositions, the poll, the job spawn, the pid and status and `os_term_set_foreground` are `exec` (process control); `os_pgid` is `env` (process context, as `os_cwd`); `os_term_foreground`, `os_term_mode` and `os_term_set_mode` are `io` (they act on a descriptor the program holds and start nothing, as `os_isatty`). **Recommend as listed.** - **R8, lupin's terminal half.** lupin forbids `unsafe` and links no libc, and stable Rust's std has `process_group` but no `tcsetpgrp`, `tcgetattr` or `sigaction`. (A) lupin mirrors what std reaches — `os_spawn_job` with a group and no `tty` or `defaults`, `os_proc_pid`, `os_wait_status` — and refuses the rest by name; (B) lupin takes a safe syscall crate (rustix) for the terminal and the group id, signals still refused. **Recommend A for this lane**; B is the maintainer's call. --- ## §9 The json query tier `[os.json]` (Written 2026-10-10, s224 — wolf-lang#124, ruling B22 of 2026-09-11.) - `[os.json.dup]` **A repeated object name is last-wins.** RFC 8259 §4 says an object's names SHOULD be unique and leaves a repeat to the implementation; the json builtins (`json_get`, `json_type`, `json_len`, and the path segments they walk) read an object as ONE member per name, at the position where that name first appears, holding the value of its LAST occurrence — a `Map` assignment, as `std.json.parse` reads the same text and as JavaScript, Python and serde (with preserved order) do. So `json_get("{\"a\": 1, \"a\": 2}", "a")` is `2` and `json_len` of that object is `1`; a path walks through the last container a repeated name holds; a nested object that `json_get` re-renders compactly carries the name once, in its first position, with its last value (`{"a":1,"b":2,"a":3}` renders `{"a":3,"b":2}`). The ROOT container's `json_get` is unchanged: it answers the document's own text, trimmed, repeats and all, because that answer is the source, not a rendering. `json_valid` accepts a repeat, as RFC 8259 does. Every machine answers alike: the checked machine (`wolf_mem::json`), native and release (`wolf_rt::json`, pinned to the first by `json_parity`), and lupin. Before s224 all four read the FIRST occurrence and counted every one. **The cost, stated:** none a program can see — the parser keeps one member per name as it reads (a scan for a small object, a name index past 16 members), and every query already parsed the whole text. Witnesses: `corpus/json/dup_*.lu` (seven rows) and `crates/wolf_driver/tests/json_dup_lanes.rs`, on four machines.