# fleet/ — Ansible config management for the overdeck estate

Audience: AI coding agents first. Read this before touching anything under `fleet/`.

## What this is

One Ansible inventory (`fleet/inventory.yml`, regenerated from
`~/.claude/buildbox-hosts.json` — never hand-edit host IPs, edit the registry
and re-run `fleet/gen-inventory.sh`) and roles under `fleet/roles/` that
transcribe what the existing bespoke convergers (`deckctl sync`, buildbox
bootstrap/parity, the seat settings template) already push, so drift can be
checked and — role by role, gated on a read diff — converged for real.

Full contract: `docs/plans/2026-08-15-fleet-configuration-management.md`.

## The three-way split (per plan)

- **CONFIG** (managed by a role, one owner per file): PATH bin symlinks,
  the seat `settings.json` template, the resource-control `*.slice` units.
- **RUNTIME STATE** (never touched): transcripts, session journals, queues,
  `~/.claude.json`.
- **CACHE** (rebuildable, never touched): plugin caches, build caches.

**One owner per managed file.** If you add a role, check no other role or
bespoke script already owns the same path — two writers is the exact defect
this plan exists to kill.

## Setup

```bash
deck-sudo apt-get install -y pipx        # one-time, laptop only
pipx install --include-deps ansible      # installs ansible-core into a pipx venv
export PATH="$HOME/.local/bin:$PATH"     # ansible-playbook on PATH
```

Ansible requires a UTF-8 locale; `fleet/check.sh`/`fleet/apply.sh` set
`LANG`/`LC_ALL=C.UTF-8` for you.

## Running

```bash
fleet/check.sh                              # check --diff, every role, every host — changes NOTHING
fleet/check.sh --tags path_bins             # one role
fleet/check.sh --limit laptop               # one host
```

`fleet/check.sh` is check-mode **forever** — it refuses `--check`/`-C` as an
explicit argument (they're implicit) and never has an apply path.

```bash
fleet/apply.sh <role> [--limit <host>]      # real convergence, ONE role
```

`apply.sh` refuses to run unless a `fleet/reports/check-*-<role>.txt`
artifact exists and is under 4 hours old — an apply without a fresh, read
diff is impossible by construction. There is no all-roles apply.

Gated apply loop for every role: **write/adjust role → `fleet/check.sh
--tags <role>` → save the diff to `fleet/reports/` → an agent reads the
whole diff (not a glance — see the plan's diff-read checklist) → if it
matches expectations, `fleet/apply.sh <role>` → re-run check to confirm an
empty diff, save that as the receipt.**

**Fold-forward rule:** if a diff would *revert* a value that is better on
the live host than in the repo (a hand-fix, a recent tuning change), that is
a STOP — pull the live value into the repo role/template, commit, re-check.
Never apply a diff that regresses a live host to a worse repo default.

## Role order (owner-fixed, lowest risk first)

1. `path_bins` — PATH tool symlinks. Low risk, easily reversible.
2. `settings_template` — seat `settings.json`, carries the retention policy
   and the security-guidance fix.
3. `systemd_units` — resource-control slices. Highest risk (can affect the
   agent session's own cgroup, and on a headless buildbox a bad unit can
   only be fixed by SSH into the same box it might be breaking).

## Known scope gaps in this pass (S1) — read before assuming "done"

- **`settings_template` is check-only, not applied anywhere.** The live
  seat `settings.json` on all four hosts diverges from the repo template far
  beyond the three named 2026-08-15 fixes — different `model`, different
  hook set (`EnterPlanMode` deny hook, `SubagentStop` journal hook,
  `session-transcript-converge` hook present live but not in the repo
  template; `task-register-gate` under a different hooks path). Applying a
  full-file copy today would silently revert real, likely-intentional live
  customization on every machine. This needs its own reconciliation pass —
  pull the live file into the repo template field-by-field, not a blind
  copy-back — before this role is ever applied. See the plan's receipt.
- **`path_bins` is not modeled correctly for buildboxes and is not applied
  there.** The workstation manifest's `source: deploy` symlink formula
  (`pi`, `cdx` → `deploy_root/modules/workstation/bin/...`) only matches
  reality on the laptop. On a buildbox, `pi` is a symlink into a **mise**
  shim (`~/.local/share/mise/shims/pi`, from `devtools.json`), `cdx` and
  `buildbox-scratch-bind` are absent, and `~/.claude/{bin,lib,hooks}` are
  real populated directories, not symlinks — a different install mechanism
  (buildbox bootstrap/parity), not drift. The buildbox side of this role is
  read-only (`stat` + report) until `buildbox-parity.sh` is read and
  transcribed into a real per-host-type desired state.
- **`systemd_units` is check-only everywhere, applied nowhere.** Buildboxes:
  `agent.slice` doesn't even exist there today (`systemctl --user show
  agent.slice` returns all-infinite/not-set) — the monitor module appears
  laptop-only in practice; forcing it onto a headless box with no proven
  deadman mechanism was judged out of scope for this pass. Laptop: applying
  would `daemon-reload` the very cgroup this agent session runs under,
  mid-session; deferred to a dedicated, lower-stakes moment.
- **The CPU/memory/PIDs doctrine landed in source, not yet reloaded live.**
  `modules/monitor/systemd/user/agent.slice` had `CPUQuota=150%` +
  `CPUWeight=1` in the repo — the same hard-quota shape that starved every
  agent shell on 2026-08-15 — while the *live* laptop already carried a
  hand-applied override drop-in (`agent.slice.d/50-no-starvation.conf`,
  `CPUWeight=50`, no quota). Fixed in source this pass (fold-forward): base
  `agent.slice` now declares `CPUWeight=50`, no `CPUQuota`. The live drop-in
  is now redundant (same effective values) and can be removed by hand; this
  role does not manage drop-in files yet. `CPUWeight=10` seen live during
  today's incident response was a temporary fire-response override, not the
  declared value — `CPUWeight=50` is the correct normal.
- **`fleet/roles/systemd_units/files/policy_lint.py`** parses the *source*
  slice files and fails (exit 1) if any agent slice carries `CPUQuota=`,
  `MemoryHigh=`, or `MemoryMax=`, or if none of them declare `TasksMax=`
  (systemd's name for the pids.max cgroup control). Run it standalone or via
  `pytest fleet/roles/systemd_units/tests/`. It currently passes.
- **13-config-homes retention claim**: this pass checks `cleanupPeriodDays`
  only inside the one `~/.claude/settings.json` per host that
  `settings_template` manages. The "13 config homes" figure in the plan's
  diagnosis includes multiple agent tool homes (`~/.codex`, `~/.gemini`,
  etc.) and possibly multiple seats per box — not enumerated or checked by
  this role. Open for a later slice.
- The name `fleet/` (this directory) is unrelated to the pre-existing
  `modules/fleet/` + `deckctl fleet` (build-node audit/converge/harden for
  build-execution fallback tuning) — different concern, no file overlap,
  flagged here only so nobody confuses the two when grepping for "fleet".

## Deadman (required before any buildbox apply — not built this pass)

No role in this pass was applied to a buildbox. Before any future
`fleet/apply.sh <role> --limit <buildbox>`: take a pre-apply backup of every
file the role touches, arm a scheduled revert (`at`/systemd timer) that
restores the backup, run the apply, confirm the box still answers SSH, then
cancel the deadman. Buildboxes are headless — sshd:2222 is the only door
(see `~/.claude/buildbox-hosts.json`) — a bad apply with no deadman can
strand a box with no recovery path but a physical visit.
