Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Trellis

Trellis is a computer that manages itself with me — a NixOS workstation where every change is deliberate, reversible, and written down.

Most computers accumulate. Trellis is the opposite: the whole operating system is declared as code, the disk wipes itself clean on every reboot, and the only things that survive are the ones I explicitly chose to keep. If something breaks, a reboot is a recovery tool. Nothing drifts.

I co-manage it with an AI agent (Claude) inside a set of hard rules the agent cannot escape. Every meaningful decision the system has ever made is recorded as an Architecture Decision Record — the reasoning, not just the result — so the “why” is never lost.

This wiki is a curated window onto that record.

The ideas that define it

  • Impermanence — the root filesystem is tmpfs; it resets to the declared state on every boot. Only an explicit “persist set” survives. (ADR-0003)
  • One real configuration — no decoy split, no drift; the source is the system. (ADR-0002)
  • The tier flow — changes move through mortal experiments, to committed and built, to a reviewed pull request, before they become permanent. (ADR-0009)
  • An authentic desktop — a faithful port of the Omarchy environment. (ADR-0005)
  • A single work surface — every task lives on one board. (ADR-0016)

How it began, and where it’s going

Browse the decisions in the sidebar. Each one is the real reasoning, preserved.

ADR-0001: one-shot synthesis from three inputs

Status: accepted (2026-07-06)

Context

Two prior systems existed: ward-os (installed, lived-in, two chapters split by the debian-era-end tag) and trellis-v0 (a frozen attempted rewrite kept as comparison material). Iterating either forward in place would drag along its accidents; a fresh repo with no synthesis discipline would lose the lessons.

Decision

trellis is written ONCE, in a single synthesis session, from exactly three inputs: Debian-era ward-os (<= debian-era-end), trellis-v0, and the running-ward-os trial chapter (ward-os > debian-era-end plus the ~/trellis-feedback.md ledger). Every design decision was interviewed against all three; the outcomes are this ADR set and docs/DESIGN.md. After synthesis the inputs are history, not upstreams - nothing merges from them.

Alternatives considered

  • Evolve ward-os in place: keeps the decoy split, dirty sentinels, and 14-workflow CI it was time to shed.
  • Promote trellis-v0: frozen at pre-trial understanding; superseded.
  • Continuous synthesis: no cut point means no clean provenance.

Consequences

Clean provenance: every carried choice has an ADR; everything dropped is in docs/dropped.md. The old repos stay readable but are never merged from.

ADR-0002: one real nixosConfiguration, no decoy split

Status: accepted (2026-07-06)

Context

ward-os shipped nixosConfigurations.workstation as a headless CI-safe decoy and hid the real machine behind a custom installable.workstation output. That kept nix flake check green in CI, but broke every stock tool (nixos-rebuild –flake targeted the wrong config), confused every session, and meant CI never evaluated the closure that actually runs.

Decision

One output: nixosConfigurations.noreply-hardware, the real machine. There is no decoy and no installable.* namespace. CI checks are made eval-safe (pure eval, no secrets read at eval time) instead of the machine being made check-shaped. nix flake check and build.yml exercise the real closure.

Alternatives considered

  • Keep the decoy/installable split: proven confusing and check-hollow.
  • Per-environment configs (ci-baseline + real): same problem, renamed.

Consequences

Stock tooling works unmodified. Anything that cannot evaluate in CI is a bug in the module, not a reason to fork the config. The wrong-disk guard moves into the config itself (ADR-0004) since there is no placeholder tree.

ADR-0003: tmpfs-root impermanence carried, whole-home persist

Status: accepted (2026-07-06)

Context

ward-os ran / on tmpfs with an explicit persist set, and the trial chapter proved the payoff: undeclared drift evaporates at reboot, which is both the integrity control and a recovery tool. The question was whether to carry it to the NVMe daily driver, and how much of $HOME to enumerate.

Decision

Carry tmpfs-root impermanence unchanged. Persist set: /home/noreply as a whole, /var/log, /var/lib/nixos, /var/lib/tailscale, /etc/NetworkManager/system-connections, /etc/machine-id, and the SSH host keys. $HOME is persisted wholesale, not enumerated - the tier system (ADR-0009) governs what inside it is reproducible; impermanence governs the system layer.

Alternatives considered

  • Persistent root btrfs with snapshots: keeps drift alive; snapshots are a weaker control than erasure plus declaration.
  • Enumerated $HOME persistence: constant friction for zero payoff on a single-user box; the trial showed whole-home is the right grain.

Consequences

A reboot is a rollback of everything undeclared. L1 state is genuinely mortal, which is what makes trellis gate (ADR-0009) meaningful.

ADR-0005: authentic Omarchy port with a declared mutable island

Status: accepted (2026-07-06)

Context

The desktop target is Omarchy (basecamp/omarchy), an Arch product. ward-os rode omarchy-nix, a third-party flake frozen at an Omarchy 1.x snapshot with a dormant upstream - it silently inherited abandoned choices. Re-expressing all of Omarchy as nix attrsets is the opposite failure: maximal contortion.

Decision

Vendor upstream basecamp/omarchy hash-pinned via fetchFromGitHub (tag + sha in omarchy/UPSTREAM), patch out the Arch machinery (omarchy/PATCHES.md), prune the rest (omarchy/prune.list). ~/.local/share/omarchy is a read-only symlink into the store. ~/.config/omarchy/current/{theme,background} are real mutable symlinks in persisted $HOME - the DECLARED MUTABLE ISLAND - so instant theme switching works exactly like upstream. Config files under the island are rendered copies, not store symlinks, so upstream’s sed-style theme tooling works. The flake declares the default; activation seeds the island if absent; trellis gate reports current-vs-declared as a promotable knob.

Alternatives considered

  • omarchy-nix: frozen pin, dormant upstream - the lesson this reverses.
  • Full nix re-expression: breaks upstream’s own tooling and every future diff.
  • Symlink-farm the island into the store: kills instant switching.

Consequences

New Omarchy releases land as one reviewed diff-and-refresh PR. The island is the only deliberately mutable config surface; everything else is store-backed.

ADR-0009: tiers L1/L2/L3, the local live branch, the trellis CLI

Status: accepted (2026-07-06)

Context

The trial chapter’s three tiers of permanence worked as a mental model but had no machinery: L2 tolerated “imperative in $HOME” as an untracked soft spot, and nothing could enumerate what was live-but-not-landed. The reboot-prep ritual was a skill prompt, not a tool.

Decision

Make the tiers first-class via the trellis CLI and a local live branch. L1: trellis try CMD executes and journals to a tmpfs L1 journal; dies at reboot. L2: trellis keep = commit on live + nix build + hand the human the pkexec activation; the machine builds from live, so main..live IS the mechanically enumerable not-yet-landed set. L3: trellis land squashes live commits into a PR to main, CI gates it, the human merges, trellis sync rebases live. trellis gate is the pre-reboot review (since-boot sweep, hyprctl drift, journal replay, main..live). Clean-tree invariant: a dirty tree is a transient authoring state, never a storage tier - the ward-os “imperative-in-$HOME as tolerated L2 debt” category is abolished.

Alternatives considered

  • Keep tiers as prose + reboot-prep skill: proven leaky in the trial.
  • No live branch (working tree as L2): unenumerable, unbuildable state.

Consequences

Every durable change has a commit; every reboot is preceded by an auditable promotion decision; erosion has to pass a PR to become permanent.

ADR-0016: GitHub issues and the Projects board are the central trellis work system

Status: accepted (2026-07-09)

Context

Work organization on this box grew several overlapping, half-built mechanisms that each captured a piece of one job - get work out of a session’s head, track it, dispatch it - but none was the whole thing:

  • the user-level spinoff queue (~/work-queue.md, cross-repo, flock-serialized, ~/.claude/skills/spinoff);
  • the trellis-board skill’s “basket” candidate convention (bare gh issues, a candidate label, an idea: prefix);
  • a 2026-07-07 issue-driven-workflow draft spec.

The cost was a split: trellis work lived partly in spinoff entries and partly in ad-hoc issues, so what was visible depended on where it happened to be filed. The spinoff queue had accumulated trellis-desktop work (the four-modes-* entries; one of them, four-modes-awaiting-truth, was ALSO already issue #36) alongside genuinely-ward-os items.

The 2026-07-09 issue-dispatch-loop spec (docs/specs/2026-07-09-issue-dispatch-loop-design.md) unified these into one design: issues as the single work currency, the Projects v2 board (issue #23) Status field as the lifecycle spine, dispatched fresh sessions carrying planning->build momentum with a per-task model/effort hand-off, all gated by Sam’s manual test gate. Two prerequisites are now met: the board exists (user project #3 “trellis board”, the six Status columns, linked to the repo, populated), and the agent classic PAT (ADR-0015) has gained the project scope.

Decision

GitHub issues on sam-henry-dev/trellis, organized by the Projects v2 board (#3) Status field, are THE central work system for trellis. The 2026-07-09 issue-dispatch-loop spec is adopted as its design; it is built in the spec’s four phases, each a reviewed PR.

  • trellis capture / digest / dispatch run as trellis-agent. This extends ADR-0015: trellis-agent is a write collaborator on the repository AND on the project board. The project scope lives on the trellis-agent PAT, superseding #23’s plaintext-hosts.yml wording.
  • spinoff (~/work-queue.md) is RETIRED FOR TRELLIS WORK: trellis tasks are board issues, not queue entries. spinoff remains only for genuinely non-trellis / cross-repo dispatch, if any survives. The live four-modes-* entries migrate onto the board (see docs/plans/2026-07-09-spinoff-to-board-migration.md); the ward-os queue entries are retired as obsolete pending Sam’s confirmation (ward-os is HISTORICAL; the NVMe migration already happened at the 2026-07-07 install).
  • The trellis-board skill’s “basket” convention is superseded by “candidates” (per #23) and delegates capture to trellis capture.
  • The 2026-07-07 issue-driven-workflow draft is removed (provenance in git history); the 2026-07-09 spec is canonical.

Alternatives considered

  • Keep spinoff as the trellis dispatcher: it is user-level and cross-repo, has no per-issue lifecycle or test gate, offers no GitHub surface for phone / hand-off, and duplicates the board. Rejected for trellis; kept for non-trellis.
  • Labels instead of a board Status field (the draft’s ready label): #23 already rejected this - built-in workflows cannot sync labels to Status, so it means two hand-maintained copies of one fact.
  • Leave the machinery parallel (do nothing): the split IS the problem; work is invisible depending on where it was filed.

Consequences

  • One surface for trellis work: the board is the single source of truth; digest and dispatch read it, capture writes it. The phone Claude app and the GitHub app become first-class hand-off surfaces (the issue trail carries the context).
  • This ADR records the direction; the phased plans carry the build (docs/plans/2026-07-09-dispatch-loop-phase1-capture-board.md is Phase 1). The board-writing verbs stay blocked until trellis-agent has Write access on project #3 - a Sam grant, mirroring the ADR-0015 repo-collaborator grant.
  • The four-modes epic’s remaining work moves onto the board.
  • spinoff’s SessionStart heads hook and skill scope note are updated so trellis work no longer routes through it.

ADR-0017: the chromium substrate - chromium for apps and the agent, firefox for the human

Status: accepted (2026-07-10)

Context

Chromium was dropped at synthesis as an Arch-policy concern (docs/dropped.md: “no Arch, no chromium”) and firefox was made the sole browser (DEVIATIONS.md: “browser: firefox, not chromium”). With no firefox –app, the webapp roster opened as plain firefox windows - a documented UI deviation (DEVIATIONS.md: “webapps: open as plain firefox windows”). Two needs now reverse that stance:

  • Upstream Omarchy’s borderless webapp UX is chromium –app; trellis had patched it away (omarchy/patches/0001-webapps-firefox.patch). Restoring it returns webapps toward the authentic upstream shape (ADR-0005).
  • The agent (ADR-0008/0015) has no browser it can drive; the only agent surface is claude-see (read-only screenshots). A real, visible, isolated browser the agent can navigate and act in is a new capability worth building deliberately.

Decision

One chromium install (pkgs.chromium, beside firefox), two isolated profiles.

  • Profile “you” (~/.local/share/chromium-apps) backs borderless app windows: omarchy-launch-webapp is re-pointed to chromium –app on this profile, returning webapps to the upstream shape. Firefox stays the human’s browser and the default http(s)/html handler (unchanged).
  • Profile “agent” (~/.local/share/chromium-agent) is a clean, isolated, VISIBLE chromium the agent drives over CDP (the claude-web actuator, Playwright-from-nixpkgs in connect-over-CDP mode - zero npm). It is never headless, summoned on demand (trellis browse), red-bordered, and every drive action fires a mandatory announce toast - the claude-see fence contract (ADR-0008) extended to the browser.
  • Chromium’s Arch policy/flags theming machinery stays dropped (omarchy-theme-set-browser stays a no-op); only the package rejoins, declared natively in the flake.

Design: docs/specs/2026-07-10-chromium-substrate-design.md. Built in four reviewed PRs (PR0 this ADR + spec; PR1 chromium + profiles + app-window flip; PR2 the visible agent browser + summon verb; PR3 the claude-web CDP drive).

Alternatives considered

  • Firefox-only + a headless agent browser: loses the upstream webapp UX and makes the agent’s browser invisible - exactly the silent, unannounced actuation the fence forbids (ADR-0008). Rejected.
  • One shared profile for apps and the agent: the agent would inherit the human’s logged-in sessions and could act as Sam everywhere. The creds-blast-radius is the whole reason to isolate; rejected.
  • An npm browser MCP server (chrome-devtools-mcp / playwright-mcp): MCP is native to Claude Code, but every mature browser MCP server is an npm package running a persistent node process - the exact supply-chain surface the npm boundary hardens against (docs/AGENT.md). Playwright from nixpkgs driven over CDP gets the same capability with zero npm. Rejected.
  • ungoogled-chromium: strips the Google API keys the “you” profile needs to sign into Google/YouTube/Maps. Rejected for pkgs.chromium.

Consequences

  • The four “chromium is absent” records are reconciled in PR1 (DEVIATIONS.md browser/webapps/exclusions lines, dropped.md, packages.nix header): chromium rejoins as the app + agent browser, firefox stays the human default. The webapps-as-firefox deviation is reversed; what remains a deviation is the split itself (firefox default while chromium serves –app
    • the agent).
  • The agent gains a web actuator with NO new escalation surface: it is a user-space CDP client to a user-space browser on a loopback port, invoking no sudo/su/pkexec/doas/run0. The fence (checks/fence-policy.nix) is untouched. The new blast radius (a browser reachable by a possibly prompt-injected agent) is contained by four walls: a clean profile, a loopback-only debug port, a URL-policy gate (no file://, no localhost, no private/metadata ranges), and the mandatory announce.
  • Chromium enters the closure (a prebuilt cache download, not a source build). Profiles live under the persisted /home, surviving the tmpfs root.