Active – Supersedes
008-unified-cli.mdand011-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 applyafter every reboot
Decision
Replace plan/apply with five intent commands:
| Command | Purpose | Risk |
|---|---|---|
braid add <name=by_id>... | Format + join pool, or recover identity-verified LUKS device | Destructive (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 pool | Long-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 disk | Long-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 device | In-place swap (preserves devid) |
braid status | Display pool health and disk info | Read-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:
- Explicit operator intent: user specifies a disk key and confirms
- 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 thatpool.jsondoes 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 blockbtrfs device add, braid runs onlywipefs --all --types btrfson the verified mapper and usesbtrfs device add -f. A disk thatpool.jsonlists 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 activebraid-<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.btrfsitself is invoked without-f, so its own signature check is the final fail-closed guard. - 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 withType 'yes' to continue:. Degraded-path warnings are informational text, not special confirmation phrases.--yesskips the prompt for scripting. - Disk name immutability: mutating commands validate names against recorded disk identity and reject name rename/reassignment. Operators must use explicit
replaceorremove+addworkflows instead of renaming. - Journal-protected mutations: mutating commands write
pending-op.jsonbefore 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. TheirPoolMutationphases may reconcile whether the primary btrfs membership mutation committed; their post-maintenance phases may only validate committed membership, repairpool.json, and finish owed resize/balance work. On any error exit, the journal persists to enablebraid 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
--oldaccepts 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-idis only valid when--oldis dead/missing. Rejected with live--old. Validated againstPoolState::missing_devids(live btrfs state viaprobe::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--oldto the devid btrfs holds the mapper at, and replace refuses until btrfs promotes that devid. Otherwise, including once btrfs reports the device MISSING,--oldhas no observable LUKS UUID, so its persisted pool.json devid binds it, and only to aPoolState::missing_devidsentry 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-idis an optional cross-check (it must equal that devid, elseOldMapperDevidMismatchorOldDevidMismatch) and is never required. - Mixed state (live
--old+ pool has missing devices) is rejected – operator must repair the missing device first withbraid replace --old <missing-name> --new <new-name>=/dev/disk/by-id/<...>.braid remove-missingis 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 frombtrfs filesystem dflogical usage –data + 2 * metadata + 2 * system, reflecting the post-balance single + DUP profile on one device – and compares it to the survivor’sdevice_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-fbalance and fails clean, with nopending-op.jsonstranded.remove-missingcollapse (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.targetfor post-boot unlock braid-online.servicelifecycle 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;
removesplit intoremove+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, whilediscoverpreviews by default and commits with--write.--yesis 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