# Cluster Source of Truth — Desired-State Plane Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make one Git-declared fleet in overdeck the single source of truth for workstation + debian1/2/3 configuration, converged by push from the workstation over local and SSH transports.

**Architecture:** One validated declaration (`modules/fleet/fleet.json`) feeds one loader whose query interface every consumer uses. Profiles expand a node into ordered `DesiredItem`s composed from the manifests and pinned tool declarations that already exist — never a second content inventory. One planner/executor audits and converges those items behind two transports; agent-home items delegate to `remote-seat-provision.mjs`, which keeps sole ownership of that tree. `deckctl fleet` is the new surface; `deckctl sync` and `buildbox audit|bootstrap|harden` become argument-translating adapters over it.

**Tech Stack:** Node ESM (loader, expansion, engine, transports), bash (deckctl/buildbox adapters), `node --test`, existing `tests/os/run-all.sh` glob discovery.

**Source spec:** `docs/specs/2026-08-06-cluster-source-of-truth-design.md`. It is canonical; this plan implements **steps 1–5 only** (the desired-state plane). Steps 6–8 (spill deletion, controller builder probing, lease endpoint, launch-door wiring) are the availability plane and get their own plan and slug — the spec forbids combining them in one release ("Never combine desired-state migration with fallback activation in one release").

**Prerequisites — all landed, verified before authoring:** 0a second disk on all three builders; 0b debian3 parity (zero-drift `buildbox audit` across debian1/2/3); 0c `system-units.sh` opt-in (`lib/deckctl/system-units.sh` reads `system_units`/`system_bin` from each `deck.module.json` and dies on an undeclared tree — the depth-2 glob is gone).

**Resolved at plan time (NOT a pre-flight gate):** `remote-seat-provision.mjs` is **delegated to**, not absorbed. Absorb-and-delete is a follow-up plan gated on the parity report Task 5 produces.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 | `spec/fleet.schema.json`, `modules/fleet/fleet.json`, `modules/fleet/deck.module.json`, `lib/fleet/types.mjs`, `lib/fleet/loader.mjs`, `modules/fleet/test/loader.test.mjs`, `modules/fleet/test/node.test.sh` | single task |
| 2 | Task 2 | `lib/fleet/expand.mjs`, `modules/fleet/test/expand.test.mjs` | single task |
| 3 | Task 3, Task 4 | `lib/fleet/transport-local.mjs` \| `lib/fleet/transport-ssh.mjs` (+ own tests) | ✅ disjoint files, interface pinned in Task 1 |
| 4 | Task 5 | `lib/fleet/engine.mjs`, `modules/fleet/test/engine.test.mjs`, `docs/reports/fleet-agent-home-parity.md` | single task |
| 5 | Task 6 | `lib/fleet/harden.mjs`, `modules/fleet/test/harden.test.mjs` | single task |
| 6 | Task 7 | `bin/deckctl`, `lib/deckctl/fleet.sh`, `modules/fleet/test/cli.test.sh` | single task |
| 7 | Task 8 | `lib/deckctl/sync.sh`, `tests/os/deckctl-sync.test.sh`, `modules/fleet/test/adapters.test.sh` | single task |
| 8 | Task 8b, Task 9 | `modules/buildbox/bin/buildbox`, `modules/fleet/test/adapters-audit.test.sh` \| `modules/fleet/fleet.json`, `lib/fleet/loader.mjs`, `lib/deckctl/agents.sh`, `modules/workstation/claude/lib/remote-build.mjs`, the four `hosts` fixtures, `modules/fleet/test/hosts-migration.test.sh` | ✅ disjoint files |
| 9 | Task 8c | `modules/buildbox/bin/buildbox`, `modules/fleet/test/adapters-harden.test.sh` | single task |

Waves are sequential by real dependency: each step's parity proof is the input to the next. Wave 3 is the one genuine fan-out — the two transports implement the interface Task 1 pins and share no file.

## File Structure

**Create**

- `spec/fleet.schema.json` — JSON Schema for the fleet declaration. Sibling of `spec/deck-module.schema.json`, same role.
- `modules/fleet/fleet.json` — the declaration itself: four nodes, roles, profiles, execution policy, fallback block.
- `modules/fleet/deck.module.json` — module descriptor (`kind: "library"`), so the module is visible to `deckctl doctor`/`install`.
- `lib/fleet/types.mjs` — `DesiredItem` and `Transport` JSDoc typedefs; type-only, no runtime exports.
- `lib/fleet/loader.mjs` — sole parser. Validates, applies every default, exposes the query interface. Nothing else parses `fleet.json`.
- `lib/fleet/expand.mjs` — profile → ordered `DesiredItem[]`.
- `lib/fleet/transport-local.mjs`, `lib/fleet/transport-ssh.mjs` — the two `Transport` implementations.
- `lib/fleet/engine.mjs` — `auditNode` / `convergeNode` over desired items and a transport.
- `lib/fleet/harden.mjs` — `hardenNode`, one SSH buildbox node only.
- `lib/deckctl/fleet.sh` — `cmd_fleet` dispatcher (`audit|converge|harden|status`).
- `modules/fleet/test/*.test.mjs` — unit suites, run by `node --test`.
- `modules/fleet/test/*.test.sh` — shell suites; auto-discovered by `tests/os/run-all.sh`'s `modules/*/test/*.test.sh` glob, so they gate from the moment they land.

**Modify**

- `bin/deckctl:8,23` — add `fleet` to the `usage()` string and the allowed subcommand list.
- `lib/deckctl/sync.sh`, `modules/buildbox/bin/buildbox` — become adapters (Task 8).
- The `build-remote.json` `hosts` consumers listed in Wave 8 — route through the loader (Task 9).

## Conventions binding every task

- **Fail closed before side effects.** Unknown key, unknown enum value, undeclared node reference, duplicate destination, or missing source aborts before any filesystem or network mutation.
- **One parser.** Consumers call `lib/fleet/loader.mjs`; no task may add a second `JSON.parse` of `fleet.json` or of `build-remote.json`'s `hosts`.
- **No secrets in Git.** The declaration may name a required secret capability; it never carries a value.
- **Never sync one node from another.** Every desired item resolves from the landed overdeck revision or a pinned external ref.
- Commit only the paths in the task's Files list.

---

### Task 1: Fleet declaration, schema and loader

**Wave:** 1
**Blocks:** Task 2, Task 3, Task 4
**Blocked by:** —

**Files:**
- Create: `spec/fleet.schema.json` — JSON Schema for the declaration
- Create: `modules/fleet/fleet.json` — the four-node declaration
- Create: `modules/fleet/deck.module.json` — module descriptor, so `deckctl doctor`/`install` see the module (its `find`-based scan skips any module directory lacking one)
- Create: `lib/fleet/types.mjs` — the shared `DesiredItem` and `Transport` seams as JSDoc `@typedef`s; the single file waves 2–4 import those shapes from
- Create: `lib/fleet/loader.mjs` — sole validating parser + query interface
- Test: `modules/fleet/test/loader.test.mjs`, `modules/fleet/test/node.test.sh`

**Contract (pin EXACTLY):**

```text
loadFleet(path?: string) -> ValidatedFleet        // throws FleetError on any violation
class FleetError extends Error { name = "FleetError" }

ValidatedFleet:
  schema_version: 1
  revision: string                                 // deterministic content digest of the validated declaration
  nodes: Map<name, Node>
  fallback: Fallback
  nodeNames(): string[]                            // declaration order
  node(name): Node                                 // throws on undeclared name
  nodesByRole(role): Node[]
  nodesByProfile(profile): Node[]

Node:
  name: string
  transport: "local" | "ssh"
  host_ref: string | null                          // required when transport === "ssh"; MUST resolve in the buildbox registry
  roles: string[]
  profiles: string[]
  execution: "normal" | "last-resort" | "none"     // absent -> "none"

Fallback:
  enabled: boolean
  node: string
  requires_all_unavailable: string[]
  max_concurrent_local_jobs: number
  activation_windows: number
  health_window_sec: number
  lease_ttl_sec: number
```

`modules/fleet/deck.module.json` — validates against `spec/deck-module.schema.json` (`additionalProperties:false`), so exactly: `schema_version: 1`, `name: "fleet"`, `kind: "library"`, `install: { mode: "in-place", needs_git: false }`. No `bootstrap`, no `health`, no `system_*` trees — this module installs no root-owned content and runs no service.

The desired-item and transport seams every later wave depends on live in `lib/fleet/types.mjs` as JSDoc `@typedef`s (no runtime exports; `export {}` keeps it an ES module). Pinned here so Wave 3 can be written in parallel and Task 2 only has to produce this shape:

```text
DesiredItem:
  id: string                                       // stable, unique within the node
  source: { kind: "repo"|"deploy"|"ref", path: string, ref: string|null }
  destination: string                              // absolute node path
  ownership: "user" | "root"
  strategy: "symlink" | "copy" | "git-checkout" | "unit"
  mode: "audit" | "converge" | "harden"
  content_digest: string
  owner: "fleet" | "remote-seat-provision"         // who writes it; see Task 5

Transport:
  read(path): Promise<{ exists: bool, digest: string|null, ownership: "user"|"root", mode: string|null, linkTarget: string|null }>
  write(item: DesiredItem, stagedBytes: Buffer): Promise<void>   // stage, validate, atomically promote
  unitState(unitName): Promise<{ enabled: bool, active: bool }>
  gitRef(path): Promise<string|null>
  name: "local" | "ssh"
```

`fleet.json` content follows the spec's §1 example verbatim in shape: `workstation` = `transport:"local"`, `roles:["control","agent-runtime"]`, `profiles:["shared-agent-tools","workstation"]`, `execution:"last-resort"`; `debian1|debian2|debian3` = `transport:"ssh"`, `host_ref` equal to the node name, `roles:["builder","agent-runtime"]`, `profiles:["shared-agent-tools","buildbox"]`, `execution:"normal"` — identical except node identity and `host_ref`.

**Behavior:**
- Reject: unknown root/node/fallback key; `schema_version !== 1`; unknown `transport`, `role`, `profile`, or `execution` value; `transport:"ssh"` with absent/unresolvable `host_ref`; `fallback.node` naming an undeclared node; a name in `requires_all_unavailable` that is not a declared `builder`. A declared builder *absent* from `requires_all_unavailable` is legal.
- `execution` absent ⇒ `"none"`. No other key gets an implicit value outside the loader.
- `revision` is stable across key reordering and formatting, and changes when any validated value changes.
- Errors name the offending path and value; they never dump the whole document.

**Acceptance (one executable check):**
- Run: `node --test modules/fleet/test/loader.test.mjs`
- Expected: PASS — including `loadFleet()` on the real `modules/fleet/fleet.json` yielding four nodes where debian1/2/3 differ only in `name` and `host_ref`, and a `requires_all_unavailable` entry naming a non-builder throwing `FleetError`.

- [ ] Write tests covering the behavior above, including the reject cases named in the spec's Testing section (malformed schema, unknown profile, missing node reference, unknown `execution`, roles-only node is placement-ineligible)
- [ ] Implement schema, declaration, descriptor, `types.mjs` typedefs and loader to satisfy the contract
- [ ] Add `modules/fleet/test/node.test.sh` running `node --test modules/fleet/test/` so the suite is gated by `tests/os/run-all.sh` discovery
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add spec/fleet.schema.json modules/fleet/fleet.json modules/fleet/deck.module.json lib/fleet/types.mjs lib/fleet/loader.mjs modules/fleet/test/loader.test.mjs modules/fleet/test/node.test.sh && git commit -m "fleet: add declaration, schema and loader"`

---

### Task 2: Profile expansion

**Wave:** 2
**Blocks:** Task 5
**Blocked by:** Task 1

**Files:**
- Create: `lib/fleet/expand.mjs` — node → ordered desired items
- Test: `modules/fleet/test/expand.test.mjs`

**Contract (pin EXACTLY):**

```text
expandNode(fleet: ValidatedFleet, nodeName: string) -> DesiredItem[]   // declaration order, deterministic
```

`DesiredItem` is the shape pinned in Task 1; this task produces it and adds no field to it.

Profile → reused source (no new content inventory):

| Profile | Reused source |
|---|---|
| `shared-agent-tools` | `modules/workstation/manifest/*.json` (agent homes, `owner:"remote-seat-provision"`), `modules/buildbox/devtools.json` (`tools` + `vendor`) |
| `workstation` | workstation module declarations and local units |
| `buildbox` | `modules/buildbox/user-config`, slice ceilings, scratch policy |
| `buildbox-root` | `modules/buildbox/host-config` — `mode:"harden"` only |

**Behavior:**
- Pinned refs come from `devtools.json` only; the fleet declaration never restates a ref.
- Every agent-home item carries `owner:"remote-seat-provision"`; everything else `owner:"fleet"`.
- `source.kind:"deploy"` resolves from the landed deploy revision, never a dirty checkout. Preserve the existing repo-vs-deploy distinction.
- Duplicate `destination` across the expanded set throws before any caller can contact a node.
- `ownership:"root"` items are emitted only with `mode:"harden"`; they never appear in a normal converge set.
- Expansion is pure: no filesystem writes, no network.

**Acceptance (one executable check):**
- Run: `node --test modules/fleet/test/expand.test.mjs`
- Expected: PASS — snapshot expansion for all four nodes; the debian1/debian2/debian3 snapshots are byte-identical after substituting node identity and `host_ref`; a fixture with two items sharing a destination throws.

- [ ] Write tests covering the behavior above (four-node snapshot, duplicate destination, root-item-in-normal-converge rejection, unknown profile)
- [ ] Implement `expandNode`
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add lib/fleet/expand.mjs modules/fleet/test/expand.test.mjs && git commit -m "fleet: expand profiles into ordered desired items"`

---

### Task 3: Local transport

**Wave:** 3
**Blocks:** Task 5
**Blocked by:** Task 1

**Files:**
- Create: `lib/fleet/transport-local.mjs` — `Transport` over the workstation filesystem and user systemd
- Test: `modules/fleet/test/transport-local.test.mjs`

**Contract:** implements the `Transport` interface pinned in Task 1, with `name === "local"`.

**Behavior:**
- `read` reports absence as `exists:false` — never throws for a missing destination.
- `write` stages next to the destination, validates the staged content digest, then atomically promotes (`rename`). An interrupted write leaves the previous active content intact and no staging path behind.
- `unitState` queries `systemctl --user`.
- Refuses any `ownership:"root"` item — root work belongs to `hardenNode`.
- Idempotent: a second `write` of an already-matching item performs no rename.

**Acceptance (one executable check):**
- Run: `node --test modules/fleet/test/transport-local.test.mjs`
- Expected: PASS — under a synthetic `HOME`: a fresh write lands, a repeat write is a no-op, a write whose staged digest mismatches leaves the prior content and removes the staging path, and a root-ownership item throws.

- [ ] Write tests covering the behavior above
- [ ] Implement `transport-local.mjs`
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add lib/fleet/transport-local.mjs modules/fleet/test/transport-local.test.mjs && git commit -m "fleet: add local transport"`

---

### Task 4: SSH transport

**Wave:** 3
**Blocks:** Task 5
**Blocked by:** Task 1

**Files:**
- Create: `lib/fleet/transport-ssh.mjs` — `Transport` over the existing buildbox SSH path
- Test: `modules/fleet/test/transport-ssh.test.mjs`

**Contract:** implements the `Transport` interface pinned in Task 1, with `name === "ssh"`. Connection parameters (host, port, user, identity file) resolve from the buildbox registry via `modules/workstation/claude/lib/buildbox-registry.mjs` keyed by the node's `host_ref` — this task adds no second copy of host/port/identity defaults.

**Behavior:**
- Same staging/atomic-promote and idempotence guarantees as the local transport; an interrupted upload deletes the staging path and leaves prior active content intact.
- An unreachable node surfaces as a transport error naming the node; it never falls back to another node and never reads desired state from one.
- `unitState` queries `systemctl --user` on the remote node.
- Refuses `ownership:"root"` items.

**Acceptance (one executable check):**
- Run: `node --test modules/fleet/test/transport-ssh.test.mjs`
- Expected: PASS — against a local SSH fixture (loopback sshd or an injected exec seam): fresh write lands, repeat is a no-op, interrupted upload leaves no staging path and prior content unchanged, unreachable host produces a node-named error.

- [ ] Write tests covering the behavior above
- [ ] Implement `transport-ssh.mjs`
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add lib/fleet/transport-ssh.mjs modules/fleet/test/transport-ssh.test.mjs && git commit -m "fleet: add ssh transport"`

---

### Task 5: Shared audit/converge engine

**Wave:** 4
**Blocks:** Task 6, Task 7
**Blocked by:** Task 2, Task 3, Task 4

**Files:**
- Create: `lib/fleet/engine.mjs` — planner/executor over desired items and a transport
- Create: `docs/reports/fleet-agent-home-parity.md` — the delegate-vs-absorb parity report
- Test: `modules/fleet/test/engine.test.mjs`

**Contract (pin EXACTLY):**

```text
auditNode(nodeName, items: DesiredItem[], transport: Transport)    -> AuditReport
convergeNode(nodeName, items: DesiredItem[], transport: Transport) -> ConvergeReport

AuditReport:    { node, revision, ok: bool, drift: DriftEntry[], unreachable: bool, delegated: DelegateReport|null }
ConvergeReport: { node, revision, changed: string[], failed: FailureEntry[], finalAudit: AuditReport }
DriftEntry:     { id, field: "digest"|"ownership"|"mode"|"link_target"|"unit_state"|"git_ref", want, have }
```

**Behavior:**
- Audit compares declared digest, ownership, mode, link target, unit state and pinned Git ref — all six fields.
- Converge applies only drifted items whose `owner === "fleet"`, then re-audits and embeds the result.
- Items with `owner === "remote-seat-provision"` are **delegated** to `modules/workstation/claude/lib/remote-seat-provision.mjs` and its report is consumed into `delegated`. The engine never writes the agent-home tree.
- Non-root convergence is idempotent and safe to fan out across nodes in parallel.
- Per-node output is buffered and printed in declaration order regardless of completion order.
- One node's failure never mutates or rolls back another node; exit status aggregates failures.
- An unreachable node yields `unreachable:true` and does not stop independent nodes.

**Deliverable — parity report** (`docs/reports/fleet-agent-home-parity.md`): compare the delegated agent-home item set against what `remote-seat-provision.mjs` deploys today, and record whether delegation is a thin pass-through. Recording the finding is the deliverable; absorbing the module is explicitly a follow-up plan, not this task.

**Acceptance (one executable check):**
- Run: `node --test modules/fleet/test/engine.test.mjs`
- Expected: PASS — local and SSH transports produce equivalent audit reports for the same items; a drifted item converges and the embedded final audit is clean; an unreachable node reports `unreachable:true` while a healthy peer still audits; agent-home items are delegated and never written by the engine; parallel audit output stays declaration-ordered.

- [ ] Write tests covering the behavior above
- [ ] Implement `engine.mjs` with the delegation seam
- [ ] Produce `docs/reports/fleet-agent-home-parity.md` from the delegated-vs-current comparison
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add lib/fleet/engine.mjs modules/fleet/test/engine.test.mjs docs/reports/fleet-agent-home-parity.md && git commit -m "fleet: add shared audit and converge engine"`

---

### Task 6: Hardening path

**Wave:** 5
**Blocks:** Task 7
**Blocked by:** Task 5

**Files:**
- Create: `lib/fleet/harden.mjs` — root convergence, one SSH buildbox node at a time
- Test: `modules/fleet/test/harden.test.mjs`

**Contract (pin EXACTLY):**

```text
hardenNode(nodeName: string, items: DesiredItem[], transport: Transport) -> HardenReport
HardenReport: { node, revision, changed: string[], rebooted: bool, bootIdBefore, bootIdAfter, finalAudit: AuditReport }
```

**Behavior:**
- Accepts exactly one node. Rejects zero nodes, multiple nodes, implicit all-node selection, the workstation node, and any node whose `transport !== "ssh"`.
- Consumes only `mode:"harden"` items (profile `buildbox-root`).
- Preserves the existing one-at-a-time reboot verification for disk, watchdog, SSH, kernel and root systemd changes: reboot, wait for the boot id to change, re-audit, and restore last-known-good on failure.
- A hardening failure stops the run; it never proceeds to another node.

**Acceptance (one executable check):**
- Run: `node --test modules/fleet/test/harden.test.mjs`
- Expected: PASS — zero, multiple and workstation targets each throw before any transport call; a single buildbox target applies root items, observes a boot-id change, and returns a clean `finalAudit`; a failed post-reboot audit restores last-known-good and returns a failure.

- [ ] Write tests covering the behavior above
- [ ] Implement `harden.mjs`
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add lib/fleet/harden.mjs modules/fleet/test/harden.test.mjs && git commit -m "fleet: add one-node hardening path"`

---

### Task 7: `deckctl fleet` command

**Wave:** 6
**Blocks:** Task 8
**Blocked by:** Task 6

**Files:**
- Modify: `bin/deckctl:8,23` — add `fleet` to BOTH the `usage()` subcommand string and the allowed subcommand case list
- Create: `lib/deckctl/fleet.sh` — `cmd_fleet` dispatcher
- Test: `modules/fleet/test/cli.test.sh`

**Contract (pin EXACTLY — user-visible surface):**

```text
deckctl fleet audit    [node...]      # default: every declared node
deckctl fleet converge [node...]      # default: every declared node, non-root items only
deckctl fleet harden   <one-buildbox-node>
deckctl fleet status
```

**Behavior:**
- `cmd_fleet` follows the existing dispatcher contract in `bin/deckctl` (sourced lib defining `cmd_<name>`, fail-closed usage on an unknown verb).
- Node arguments are validated against the declaration before any work; an undeclared name exits nonzero with the name quoted.
- `harden` requires exactly one explicit node — never a default, never a list.
- `status` prints, per node: desired revision, profile expansion digest, drift count, last successful audit, transport reachability, and runtime role/state. The workstation fallback state, lease expiry and local job counts are availability-plane fields and are shown as `n/a (availability plane not landed)` until the follow-up plan lands them.
- Exit nonzero if any node fails; machine-readable report to stdout, concise summary to stderr.

**Acceptance (one executable check):**
- Run: `bash modules/fleet/test/cli.test.sh`
- Expected: PASS — `deckctl fleet` with no verb exits 2 with usage; an undeclared node name exits nonzero naming it; `deckctl fleet harden` with zero or two nodes exits nonzero; `deckctl fleet status` against a fixture declaration lists all four nodes in declaration order.

- [ ] Write the suite covering the behavior above
- [ ] Implement `lib/deckctl/fleet.sh` and register `fleet` in `bin/deckctl`
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add bin/deckctl lib/deckctl/fleet.sh modules/fleet/test/cli.test.sh && git commit -m "deckctl: add fleet audit/converge/harden/status"`

---

### Task 8: Convert `deckctl sync apply` into a fleet adapter

**Wave:** 7
**Blocks:** Task 8b, Task 8c, Task 9
**Blocked by:** Task 7

> **Re-scoped 2026-08-09 (parent decision, implementation-time finding).** The original
> Task 8 mapped all four rows at once under "argument translation only". Three of them
> cannot be translated, for the same reason:
>
> - `buildbox audit` / `buildbox bootstrap` — the engine models ~26 of the ~115 facts
>   those bodies assert (`probe_doors`, the seven `push_*` calls, the 28 remote
>   `lib/buildbox-checks.sh` assertions, `fleet_gate_fanout`, `fleet_second_door`,
>   `fleet_ledger_record`). → **Task 8b**, additive.
> - `buildbox harden` — `harden_host` ships and runs `modules/buildbox/host-config/apply.sh`
>   as root (`modules/buildbox/bin/buildbox:314-320`), which does scratch-disk discovery,
>   partitioning, mkfs, fstab convergence and bind-mount migration (`apply.sh:202-334`),
>   `converge_rescue_door` / `converge_tailscale_ssh` / `converge_sshd_access`
>   (`apply.sh:167-200`), subuid/subgid allocation and linger for `ods-*` seats
>   (`apply.sh:130-152`), and `sysctl --system` + journald/oomd restarts (`apply.sh:336-350`).
>   `hardenNode` only ever touches the file-install items `expandBuildboxRoot` emits.
>   → **Task 8c**, additive.
>
> In every case `tests/os/run-all.sh` has no end-to-end coverage of the command, so a
> translate-only conversion would delete capability and the gate would stay green.
> Separately, no node declared the `buildbox-root` profile, so `hardenNode` selected
> nothing at all; fixed ahead of these tasks in `8041d333`.

**Files:**
- Modify: `lib/deckctl/sync.sh` — becomes local-workstation convergence over the fleet engine
- Modify: `tests/os/deckctl-sync.test.sh` — its sandbox must also copy `modules/fleet/` and `modules/buildbox/devtools.json`, since `expand.mjs` anchors its repo root at `lib/../..` and the converted `sync.sh` now reads the declaration. **Assertions and expected output MUST NOT change** — only the fixture's file-copy list.
- Test: `modules/fleet/test/adapters.test.sh`

**Contract (pin EXACTLY — existing surfaces MUST NOT change):**

```text
deckctl sync <pull|apply|diff> [agent] | deckctl sync apply <agent> <entry>   # unchanged argv
```

Mapping — argument translation only, **no desired-state logic in the adapter**:

| Adapter invocation | Fleet call |
|---|---|
| `deckctl sync apply` | `convergeNode("workstation", …, localTransport)` over the `agent-home:<agent>:` items of `expandNode(fleet, "workstation")` |
| `buildbox audit`, `buildbox bootstrap` | **not in this task** — Task 8b, additive |
| `buildbox harden` | **not in this task** — Task 8c, additive |
| `deckctl agents` | unchanged behavior and unchanged file in this task — its node discovery still reads `build-remote.json`; moving it to the loader is **deferred to Task 9**, which owns that file |

**Behavior:**
- Every existing acceptance test for `deckctl sync` stays in place and stays green; it is the parity proof and is not to be rewritten to match new output.
- **The engine call is wrapped, not substituted for, `sync.sh`'s existing safety layers.** `sync_assert_not_denied`, `sync_backup_target` / `sync_record_baseline`, and `sync_secret_scan` MUST still run around every write. `convergeNode` has none of them; dropping `sync_secret_scan` in particular would let a credential reach an agent home unscanned.
- Terminal output format stays recognizable — a drift line still names host and item.
- The adapter does not parse `fleet.json`, `devtools.json` or `build-remote.json` directly.

**Acceptance (one executable check):**
- Run: `bash modules/fleet/test/adapters.test.sh && bash tests/os/run-all.sh`
- Expected: PASS — the adapter delegates to the engine (asserted via an injected engine seam), the secret scan and baseline/backup layers are still invoked on the write path (asserted by a sentinel), and every pre-existing deckctl suite stays green.

- [ ] Write the adapter suite covering the mapped row and the three preserved safety layers
- [ ] Convert `lib/deckctl/sync.sh`
- [ ] Extend the `tests/os/deckctl-sync.test.sh` sandbox copy list — assertions untouched
- [ ] Run `bash tests/os/run-all.sh` — green, including every pre-existing deckctl suite
- [ ] Commit: `git add lib/deckctl/sync.sh tests/os/deckctl-sync.test.sh modules/fleet/test/adapters.test.sh && git commit -m "fleet: make deckctl sync apply an adapter over the engine"`

---

### Task 8b: Add engine audit coverage alongside the existing `buildbox audit|bootstrap` checks

**Wave:** 8
**Blocks:** —
**Blocked by:** Task 8

**Files:**
- Modify: `modules/buildbox/bin/buildbox` — `audit` and `bootstrap` gain a fleet-engine pass; every existing check stays
- Test: `modules/fleet/test/adapters-audit.test.sh`

**Why additive, not translating:** `bin/buildbox audit` asserts roughly 115 distinct
facts. The fleet engine models the ~26 file-content items `expandNode` emits. The
remainder — door probing, tool pushes, the remote `lib/buildbox-checks.sh` assertions,
gate fan-out, second-door and ledger recording — has no engine equivalent and no
end-to-end test coverage, so a replacement would silently delete capability.

**Contract (pin EXACTLY):**

```text
buildbox audit|bootstrap [host...]     # unchanged argv, unchanged exit-code semantics
```

- `audit` runs its existing checks, then additionally runs `auditNode` for that host and folds engine drift into the same report and the same nonzero exit.
- `bootstrap` runs its existing sequence, then additionally runs `convergeNode` (non-root) for that host.
- Engine drift lines are visually distinguishable from the pre-existing check lines but use the same host/item naming.
- No desired-state logic is duplicated into the bash body — the engine call is the only new source of item facts.

**Behavior:**
- No pre-existing check is removed, reordered, or made conditional.
- A host whose engine expansion is clean and whose existing checks pass exits 0, exactly as today.
- Engine drift alone is enough to make `audit` exit nonzero.

**Acceptance (one executable check):**
- Run: `bash modules/fleet/test/adapters-audit.test.sh && bash tests/os/run-all.sh`
- Expected: PASS — the suite asserts every pre-existing check function is still invoked (grep the body for each name and assert presence), that `auditNode` is invoked via an injected engine seam, and that engine-only drift produces a nonzero exit.

- [ ] Write the additive-coverage suite, including the "no pre-existing check removed" assertion
- [ ] Add the engine pass to `audit` and `bootstrap`
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add modules/buildbox/bin/buildbox modules/fleet/test/adapters-audit.test.sh && git commit -m "buildbox: add fleet engine coverage to audit and bootstrap"`

---

### Task 8c: Add `hardenNode` alongside the existing `buildbox harden` apply path

**Wave:** 9
**Blocks:** —
**Blocked by:** Task 8b

**Files:**
- Modify: `modules/buildbox/bin/buildbox` — `harden_host` gains a `hardenNode` pass; `apply.sh` shipping stays
- Test: `modules/fleet/test/adapters-harden.test.sh`

**Why additive, not translating:** `apply.sh` converges scratch disks (discover, partition,
mkfs, fstab, bind-mount migration), the rescue door, tailscale ssh, sshd-access, subuid/subgid
and linger, then reloads sysctl and restarts journald/oomd. `hardenNode` models only the
file-install items `expandBuildboxRoot` emits. Replacing one with the other would unmount
build scratch and remove the second door on a headless box with no console.

**Contract (pin EXACTLY):**

```text
buildbox harden <host>     # unchanged argv, unchanged single-host guard, unchanged exit-code semantics
```

- The existing deadman arm → `apply.sh` → reboot → post-reboot audit → deadman disarm sequence is unchanged and still owns rollback.
- `hardenNode` runs **inside** that protected window, after the deadman is armed and before the reboot, so a bad engine write is rolled back by the same last-known-good path.
- `hardenNode` selecting zero items for a declared builder is a hard error, not a no-op — it means the node lost its `buildbox-root` profile.
- No desired-state logic is duplicated into `apply.sh`; the engine call is the only new source of item facts.

**Behavior:**
- No step of the existing sequence is removed, reordered, or made conditional.
- Engine drift alone is enough to make the host count as changed, so the reboot proof still runs.
- A host that is clean by both paths behaves exactly as today.

**Acceptance (one executable check):**
- Run: `bash modules/fleet/test/adapters-harden.test.sh && bash tests/os/run-all.sh`
- Expected: PASS — the suite asserts the deadman is armed before any engine write, that `hardenNode` is invoked via an injected engine seam within the protected window, that zero selected items exits nonzero, and that every pre-existing step of `harden_host` is still present and invoked.

- [ ] Write the additive-harden suite, including the deadman-ordering and zero-item assertions
- [ ] Add the `hardenNode` pass inside `harden_host`'s protected window
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add modules/buildbox/bin/buildbox modules/fleet/test/adapters-harden.test.sh && git commit -m "buildbox: run hardenNode inside the harden deadman window"`

---

### Task 9: Move `hosts` out of `build-remote.json`

**Wave:** 8
**Blocks:** —
**Blocked by:** Task 8

**Files:**
- Modify: `modules/fleet/fleet.json`, `lib/fleet/loader.mjs` — the loader becomes the sole host-list source
- Modify: `lib/deckctl/agents.sh`, `modules/workstation/claude/lib/remote-build.mjs` — the only two consumers that read the `hosts` key; both route through the loader query interface
- Modify: `modules/workstation/claude/tests/local-gate-requeue.test.sh`, `modules/workstation/claude/tests/buildbox-registry.test.mjs`, `modules/workstation/claude/tests/remote-build-transport.test.sh`, `modules/harness/seat/test/seat-contract.test.sh` — fixtures carrying a `hosts` key, updated to the new shape
- Test: `modules/fleet/test/hosts-migration.test.sh`

`modules/workstation/claude/lib/cpu-guard.sh`, `modules/harness/seat/remote-seat.mjs`, `modules/harness/wrappers/lib/remote-seat.sh`, `modules/buildbox/lib/buildbox-checks.sh` and `modules/buildbox/host-config/apply.sh` name `build-remote.json` but read no `hosts` key (tuning keys or comments only) — they are **not** in scope. The last two execute on the buildbox over `bash -s`, where `lib/fleet/loader.mjs` does not exist; if either ever needs a host list it receives it as a parameter from the workstation caller, matching the existing `DEVTOOLS_B64` pattern.

**Contract (pin EXACTLY):**
- `~/.claude/build-remote.json` keeps **per-host tuning only**, keyed by fleet node name: `port`, `ssh_user`, `identity_file`, `remote_root`, timeouts, `max_remote_jobs`, `rsync_only`, `ship_ignored`, `local_only`.
- Its `hosts` key is **removed**. A `hosts` key still present after this task **fails the audit** — it is never silently preferred.
- `local_fallback` stays present and stays `false` forever. It is the dead legacy path. Nothing in this plan reads it as permission; `local-gate` keeps reading it only to assert it is `false`.
- `fallback.enabled` in `fleet.json` is a distinct key and is **not** an alias of `local_fallback`. They are never collapsed.

**Behavior:**
- Every listed consumer resolves hosts via the loader; none retains its own parse of `hosts`.
- A tuning entry keyed by a name not declared in `fleet.json` fails closed.
- The four fixture files listed above are updated to the new shape in this task — they are consumers, not a reason to keep the key.

**Acceptance (one executable check):**
- Run: `bash modules/fleet/test/hosts-migration.test.sh && bash tests/os/run-all.sh`
- Expected: PASS — a fixture `build-remote.json` carrying `hosts` makes `deckctl fleet audit` exit nonzero naming the leftover key; a tuning entry for an undeclared node fails closed; `local_fallback:true` in the fixture grants nothing; every pre-existing consumer suite stays green.

- [ ] Write the migration suite covering the behavior above
- [ ] Move the host list into `fleet.json` and route every consumer through the loader
- [ ] Add the leftover-`hosts` audit failure
- [ ] Run `bash tests/os/run-all.sh` — green
- [ ] Commit: `git add modules/fleet/fleet.json lib/fleet/loader.mjs lib/deckctl/agents.sh modules/workstation/claude/lib/remote-build.mjs modules/workstation/claude/tests/local-gate-requeue.test.sh modules/workstation/claude/tests/buildbox-registry.test.mjs modules/workstation/claude/tests/remote-build-transport.test.sh modules/harness/seat/test/seat-contract.test.sh modules/fleet/test/hosts-migration.test.sh && git commit -m "fleet: move host list out of build-remote.json"`

---

## Live rollout (after Task 9 lands)

Run in order; each step must be clean before the next.

1. `deckctl fleet audit workstation` — no mutation.
2. `deckctl fleet converge workstation`; re-audit clean.
3. `deckctl fleet converge debian1`; `deckctl fleet harden debian1` where root changes require it; audit clean.
4. Repeat step 3 for debian2, then debian3 — one host at a time, never in parallel.

   **Per-host reboot gate — `harden` reboots, and a reboot is what took debian2 and debian3 dark.** Before hardening any host, that host itself MUST satisfy both: the sshd `RestartPreventExitStatus` drop-in is present on it, and it has already survived one power-cycle since that drop-in landed. debian1's proof does NOT transfer. A host that has not survived a post-drop-in reboot gets `converge` only; hardening waits. These boxes are headless with no console — sshd on port 2222 is the only door.
5. Confirm all four nodes report the same `shared-agent-tools` expansion digest in `deckctl fleet status`.

## Out of scope — follow-up plan

Spec steps 6–8 (availability plane): delete the `scheduler.ts` capacity spill and `store.tryPlaceSpill`, add controller builder probing and the `unavailable` host state, add `POST /fallback/lease`, and wire every local launch door through one `fallbackLeaseCheck` library. New slug, new session file, `base_branch` = wherever this plan lands. Also deferred to that plan: absorbing `remote-seat-provision.mjs` if Task 5's parity report shows delegation is a thin pass-through.
