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

Active – Supersedes 008-unified-cli.md and 011-two-phase-apply.md.

Intent CLI

Context

Braid’s plan/apply reconciliation engine was over-engineered for NAS drives, which have ~4 events in their lifetime (create pool, add disk, add another, replace a dead one). The generic reconciler created problems:

  • Risk flattening: routine reboot and adding a disk produced the same output format (a “plan” with “actions”)
  • Combinatorial complexity: --allow-remove-missing, --allow-remove-ambiguous, BRAID_CONFIRM='phrase1;phrase2'
  • Ceremony for routine operations: braid apply after every reboot

Decision

Replace plan/apply with five intent commands:

CommandPurposeRisk
braid add <name=by_id>...Format + join pool, or recover identity-verified LUKS deviceDestructive (new disk), safe (returning braid disk with matching FSID), or refused (non-braid LUKS, foreign pool, no pool to verify)
braid remove <name>Migrate data off present disk, detach from poolLong-running
braid remove-missing --missing-id <devid>Clean up a stale missing-device entry; restores RAID1 profiles if this clears the last missing device; on a 2-disk pool, collapses the pool onto its present diskLong-running
braid replace --old <name> --new <name=by_id>Replace a disk (live or dead) using btrfs replace start; restores RAID1 profiles for missing-path when clearing the last missing deviceIn-place swap (preserves devid)
braid statusDisplay pool health and disk infoRead-only

Disk keys

Disk membership is CLI-owned runtime state in /var/lib/braid/pool.json (see 017-runtime-disk-membership.md). pool.json is keyed by LUKS UUID; the disk name is stored as presentation metadata. Disks are added with name=by_id syntax:

braid add toshiba=/dev/disk/by-id/ata-Toshiba_MN07_XXXX \
          ironwolf=/dev/disk/by-id/ata-Ironwolf_ST12_YYYY

Mapper names are braid-<name> (e.g., braid-toshiba) — human-friendly, debuggable in lsblk/systemd logs, deterministic. They are runtime handles, not persistent identity.

Safety model

The old architecture used a structural code boundary — luksFormat was literally unreachable from apply. The new architecture replaces this with:

  1. Explicit operator intent: user specifies a disk key and confirms
  2. Layered identity check for existing LUKS devices: a. LUKS UUID is the persistent identity. LUKS label braid-<key> is an adoption-safety gate for returning disks; non-braid LUKS is refused outright. b. Pool must be mounted — bootstrap refuses existing LUKS (no pool to verify against). c. Opened mapper’s btrfs FSID must match the current pool — foreign-pool disks are refused. d. Braid-labeled LUKS with no btrfs superblock is refused – this state is ambiguous (clean eviction, partial init, manual wipe, stale data) and cannot be distinguished without tombstones. e. A braid-labeled LUKS disk that pool.json does not list, with a btrfs superblock whose FSID matches the mounted pool, may be accepted as a returned-disk add target. The add journal records the LUKS UUID before mutation. If the stale btrfs signature would block btrfs device add, braid runs only wipefs --all --types btrfs on the verified mapper and uses btrfs device add -f. A disk that pool.json lists under the same name and by-id is that member, never a returned-disk add target: btrfs does not take a returning devid back into a mounted pool, and a new devid would strand the MISSING one. Add skips the member when it is live on the same disk, and otherwise refuses it with the reopen or replace route for its state (cli/src/add.rs#resolve_existing_member). f. Superblock guard is defense-in-depth on the FSID-matching path for existing-LUKS adds. The bootstrap path accepts only disks classified as fresh non-LUKS during add planning, the fresh-disk readiness check (cli/src/luks.rs#check_fresh_target_ready) refuses any active braid-<key> mapper before the journal, and the LUKS open helpers still verify that the mapper they open is backed by the requested by-id disk before pool creation proceeds. mkfs.btrfs itself is invoked without -f, so its own signature check is the final fail-closed guard.
  3. Unified confirmation with device context: all mutating commands (add, remove, remove-missing, replace) show a rich device-info block (model, size, serial via lsblk) and confirm with Type 'yes' to continue:. Degraded-path warnings are informational text, not special confirmation phrases. --yes skips the prompt for scripting.
  4. Disk name immutability: mutating commands validate names against recorded disk identity and reject name rename/reassignment. Operators must use explicit replace or remove+add workflows instead of renaming.
  5. Journal-protected mutations: mutating commands write pending-op.json before the first irreversible step; it is cleared only after the full operation (including follow-up work like the owed RAID1 balance) succeeds. Existing-pool add, replace, and remove-missing journals are phased. Their PoolMutation phases may reconcile whether the primary btrfs membership mutation committed; their post-maintenance phases may only validate committed membership, repair pool.json, and finish owed resize/balance work. On any error exit, the journal persists to enable braid recover.

--dry-run performs side-effect-free, passphrase-free LUKS probes only – LUKS label reads, and the keyfile credential test used by braid enroll (cryptsetup open --test-passphrase --key-file, which evaluates a credential without activating the device). Checks that require a passphrase or an open mapper – e.g. full identity verification (FSID comparison) – are deferred to execution time when the mapper is closed.

The dry-run preview itself stays on stdout. Side-effect-free probes that nevertheless do bound long-running work – specifically the Argon2-bounded --test-passphrase evaluation in braid enroll --dry-run – emit canonical [wait]/[ok]/[skip] status rows to stderr per Principle 13. Announce long-running work. The previous “successful dry-run leaves stderr empty” contract is intentionally relaxed for this case: an Argon2 derivation runs whether or not the user can see it, and silent dry-runs that take seconds-to-minutes look like hangs. The structured preview output is unchanged.

Replace safety constraints

  • --old accepts both live (present in pool) and dead/missing disks.
  • Both paths use btrfs replace start — the sole replacement primitive. Live disks replace in-place; missing disks are rebuilt from RAID redundancy by devid.
  • --missing-id is only valid when --old is dead/missing. Rejected with live --old. Validated against PoolState::missing_devids (live btrfs state via probe::probe_pool).
  • The missing devid is auto-resolved from --old’s identity, independent of how many devices are missing. If --old’s disk was hot-unplugged and btrfs still lists it through its open mapper (null-underlying, before promotion to MISSING), the LUKS UUID that mapper records binds --old to the devid btrfs holds the mapper at, and replace refuses until btrfs promotes that devid. Otherwise, including once btrfs reports the device MISSING, --old has no observable LUKS UUID, so its persisted pool.json devid binds it, and only to a PoolState::missing_devids entry no open mapper holds (cli/src/types.rs#PoolState::devid_only_missing; see decision 024). Because --old’s name already identifies the member, no missing-count gate is needed; --missing-id is an optional cross-check (it must equal that devid, else OldMapperDevidMismatch or OldDevidMismatch) and is never required.
  • Mixed state (live --old + pool has missing devices) is rejected – operator must repair the missing device first with braid replace --old <missing-name> --new <new-name>=/dev/disk/by-id/<...>. braid remove-missing is only for intentional cleanup: forgetting stale entries without rebuilding data, or collapsing a 2-disk pool onto its present disk.
  • No replacement path uses btrfs device add. Missing-path replace may run a post-commit soft RAID1 balance only when it clears the last missing device.

ENOSPC pre-flight check

remove and remove-missing validate that surviving devices have enough unallocated space to absorb the target device’s allocations before invoking btrfs device remove. Without this, btrfs will either ENOSPC instantly or crash the filesystem to read-only mid-relocation (reproduced in tests/repro/).

The >=2-survivor remove path treats relocation-probe uncertainty as warn-and-proceed – a miss falls through to btrfs’s clean instant-ENOSPC – while remove-missing and the 2→1 remove path are fail-closed on any uncertainty, because a miss there can crash the filesystem read-only with pending-op.json already written. A nonzero exit from btrfs device usage is btrfs refusing, not probe uncertainty, so it refuses on every remove path (cli/src/remove.rs#check_eviction_space).

remove and remove-missing plan again after the confirmation prompt (or straight away under --yes), above journal::write_journal, and refuse unless the new plan matches the confirmed one (cli/src/remove.rs#RemovePlan::recheck_before_journal, cli/src/remove_missing.rs#RemoveMissingPlan::recheck_before_journal). Survivor space, device presence, a paused balance, a read-only mount and UPS power are state the pool lock cannot pin, and the prompt has no time limit, so a change during the wait would otherwise reach the btrfs mutation with pending-op.json already written: a relocation that crashes the pool read-only, an --enqueue that waits forever behind a paused balance, or a migration that starts on battery. Re-running the whole planner, not a chosen list of its checks, keeps every planner check fresh at the journal, including checks added later; execute still runs the confirmed plan. The gate runs before any --enqueue wait, so it does not cover changes while the removal waits behind another exclusive operation or while it runs. Only stopping pool writes would close that.

The remove re-plan reverses two earlier choices for the >=2-survivor path, which re-checked no space before the journal: plans/impl/2026-06-03-remove-execute-survivor-capacity-recheck.md called an execute-time re-check there unnecessary, and plans/impl/2026-06-16-remove-multi-survivor-no-recheck.md pinned that no space probe runs at all. Their premise was that btrfs device remove ENOSPCs cleanly. That holds for the filesystem but not for braid: when device remove fails, execute keeps pending-op.json, so the operator must run braid recover before anything else. A refusal before the journal writes nothing. The re-plan keeps the >=2 error policy, so a probe that fails to spawn or parse still warns and proceeds; only a nonzero btrfs exit or a confirmed shortfall refuses, as at plan time. Do not restore the pin.

Before the space check, remove-missing refuses while any member other than the target is hot-unplugged but btrfs has not yet promoted it to MISSING (cli/src/types.rs#PoolState::hot_unplugged_not_missing). btrfs still treats such a member as writable, and btrfs device usage still reports its mapper path and full size. The chunk allocator uses every writable device and tries the ones with the most free space first (linux 6.18.33, fs/btrfs/volumes.c (gather_device_info)). So relocated chunks would get a copy on a disk that is gone. RAID1 tolerates one failed write per chunk, so the removal could report success while some chunks hold one real copy. No space arithmetic prevents that. Only promotion to MISSING, by a relock and a degraded unlock, takes the device off the allocator. The refusal therefore runs on every pool shape, the collapse included. With it in place, every non-missing usage stanza with a nonzero size belongs to a present member, so the survivor set needs no second filter. Counting only probe-present members as survivors was rejected: it corrects the number but not where the kernel writes.

remove-missing also refuses an untrusted missing-device allocation shape before btrfs device remove. Its trust check validates target identity and shape, not per-type completeness: the targeted missing devid must have exactly one usage stanza, that stanza must carry the kernel missing-device path marker (<missing disk>, or the missing fallback) together with device_size == 0, every positive target allocation row must be one of Data/Metadata/System RAID1, and at least one positive supported row must be present. A stanza with a real device path – even at device_size == 0, which btrfs-progs emits when a present device’s size probe fails – is a probe disagreement between btrfs filesystem show and btrfs device usage, and is refused rather than trusted as the relocation target; size alone never proves missing-ness. Missing supported row types are treated as zero demand because a sparse 3+ device RAID1 member may legitimately hold only a subset of Data, Metadata, and System chunks.

Single-survivor cases use a path-specific check:

  • remove (2→1): the RAID1-aware relocation check does not apply (there is only one remaining device, not two). Instead, a single- survivor capacity check derives demand from btrfs filesystem df logical usage – data + 2 * metadata + 2 * system, reflecting the post-balance single + DUP profile on one device – and compares it to the survivor’s device_size - device_slack. This check runs at plan time and again in the pre-journal re-plan (cli/src/remove.rs#RemovePlan::recheck_before_journal), closing the plan/execute drift window – a survivor over-committed by writes during the confirmation + inhibitor wait is caught before the irreversible -f balance and fails clean, with no pending-op.json stranded.
  • remove-missing collapse (one present disk, the last missing one targeted; cli/src/remove_missing.rs#classify_removal_outcome): the same single-survivor model, against the present disk, fail-closed on every probe uncertainty (cli/src/preflight.rs#probe_single_survivor_inputs). It also requires the target’s usage stanza to carry the missing marker and zero size, as the relocation check does, because the collapse is safe only while btrfs keeps the target off its allocator. It skips the RAID1 allocation-shape check: once the convert has finished and the device remove has not, the missing stanza holds no RAID1 rows, so that check would refuse every re-run. See Two-to-one remove.

Two-to-one remove

The kernel refuses to drop a RAID1 pool below two devices, so a 2->1 remove converts the pool to ONE_DISK_PROFILES (single data, DUP metadata and system) before btrfs device remove. The convert runs while the departing disk is still a writable member. The chunk allocator uses every writable device and tries the ones with the most free space first (linux 6.18.33, fs/btrfs/volumes.c (gather_device_info)), and only btrfs_rm_device takes a device off the allocator. So once the balance starts, some data has its only copy on the departing disk until the removal finishes, which can take hours. If that disk fails in the window, that data is lost. A 3->2 remove has no such window: btrfs device remove takes the target off the allocator before it relocates anything, and the RAID1 copies stay on the survivors.

remove therefore gates the 2->1 path on the departing disk’s health (cli/src/remove.rs#check_two_to_one_target_health). It refuses when any raw btrfs device stats counter for the target devid is nonzero, when it cannot read those counters or finds no row for the devid, and when SMART on the live backing device reads failing or warning. Like every planner check, the gate runs again in the pre-journal re-plan. The counters are raw, not net of the acked baseline: the usual retire flow is an alert, braid ack, then remove, and ack moves the counters into the baseline, so a baseline gate would pass the very disk it exists for. SMART unknown only warns. virtio disks and many USB bridges never report SMART, so refusing there would block a 2->1 remove on them for good, and the counters still fail closed for them. Every other uncertainty refuses, because the target can become the only holder of data (safety heuristics). A warning alone was rejected: --yes runs never read it.

The refusal names two routes in which the departing disk never holds the only copy of anything: braid replace onto a new disk, or, with no spare, the collapse below. The window stays open for a target that is clean on both signals. That risk is about that of the survivor failing in the same hours, which the operator accepts by choosing a one-disk pool. Steering the allocator was rejected: a balance devid filter picks which chunks move, not where the new chunk goes, and shrinking the target only changes which disk has more free space at the start.

The collapse is remove-missing on a 2-disk pool with one disk missing (cli/src/remove_missing.rs#RemovalOutcome::CollapsesToOneDisk). It runs btrfs balance start -dconvert=single -mconvert=dup -f, then btrfs device remove on the missing devid. A MISSING device never joins the allocator’s device list (linux 6.18.33, fs/btrfs/volumes.c (add_missing_dev, gather_device_info)), so every converted chunk lands on the present disk. Balance allows single and DUP there because it counts writable devices only (fs/btrfs/volumes.c (btrfs_balance)). Once the last RAID1 block group is gone, btrfs drops RAID1 from the available profiles (fs/btrfs/block-group.c (clear_avail_alloc_bits)), so the device remove passes btrfs_check_raid_min_devices. The device remove alone fails that check, which is why remove-missing used to refuse this shape, so an operator with no spare had no route. btrfs-progs warns that a convert with a missing device can be dangerous (btrfs-progs v6.19.1, cmds/balance.c (cmd_balance_start)), and ADR 001 avoids a degraded balance for that reason. Neither applies to the collapse: the missing device is off the allocator, so no chunk can land on it, and the present disk already holds the only live copy of all data, so the convert drops no copy that still exists. -f, which the metadata downgrade needs, also skips that warning’s countdown. braid’s confirmation prompt stands in for it, and --yes skips both. braid mounts with skip_balance, so a crash leaves the convert paused. Recover never resumes it, as for a paused 2->1 remove convert, and since the re-run refuses a paused balance, recover’s guidance names btrfs balance resume and btrfs balance cancel (cli/src/recover.rs#remove_missing_not_committed_guidance).

The ideal is an offline 2->1 inside remove: detach the departing disk first, mount the survivor degraded, convert, then remove the missing devid, so no 2->1 remove can hit the window, healthy target or not. It was not chosen. remove would mount degraded without the explicit --allow-degraded that principle 1 requires, a healthy 2->1 remove would stop being online, and a failure partway would leave the pool unmounted or degraded-mounted in journal states recover does not handle. If every 2->1 remove needs the guarantee, build that, reusing the collapse step.

NixOS-native automation

  • systemd braid-unlock.service + braid-pool.target for post-boot unlock
  • braid-online.service lifecycle owner (ExecStop=braid lock, RemainAfterExit=yes)

Rejected alternatives

  • Keep plan/apply with simpler flags: Still risk-flattening. The core problem is that a generic reconciler treats “reboot recovery” and “add a new disk” as the same kind of operation.
  • Separate init-disk + apply: The original approach. Created an artificial code boundary that was hard to explain and required ceremony for the common case.

Consequences

  • Five commands instead of three (no init-disk, no plan, no apply; remove split into remove + remove-missing)
  • Dry-run/confirmation coverage is a command category, not a blanket guarantee. The pool/LUKS-lifecycle mutators (add, remove, remove-missing, replace, unlock, lock, enroll, recover) support --dry-run, while discover previews by default and commits with --write. --yes is scoped to the confirmation-gated mutations (add, remove, remove-missing, replace) for scripting. Reactive notification-state maintenance (ack) and internal systemd-invoked paths (scrub-*) are deliberately excluded – they are reversible/self-correcting or machine-contract commands where a dry-run preview adds no operator value.
  • Tab completion returns disk names from pool.json