# Proposal: POSIX Compatibility Adapter

How capOS should host POSIX-shaped C software without recreating the ambient
authority that makes POSIX hard to confine, and which two ports validate the
adapter for the first time.


## Problem

capOS is not POSIX and is not trying to become POSIX. But useful software --
DNS resolvers, line-editing libraries, shells, archivers, compilers, network
clients -- assumes a POSIX surface. Rewriting each of these in capability-
native Rust would forfeit decades of debugging, security review, and
performance work for no isolation gain: a POSIX program whose only authority
is a typed capability set is already as confined as an equivalent native one.

The risk pattern is the one POSIX historically gets wrong: a translation layer
that synthesises ambient authority (a global `/`, an inherited credential
table, a kernel-managed file descriptor map) rebuilds the property capOS is
trying to leave behind. A useful adapter must do the opposite -- every POSIX
call must be backed by a typed capability the calling process already holds,
or it must fail closed with a documented errno.

Two upstream programs are the natural first validators of that adapter:

- A **POSIX shell** exercises the broadest surface (process, pipe, file, env,
  signal stubs, stdio).
- A **DNS resolver** exercises the smallest network surface (UDP socket,
  one-shot poll-equivalent, time, log).

Both are already small, mature, and BSD/MIT-licensed. Picking the smallest
representative of each category makes the adapter's first job a real port,
not a synthetic test.

## Scope

In scope:

- A two-layer C substrate: `libcapos` (thin Rust staticlib, capability ring +
  CapSet + raw syscalls + heap, C ABI) and `libcapos-posix` (POSIX shape on
  top: fd table, errno, path resolution, posix_spawn shim, signal stubs,
  pthread mapping).
- A first POSIX shell port that builds against `libcapos-posix` with no
  hidden ambient authority.
- A first DNS resolver port that builds against `libcapos-posix` with no
  hidden ambient authority.
- Phase decomposition (P1.1, P1.2, P1.3) that defers the adapter's biggest
  dependencies (Namespace + File caps for the shell file path; UDP cap for
  the resolver) into clearly-named gating phases.
- Validation through QEMU smokes that prove granted and ungranted paths.

Out of scope for the first implementation:

- Binary compatibility with Linux ELFs. Both ports are sources-on-disk
  recompiled against `libcapos-posix`.
- Full POSIX compliance. The adapter ships exactly the surface dash and dns.c
  exercise, plus any free additions that fall out.
- Real `fork()` (parent state inheritance, COW, sibling address-space surgery
  before exec). Only `fork()` followed promptly by `execve()` is supported,
  via a `posix_spawn`-shaped shim.
- Real signal delivery. `signal()`/`sigaction()` accept the call, store the
  handler, never invoke it. `kill(2)` requires a future `ProcessHandle` cap.
- Job control, process groups, sessions, controlling terminals.
- musl, glibc, or any other host libc. The substrate is Rust-authored and
  exposes a C ABI; it is not a libc port.
- Hosted C++. ABI decisions for C++ remain tracked in
  `docs/proposals/userspace-binaries-proposal.md`.

## Current Manual Pages

- [Programming Languages](../programming-languages.md) summarizes POSIX
  adapter status relative to Rust, C/C++, Python, Go, Lua, and WASI tracks;
  the **C row** records the shipped `libcapos.a` + `libcapos_posix.a`
  surface, and the **POSIX-shaped software row** records P1.1/P1.2/P1.3
  closeouts plus the in-progress P1.4 dash-port phase shape over the
  bootstrap-granted root `Directory` cap surface, including the
  signal/time stub closeout.
- [Userspace Binaries](userspace-binaries-proposal.md) **Part 4: POSIX
  Compatibility Adapter** sketches the POSIX adapter at a higher level.
  This proposal supersedes that sketch with the full design surface; the
  userspace-binaries proposal continues to own the broader native-binary,
  language, and adapter roadmap.
- [Userspace Runtime](../architecture/userspace-runtime.md) documents the
  implemented `capos-rt` surface that `libcapos` mirrors for C consumers.
- [Networking](networking-proposal.md) defines `NetworkManager`,
  `TcpListener`, and `TcpSocket` and explicitly defers `UdpSocket` until
  DNS / userspace-network work needs it. The DNS resolver port in this
  proposal defines the UDP cap surface; the TCP cap surface is reused
  unchanged.
- [Storage and Naming](storage-and-naming-proposal.md) defines the
  `Namespace`, `Directory`, `File`, and `Store` cap shape; these gate the
  shell port's filesystem surface (Phase 2/3 of that proposal).
- [Service Architecture](service-architecture-proposal.md) frames the future
  `Resolver` cap as the long-term consumer of the resolver process built in
  this track.
- [Shell](shell-proposal.md) covers the native `capos-shell`. The POSIX shell
  port (dash) is for porting validation, not as a replacement for the native
  shell.
- [WASI Host Adapter](wasi-host-adapter-proposal.md) is the parallel
  untrusted-portable execution path; both proposals share fd-table and
  per-import authority insight, but target different substrates.

## Research Grounding

Relevant research and external references:

- POSIX shell candidates surveyed: dash (Debian Almquist Shell, ~13 kSLOC,
  BSD; the canonical small POSIX-strict shell); busybox `ash`; OpenBSD ksh
  (oksh); toybox `toysh`. Source repositories cited inline in the candidate
  comparison table.
- DNS resolver candidates surveyed: `dns.c` by William Ahern (single-file
  MIT, ~10 kSLOC, no dependencies); c-ares; GNU adns; udns; SPCDNS; musl's
  embedded `res_query`; trust-dns-resolver. Source repositories cited inline
  in the candidate comparison table.
- libcapos prior art: this proposal builds on the `libcapos` shape sketched
  in [Userspace Binaries](userspace-binaries-proposal.md) "Future: C via
  `libcapos`" / "Future Phase: libcapos for C". The C substrate is designed
  as a Rust staticlib with a C ABI rather than musl, redox relibc, or a
  hand-rolled libc. Fuchsia's fdio + musl pattern and Redox's relibc
  pattern are the comparable points; capOS deliberately picks neither.
- POSIX surface translation: Cygwin's `fork()` emulation is the closest
  prior art for fork-for-exec semantics on top of a non-fork substrate; the
  capOS shim inverts the default (capOS *cannot* fork; the shim emulates
  the useful case) but uses the same call-pattern recognition.

In-tree research grounding:

- [Genode](../research/genode.md) -- per-session typed service interfaces
  and resource accounting are the closest precedent for routing every
  POSIX wrapper through a typed cap rather than through an ambient kernel
  syscall table. POSIX adapter wrappers should follow the same pattern at
  the library boundary instead of the kernel boundary.
- [OS Error Handling](../research/os-error-handling.md) -- cross-OS
  comparison of error-model surfaces. Informs the bidirectional mapping
  between `CapError` / `CapException` and POSIX errno (Open Question §4)
  and the decision to keep one shared mapping table at the C boundary
  rather than per-wrapper bespoke mappings.
- [LLVM Target](../research/llvm-target.md) -- target triple, calling
  convention, and bare-metal toolchain options for capOS C consumers.
  Informs Open Question §11 on the linker / toolchain choice (`clang
  --target=x86_64-unknown-none-elf -nostdlib -static`).

This proposal also lifts the capability-mapping shape and the "every
translation has authority backing" property from the WASI host adapter
proposal, and the `libcapos` staticlib shape from the userspace-binaries
proposal Part 2. It deliberately does not adopt the musl + `__syscall`
hook pattern noted in the userspace-binaries proposal "musl as a Base
(Optional, Later)" section, because the layered Rust staticlib shape is
preferred over a libc port for the v0 surface.

External:

- [dash][dash-debian] -- Debian Almquist Shell, ~13 kSLOC, Debian's
  `/bin/sh` since Squeeze (2011).
- [busybox `ash`][busybox-ash] -- alternative Almquist port, embedded.
- [oksh][oksh] -- portable OpenBSD ksh, public domain, larger surface.
- [toybox toysh][toybox] -- 0BSD, currently incomplete.
- [c-ares][c-ares] -- modern async DNS resolver, MIT, larger.
- [dns.c][wahern-dns] -- single-file non-blocking DNS, MIT, no deps.
- [GNU adns][gnu-adns] -- async DNS resolver, GPL-2.0+.
- [musl resolver][musl-resolver] -- embedded in musl libc; not available
  without linking musl.
- [udns][udns] -- small async stub-only resolver, LGPL-2.1.

## Design Principles

1. **POSIX is not a kernel feature.** The kernel sees ordinary userspace
   processes with a CapSet and a capability ring. `libcapos` and
   `libcapos-posix` are static libraries linked into those processes.
2. **Two layers, one C ABI per layer.** `libcapos` is the C-ABI mirror of
   `capos-rt`: capability ring, CapSet, raw syscalls, heap. It has no errno,
   no fd table, no `open`/`read`/`write`. `libcapos-posix` builds the POSIX
   shape on top. Programs that do not need POSIX semantics may link only
   `libcapos`.
3. **Authority is per-process, granted at spawn.** Every fd a POSIX program
   sees was granted to its parent process at spawn time and projected onto
   an fd by `libcapos-posix`. There is no ambient `/`, no inherited
   credential table, no global signal source.
4. **Schema-first, not POSIX-first, at the boundary.** Each POSIX wrapper is
   backed by a typed capability call with a documented errno mapping.
   POSIX-shaped integer fds and POSIX-shaped errno are an ABI requirement
   of the C substrate, not a capability-model concession.
5. **Fail closed.** Any unimplemented POSIX call returns `ENOSYS` and sets
   errno. Any cap lookup that fails returns the documented errno. Programs
   cannot probe absent caps for ambient behaviour.
6. **No fork without exec.** Only `fork()` followed by `execve()` is
   supported. The shim turns the pair into `posix_spawn()`. Bare `fork()`
   used to clone state in-process fails on the next non-trivial syscall.
7. **No real signals.** Handlers are accepted and stored, never delivered.
   `kill(2)` requires a future `ProcessHandle` cap and even then is limited
   to `SIGKILL`. Programs that depend on `SIGCHLD` job control are out of
   scope.
8. **The C substrate is Rust.** `libcapos` and `libcapos-posix` are Rust
   crates with `crate-type = ["staticlib"]`, all symbols `#[no_mangle]
   extern "C"`. This is **not** musl, **not** a hand-rolled libc.

## Architecture

```mermaid
flowchart TD
    Shell["POSIX shell binary<br/>(e.g. dash)"]
    Resolver["DNS resolver binary<br/>(e.g. dns.c)"]
    Posix["libcapos-posix<br/>(POSIX adapter, Rust staticlib, C ABI)"]
    PosixDetail["fd table per process<br/>path resolver over Namespace + Store<br/>errno mapping (TLS cell)<br/>posix_spawn over ProcessSpawner<br/>signal stubs<br/>pthread over ThreadSpawner"]
    Posix --> PosixDetail
    Capos["libcapos<br/>(thin Rust staticlib, C ABI)"]
    CaposDetail["cap_call / capset_get / capset_iter<br/>sys_exit / sys_cap_enter<br/>heap (malloc/free over capos-rt allocator)<br/>typed wrappers for Console / Terminal / etc."]
    Capos --> CaposDetail
    Rt["capos-rt<br/>(no_std + alloc Rust)"]
    Ring["capability ring"]
    Kernel["kernel CapObject dispatch"]
    Services["userspace services"]

    Shell -->|"open/read/write/exec/..."| Posix
    Resolver -->|"socket/sendto/recvfrom"| Posix
    Posix -->|"extern C"| Capos
    Capos -->|"Rust FFI re-export"| Rt
    Rt --> Ring
    Ring --> Kernel
    Ring --> Services
```

`libcapos` is the C-ABI projection of `capos-rt`. `libcapos-posix` is the
POSIX projection on top. Every POSIX call ultimately resolves to either a
capability invocation through the ring or a synthetic answer (errno,
ENOSYS) computed without authority.

## libcapos: C-Facing Substrate

Headers expected to ship under `include/capos/`:

```c
// capos.h -- capability primitives only
typedef struct cap_ring cap_ring_t;
typedef uint32_t        cap_id_t;
typedef uint64_t        iface_id_t;

cap_ring_t *capos_ring(void);                     // process ring handle
int  cap_call(cap_ring_t *ring,
              cap_id_t cap, uint16_t method,
              const void *params, size_t plen,
              void *result, size_t rlen,
              size_t *out_len);
int  capset_get(const char *name,
                cap_id_t *out_cap, iface_id_t *out_iface);
size_t capset_iter(void (*cb)(const char*, cap_id_t, iface_id_t,
                              void*), void *ud);
_Noreturn void sys_exit(int code);
uint32_t       sys_cap_enter(uint32_t min_complete, uint64_t timeout_ns);

// Heap (backed by capos-rt fixed heap; grow-on-demand later if needed)
void *capos_malloc(size_t);
void  capos_free(void*);
void *capos_calloc(size_t, size_t);
void *capos_realloc(void*, size_t);
```

There is **no** `errno` here, **no** `open`/`read`/`write`. Those live one
layer up. `libcapos` is the C-ABI mirror of `capos-rt`: startup, ring,
CapSet, raw syscalls, heap.

Build artifact: `target/.../libcapos.a` plus headers. Naming for the C
library is intentionally just **`libcapos`**, mirroring how the Rust
runtime crate is `capos-rt`. The C library name **`libcapos`** is
distinct from any Rust service framework that may carry a similar name;
this proposal owns the C-substrate name and treats Rust-framework
naming as out of scope.

## libcapos-posix: POSIX Surface

Headers under `include/capos/posix/`: `unistd.h`, `fcntl.h`, `errno.h`,
`sys/socket.h`, `netdb.h`, `sys/stat.h`, `dirent.h`, `string.h`, `stdlib.h`
(subset), `sys/types.h`, `pthread.h` (subset), `signal.h` (stub).

Implementation language: **Rust**, same crate-type pattern as `libcapos`,
but linked separately so a binary that does not need POSIX can omit it.

Errno bridge: per-thread `errno` cell stored in TLS slot owned by
`libcapos-posix`; populated by every wrapper that maps a Rust `CapError` to
a POSIX errno value. See "errno Convention" below.

### File descriptor table

Per-process userspace state inside `libcapos-posix`. Not a kernel object --
neither `libcapos` nor the kernel know anything about fds.

```rust
// libcapos-posix/src/fd.rs (sketch)
struct FdEntry {
    backing: FdBacking,       // Console / Stream / Listener / File / Dir
    flags:   i32,             // O_NONBLOCK, FD_CLOEXEC, ...
    cursor:  u64,             // for seekable backings
}

enum FdBacking {
    Stdin,                    // Console / TerminalSession (read side)
    Stdout,                   // Console (write side)
    Stderr,                   // Console (write side)
    File   { file: Cap<File>, dirty: bool },
    Dir    { dir:  Cap<Directory>, iter: usize },
    Tcp    { sock: Cap<TcpSocket> },
    Udp    { sock: Cap<UdpSocket> },
    Listener { l: Cap<TcpListener> },
}

static FD_TABLE: Mutex<BTreeMap<i32, FdEntry>> = ...;
static NEXT_FD:  AtomicI32 = AtomicI32::new(3);
```

`dup`/`dup2`/`close` operate on this table. `dup` increments a refcount on
the underlying cap; `close` releases when the last fd holding the cap drops.
Cap drop runs through `capos-rt` owned-handle release. The fd table is a
strict per-process userspace structure; it is not shared with the kernel
and is never serialised on the wire.

Standard fds wired at `_start`:

- fd 0: `stdin` cap from CapSet (TerminalSession, Console, or future
  StdinReader-shaped cap, whichever is granted).
- fd 1: `stdout` Console cap.
- fd 2: `stderr` Console cap (or distinct Log cap if granted).

### Process model: fork-for-exec only

capOS process creation is `ProcessSpawner.spawn(name, binaryName, grants)`
(`kernel/src/cap/process_spawner.rs`). There is no `fork()`, no
`exec()`-in-place.

Decision matrix (working answers; the policy choice is Open Question §6
and is not settled until that question is confirmed):

| Option | What it provides | Cost | Working answer |
|---|---|---|---|
| Emulate `fork()` as `posix_spawn` with inherited cap-set, recording inter-call `dup2`/`close` as posix_spawn file actions | Existing fork+exec and fork+dup2+exec pipeline patterns work with one patch site | Daemonisation and arbitrary COW state inheritance between fork and exec still break | Recommended primary for the shell, with documented "fork-for-exec only" semantics. Whether the shim records inter-call file actions or requires the port to call `posix_spawn` with explicit file actions is Open Question §6. |
| Return ENOSYS for any `fork()` | Honest | Every POSIX program that uses fork must be patched | Recommended **safety net** when fork-for-exec is misused |
| Process-shadow: a "POSIX process" wraps a capOS process | General | Large kernel + runtime change; doubles process accounting | Recommended **reject** for v0; revisit only if a real POSIX program needs it |

Working answer: fork-for-exec, with hard-fail as the safety net (subject to
Open Question §6 confirmation before P1.3 begins). Two `libcapos-posix`
shim variants are on the table; §6 selects between them:

- **Variant A -- recording shim.** `libcapos-posix` exposes `fork()` and
  `execve()` as a coupled shim that:
  1. `fork()` records "next exec is the real spawn" in TLS and returns
     0 unconditionally. Only the `if (pid == 0)` branch ever executes;
     the legacy `else` branch is unreachable because `pid` is always
     0. Porters MUST move the parent flow (drop unused write end,
     drain read end, `waitpid`) to AFTER the if-block, with the
     synthetic pid handed off via `child = execve(...);` near the end
     of the if-body. Pictorially:
     ```c
     pid_t child = fork();          // returns 0 unconditionally
     if (child == 0) {
         dup2(...); close(...);     // recorded into TLS
         child = execve(...);       // returns synthetic_pid > 0
         if (child < 0) {           // surface to error path
             goto exec_failed;
         }
     }
     /* parent flow runs here, NOT in an else branch */
     close(...);
     read(...);
     waitpid(child, ...);
     ```
     There is no `else` branch in the v0 contract, only the post-if
     parent flow.
  2. `dup2()` / `close()` calls between `fork()` and `execve()` are
     recorded as `posix_spawn` file actions on the pending spawn rather
     than mutating the parent's fd table.
  3. `execve(path, argv, envp)` consumes the recorded intent, calls
     `ProcessSpawner.spawn()` with attenuated grants and the recorded
     file actions, and **returns the synthetic child pid as its own
     return value** (a deliberate v0 deviation from POSIX). The
     pseudo-child branch is still the original parent process, so
     porters MUST NOT call `_exit()` on failure: `_exit()` would
     terminate the actual shell. The recommended pattern surfaces
     the failure to the caller's normal error path:
     ```c
     int spawn_pid = execve(...);
     if (spawn_pid < 0) {
         /* execve() failed before any spawn; recording state is
          * already cleared and the parent fd table is unchanged.
          * Return up to the caller with the matching errno. */
         goto exec_failed;     /* or equivalent error-recovery path */
     }
     child = spawn_pid;        /* parent flow: waitpid(child) */
     ```
     On failure `execve()` returns -1 with `errno` set; callers MUST
     surface the failure to their normal error path rather than
     calling `_exit()`, because the pseudo-child branch is still the
     parent process and `_exit()` would terminate the actual shell.
  4. Any `fork()` not followed by `execve()` before a syscall outside
     the recorded-action allowlist (e.g. `setsid`) returns -1 / ENOSYS
     on that downstream call.
- **Variant B -- patched-port shim.** `libcapos-posix` exposes only
  `posix_spawn()` with explicit file actions, plus stub `fork()` /
  `execve()` that return -1 / ENOSYS. Each port (dash and successors)
  is patched to translate its fork+dup2+exec sequence into a single
  `posix_spawn()` call with the equivalent file actions.

`posix_spawn()` is the preferred primitive in either variant and gets a
direct mapping to `ProcessSpawner.spawn()`. The choice between Variant
A and Variant B is Open Question §6.

**fd-backing-cap inheritance (kernel precursor).** For a fork/execve child to
inherit a parent fd that is backed by an opened `Directory`/`File` cap, that cap
must be forwardable through `ProcessSpawner.spawn`. Read-only `Directory`/`File`
caps are now minted `Copy`/`SameSession` (`directory::transfer_result_cap`,
`readonly_fs`, `installable_image`, and the `kernel:directory`/`kernel:file`
bootstrap sources), so the shim can forward an opened read-only directory or
file to the spawned child as a Raw spawn grant; the child looks it up by name
from its CapSet and projects it back onto the inherited fd. The disk-backed
*writable* filesystem stays `NonTransferable` (single-writer policy), so a
writable fd cannot be inherited this way. The kernel handoff is proven in
isolation by `make run-spawn-grant-directory`; see
[capability-model.md](../capability-model.md) "Read-only filesystem caps are
forwardable". The recording shim emits these grants. As of `posix-recording-shim-full-fd-inherit`
(done 2026-05-27) inheritance is **full-fd-table by default**, matching POSIX
`fork`+`execve`: `execve` forwards every open parent slot -- not only
`dup2`/`close`-touched ones -- as a `stdio_<child-slot>` spawn grant, with the
recorded actions applied as edits on top of that baseline. Per backing:
`Directory`/`Console`/`File`/`TerminalSession` forward as `SpawnGrantMode::Raw`
over the Copy-transferable cap (the parent keeps its own fd; an aliased slot
Copy-shares to several child slots), and a `Pipe` end forwards as a single
`Move` (leaving the parent slot a `Moved` sentinel). A slot marked
`FD_CLOEXEC`/`O_CLOEXEC` is dropped from the child unless an explicit recorded
`dup2` named that child slot (POSIX `dup2` clears close-on-exec). A
non-forwardable backing inherited implicitly (`Udp`, an already-moved slot, or a
shared `Pipe`) is skipped non-fatally; an explicit `dup2` of one fails closed.
The child's `posix_inherit_stdio()` reconstructs each grant into the matching fd
slot by interface id, wrapping an inherited directory fd through `fdopendir()`.
End-to-end proofs `make run-posix-fd-inherit-default` (parent inherits stdio +
directory by default with no stdio dup2; CLOEXEC fd excluded; terminal retained
via Raw; Copy-share alias) and `make run-posix-execve-inherit-smoke` (the
explicit-dup2 parent, now redundant but still correct). Because the v0 POSIX
`open` surface mints only `Copy`/`SameSession` File/Directory caps, the
disk-backed *writable* `NonTransferable` filesystem cannot enter the fd table
here; if a future writable `open` path mints one, full inheritance needs a
pre-spawn transferability check to skip it (today it would surface as the
whole-spawn `ENOEXEC`). An inherited `File` resets to offset 0 (the parent's seek
position is userspace state that does not travel with the cap).

The recording-shim `execve(path, argv, envp)` path also forwards argv without
changing the generated `ProcessSpawner.spawn(name, binaryName, grants)` schema:
the parent validates the C argv vector, writes a bounded binary argv record into
a private `Pipe`, and grants only the read end to the child as `posix_argv`.
Child code opts in with `posix_args()`, which prefers `posix_argv` when present
and otherwise falls back to manifest `initConfig.init.posixArgs` through the
`boot` BootPackage cap. The pipe payload is capped by the existing 4 KiB Pipe
transport, so direct large manifest `posixArgs` remain the wider PID-1 channel.
Malformed or over-budget execve argv fails before fd-action replay; the focused
proof asserts this does not mutate the parent's fd table.

### Signals

Stubbed. capOS has no signal mechanism today and the cap model disagrees
with ambient asynchronous interrupts.

- `signal()` / `sigaction()` accept the call, store the handler in a
  per-process table, never invoke it. Return success.
- `kill(pid, sig)` returns -1 / EPERM unless the caller has a
  `ProcessHandle` cap for the target -- and even then the only signal
  honoured would be `SIGKILL`, which maps to a future
  `ProcessHandle.kill()` outside this v0 POSIX surface.
- `raise(sig)` returns -1 / ENOSYS. Self-delivery is still signal delivery,
  and capOS v0 intentionally does not fake it.
- `sigemptyset` / `sigfillset` / `sigaddset` / `sigdelset` / `sigismember`
  are real bit operations on the caller's `sigset_t` (a `uint64_t`).
  `sigprocmask` keeps a per-process blocked mask so ports can save and restore
  it during job control, honours `SIG_BLOCK` / `SIG_UNBLOCK` / `SIG_SETMASK`,
  and force-clears `SIGKILL` / `SIGSTOP` per POSIX -- but the mask is stored,
  never enforced, because there is no delivery to block. `sigpending` always
  reports an empty set for the same reason.
- `pause()` / `sigsuspend()` / `sigwait()` block forever (or with timeout)
  via `sys_cap_enter(0, timeout)`; they never wake from a signal.
- `SIGPIPE` is never delivered. Writes on a closed connection return -1 /
  EPIPE.

This is acceptable for a shell + DNS resolver. Anything that depends on
real signals (job control with Ctrl-Z, Ctrl-C across pipelines, real
`SIGCHLD`) is out of scope for the first port. Job control in the shell
must be reimplemented over typed control caps, not signals.

### errno convention

Per-thread `errno` cell in TLS owned by `libcapos-posix`. Mapping table
(`libcapos-posix/src/errno_map.rs`):

| capOS `CapError` / `CapException` | POSIX errno |
|---|---|
| `CapError::NotFound`              | `ENOENT` |
| `CapError::PermissionDenied`      | `EACCES` |
| `CapError::Disconnected`          | `ECONNRESET` |
| `CapError::Timeout`               | `ETIMEDOUT` |
| `CapError::ResourceExhausted`     | `ENOMEM` / `EMFILE` (context dependent) |
| `CapError::InvalidArgument`       | `EINVAL` |
| `CapError::WouldBlock`            | `EAGAIN` |
| (fall-through)                    | `EIO` |

Wrappers always: clear errno, call, on error set errno + return -1 (int) or
NULL (pointer). Same convention as glibc / musl.

### Threading

pthreads -> capOS in-process threading. Substrate already exists in the
kernel: `ThreadSpawner`, `ThreadControl`, `ThreadHandle`, per-thread
FS-base, `ParkSpace`.

Mapping:

- `pthread_create` -> `ThreadSpawner.spawn` + start-routine trampoline.
- `pthread_exit`   -> `ThreadControl.exitThread`.
- `pthread_join`   -> `ThreadHandle.join` (block via `cap_enter`).
- `pthread_self`   -> TLS slot or `ThreadControl.currentId`.
- `pthread_mutex_*` -> ParkSpace-backed mutex (futex-style park / unpark).
- `pthread_cond_*`  -> ParkSpace + bounded waiter queue.
- `pthread_key_*`   -> fixed-size TLS slot table per thread.

This is in scope but **not on the critical path** for the shell or DNS
resolver -- both can run single-threaded for v0. The pthread shim is
deferred to a v1 successor.

## First Port: POSIX Shell

### Candidate survey

| Shell | License | Size | Deps | POSIX coverage | Verdict |
|---|---|---|---|---|---|
| **dash** ([upstream][dash-debian]) | BSD | ~13 kSLOC, ~134 KB | tiny libc subset; no readline; no termcap | Strict POSIX, no extensions | **Recommended primary** |
| **busybox ash** ([upstream][busybox-ash]) | GPL-2.0 | ~8 kSLOC of `shell/ash.c` + busybox infra | Designed for embedded, modular | POSIX + selectable extensions | Heavier framework cost; useful later when capOS wants a coreutils set |
| **toybox toysh** ([upstream][toybox]) | 0BSD | currently incomplete | Designed for self-contained ELF | POSIX + Bash compat target, **not finished** | Skip -- explicitly described upstream as still under development |
| **oksh** ([upstream][oksh]) | Public domain | ~308 KB binary, 0 deps | Optional ncurses for clear-screen only | Korn-shell superset of POSIX | Bigger surface than v0 needs to validate `libcapos-posix` |
| **Custom Rust shell** | n/a | n/a | n/a | n/a | **Reject -- defeats the purpose of porting C.** Native shell already exists at `shell/` (`capos-shell`). |

Recommended primary: **dash**.

Reasons:

1. Smallest established POSIX-strict shell. ~13 kSLOC is small enough for
   the porting team to read the entire codebase.
2. No readline / termcap dependency. The shell talks to whatever fd 0
   gives it. This is exactly what `libcapos-posix` provides through
   `TerminalSession` or `Console`.
3. Strict POSIX means the port does not accidentally validate Bash
   extensions that `libcapos-posix` does not implement.
4. Already proven as a porting target on Linux from Scratch, OpenWrt, and
   Alpine. Patterns for replacing the libc layer (`__syscall`, stubbed
   `sigaction`) are well documented.
5. Debian uses it as `/bin/sh` since Squeeze (2011), so any "POSIX shell
   only" script base in the wild is dash-compatible.

Open Question §1 below records this candidate as the final decision
(**Decided (P1.4 Slice 1, `2026-05-24 00:53 UTC`)**).

### Required POSIX surface (v0)

What a `dash` instance actually exercises before printing a prompt and
running `ls | grep foo`:

| Group | Calls (minimum set) | Backed by |
|---|---|---|
| Process startup | `_start` shim, `argv`/`envp` parsing, `exit` | `libcapos` `_start`, `sys_exit` |
| Stdio | `read(0,...)`, `write(1,...)`, `write(2,...)` | Console / TerminalSession cap |
| Allocation | `malloc`/`free`/`calloc`/`realloc` | `libcapos` heap |
| String/format | `printf`/`fprintf`/`memcpy`/`strlen`/`strcmp`/`strchr`/`strncpy`/... | `libcapos-posix` string/printf subset |
| File I/O | `open`/`close`/`read`/`write`/`lseek`/`stat`/`fstat`/`access`/`unlink` | Namespace + File caps |
| Directory | `opendir`/`readdir`/`closedir` | Directory cap |
| Pipes | `pipe()`, `dup2()`, `close()` on fds | NEW `Pipe` capability (P1.3) |
| Process | `fork`+`execve` (fork-for-exec only), `posix_spawn`, `wait`/`waitpid` | ProcessSpawner + `ProcessHandle.wait` |
| Env | `getenv`/`setenv`/`putenv` | Per-process env vector in `libcapos-posix`; populated from a future `LaunchParameters` cap when one lands |
| Signals | `signal`/`kill`/`sigaction` (stubs) | TLS-stored handlers, never delivered |
| Time | `time`/`gettimeofday`/`nanosleep` | Timer cap |
| Control flow | `setjmp`/`longjmp` over `jmp_buf` | `libcapos` x86_64 SysV `global_asm` (`<setjmp.h>`); no `sigsetjmp` |
| Misc | `getpid`/`getuid`/`getgid` | `getpid` from capos-rt bootstrap pid; uid/gid hardcoded for v0 |

The control-flow row was absent from the original minimum set above; dash's
exception/interpreter control flow is built on `setjmp`/`longjmp` over a real
`jmp_buf` (pervasive in `error.h`/`main.c`/`eval.c`/`parser.c`/`trap.c`), so it
is a hard precursor for the dash build pipeline. It landed via the
`libc-setjmp-longjmp` task: the x86_64 SysV primitive in `libcapos/src/setjmp.rs`
with a `<setjmp.h>` header, re-exposed under `libcapos-posix/include/capos/posix/`,
and proven in QEMU by `make run-posix-setjmp`. `sigsetjmp`/`siglongjmp` are
intentionally absent (dash uses only the plain primitive; the v0 signal layer
has no asynchronous delivery and thus no signal mask to save).

Like the control-flow row, the table above also understated the **header
layout** and breadth of the libc surface a program of dash's size needs. A
`-nostdinc` compile/link probe of the full vendored dash TU set
(`2026-05-25 21:40 EEST`) showed dash uses **bare** POSIX includes
(`<unistd.h>`, `<fcntl.h>`, …) — not the capOS `capos/posix/*.h` namespace — so
it requires a `-nostdinc` capOS POSIX **sysroot** plus a missing surface. This
landed (`2026-05-25 22:23 UTC`, `libc-dash-sysroot-surface`):
`libcapos-posix/sysroot/include/` is the bare-header sysroot forwarding into
the `capos/posix/*` namespace, and the surface was completed —
`strerror`/`qsort`/`umask`/`abort`/`setlocale`/`getrlimit`/`times`/`tcgetattr`/
`strtoll`/`strtoull`/`sig_atomic_t`/`NSIG`/`sigsuspend`, the `str*` set, the
`<termios.h>`/`<sys/resource.h>`/`<sys/times.h>`/`<locale.h>`/`<sys/types.h>`
headers, **and** further items the table still understated: the C/POSIX-locale
multibyte layer (`<wchar.h>`/`<wctype.h>`, `mbrtowc`/`wctype`/`iswctype`/…) that
`expand.c` uses unconditionally, `strpbrk`, `lstat`, `getgroups`, `wait3`,
`vfork`, byte-order helpers, `environ`, and the `sys_siglist` array. The full
vendored dash TU set now compiles `-nostdinc` against the sysroot with no
unresolved libc symbols; proof `make run-c-libc-surface`. The dash build
pipeline (`posix-p1-4-dash-build-pipeline`) landed on top of it
(`2026-05-26 05:11 UTC`): `make dash` builds and links `target/dash/dash.elf`.
See `docs/backlog/posix-adapter-dash-port.md` Slice 12.5.

**Critical gap:** `pipe()`. The shell pipeline `ls | grep foo` requires fd 1
of `ls` to feed fd 0 of `grep`. capOS has no pipe capability today. This is
the first-port-blocking item; see Phase P1.3.

### What dash will not get in v0

- Job control (Ctrl-Z, `bg`, `fg`, `&` background): requires real
  `SIGCHLD`/`SIGTSTP`. Skip; documented as out of scope.
- Process groups, sessions, controlling terminals: same reason.
- `trap` for signals other than `EXIT`: handlers stored, never fired.
- `read -t` (timeout): doable via Timer cap; defer to v1.
- `ulimit`: returns 0 / ENOSYS. Quotas are kernel-side capability ledgers,
  not POSIX rlimits.

### Validation smoke

`make run-posix-shell-smoke`:

1. Boot a manifest that grants `dash` a `TerminalSession` (stdio), a
   read-only bootstrap-granted `Directory` cap rooted at a tiny
   in-rodata pseudo-fs (the resolver remains `Namespace`-shaped for
   forward parity with the future userspace `Namespace` service; the
   v0 manifest grants a `Directory` because that is what Storage
   Phase 3 slice 2 ships as a kernel `CapObject` today), a
   `ProcessSpawner` narrowed to one allowed binary (`ls-shim`), and a
   `Timer` cap.
2. Pipe a heredoc into stdin: `ls; echo done`.
3. Assert kernel log shows `done` and clean exit.

Stretch goal smoke: `cat foo | grep bar` end-to-end (depends on the pipe
primitive landing).

## First Port: DNS Resolver

> **Status update (post-smoltcp).** The original v0 DNS smoke
> (`posix-dns-resolver`, Phase P1.2 Phase B) drove a hand-rolled A query through
> a raw kernel `UdpSocket` cap; that smoke is retired with the qemu-only kernel
> UDP owner. Name resolution now goes through a typed system `DnsResolver`
> capability (`network-system-dnsresolver-cap-local-proof`), and `libcapos-posix`
> exposes the standard POSIX surface over it:
> `getaddrinfo` / `freeaddrinfo` / `gai_strerror` (`src/netdb.rs`,
> `include/capos/posix/netdb.h`) resolve one IPv4 `A` result through a granted
> `dns_resolver` endpoint and map the typed resolver status onto
> `addrinfo` / `EAI_*`, with no ambient UDP fallback (a process without the cap
> gets a deterministic `EAI_FAIL`). A read-only `/etc/resolv.conf` projection is
> materialized at `open()` time from the resolver status (writes fail closed with
> `EACCES`; absent without the cap). Proof: `make run-posix-getaddrinfo`. The
> candidate survey below is retained as the original design rationale; vendored
> `dns.c` is no longer on the critical path for the resolver bridge. AAAA /
> `sockaddr_in6`, `AI_*` flags, and `/etc/services` remain follow-ups (each fails
> closed: `EAI_FAMILY` / `EAI_BADFLAGS` / `EAI_SERVICE`).

### Candidate survey

| Library | License | Source size | Deps | Async style | Verdict |
|---|---|---|---|---|---|
| **musl `res_query`** ([upstream][musl-resolver]) | MIT | ~2 kSLOC for resolver core | Embedded in musl | Synchronous (parallel queries internally) | Available *only if* the build links musl; capOS does not. **Skip.** |
| **c-ares** ([upstream][c-ares]) | MIT, C89 | ~30+ kSLOC, multi-file, configure-driven | POSIX sockets, optional threads | Native async (callbacks + select/poll/event loop) | Largest surface, most mature, most invasive port |
| **dns.c (wahern)** ([upstream][wahern-dns]) | MIT | **single-file C, ~10 kSLOC, no deps** | None -- caller provides socket I/O via three pluggable patterns (pollfd / events / timeout) | Non-blocking, no required callback shape | **Recommended primary** |
| **GNU adns** ([upstream][gnu-adns]) | GPL-2.0+ | Multi-file, ~10-15 kSLOC | POSIX, no event-loop integration | Async, opaque state | License is GPL-2.0+, not BSD/MIT. Skip unless capOS accepts a GPL component in the demo path. |
| **udns** ([upstream][udns]) | LGPL-2.1 | small | POSIX | Async stub-only | LGPL plus older project; skip unless dns.c blows up |
| **SPCDNS** | LGPL | small | encode/decode only, no socket | n/a | Skip -- provides no resolver loop |
| **trust-dns-resolver in Rust** | Apache-2 / MIT | large | Tokio | async | **Reject -- defeats the purpose of porting C.** Native Rust resolver is a separate path. |

Recommended primary: **dns.c** by William Ahern.

Reasons:

1. **Single-file, zero deps.** Drops into the build with a minimal `cc`
   rule. The build avoids configure scripts, pkg-config, optional
   feature matrices, and multi-file build orchestration.
2. **No fixed I/O model.** dns.c is designed around three common methods
   (pollfd, events, timeout). The host adapter plugs capability-backed
   socket I/O without rewriting the resolver core, replacing
   `socket()`/`sendto()`/`recvfrom()`/`poll()` with `libcapos-posix`
   wrappers that return fd-shaped results backed by `UdpSocket` /
   `TcpSocket` caps.
3. MIT license is capOS-compatible.
4. ~10 kSLOC means port review can read it end-to-end.
5. C89, no threading assumption, no global state surprises (resolver
   handle is opaque per-instance) -- fits a single-process v0 design.

Open Question §2 below records that the candidate is a recommendation,
not a final decision.

### Required POSIX surface (v0)

The DNS resolver port exercises a *very* narrow POSIX subset:

| Group | Calls | Backed by |
|---|---|---|
| Stdio (logs only) | `write(2,...)` | Console cap |
| Allocation | `malloc`/`free`/`calloc`/`realloc` | `libcapos` heap |
| Time | `clock_gettime`/`gettimeofday` | Timer cap |
| Sockets (UDP) | `socket(AF_INET, SOCK_DGRAM, 0)`, `sendto`, `recvfrom`, `bind`, `close`, `setsockopt` (subset) | NetworkManager + UdpSocket cap |
| Polling | `poll(fds, nfds, timeout_ms)` | Synthesised: each fd carries its underlying cap; `libcapos-posix` uses `cap_enter(min_complete=1, timeout_ns)` with one CQE per ready fd. No new kernel surface needed for v0 if dns.c uses one fd per query. |
| Resolv config | One in-rodata bounded text blob inlined into `libcapos-posix` (single nameserver entry; v0 ships before any storage cap exists) | No `open` / Namespace cap required for v0 |

No pipes, no fork, no exec, no signals, no `/etc/resolv.conf`-by-path,
no Namespace or File caps required. The DNS resolver is strictly easier
than the shell.

The v0 surface intentionally omits TCP fallback for truncated responses
and intentionally omits any path-based config file. The optional TCP
fallback row uses `socket(SOCK_STREAM)`, `connect`, `send`, `recv`
through the existing `NetworkManager` + `TcpSocket` cap, but only on a
later iteration once the v0 UDP-only smoke is green; see "What dns.c
will not get in v0" below.

**Critical gaps:**

- `UdpSocket` capability. The networking proposal Phase B implements TCP +
  listener only; UDP "is deferred until the userspace network stack or DNS
  work needs it; it is not part of the Telnet Shell Demo contract"
  (`networking-proposal.md`). The resolver port creates the UDP path; it
  does not consume an existing one.
- The future `Resolver` cap concept (in `service-architecture-proposal.md`
  "DNS resolver -- consumes a `UdpSocket`, exports `Resolver`") is a target
  once the UDP path exists. The first port produces the exported shape.

### What dns.c will not get in v0

- DNSSEC validation: dns.c supports it, depending on `/etc/resolv.conf`
  trust anchor config. Defer.
- TCP fallback for truncated responses: implement on a second iteration
  once the TCP capability path is reusable.
- `mDNS`: out of scope.
- Recursive mode (acting as a recursive resolver): out of scope; v0
  ships stub-only.

### Validation smoke

`make run-posix-dns-smoke`:

1. Boot a manifest that grants the resolver process a `NetworkManager`
   (or future narrowed `UdpSocket`-only authority), a Console cap, and
   a Timer cap. The single-nameserver resolv config is the in-rodata
   bounded text blob compiled into `libcapos-posix`; no Namespace or
   File cap is needed for v0.
2. The resolver opens a UDP socket, sends a query for a known A record
   to QEMU's user-mode 10.0.2.3 (slirp's built-in DNS) or to an in-host
   test resolver.
3. Resolver prints the resolved IPv4 address.
4. Assert kernel log line matches.

## Trade-offs and Ordering

### Smallest-deps comparison

| Port | C surface needed | New capOS infrastructure required | Difficulty |
|---|---|---|---|
| **DNS resolver (dns.c)** | malloc, time, socket subset, write(2), open RO file, poll-equivalent | UDP socket cap + NetworkManager exposure of UDP; otherwise reuses Phase B TCP path infra | **Smaller** -- strictly additive (UDP is missing today but the kernel-side smoltcp stack supports it) |
| **POSIX shell (dash)** | malloc, full stdio, file I/O, directory iteration, **pipe()**, fork-for-exec, exec, wait, env, time, signals (stub) | Pipe primitive (new), Namespace+File cap surface, ProcessSpawner sidecar work to honour fd-action grants, env-vector handoff | **Larger** -- touches storage / IPC / process surfaces |

### Which blocks which

- Both ports can run in parallel at the `libcapos` / `libcapos-posix`
  layer level: each pulls a disjoint subset of POSIX surfaces.
- DNS resolver blocks on a new capOS surface (UDP cap exposure) but does
  not block on `pipe()`, `fork()`, or `exec()`.
- Shell blocks on (in order of probable cost): pipe primitive,
  ProcessSpawner fd-action support for stdin / stdout redirection,
  Namespace+File cap availability, env vector / `LaunchParameters`.
- The library substrate (`libcapos` staticlib + `libcapos-posix` scaffold)
  blocks both. Once the substrate exists, the two ports proceed in
  parallel.

### Recommended sequence

1. **libcapos staticlib v0** (Phase P1.1). The thin Rust `.a` with
   `cap_call`, `capset_get`, `sys_exit`, `sys_cap_enter`, heap. Plus a "C
   hello world" smoke that calls `console_write_line()` (mirrors the
   userspace-binaries proposal "Future Phase: libcapos for C"). This phase
   is the prerequisite for both P1.2 and P1.3.
2. **libcapos-posix scaffold** -- fd table, errno cell, stdio wrappers for
   fd 0/1/2, stub signals, `_start` glue that registers `argv` / `envp`
   from `LaunchParameters` (or empty arrays if that surface has not
   landed), basic `malloc`/`free` re-export.
3. **dns.c port** (Phase P1.2). The schema half of P1.2 (the
   `UdpSocket` interface and `NetworkManager.createUdpSocket` method)
   landed in Phase A and released the shared schema serial surface;
   Phase B (kernel UDP path, `libcapos-posix`, `dns.c` vendoring,
   demo) does not re-acquire the surface and so does not contend with
   P1.3 on the schema half.
4. **dash port** (P1.3 lays the pipe + fork-for-exec primitives;
   Storage Phase 3 slices 1-3 land the kernel-side `File` / `Directory`
   / `Store` / `Namespace` `CapObject`s and `KernelCapSource` grant
   sources that back the dash v0 smoke's read-only in-rodata pseudo-fs;
   the actual dash vendoring is a successor task that owns the
   libcapos-posix file / dir / stdio / env / printf surface and the
   smoke harness rather than new kernel surface). P1.4 does not touch
   `schema/capos.capnp` and so does not contend on the shared schema
   serial surface.

### Critical path

The DNS resolver is the smaller-deps first slice **only because** of the
shell's pipe / file dependencies. With P1.3 (pipe + fork-for-exec) and
Storage Phase 3 slices 1-3 (RAM-backed `File` / `Directory` / `Store` /
`Namespace` `CapObject`s) both landed, the shell-first prerequisite
gates are closed; the remaining P1.4 work is dash vendoring +
per-call-site patching, the multi-translation-unit C build, and the
smoke harness.

### What this slice does not promise

- Not a path to running glibc-built binaries unchanged. Both ports are
  sources-on-disk recompiled against `libcapos-posix`. Binary
  compatibility with Linux ELFs is not in scope.
- Not job control, not signals, not full POSIX session/pgrp model.
- Not a libc -- the POSIX surface ships *just enough* for dash and dns.c.
  `printf` family lands in `libcapos-posix` only because both ports need
  it; this is not a `<stdio.h>` for general use.
- Not a reason to skip the native Rust paths -- `capos-shell` (Rust
  `shell/` crate) remains the default capOS shell. dash is for porting
  validation, not as the system shell.
- Not a foundation for hosted C++. C++ requires explicit ABI decisions
  tracked separately in `docs/proposals/userspace-binaries-proposal.md`.

## Phase Decomposition

Phases are dispatch-ready. P1.1 closed `2026-05-05 13:28 UTC` at merge
`fe5f5208`. P1.2 splits into Phase A (closed `2026-05-05 18:02 UTC`,
schema additions + open questions + capos-rt typed client) and Phase B
(open, kernel UDP path + dns.c demo). P1.2 Phase B does not touch
`schema/capos.capnp` and so does not contend with P1.3 on the shared
schema serial surface; P1.3 still adds a `Pipe` interface and must
queue on the surface per `docs/backlog/index.md` Concurrency Notes when
selected.

### Phase P1.1 -- libcapos C-substrate v0 + C hello-world smoke

**Closed `2026-05-05 13:28 UTC` at merge `fe5f5208`** (initial slice
`b2e09bce`, transfer-record helper `81a88fab`). Delivered scope:

- New crate `libcapos/` with `crate-type = ["staticlib"]` (cargo
  `[lib].name = "capos"` so the archive lands as `libcapos.a`)
  exposing the capos-rt syscall, ring CALL, CapSet lookup, typed
  `Console.writeLine` wrapper, and `malloc`/`free`/`calloc`/`realloc`
  heap shims through `extern "C"`.
- Public C header at `libcapos/include/capos/capos.h`.
- `make c-hello` builds the C smoke directly with clang + lld using
  the shared `demos/linker.ld`, links against
  `libcapos/target/.../libcapos.a`, and reuses capos-rt's `_start`
  through libcapos's `capos_rt_main` trampoline.
- Demo `demos/c-hello/` (single `.c` file calling `console_write_line`).
- Manifest `system-c-hello.cue`.
- No POSIX surface, no errno, no pthreads.
- Validation: `make run-c-hello` boots; the C binary prints
  `[c-hello] hello from c-hello` (the marker
  `tools/qemu-c-hello-smoke.sh` greps) and exits cleanly.

### Phase P1.2 -- UDP cap surface + dns.c stub resolver smoke

P1.2 splits into two dispatch waves so the kernel-side wave can
serialise behind the active DDF hostile-smoke work on
`kernel/src/cap/network.rs` and `kernel/src/virtio.rs` without holding
the schema-only wave.

#### Phase P1.2 Phase A -- schema + open questions + capos-rt client

**Closed `2026-05-05 18:02 UTC`.** Delivered scope:

- Open questions §2 (DNS resolver = dns.c by William Ahern), §4 (errno
  via per-thread TLS cell exposed through `__errno_location()`), §5
  (static-array fd table in `libcapos-posix`, 32-fd cap for v0), and
  §8 (four-method blocking UDP shape with the wait deadline owned by
  the ring client, not a per-method `timeoutNs` parameter) resolved
  in this proposal.
- Schema additions to `schema/capos.capnp`: new `UdpSocket` interface
  (`sendTo`, `recvFrom`, `close`) plus the new
  `NetworkManager.createUdpSocket` method. Generated bindings refresh
  verified via `make generated-code-check`.
- New `UDP_SOCKET_INTERFACE_ID` constant in `capos-config/src/lib.rs`.
- New typed `UdpSocketClient` in `capos-rt/src/client.rs`, mirroring
  the existing `TcpSocketClient` shape (`create`/`send_to`/`recv_from`/
  `close`).
- Schema serial-surface release: this slice held the surface during
  schema additions and released it at merge.

#### Phase P1.2 Phase B -- kernel UDP path + dns.c + demo

**Closed `2026-05-05 21:21 UTC`.** Delivered scope:

- Kernel: extended `kernel/src/cap/network.rs` with the UDP path
  mirroring the existing TCP path (`UdpSocketCap`,
  `handle_create_udp_socket`/`handle_udp_send_to`/`handle_udp_recv_from`/
  `handle_udp_socket_close`, deferred-recv parking via
  `PendingUdpRecv`), and added UDP runtime methods on the existing
  scheduler-polled smoltcp runtime in `kernel/src/virtio.rs`
  (`create_udp_socket`/`send_udp`/`recv_udp`/`close_udp_socket` over a
  bounded `MAX_PUBLIC_UDP_SOCKETS` slot table with generation-bumped
  handles).
- New standalone Rust staticlib crate `libcapos-posix/` (NOT a
  workspace member, mirrors the libcapos pattern) producing
  `libcapos_posix.a`. Provides:
  - per-process static-array fd table (`MAX_FDS = 32`), per Open
    Question §5;
  - single-thread errno cell exposed via `__errno_location()`, per
    Open Question §4;
  - `socket(AF_INET, SOCK_DGRAM, 0)` / `sendto` / `recvfrom` /
    `close()` over `UdpSocket` and `clock_gettime(CLOCK_MONOTONIC,
    ...)` / `gettimeofday(&tv, NULL)` over `Timer` (single-shot
    `Timer.now()` calls in v0; long retry loops handled by the
    consumer).
  - C headers under `libcapos-posix/include/capos/posix/`:
    `errno.h`, `sys/socket.h`, `time.h`, `unistd.h`.
  - Reuses libcapos's installed runtime through a renamed extern crate
    `libcapos_::runtime::with(...)` (the underscore avoids colliding
    with libcapos's C-side `capos_*` exports). libcapos was promoted
    to `crate-type = ["staticlib", "rlib"]` to support this.
- Vendored `vendor/dns-c-wahern/` (William Ahern dns.c at
  `rel-20160808`, commit `4ec718a77633c5a02fb77883387d1e7604750251`,
  MIT). Mirror-as-is; only `src/dns.c` and `src/dns.h` retained
  alongside `LICENSE` and `README.md` per the WASI W.1 vendoring
  discipline. See `vendor/dns-c-wahern/VENDORED_FROM.md`.
- New C smoke `demos/posix-dns-resolver/main.c` that links against
  `libcapos.a` + `libcapos_posix.a` and drives a hand-rolled DNS A
  query for `example.com` to QEMU slirp DNS at 10.0.2.3:53. The
  binary uses the vendored dns.c as a *reference* but does NOT
  compile dns.c whole into the smoke. Rationale: dns.c expects a
  POSIX header set (`signal.h`, `fcntl.h`, `poll.h`, `netinet/in.h`,
  `arpa/inet.h`, `netdb.h`, `sys/select.h`, `sys/un.h`) substantially
  wider than the v0 `libcapos-posix` surface. Compiling dns.c whole
  would require either patching the vendored tree or shipping a
  much larger POSIX header surface than this slice scopes;
  documented as follow-on work in `VENDORED_FROM.md`.
- New focused-proof manifest `system-posix-dns.cue` (own CUE
  package, imports the shared `capos.local/cue/defaults` package per
  the slice-3 defaults pattern) granting the smoke `console`,
  `network_manager`, and `timer`.
- New Makefile target `run-posix-dns-smoke` and harness
  `tools/qemu-posix-dns-smoke.sh`. The smoke prints
  `[posix-dns-resolver] resolved example.com -> <addr>` (an arbitrary
  IPv4 dotted-quad slirp returns from upstream resolution) and
  exits cleanly. Verified at `2026-05-05 21:21 UTC`: `make
  run-posix-dns-smoke` returns 0 with `resolved example.com ->
  104.20.23.154` in the kernel log; `make run-net` regression keeps
  S.11.2.7 / S.11.2.8 hostile-smoke proof lines green.

Depended on Phase P1.1 and Phase P1.2 Phase A.

### Phase P1.3 -- Pipe capability + fork-for-exec scaffolding

**Closed `2026-05-07 09:55 UTC`.** `make run-posix-pipe-smoke` is
the load-bearing gate; it drives the dash-shaped pipeline pattern
end to end through the kernel `Pipe` capability and the
recording-shim `fork`+`execve` path.

What landed:

- Schema: new `Pipe` interface (`read` / `write` / `close` /
  `isClosed`) and `ProcessSpawner.createPipe(bufferBytes)`. The
  generated `tools/generated/capos_capnp.rs` baseline was refreshed
  through the canonical capnpc step and `make generated-code-check`
  passes.
- Kernel: `kernel/src/cap/pipe.rs` ships the bounded SPSC byte ring
  with EOF-on-close semantics, kept symmetric with the UDP recv
  ceiling (4 KiB). Each cap half stores an `Arc<PipeShared>` plus a
  direction; close on one side flips the shared closed flag and the
  per-tick poll completes the peer. `kernel/src/cap/mod.rs` and
  `kernel/src/sched.rs` integrate the new poll alongside the
  existing network poll.
- Kernel: `kernel/src/cap/process_spawner.rs` gains
  `handle_create_pipe`, mirroring the UDP-socket result-cap transfer
  pattern. The existing `spawn` Move-grant path is reused; no
  changes to the spawn ABI.
- Userspace runtime: `capos-rt/src/client.rs` exposes typed
  `PipeClient` (read/write/close/isClosed and matching `*_wait`)
  plus `ProcessSpawnerClient::create_pipe / create_pipe_wait` and
  the `CreatePipeResult` projection of the two transferred halves.
- `libcapos-posix`: new `pipe.rs` and `process.rs` modules. The fd
  table grows a `FdBacking::Pipe` variant; `dup_for_dup2()` clones
  the `OwnedCapability<Pipe>` so an aliased fd does not release the
  underlying cap until the last fd drops. `pipe`, `read`, `write`,
  `dup`, `dup2`, `fork`, `execve`, `waitpid`, `_exit`, and
  `posix_inherit_stdio` are exposed via C ABI. `dup2` and `close`
  inside a fork-recording window route through
  `process::maybe_record_dup2` / `maybe_record_close` rather than
  mutating the parent fd table; `execve` consumes the recorded
  actions as `stdio_<N>` spawn grants -- `Pipe`/`TerminalSession`
  forwarded `Move`, `Console`/`Directory`/`File` forwarded `Raw` over
  their Copy-transferable caps -- and **returns the
  synthetic child pid as its own return value** so the user pattern
  becomes `int spawn_pid = execve(...); if (spawn_pid < 0) /*
  surface error to the caller; do NOT _exit because the
  pseudo-child branch is still the parent process */; child =
  spawn_pid;` (no `setjmp` / `longjmp` involved -- earlier
  iterations longjmp'd back to the `fork()` call site, which dropped
  back into a returned-and-deallocated stack frame and was undefined
  behaviour). After a successful spawn, each `Move`-granted source fd
  slot is replaced with a `FdBacking::Moved` sentinel and the
  underlying `OwnedCapability` is forgotten so the parent does not
  queue a stale CAP_OP_RELEASE for the moved cap_id; a subsequent
  `close(src)` on the parent side (the dash-shaped pattern's "I no
  longer hold the write end") removes the sentinel without a kernel
  round trip. A `Raw`/Copy grant (`Console`/`Directory`/`File`) is
  non-destructive: the parent's own fd is restored intact, since the
  kernel handed the child a separate alias. The child side adopts each
  `stdio_<N>` grant back into slot `N` by interface id (`fd::inherit_stdio_grants`),
  wrapping an inherited directory fd through `fdopendir()`; proof
  `make run-posix-execve-inherit-smoke`.
- `libcapos-posix` successor surface: direct `posix_spawn` and
  `posix_spawn_file_actions_init` / `destroy` / `adddup2` /
  `addclose` reuse the same action-replay helper behind the
  recording-shim `execve` path. Recording-shim `execve` now delivers
  argv through the private `posix_argv` Pipe grant described above.
  Direct `posix_spawn` still accepts `argv` and `envp` for source
  compatibility but does not deliver them to the child yet; direct-spawn
  argv/environment remain empty until a typed LaunchParameters /
  environment grant exists.
- `libcapos-posix` stdio successor: landed at commit `aa6a56d7`
  (`2026-05-13 11:03 UTC`). fd 1 and fd 2 initialize to the granted
  Console cap when present, but only after any `stdio_<N>` recording-shim
  grants have been adopted into their slots. fd 0 is not synthesized from
  Console; `read(0, ...)` stays closed unless a real stdin backing is
  granted. `make run-posix-stdio-smoke` prints distinct stdout/stderr
  markers through POSIX `write` and proves the no-stdin refusal path.
- Demo: `demos/posix-pipe-shim/main.c` (parent) and
  `demos/posix-pipe-child/main.c` (child). The parent pipes,
  forks, the child-pseudo-context dup2()s the write end onto
  STDOUT_FILENO, closes both pipe fds, and execve()s the child;
  the child calls `posix_inherit_stdio()`, writes "hello via pipe"
  to fd 1, closes it, and exits 0; the parent drains the read end
  through `read()` until EOF, `waitpid()`s, and emits
  `[posix-pipe] read 14 bytes: hello via pipe`.
- New manifest `system-posix-pipe.cue` (own CUE package, imports
  the shared `capos.local/cue/defaults` package). New Makefile
  target `run-posix-pipe-smoke` and harness
  `tools/qemu-posix-pipe-smoke.sh`. Verified `2026-05-07 09:55 UTC`:
  `make run-posix-pipe-smoke` returns 0 with the proof line in the
  kernel log; `make run-smoke` and `make run-spawn` regressions stay
  green.
- Schema serial-surface coordination: held the surface for the
  P1.3 schema additions and released on merge.

Open Question §6 closed: **Variant A (recording shim)** is the
adopted answer. `fork()` records "next exec is the real spawn" in
TLS and returns 0; the shim translates inter-call `dup2`/`close` into
spawn-grant Move actions; and `execve()` performs the spawn and
**returns the synthetic child pid as its own return value** (the
caller forwards the pid to the parent flow's `waitpid` via
`int spawn_pid = execve(...); if (spawn_pid < 0) /* surface error to
the parent's normal error path; the pseudo-child branch is still
the parent process so do NOT _exit */ ; child = spawn_pid;`).
Earlier iterations used `setjmp` / `longjmp` to
fake the fork-return-twice semantic; that approach was replaced
because the longjmp jumped back into `fork()`'s already-returned
(and deallocated) stack frame, which is undefined behaviour. Variant
B (patched-port `posix_spawn` only) is **rejected** for v0. Variant
A still requires a small dash-side patch -- the four-line
"capture spawn_pid; bail on -1; assign back to child" snippet at
each fork-exec site -- because successful `execve()` now returns the
synthetic pid where unmodified dash assumes execve only returns on
failure. That patch surface is much narrower than Variant B's
"consolidate every fork+dup2+exec into a single posix_spawn call
with explicit posix_spawn_file_actions" rewrite, which is why
Variant A is the chosen v0 path. A 2026-05-13 successor exports the
direct `posix_spawn()` surface over the same code path. Recording-shim
`execve` argv now travels through a private `posix_argv` Pipe grant; direct
`posix_spawn` argv/envp remain ignored until LaunchParameters / environment
support lands.

Open Question §9 closed: **kernel-allocated bounded SPSC ring
with EOF-on-close**, exposed as two cap halves sharing
`Arc<PipeShared>`. Reader-closed surfaces `bytesWritten = 0` to
the writer (the EPIPE-equivalent chosen to avoid expanding the
kernel `ExceptionType` vocabulary). Writer-closed surfaces `eof =
true` to the reader after the buffered bytes drain. The shared
MemoryObject + userspace ring alternative is **rejected**
because EOF across process exits and bounded waiter wake
semantics need kernel-side state anyway.

Depended on Phase P1.1.

### Phase P1.4 -- dash vendoring + libcapos-posix file/dir/stdio/env/printf surface

**Status (`2026-05-23 07:52 UTC`): in flight.** Slice 3 (libcapos-posix
`FdBacking` File / Directory / Terminal variants + smoke) closed at commit
`ae58f936`; Slice 4 (absolute-path resolver over a bootstrap-granted root
`Directory` cap plus functional `open()`/`opendir()`) landed at commit
`94b29177`; the `posix-file-directory-client-capos-rt` closeout at commit
`f97d9833` (`2026-05-23 06:23 UTC`) adds functional `lseek()`, lazy
`readdir()` over `Directory.list`, and the focused `make run-posix-file`
proof. Slice 7 adds the focused printf/string C library subset and proves it
with `make run-posix-printf`. Slices 8/9 add signal-registration stubs plus
Timer-backed `time()` / `nanosleep()` / `sleep()` and prove them with
`make run-posix-signal-time`. The kernel-side capability surface required for
the v0 dash smoke landed under
[Storage and Naming](storage-and-naming-proposal.md) Phase 3 slices 1-3:
RAM-backed `File` (`kernel/src/cap/file.rs`), `Directory`
(`kernel/src/cap/directory.rs`), and `Store` / `Namespace`
(`kernel/src/cap/store.rs`, `kernel/src/cap/namespace.rs`) `CapObject`s,
plus the matching `KernelCapSource::file` / `directory` / `store` /
`namespace` manifest grant sources, are sufficient backing for the
"read-only Namespace cap rooted at a tiny in-rodata pseudo-fs" the
smoke described in §Validation smoke needs. Earlier proposal drafts
called Phase P1.4 "blocked on the `Namespace` + `File` cap surface";
that framing is stale -- the open work has moved out of the kernel and
into the `libcapos-posix` userspace surface, the dash port itself, and
the smoke harness. A userspace `Store` / `Namespace` service over a
real backing store (the remaining Phase 3 item in the storage proposal)
is **not** a prerequisite for the v0 dash smoke; the kernel
bootstrap-grant `Directory` cap is the v0 backing.

The concrete checklist lives in `docs/proposals/posix-adapter-proposal.md` Task 4
and the long-form decomposition is in
`docs/backlog/posix-adapter-dash-port.md`. This proposal records the
phase shape and the substantive outstanding work groups; the backlog
file owns per-step ordering.

Current closed surfaces and outstanding work groups, all in userspace and
userspace-adjacent harness surface (no further kernel cap work needed for the
v0 smoke):

- **dash vendoring + patch.** *Closed (`posix-p1-4-dash-vendor`,
  `2026-05-24 19:40 UTC`).* dash `v0.5.13.4` is vendored mirror-as-is
  (full upstream tree, byte-identical) under `vendor/dash/` with
  `vendor/dash/VENDORED_FROM.md`. The per-call-site Variant A patch
  (capture `execve()`'s synthetic pid return value, bail on `-1`, assign
  back to `child`) -- the shape recorded in Open Question §6 and the
  Decisions §6 entry -- lives under `vendor/dash/patches/` as two
  `.patch` files: `0001-execve-return-synthetic-pid.patch` propagates the
  synthetic pid up through `tryexec()`/`shellexec()` (the `execve()` call
  site), and `0002-vforkexec-adopt-synthetic-pid.patch` adopts it at the
  `vforkexec()` fork-exec site. Cumulative diff 45 changed lines (< 50).
  dash's inter-call `dup2` / `close` between fork and execve already
  records through `libcapos-posix` and needs no per-call patching. Design
  evidence only: nothing compiles/runs at this slice; the C-build and
  shell-smoke slices below prove the behavior.
- **C-build pipeline for vendored multi-file C sources.** *Landed
  (`posix-p1-4-c-multifile-build`).* The existing `c-build` helper compiles
  single-file `demos/*/main.c` smokes against `libcapos.a` +
  `libcapos_posix.a`. dash is a multi-translation-unit C codebase; the
  Makefile gained the reusable `capos-c-multitu-elf` `define` (instantiated
  with `$(eval $(call ...))`) that compiles a list of vendored `.c` files
  each to an object and links them with `libcapos_posix.a` + `libcapos.a`
  into a userspace ELF without dragging in an external libc. Toolchain
  remains `clang --target=x86_64-unknown-none-elf -nostdlib -static` per Open
  Question §11 and the libcapos C-substrate plan. Proven by the two-TU
  `demos/c-multifile/` demo and `make run-c-multifile`, which asserts a
  cross-TU computed line.
- **dash build pipeline (autotools config.h + host table generators).**
  *Landed (`posix-p1-4-dash-build-pipeline`, `2026-05-26 05:11 UTC`).* The
  generic multi-TU rule runs no `configure` and no host generators, so the
  dash-specific prerequisites live under `vendor/dash/capos/`: a pinned
  `config.h` (derivation + host-table caveat in
  `vendor/dash/VENDORED_FROM.md`) and `gen-tables.sh`, which stages a patched
  source copy (keeping `vendor/dash/src` byte-identical) and runs dash's six
  host generators (`mktokens`, `mksyntax`, `mknodes`, `mksignames`,
  `mkbuiltins`, `mkinit`). The Makefile `dash` target funnels `dash_CFILES` +
  the five generated tables through `capos-c-multitu-elf` against
  `libcapos_posix.a` + `libcapos.a` in the `-nostdinc` sysroot mode, producing
  `target/dash/dash.elf` (static, 0 undefined symbols, both Variant A
  fork-exec patches compiled in). `make dash` proves build + link; the runtime
  QEMU proof is the dependent shell smoke below.
- **File / directory I/O surface in `libcapos-posix`.** Typed
  `FileClient` and `DirectoryClient` wrappers landed in
  `capos-rt/src/client.rs` at commit `747a8611`
  (`2026-05-16 20:07 UTC`); `FILE_INTERFACE_ID` /
  `DIRECTORY_INTERFACE_ID` constants are already in
  `capos-config/src/lib.rs`. Slice 3 added the
  `FdBacking::File` / `FdBacking::Directory` / `FdBacking::Terminal`
  variants in `libcapos-posix/src/fd.rs` at commit `ae58f936` and
  the matching smoke. The current surface implements `open`, `close`,
  `read` / `write` (joining the existing pipe/UDP read/write dispatch),
  `lseek`, `opendir`, `readdir`, and `closedir`; `make run-posix-file`
  proves these through a live POSIX C process. File-backed fds now store
  the POSIX access mode from `open()`: `read` rejects `O_WRONLY`, `write`
  rejects `O_RDONLY`, `ftruncate` requires a write-capable fd, and
  `O_RDONLY | O_TRUNC` is denied before the resolver can reach
  `Directory.open`. `dup` / `dup2` preserve the stored mode, and the
  recording-shim `execve` path grants a private `posix_fd_rights` metadata
  pipe so inherited File fds reconstruct the same attenuation in the child
  fd table. `make run-posix-open-smoke` and `make run-posix-file` carry the
  same-process denial checks; `make run-posix-execve-inherit-smoke` proves the
  recording-shim inheritance path preserves read-only and write-only File fd
  modes.
- **Path resolver over a root `Directory` cap.** A resolver in
  `libcapos-posix/` walks a path through a bootstrap-granted root
  `Directory` cap and returns `File` / `Directory` result caps via
  existing IPC cap-transfer machinery. A v0 per-process current-working-
  directory string (`getcwd` / `chdir`, `libcapos-posix/src/cwd.rs`) plus
  cwd-relative resolution for `open` / `opendir` / `stat` / `access` /
  `unlink` / `mkdir` landed (`make run-posix-cwd`); `chdir` stores only the
  normalized path string and drops the validated cap, so cwd inheritance
  across spawn is still deferred. `..` is not collapsed: escape is prevented
  by the kernel `Directory` cap's lack of a parent edge, not a resolver
  clamp. The `Namespace` / `Store` resolver shape remains documented for a
  future real filesystem service.
- **Remaining file metadata calls.** `stat`, `fstat`, `access`, and
  `unlink` remain fail-closed stubs until a dash call site requires the
  stable `struct stat` and remove-contract shape.
- **Stdio over `TerminalSession`.** `FdBacking::Terminal` adopting the
  bootstrap-granted `TerminalSession` cap as fd 0 / fd 1 / fd 2 when
  the manifest supplies one. Implements Open Question §7's decision
  (canonical fd 0 backing = `TerminalSession`). The existing
  pipe-backed inheritance path stays in place for `posix_spawn`-driven
  pipeline children. `posix_inherit_stdio()` becomes a one-shot adopter
  for the terminal grant too.
- **Env vector + `getenv` / `setenv` / `putenv`.** Per-process env
  vector in `libcapos-posix`, populated at startup from manifest rodata
  (a bounded env grant on `initConfig.init`, mirroring the
  `wasiEnv :Text` bounded grant the WASI host adapter already uses for
  Preview 1 `environ_get`). The eventual typed `LaunchParameters` cap
  remains a follow-on; the v0 env source is the manifest rodata grant.
- **printf / string subset.** Implemented in `libcapos-posix`: `printf` /
  `fprintf` / `vprintf` / `vfprintf` / `snprintf` / `vsnprintf`;
  `memcpy` / `memmove` / `memset` / `memcmp`; `strlen` / `strcmp` /
  `strncmp` / `strchr` / `strrchr` / `strcpy` / `strncpy` / `strcat` /
  `strncat` / `strdup`; `atoi` / `strtol` / `strtoul`; and the ctype
  subset (`isspace` / `isdigit` / `isalpha` / `isalnum` / `tolower` /
  `toupper`). Formatted output is bounded to the documented v0 integer /
  string conversions and width/precision caps; floating-point, `fopen`,
  stream buffering, and locale stay out of scope. `make run-posix-printf`
  proves the surface from a live capOS C process. `libcapos` already exports
  `malloc` / `free` / `calloc` / `realloc` for C consumers.
- **Signal stubs.** Implemented in `libcapos-posix`: `signal` /
  `sigaction` validate and store handlers in a per-process table but
  never deliver them; `kill` fails closed with `EPERM` because this POSIX
  surface has no target `ProcessHandle` authority; `raise` fails closed with
  `ENOSYS` because self-delivery is not implemented. `make
  run-posix-signal-time` proves the documented behavior from live capOS C
  process output. Real `SIGCHLD` / `SIGTSTP` delivery and job control remain
  out of scope.
- **Time additions.** Implemented in `libcapos-posix`: `time(2)`,
  `nanosleep`, and `sleep` reuse the existing `Timer` cap path already used
  by `clock_gettime` / `gettimeofday`. `make run-posix-signal-time` proves
  monotonic-since-boot `time()` output, bounded `nanosleep()`, and one-second
  `sleep()` from live capOS C process output.
- **Identity stubs.** Implemented: `getpid` returns the stable capos-rt
  bootstrap pid for the current process, while the recording-shim child
  pid allocator stays above the caller's pid for the `waitpid` table;
  `getuid` / `getgid` return the hardcoded single-identity uid/gid `0`.
  `make run-posix-identity` proves a parent and fork/exec child observe
  distinct process-visible pids from live capOS C code.
- **`isatty` / `getppid` (closed `2026-05-24 08:47 UTC`).** Both are
  pure-userspace dash prerequisites over the existing fd table -- no
  kernel, cap, IPC, or schema change. `isatty(fd)` returns `1` for an
  `FdBacking::Terminal` slot, `0` with `errno = ENOTTY` for any other
  live backing, and `0` with `errno = EBADF` for an empty/closed slot.
  `getppid()` returns the v0 single-identity parent constant (`1`); no
  kernel parent handoff exists yet, so it is an honest stub alongside
  the `getpid` single-identity path. `make run-posix-isatty` proves
  `isatty(0/1/2)=1` over bootstrap-granted TerminalSession stdio,
  `isatty(pipe_fd)=0 errno=ENOTTY`, and `getppid=1` from live capOS C
  process output.
- **`fcntl` (closed `2026-05-24 09:23 UTC`).** A pure-userspace dash
  prerequisite over the existing fd table -- no kernel, cap, IPC, or
  schema change. `F_DUPFD`/`F_DUPFD_CLOEXEC` duplicate into the lowest
  free slot `>= arg` over the same `dup_for_dup2` alias path `dup`/`dup2`
  use; `F_GETFD`/`F_SETFD` round-trip a per-fd `FD_CLOEXEC` byte;
  `F_GETFL` reports a stable access mode (`O_RDWR` for
  Console/Udp/Pipe/Terminal, the stored `open()` mode for File,
  `O_RDONLY` for the read-only Directory); `F_SETFL` fails closed with
  `EINVAL` when the argument carries `O_NONBLOCK` (the v0 ring calls
  block with `WAIT_FOREVER`, so there is no non-blocking mode to switch
  into), except on UDP socket fds, where it is accepted-and-ignored for
  the vendored dns.c snapshot whose documented contract already drives
  deadlines from userspace; other status bits (e.g. `O_APPEND`) stay
  accept-and-ignore. Unknown `cmd` yields `EINVAL`
  and a closed/out-of-range fd yields `EBADF`. CLOEXEC is enforced at
  recording-shim `execve` time: the full-fd-table inheritance walk skips
  a slot whose flags byte carries `FD_CLOEXEC` unless an explicit
  recorded `dup2` named that child slot. `make run-posix-fcntl` proves the `F_DUPFD,10`
  relocation, the `FD_CLOEXEC` round-trip, `F_GETFL=O_RDWR` for a pipe,
  and the `EBADF`/`EINVAL` error paths from live capOS C process output.
- **Manifest + smoke harness (landed `2026-05-27 09:36 UTC`).**
  `system-posix-shell.cue` grants dash a `TerminalSession` (stdio), a
  bootstrap RAM `Directory` (`root`), a `ProcessSpawner`, and a `Timer`.
  New `demos/ls-shim/` one-binary listing helper wraps the inherited
  directory fd with `fdopendir()` (the smoke's only allowed spawn target).
  `make run-posix-shell-smoke` + `tools/qemu-posix-shell-smoke.sh` feed a
  heredoc into the shell's fd 0 -- the shell creates two RAM-root entries,
  opens the directory as fd 3 (`exec 3< /`), runs `/ls-shim`, and prints
  `done` -- and assert the `alpha`/`beta` entry lines, `done`, two
  clean-exit log lines, the scheduler halt line, and clean QEMU exit. The
  `ls`-by-bare-name vs `/ls-shim` PATH-stat workaround uses the
  slash-bearing path, which the recording-shim spawn maps to the manifest
  binary name by basename. Stretch: extend the smoke to
  `cat foo | grep bar` end-to-end, exercising the P1.3 `Pipe` primitive
  through a shell pipeline. **Stretch closed (`2026-05-27`,
  `posix-dash-pipeline-exec-reconcile`):** dash patch
  `0004-pipeline-evexit-recording-shim.patch` reconciles the `EV_EXIT` in-place
  `shellexec` path with the recording shim (every `evalpipe` element takes that
  path, which the original patch set had left unreconciled), and `libcapos-posix`
  gained wildcard `waitpid(-1)`/`wait3` reaping. `make run-posix-shell-smoke`
  now drives the pipeline (`match bar here` filtered through, four clean child
  exits). See `docs/backlog/posix-adapter-dash-port.md` Slice 14 and
  `vendor/dash/VENDORED_FROM.md`.
- **`read` builtin over fd 0 (landed `2026-05-31 20:35 UTC`,
  `posix-dash-read-builtin-terminal-line`).** Proves dash's `read VAR` builtin
  consuming interactive input off its fd 0 `TerminalSession` cooked-mode line
  discipline -- the one stdin path every prior smoke skipped (`run-posix-shell-smoke`
  feeds no stdin). No dash patch or libcapos-posix change was needed: dash's
  `tcgetattr(0)`-derived canonical buffering takes the plain `read(0, ...)`
  branch, which the `FdBacking::Terminal` adapter satisfies one line at a time.
  `make run-posix-read-builtin` (`system-posix-read-builtin.cue` +
  `tools/qemu-posix-read-builtin-smoke.sh`) echoes back the harness-fed lines
  `got=[hello world]` / `raw=[raw\back\slash]` (the second under `read -r`,
  proving the no-escape path). The harness handshakes each feed on dash's own
  terminal output because the kernel line discipline has no inter-read input
  buffer and the UART carries no EOF. See
  `docs/backlog/posix-adapter-dash-port.md` Slice 18.
- **Open question closures (Slice 1, closed `2026-05-24 00:53 UTC`).**
  Open Question §1 (dash 0.5.13.x candidate) and §7 (fd 0 backing =
  `TerminalSession`) are promoted to final decisions in this proposal's
  "## Open Questions" section ahead of vendoring.

Recommended dispatch ordering: P1.1 -> P1.2 Phase A (schema + client,
landed) -> P1.2 Phase B (kernel UDP path + dns.c, landed) and P1.3
(Pipe cap + fork-for-exec, landed) in either order, since they no
longer contend on the schema serial surface -> P1.4 dash-port
successors. P1.4 itself does not touch `schema/capos.capnp` and so does
not contend on the shared schema serial surface.

## Trust Boundaries

| Boundary | Native capOS service | POSIX-shaped C binary on capOS |
|---|---|---|
| Authority source | Process CapSet | Process CapSet projected through `libcapos-posix` fd table |
| Memory isolation | Page tables | Page tables (no wasm-style sandbox; libc has no extra runtime check) |
| Code integrity | W^X + NX | W^X + NX |
| Cap forgery | Kernel-owned `CapTable` | Same; the fd table is per-process userspace state, not authority |
| Resource limits | Kernel quotas | Kernel quotas; `ulimit` is ENOSYS |
| Side channels | Hardware-level (Spectre etc.) | Same hardware level |

A POSIX binary on capOS is more constrained than on Linux, not less. The
adapter provides familiar function signatures, not familiar authority.

## Validation

The first ports are not complete until they have QEMU evidence:

- A POSIX binary prints through a granted Console / TerminalSession.
- The same binary cannot use `write` to a fd it was not granted, cannot
  `open()` a path outside its preopened namespaces, and cannot call an
  unimplemented POSIX function without receiving `ENOSYS`.
- A missing or wrong-interface cap lookup returns the documented errno
  (not a host-side panic, not silent success).
- An owned result cap is released deterministically when the binary
  exits.
- Each demo binary exits cleanly and does not wedge the kernel.

Host tests should cover errno mapping and the per-process fd table once
those pieces are pure enough to test outside QEMU. Do not claim "POSIX
adapter works" from host tests alone; the useful behavior is authority-
shaped POSIX execution in capOS.

## Open Questions

The following design decisions are documented as open questions because
the planning phase recommends an answer but has not yet committed to one.

1. **POSIX shell candidate.** **Decided (P1.4 Slice 1,
   `2026-05-24 00:53 UTC`):** **dash 0.5.13.x**, vendored at a pinned tag
   under `vendor/dash/`. Rationale: smallest established POSIX-strict
   shell (~13 kSLOC, readable in full by the porting team), no
   readline/termcap dependency (it talks to whatever fd 0 gives it), and
   a single-purpose `/bin/sh` posture that does not accidentally validate
   Bash extensions `libcapos-posix` does not implement. **Rejected:**
   busybox `ash` (heavier embedded framework cost), oksh (ksh-superset,
   larger surface than v0 needs), toysh (incomplete upstream), and a
   custom Rust shell (it defeats the purpose of porting a real C program;
   the native `shell/` `capos-shell` already exists). Vendoring, the
   Variant A patch, the multi-TU C build, and the shell smoke are later
   P1.4 slices (11-14).
2. **DNS resolver candidate.** **Decided (P1.2 Phase A,
   `2026-05-05 18:02 UTC`):** **dns.c (William Ahern)**, vendored at a
   pinned tag under `vendor/dns-c-wahern/`. Rationale: single-file MIT C
   (~10 kSLOC `.c` plus header), no Cargo/CMake build system, no
   configure script, no required I/O model (caller plugs the socket
   layer), and a track record as a reusable resolver core in production
   software outside libc. The license is capOS-compatible and does not
   force a transitive libc port. **Rejected: musl libresolv** -- tied
   to the rest of musl's headers, build, and `__syscall` shape; pulling
   it in either drags musl as a transitive dependency or forces a
   per-symbol carve-out that defeats the "single .c plus header" cost
   profile. **Rejected: c-ares** (configure-driven, ~3x larger, more
   invasive port). **Rejected: GNU adns** (GPL-2.0+ license question).
   **Rejected: pure-Rust trust-dns** (defeats the C-port purpose).
3. **libcapos versioning and naming.** The C library is just **`libcapos`**
   (mirrors the Rust `capos-rt`). Open question: should the POSIX layer
   be **`libcapos-posix`** (current recommendation), or a different name
   that avoids any Rust-side framework name collision? The C-side naming
   is settled; the POSIX-layer name remains an open question pending
   confirmation that no Rust framework will reuse the `libcapos-posix`
   identifier. Working answer: keep `libcapos-posix` for the POSIX
   layer.
4. **POSIX errno representation.** **Decided (P1.2 Phase A,
   `2026-05-05 18:02 UTC`):** **per-thread `errno` cell exposed via
   `__errno_location()`** -- the standard POSIX shape. Storage lives in
   `libcapos-posix`, owned by a thread-local cell accessed through a
   stable `extern "C" int *__errno_location(void);` function so vendored
   ports (dns.c, dash, future C software) compile against `errno`
   exactly as on Linux/musl. Rust internals keep the typed
   `CapError`/`CapException` shape; one bidirectional mapping at the C
   boundary writes the `int` value into the TLS cell so internal
   callers cannot invent unmapped values. **Rejected: per-fd error
   field** -- breaks source compatibility with every POSIX program that
   reads `errno` after `read`/`recvfrom`/`open`, requires every
   vendored port to be patched, and provides no isolation gain over
   the per-thread cell that the cap layer already exclusively writes.
5. **File descriptor table location.** **Decided (P1.2 Phase A,
   `2026-05-05 18:02 UTC`):** **static-array fd table in
   `libcapos-posix`** with a small fixed cap (target: 32 open fds per
   process for v0). Rationale: the lookup is one bounds-check + one
   array index in userspace with no syscall; the kernel keeps zero
   knowledge of fds, so capOS authority remains exactly the per-process
   `CapTable` and is not duplicated in a parallel kernel-side fd map.
   The fixed cap matches the surfaces dns.c (single fd) and a v0 shell
   port (a handful of stdio + pipe fds) actually exercise. **Rejected:
   capability-table-backed fd map that resolves fd numbers through the
   process cap table** -- larger blast radius (fd churn would touch the
   kernel cap table on every `dup`/`close`), and the cap-table object
   id is already a userspace-visible handle through `OwnedCapability`,
   so a separate dense fd index in userspace is the right layer. The
   32-fd cap can grow later (or migrate to a sparse representation) if
   a real consumer needs more, without changing the kernel surface.
6. **Fork policy.** **Decided (P1.3, `2026-05-07 09:55 UTC`;
   refined `2026-05-07 10:30 UTC` to drop `setjmp`/`longjmp`):**
   Variant A -- the recording shim. `fork()` records "next exec is
   the real spawn" in TLS and returns 0 unconditionally. `dup2()` and
   `close()` calls between `fork()` and `execve()` route through
   `process::maybe_record_dup2 / maybe_record_close` and are not
   applied to the parent fd table. `execve()` consumes the recorded
   actions, dispatches `ProcessSpawner.spawn()` with the matching
   pipe halves moved into the child as `stdio_<dst>` grants, parks
   the resulting `OwnedCapability<ProcessHandle>` in a per-process
   table, and **returns the synthetic child pid as its own return
   value** (a deliberate v0 deviation from POSIX, where `execve`
   only returns -1 on failure). The user pattern becomes `int
   spawn_pid = execve(...); if (spawn_pid < 0) /* surface error to
   the parent's error path; do NOT _exit because the pseudo-child
   branch is still the parent */ ; child = spawn_pid;`. After a
   successful Move-grant spawn the parent's
   source fd slot is replaced with a `FdBacking::Moved` sentinel so
   a subsequent `close(src)` (the dash-shaped pattern's "I no
   longer hold the write end") removes the sentinel without a kernel
   round trip. The earlier `setjmp`/`longjmp` design longjmp'd back
   to `fork()`'s call site after `execve()` had returned -- the
   saved jmp_buf RSP/RIP pointed into `fork()`'s stack frame, which
   was deallocated when `fork()` first returned, so the longjmp
   resumed inside a stale frame whose memory had already been
   reused by `dup2`/`close`/`execve`. **A targeted dash patch is
   still required for the v0 contract**: `execve()` returns the
   synthetic pid on success, where unmodified dash assumes `execve()`
   only returns on failure (and falls into its post-exec error
   path). Variant A keeps that patch surface narrow -- the change is
   the four-line "capture spawn_pid; bail on -1; assign back to
   child" snippet shown above per fork-exec call site, not a
   wholesale rewrite of the fork-dup2-exec pattern -- and dash's
   inter-call dup2/close still record into the spawn grants without
   per-call patching. **Rejected: Variant B** (patched-port
   `posix_spawn` only) requires the port to consolidate every
   fork+dup2+exec sequence into a single `posix_spawn` call with
   explicit `posix_spawn_file_actions`, a much wider patch surface.
   A 2026-05-13 successor now exports direct `posix_spawn()` over the
   same execve-backed action replay. Recording-shim `execve` argv now
   travels through a private `posix_argv` Pipe grant; direct
   `posix_spawn` argv/envp remain ignored until LaunchParameters /
   environment support lands.
7. **fd 0 backing for the shell.** **Decided (P1.4 Slice 1,
   `2026-05-24 00:53 UTC`):** the canonical fd 0 / 1 / 2 backing for the
   v0 dash smoke is **`TerminalSession`** -- the natural mapping (read
   line + cooked-mode line discipline already exists in kernel and
   migrates to userspace at networking Phase C). For the DNS resolver
   fd 0 is unused and stays unmapped. The backing is realized by the
   `FdBacking::Terminal` variant in `libcapos-posix/src/fd.rs` plus
   `posix_inherit_stdio()` adopting the bootstrap-granted
   `TerminalSession` cap, mirroring the existing pipe-inheritance path;
   that implementation **already shipped under P1.4 Slice 5** and is
   proven by `make run-posix-stdio-terminal-smoke`. This slice only
   records the backing choice as final.
8. **UDP cap surface scope.** **Decided (P1.2 Phase A,
   `2026-05-05 18:02 UTC`):** four-method blocking shape that mirrors
   the existing TCP cap pattern, with the wait deadline owned by the
   ring client (not the method parameter list). Methods:
   - `NetworkManager.createUdpSocket(localAddr :Data, localPort :UInt16)
     -> (socketIndex :UInt16)` -- bind a UDP socket to the given local
     `(addr, port)` (`localAddr` empty selects the configured interface;
     `localPort = 0` selects an ephemeral port). The result cap is
     transferred via `socketIndex` in the CQE result-cap list, matching
     `connectTcp`.
   - `UdpSocket.sendTo(addr :Data, port :UInt16, data :Data) ->
     (bytesSent :UInt32)`.
   - `UdpSocket.recvFrom(maxLen :UInt32) -> (addr :Data, port :UInt16,
     data :Data)` -- **blocking, no in-method timeout**. Same
     CQE-on-completion shape as `TcpSocket.recv`: the kernel parks the
     SQE until a datagram arrives. The caller bounds the wait through
     the existing `RingClient::wait(call_id, timeout_ns)` mechanism;
     dns.c-style retry/deadline loops drive that bound from userspace.
     If the caller wants to abort a parked `recvFrom` early, it issues
     `close()` on the socket; the parked completion then returns a
     `Disconnected`-class `CapException`. The v0 surface deliberately
     does not introduce a new `Timeout` exception class, since none
     exists today (`ExceptionType` covers only `failed`, `overloaded`,
     `disconnected`, `unimplemented`) and inventing one for a single
     method would expand the kernel error surface ahead of any
     consumer that needs to distinguish wait-expiry from generic
     disconnect.
   - `UdpSocket.close() -> ()`.
   Rationale: the blocking shape maps directly onto dns.c's existing
   retry/timeout loop (dns.c does its own resend and deadline
   tracking, then issues a bounded blocking read backed by the ring
   wait), so the v0 port plugs in without a separate readiness/poll
   surface. The shape also reuses every primitive already present for
   TCP -- ring-side `cap_enter` parking, transferred result caps,
   client-side `RingClient::wait` deadline -- so the kernel UDP path
   in P1.2 Phase B is a near-mirror of the TCP path. **Rejected
   (deferred): readiness/poll-style `recvFrom`** -- the cap surface
   decision (one-shot wait vs an event stream over an `Endpoint`) is
   itself unsettled, has no live consumer, and adding a second wait
   shape now would force every port to choose. Add a separate
   readiness method (or a generic `Pollable` cap) when a real consumer
   needs it, not before. **Rejected: per-method `timeoutNs`
   parameter** -- creates two competing deadlines (the in-method
   timeout and the ring wait) that race on the same call, would
   require either inventing a new `Timeout` exception class or
   overloading `Disconnected` ambiguously, and is redundant with the
   ring wait the client already issues.
9. **Pipe cap design.** **Decided (P1.3, `2026-05-07 09:55 UTC`):**
   kernel-allocated bounded SPSC ring (4 KiB ceiling, default to
   the maximum) with EOF on close. The two halves share an
   `Arc<PipeShared>` and store their direction; close on one side
   flips the matching closed flag and the per-tick poll completes
   the peer. Both halves implement the same `Pipe` interface
   (read / write / close / isClosed); the kernel rejects
   wrong-direction calls with a `failed` exception. Reader-closed
   surfaces `bytesWritten = 0` to the writer (the EPIPE-equivalent
   chosen to avoid expanding the kernel `ExceptionType`
   vocabulary). Writer-closed surfaces `eof = true` to the reader
   after the buffered bytes drain. **Rejected: shared MemoryObject
   + userspace ring** because EOF across process exits and bounded
   waiter wake semantics need kernel-side state anyway, and the
   userspace path would still need a kernel cap to coordinate
   close races.
10. **argv / envp source.** This proposal assumes a future
    `LaunchParameters` cap delivers argv / envp through a typed cap.
    Until that cap lands, `libcapos-posix` can carry argv / envp via a
    fixed well-known cap or rodata blob. Confirm gate-on-`LaunchParameters`
    versus ship-stub.
11. **Linker / toolchain for C consumers.** Recommended: `clang
    --target=x86_64-unknown-none-elf -nostdlib -static`, link against
    `libcapos.a` (and optionally `libcapos-posix.a`), reuse the existing
    `capos-rt` linker script. Confirm clang vs gcc and whether the
    track ships a shared `cc-glue` Cargo crate or a Make rule invoking
    `cc` directly.
12. **Vendoring policy.** In-tree `vendor/dash/`,
    `vendor/dns-c-wahern/` versus out-of-tree submodule versus separate
    repo. **Working answer:** in-tree vendoring with pinned tags,
    mirroring the planned `vendor/piccolo-no_std/` shape from the Lua
    track.
13. **Audit / measure-mode interaction.** The `libcapos-posix` wrappers
    must not break measure mode (the `measure` feature). Most wrappers
    only call `libcapos`, which only calls `capos-rt`, which is already
    measure-mode-clean, so this should be free; confirm whether the
    track adds a `make run-measure` smoke for one `libcapos-posix`
    binary as a regression gate.

## Relationship to Other Proposals

- **[Userspace Binaries](userspace-binaries-proposal.md)** owns the
  broader native-binary, language, and POSIX-adapter roadmap. This
  proposal supersedes **Part 4: POSIX Compatibility Adapter** of that
  proposal with the full POSIX adapter design.
- **[Programming Languages](../programming-languages.md)** is the
  reader-facing summary of language support. The **C row** records the
  shipped `libcapos.a` + `libcapos_posix.a` surface (P1.1 + P1.2 + P1.3,
  plus the 2026-05-13 `posix_spawn` successor and Console-backed stdio
  slice). The **POSIX-shaped software row** cross-links this proposal
  as the long-form design source and records the P1.4 dash-port block
  on `Namespace` + `File` caps.
- **[Networking](networking-proposal.md)** defines `NetworkManager`,
  `TcpListener`, and `TcpSocket` and defers UDP. The DNS resolver port
  in Phase P1.2 adds the `UdpSocket` cap surface; the TCP cap surface
  is reused unchanged.
- **[Storage and Naming](storage-and-naming-proposal.md)** defines the
  `Directory` / `File` / `Store` / `Namespace` surfaces that the shell
  port consumes. Phase 2/3 of that proposal gates the dash file I/O
  surface.
- **[Service Architecture](service-architecture-proposal.md)** defines
  the future `Resolver` cap that the resolver port eventually exports.
- **[Shell](shell-proposal.md)** covers the native `capos-shell`. The
  POSIX shell port is for porting validation and does not replace
  `capos-shell`.
- **[WASI Host Adapter](wasi-host-adapter-proposal.md)** is the
  parallel untrusted-portable execution path. POSIX adapter targets
  trusted source-recompiled C; WASI adapter targets sandboxed wasm
  modules. Both share the per-process fd-table and per-import authority
  pattern.
- **[Lua Scripting](lua-scripting-proposal.md)** is the
  capability-scoped trusted-script path; PUC Lua's native build assumes
  a C substrate, so it eventually consumes `libcapos`.

[dash-debian]: https://packages.debian.org/sid/dash
[busybox-ash]: https://github.com/brgl/busybox/blob/master/shell/ash.c
[oksh]: https://github.com/ibara/oksh
[toybox]: https://landley.net/toybox/about.html
[c-ares]: https://c-ares.org/
[wahern-dns]: https://github.com/wahern/dns
[gnu-adns]: https://www.gnu.org/software/adns/
[musl-resolver]: https://git.musl-libc.org/cgit/musl/commit/?id=51d4669fb97782f6a66606da852b5afd49a08001
[udns]: https://www.corpit.ru/mjt/udns.html
