070 results
What was built and checked for each unit of 070. Each unit keeps to its own section.
070 is done
On 2 November 2026, with the close-out’s two units, 070 is built as far as it goes. Units 12 and 13 are left for later, not built, as a decision of their own. What is live is in bb-box’s code and was checked live on a separate BB, but for microsandbox on a Mac: there the evidence is the probe’s 29 of 29 through bb-box’s exact commands, and the lead smoke-tests production’s microsandbox on the Mac after merge. Some of the rest waits for a production step, said on its line.
| Backend | Live |
|---|---|
| Proxmox LXC | Take and delete; restore in place with its undo, checked on LVM-thin; forks by a full copy; Keep by a held copy. Unit 8 made G16’s node reboot a condition before production’s Keep is turned on |
| Proxmox QEMU, Linux | As Proxmox LXC, with or without memory; forks and Keep from the disk only |
| boat.dev, a paid plan | Saved snapshots within the paid ceiling, with the price on each; restore as a swap to a new sandbox; forks and Keep from version 8 of the reset. Arming boat.dev’s reset stays off in production until the owner arms it |
| boat.dev, the free trial | No snapshots: the trial has no saved snapshots |
| microsandbox | Take with or without memory; restore as a swap; forks from the disk; Keep as it is; chains, deleted with --force. On Linux hosts and, since the close-out, Macs, with msb 0.7.7 only. The lead smoke-tests it on production’s Mac after merge |
| Tart, macOS | Take of the suspended VM with its memory; restore in place; forks, cleaned on the Mac first, and Keep, on a Mac whose diskutil has image attach (macOS 27) |
| Tart, Linux | Take and restore while stopped; since the close-out, forks and Keep, for boxes from an image refreshed since. Production forks once its linux-arm64 image is refreshed after merge |
Everywhere they apply: the box log’s events, the Snapshots tab, the box page’s card, Settings’ Snapshots page, the agent’s tools and the CLI.
Left for later, not built:
- Unit 12, Docker, Hetzner and Namespace Linux boxes: a commit of the paused container with the box’s Docker volume, and a restore as a swap to a new container.
- Unit 13, Android devices: the emulator’s own snapshots, of the device only, with the device’s own capture order and losses.
- Windows and iOS boxes, and the SSH host pool’s machines, take no snapshots; 070’s “Later” list stands.
Known gaps, in plain words:
- Restores of boxes threads run on stay off until BB holds every turn (gate G17): “Send now” can still start a turn on a box being stopped for a restore (request 39). A person’s and an agent’s restores of other boxes work.
- A fork made outside bb-box keeps its source’s identity. bb-box’s own forks always reset. A copy made with a provider’s own tools, such as a fork in boat.dev’s dashboard, gets its source’s identity value, so its reset does not run.
- The identity reset across a node reboot (G16) is unproved. Held copies and earlier guests were checked not to start by themselves, by their settings, and an earlier guest started by hand reset before its network. But no node was rebooted for the checks, so it is not known what they do when one is. Unit 8 made it a condition before production’s Keep on Proxmox is turned on.
- ZFS restores stay off until gate G4 runs on a ZFS node; there was none.
- A snapshot of a stopped Tart box forks only if bb-box stopped it and it has not run since.
- The identity reset is off on a backend until it is armed in
general.identityResetBackends; until then its snapshots neither fork nor keep. - Follow-up: cleaning a macOS fork’s raw disk with
hdiutilon a Mac withoutdiskutil image attach.
Unit 1a: the snapshot record, and Proxmox LXC take and delete
Built on 30 October 2026, on main 9e7f9b6b (unit 0 included).
What changed
- The record and
snapshots.db.snapshots.dbis a file of its own beside bb-box’s database, attached asbox-log.dbis, with its own migrations, write-ahead log and a 64 MB cap. It holds two tables: the snapshots, with every field of section 1’s record, and their operations, with every field of section 6’s. Records and operations are never evicted; an operation whose snapshots are all gone is pruned 30 days after the last of them went. The tables’ checks already allow every state, operation kind and picture state 070 names, so later units need no rebuild. An existingsnapshots.dbthat cannot be opened fails closed: no ledger takes its place, every snapshot change is refused, the box sweep keeps every leftover guest, and the doctor fails. Only where there is no such file and BB’s handle refuses to attach one do the records live in bb-box’s own database; they move intosnapshots.dbthe first time it attaches.src/plugin/snapshot-store.ts,src/plugin/attached-files.ts. - The rules, pure and tested on a set clock, in
src/core/snapshot.ts: IDs (snoropand 12 letters and digits), names (60 characters, unique per box with “ (2)“, the time by default), notes (1 KB),eligible(snapshot, change),mayAct(caller, act, standing, snapshot)(which decides what an agent may see first, then applies a kept snapshot’s rules, then the box’s row of section 3), what counts against the 5 per box (undos apart, a kept undo counted), the limit’s victim, the undo a new undo may replace, the room sweep’s candidates, and bulk judging with a reason for each one skipped. - Pins are the operations. A snapshot is pinned while an operation that
names it is
runningorunknown; nothing else pins. So pins end with their operation by construction, and an operation bb-box cannot confirm keeps its pin. - Operations, on the box’s queue.
Boxes.inTurnruns a snapshot operation on the box’s#chainwith its start, stop, resize and release.src/core/snapshots.ts:- A take plans first (
planSnapshots), refuses what the plan refuses (a lock, with the host’s command; the token’s rights; storage that takes none, for any of the guest’s disks), then in one transaction onsnapshots.dbchecks the limit, the room (85% full, or 85% with what it adds, or 80% of thin metadata, counting what unsettled operations reserved on that physical storage, whichever backend made them), names it, and writes the record (taking) and its operation (running) before the backend is asked. A memory take checks and reserves the storage the plan says its memory goes to, at the size the plan says. The operation recordssubmittingjust before the provider is asked, and the provider’s task just after it answers, before bb-box waits. The record isreadyonly when the backend confirms it complete and its task succeeded;failedwhen the backend says nothing is held;incompletewhen part of it is, or its task failed; and staystaking, its operationunknown, when bb-box cannot tell. - A delete checks
eligibleat the moment of the change, on the box’s queue, then recordsdeletingand its operation before the backend is asked. A failed or lost record is removed with no call to the backend. - A request ID is the caller’s own, bound to its box, kind and arguments. A repeat returns the same operation, also once its snapshot is gone and after bb-box restarts; a reuse for anything else is refused (“Request ID “r” was used already, for another take or delete. Make a new one for this.“) and changes nothing. The check runs again inside the transaction that writes the operation, so the same request on two boxes at once makes one take and refuses the other.
- Operations run as bb-box’s own, under its stop signal, not the caller’s.
- A take plans first (
- Restart recovery. At start, and at each sweep for operations bb-box
could not confirm,
recoverlists each backend’s snapshots once and reads each operation’s provider task. An operation that reachedsubmittingwith no task recorded has its task looked up by its guest, type and time, skipping tasks another operation holds. Then:- a take is
readyonly if its task succeeded and the snapshot is whole;incompleteif part of it is left or its task failed;failedif its task failed and nothing is left, or if it never reached the provider (never taken again); - a delete is done if the snapshot is gone, finished if it never began, and the record put back if its task failed;
- a task still running, a task bb-box cannot read, a whole snapshot with
no task to confirm it, and a task Proxmox no longer has for a snapshot
that is absent each leave the operation
unknown, pinned, with why.
- a take is
- The reconcile, at each sweep, before the box sweep: a failed listing
makes
readyrecordsunavailable(logged once) and marks nothing else; a backend not in the settings makes themunavailabletoo; a successful listing brings them backready. A record missing from a successful listing islostonly when a second listing 10 minutes later still lacks it. Anincompleterecord whose snapshot is gone is removed, logged as outside bb-box. A labelled snapshot with no record gets one again: by no one bb-box knows, named by its time; as kept when its label carries a keep date not passed; as found on its host when its box is gone, deleted after 7 days. A snapshot whose record changed after the listing began, a release’s removal included, is left for the next listing. Housekeeping removes failed records after 24 hours and lost ones after 7 days, and deletes kept snapshots past their date and room-sweep candidates past 92%, each througheligibleat the moment of the change. - The box sweep keeps guests that hold snapshots bb-box keeps. Before
it releases a guest no box records, it asks the snapshots’ ledger: a
guest holding a found, kept or pinned snapshot stays, and the sweep
reports it as “Guests kept for the snapshots they hold”. With
snapshots.dbunreadable, it keeps every such guest. - A box’s end. A released box’s snapshots go with it (Proxmox deletes
them with the guest), each logged “by bb-box: its box was released”, and
each removal is a settled operation, so a listing that began before the
release records none of them again; a forgotten box’s stay,
unavailableand marked gone; a kept one stays, marked gone. - Removing a backend that holds snapshots is refused in the
configuration’s check, naming how many, their size and price;
bb box snapshot forget <backend> --yesdrops bb-box’s records of them. - The backend interface:
Snapshotting,SnapshotPlan(with a physical storage key, and where a memory snapshot goes and what it adds),snapshotting,planSnapshots,snapshot,deleteSnapshot,heldSnapshots,providerTaskandfindTasks, withSnapshotFailedandSnapshotIncompletefor what a backend knows it left.src/backends/types.ts. - Proxmox, shared by LXC and QEMU in
src/backends/proxmox/snapshotting.ts: a snapshot isbb<id>, labelledbb-box install <install> box <box> snapshot <id>, withkept <yyyy-mm-dd>once kept. The plan reads the guest’s config, the status of each disk’s storage, the token’s rights on the pool (kept a minute) and, for LVM-thin, the metadata of the storage’s own thin pool, found through the storage’s settings; when it cannot read them it says why, and so does the doctor’s Snapshots check. A storage’s key is the node and its name, or its name alone when it is shared, so two backends on one pool share reservations. A take and a delete start Proxmox’s task, record its UPID, wait, then read the guest’s snapshot list:snapstatemeans part of it is left. A delete first checks the snapshot’s label names this install, the box and the snapshot, and refuses otherwise; a part-made snapshot is deleted withforce. A locked guest is refused: while a task runs on it, “try again when it ends”; with none, the host’spct unlock <vmid>; when bb-box cannot read its tasks, the unlock only “if no task is running”. The listing reads each guest of this install’s boxes, and calls a part-made snapshot whose guest has a task running, or whose tasks cannot be read, “in progress”, not incomplete. Proxmox LXC offers disk snapshots, taken live. - The box log gains
snapshot-taken,snapshot-failed,snapshot-deleted,snapshot-lostandsnapshot-unavailable. Its first migration is kept as it was (a test pins its hash), and a new one rebuilds the table so that it allows every kind 070 names, keeping every event. - Words.
box_listandbb box kindssay each offer’s snapshots (“Snapshots: its disk, while it runs.”);box_statusandbb box statussay the box’s, naming only those the caller may see and counting all of them against the 5 (“Snapshots: 4 of 5: …, and 3 you cannot see”). The doctor has a Snapshots section forsnapshots.db. - The CLI:
bb box snapshot take <box> [--name] [--note] [--memory] [--request],list [<box>] [--all] [--kept] [--json],delete <box> <snapshot> [--request],forget <backend> --yes, and, only withBB_BOX_LIVE_CHECKS=1,hold-next --at planned|taskfor the restart checks.bb box sweepruns the snapshots’ sweep first. - Docs: the guide’s Snapshots page, the reference regenerated, and 070’s Decided during unit 1a.
- Deferred, not proved: when an agent takes a sixth snapshot, section 8
deletes its thread’s oldest unkept one once the new one is ready. Agents
take no snapshots until unit 5, so 1a refuses a sixth for everyone;
limitVictimchooses the snapshot and is tested alone, and unit 5 wires it into the take.
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, on this unit’s commit over
main 9e7f9b6b: npx tsc passes; the node suite ran 2,989 tests,
2,701 passed, 0 failed, 288 skipped (the live and conformance
ones); vitest ran 380, all passed. The node suite includes the docs,
site and reference checks. New tests, each on a set clock where time
matters:
test/snapshot-rules.test.ts, 18: IDs and names; a pin refuses every change and names its operation; pins end with their operation, and an unknown one still pins; automatic deletions never take a kept, the person’s, an undo or a snapshot no agent took; delete, expiry, found orphans, keep, unkeep, rename and note; what counts against the 5; the limit’s victim;mayActfor a thread’s own box, a box it runs on, another thread’s box, a guessed ID, a box that is gone, the person, a command, and a kept snapshot; and G9’s four reviewers’ sequences at the level 1a builds, pins andeligible(below).test/snapshots.test.ts, 40: the engine throughBoxesand a fake provider: take, names, the limit, room and reservations (by physical storage, and on a memory take’s storage), locks and refusals, failed, incomplete and unconfirmed takes, a take ready only when its task succeeded, G9’s races (two takes at once, a repeated request, a request bound to its caller, box, kind and arguments, the same request on two boxes at once, a take and a release, a delete and a take, two deletes), a restart at each step of a take and of a delete, including the gap between asking the provider and recording its task, a task that cannot be read and one Proxmox no longer has, G2 (an hour unlisted is unavailable, never lost; lost after two listings 10 minutes apart; a release during a listing; a backend that leaves the settings; the refusal to remove it), G3 (records gone, every snapshot found, kept ones as kept, an orphan deleted after 7 days only), the box sweep keeping a guest that holds snapshots, a forgotten box, agents’ sight and deletes,box_statuscounting every snapshot, asnapshots.dbthat is unwritable, or exists and cannot be opened, records moving into it from bb-box’s database, pruning, and the box log’s migrations (the first one’s hash pinned).test/proxmox-lxc-snapshots.test.ts, 13: labels; the offer; the plan’s storage, its own thin pool’s metadata, and refusals (storage, lock withpct unlock, a lock a running task holds, the token’s right); metadata that cannot be read, in the plan and the doctor; a take recording its UPID before waiting, and found by guest and time when it was not; a take complete only when its task succeeded; G1 (a killed task is incomplete, a locked delete names the host’s command, and after the unlock a forced delete cleans up); failed takes; a guest that is gone; a delete checking the label; the listing (other installs’ and hand-made snapshots left out, a keep date, a part-made snapshot whose task still runs is in progress).test/plugin.test.ts, one more:bb box snapshot take,list,deleteandforgetthrough the plugin,box_listandbox_statuswords, another thread seeing nothing, and the doctor’s Snapshots section.
G9’s reviewers’ sequences involve restores, keeps, bulk actions and the
room sweep, which come in units 3, 4, 6 and 8. 1a builds the pins and
eligible they rest on, so they are tested there: a restore operation
pinning its target, then the undo to replace, a kept undo, bulk delete,
bulk keep and rename of the target, and the room sweep’s candidates
checked again at the moment of deletion. They run live once those units
exist.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1
only, in a Proxmox LXC box. bb-box installed from this branch with bb plugin install path:. The lead gave it a Proxmox LXC backend and a Proxmox
QEMU one with the production token, their own VMIDs and addresses, and a
Linux template each; its install ID keeps production’s guests and these
apart. Its log on the first start: “Opened snapshots.db, attached to
bb-box’s database: ran 4 migrations.” bb box doctor: “[ok] Snapshots: The
token may take, delete and restore snapshots on /pool/bbbox. local-lvm
(LVM-thin) restores any snapshot.” and “[ok] Snapshots’ records: …
snapshots.db, attached to bb-box’s database: 0 snapshots, 0 operations not
settled, 40 KB used.”
All through bb box, on a Linux LXC box of a thread. In the words quoted
below, the node’s name is written pve-1, as 070 writes it.
| Check | Result |
|---|---|
| Take, with a name and a note | ready in 0.3 s as bb-box timed it, 0.8 s for the command |
| Take again with the same name | “before the migration (2)” |
| Take with no name | “Fri 05:53”, the server’s time |
| List | each with its ID, state, time, “by a bb box command”, disk, size unknown; “3 of 5 for this box” |
| A sixth | refused: “Box calm-island holds 5 snapshots, the most a box may. Delete one first: …” naming all five |
| Delete by name, and by ID | 0.6 s for the command; “it freed what it no longer shared with its box” |
| A repeated request, sent twice at once | one snapshot: one answer “Took snapshot”, the other “was asked for with that request already. It is ready.” |
| Two takes at once | both ready, one after the other |
| A take and a release at once | the release was asked during the take, waited, and released the box after it; the box log: release-asked 29.901, snapshot-taken 30.111, released 31.933, snapshot-deleted “by bb-box: its box was released” 31.935 |
bb-box restarted (bb plugin reload) after a take’s Proxmox task began (hold-next --at task) |
at the next start the take was ready, logged “confirmed after bb-box restarted” |
| bb-box restarted after a take was recorded, before Proxmox was asked | failed: “bb-box restarted before the take began, and pve-1 has no such snapshot”; never taken again |
| bb-box restarted after a delete’s task began | gone at the next start, logged “confirmed after bb-box restarted” |
| bb-box restarted after a delete was recorded, before Proxmox was asked | finished at the next start: gone |
Each restart’s command ended with “bb-box is stopping. (restarting)”.
G1. The lead ran live-root.sh
as root on the node: it waited for a vzsnapshot task on the box’s VMID,
matched by its UPID and its worker’s title, and SIGKILLed that one worker.
The take then said: “bb-box could not finish snapshot “killed” of box
calm-island: Proxmox left it part-made (snapstate prepare): unexpected
status. Part of it is left on pve-1: delete it to finish the clean-up
there.” It listed as incomplete, and stayed so through a sweep. Its
delete was refused: “Proxmox has guest 3240 locked (snapshot), so it cannot
delete the snapshot. Run pct unlock 3240 on pve-1, then delete it
again.” The box still answered commands. After the lead’s unlock, the
delete finished Proxmox’s clean-up: “Deleted snapshot “killed” …“.
G2. iptables in the BB’s box dropped every packet to the Proxmox
API’s port from 05:59:53 to 06:59:55 UTC. The first sweep after it, at
06:03:06, made all three of calm-island’s snapshots unavailable,
logged once each, with the backend’s words: “bb-box could not list Proxmox
LXC on pve-1’s snapshots: Proxmox GET /cluster/resources failed (0):
cannot reach the Proxmox API: Connect Timeout Error”. A list every 10
minutes showed them still unavailable, none lost, for the hour. 47 s
after the API came back, the sweep had them ready again, the kept one
still kept. During the hour, bb box backend remove proxmox-lxc --yes was
refused: “Backend proxmox-lxc has 1 box, so it cannot be removed or
renamed. Turn it off and release them first.” and “Backend proxmox-lxc
holds 3 snapshots on pve-1, an unknown size, at no charge, so it cannot
be removed or renamed. Delete them first, or forget them with bb box
snapshot forget proxmox-lxc: bb-box then drops its records of them, and
they may still exist there and be billed.” (Those words now give the
backend’s error alone, without “bb-box could not list …”, which
repeated what unavailable says.)
G3. The lead added a keep date to one snapshot’s label with the same
script (keep-label). bb-box was disabled, snapshots.db deleted, and
bb-box enabled again. Within 20 s the sweep had found all three snapshots
again, by “someone bb-box has no record of”, named by their times, the
labelled one “kept until 2026-11-30”, and deleted none. The failed record
of the earlier restart, which held nothing on Proxmox, was not found again,
as there was nothing to find.
After the reviews
Two independent reviews read 1a and 1b: Opus said merge after fixes, and Astra merge after named fixes. Every finding is fixed in this round, with tests, except where the table says it is deferred.
| Finding | From | Fix | Tests |
|---|---|---|---|
| A request ID was global: another box’s or kind’s reuse answered with the first operation, a delete reusing a take’s answered “already”, the same request on two boxes at once failed with SQLite’s UNIQUE error and marked the store failing, and another thread could read the answer | Opus 1a-1, Astra 5 | The ID is the caller’s own (stored with the caller), bound to its box, kind and arguments by a digest; a mismatch is refused in plain words; the check runs again inside the writing transaction; a refusal no longer touches the store’s failure state | snapshots.test.ts: “a request ID is bound to its caller, box, kind and arguments”, “the same request on two boxes at once” |
| A repeated delete after the first finished said not found; the test expected that | Opus 1a-2, Astra 5 | The request is looked up before the snapshot is found, and answers with its operation, also after a restart | “a repeated request is the same take, and the same delete, also after the delete finished and after a restart” |
A lock held by a running task (a nightly backup) was answered with pct unlock |
Opus 1a-3 | The guest’s running tasks are read first: with one, “try again when it ends”, and no unlock; with tasks unreadable, the unlock only “if no task is running” | proxmox-lxc-snapshots.test.ts: “a lock a running task holds gets no unlock advice” |
| A release during a listing could give its snapshots a ghost “found” record and a false lost | Opus 1a-4 | Each removal at a release is a settled operation, which the reconcile’s check sees | “a release during a sweep’s listing leaves no ghost record and no false lost” |
| Thin metadata was skipped silently with no pool or several | Opus 1a-5 | The storage’s thin pool is found from its settings; when it cannot be read, the plan says why, and the doctor warns with the token’s missing rights | “thin metadata is read from the storage’s own thin pool; when it cannot be, the plan and the doctor say so” |
ready did not need the task to succeed, in a take and in recovery |
Opus 1a-6, Astra 3 | Both are needed; a failed task with a whole-looking snapshot is incomplete, with its words |
“a take is ready only when its task succeeded”, and its Proxmox twin |
| Recovery took an unreadable task, or a missing task with an absent snapshot, as an ending, released the pin, and said the take never began; task-list errors read as an empty list; the gap between the request and its task being recorded was not covered | Opus 1a-7, Astra 3 | Each of these stays unknown and pinned, with why; a submitting step is recorded before the request, and a task not yet recorded is found by guest, type and time, skipping tasks other operations hold; a part-made snapshot whose guest’s tasks cannot be read is in progress |
“after a restart, a take whose task Proxmox no longer has”, “a task bb-box cannot read”, “the gap between asking the provider and recording its task” |
A forgotten box’s snapshots said “its backend is not in bb-box’s settings”; box_status counted only what the caller sees |
Opus 1a-8 | “its box was forgotten while bb-box had no working backend for it”; every snapshot counts against the 5, and only the visible are named | “box_status counts every snapshot against the limit” |
| The box sweep destroyed a gone box’s guest with found or kept snapshots at its second sweep | Astra 1 | The snapshots’ sweep runs first; the box sweep keeps a guest holding a found, kept or pinned snapshot, and every guest when the ledger is unreadable | “the sweeps together: a gone box’s guest holding a found snapshot is kept by the box sweep”, “with snapshots.db unreadable, the box sweep keeps every leftover guest” |
An existing snapshots.db that could not be opened silently became an empty ledger in bb-box’s database |
Astra 2 | It fails closed; the fallback is only for a handle that attaches nothing and no file; its records move into snapshots.db once it attaches |
“an existing snapshots.db that cannot be opened fails closed”, “records kept in bb-box’s database while its handle refused to attach move into snapshots.db” |
| A delete chose the snapshot by name alone | Astra 4 | The label must name this install, the box and the snapshot, or nothing is deleted | “a delete checks the snapshot’s own label before it deletes” |
mayAct let an agent restore or fork a kept snapshot on its own box, and see its picture |
Astra 6 | What an agent may see is decided first, then a kept snapshot is the person’s for everything but seeing it | snapshot-rules.test.ts: “a kept snapshot is the person’s first” |
| An operation’s receipt was pruned 30 days after it ended, even when its snapshot went today | Astra 7 | Kept 30 days from the removal of its last snapshot | “an operation’s receipt lasts 30 days from the removal of its last snapshot” |
| The agent’s sixth take is refused, not evicting as section 8 says | Opus note, Astra note | Recorded above as deferred to unit 5 | limitVictim alone |
Live again, after the reviews
A fresh npx bb-app set up as before, with bb-box from this branch after
the fixes, and the lead’s two Proxmox backends. All through bb box, on
Linux LXC boxes of a thread. The node’s name is written pve-1.
| Check | Result |
|---|---|
| G9: the same request sent twice at once, on one box | one snapshot: “Took snapshot “r1” … in 0.6 s”, and “… was asked for with that request already. It is ready.” |
| G9: the same request on two boxes at once | one took; the other: “Request ID “live-both” was used already, for another take or delete. Make a new one for this.” (reused_request); the doctor stayed ok |
| G9: the request reused for a take with another name, and for a delete | both refused with those words |
| G9: a delete, the same delete again, and again after a restart | deleted once; each repeat answered with the first delete, and the take’s request after the restart with the take. Those answers named the operation; they now say “Snapshot |
snapshots.db replaced, while bb-box was off, by a file that is not a database |
fails closed: the doctor “[fail] Snapshots’ records: … its existing snapshots.db cannot be opened: file is not a database.”; a take refused, “So bb-box takes no snapshot until it can.”; no records listed and nothing written over the file. With the real file back, every record was there. The doctor’s words then said bb-box’s handle refused to attach the file, which is not what happened; they now name the file alone |
| G1 again: the lead’s script killed the take’s worker | incomplete with the same words, still so after a sweep; its delete and a new take refused with pct unlock 3240; after the lead’s unlock, the delete finished |
A backup (vzdump) of the box running, holding its lock |
take: “bb-box cannot take a snapshot of box green-cedar now: Proxmox is running a backup on guest 3240, which holds its lock (backup). Try again when it ends.”; delete: the same reason, and the snapshot stayed ready; no unlock advice |
| The box sweep and a gone box’s guest holding a found snapshot | the box’s thread archived and its record removed while BB was off, so its guest was left over with a snapshot the reconcile found again; three sweeps in a row each “Guests kept for the snapshots they hold: 1”, logged “Kept guest 3242 of box …: it holds a snapshot bb-box keeps: found and kept 7 days.”; once that snapshot was deleted, the next sweep removed the guest |
G3 again: snapshots.db deleted while bb-box was off |
within 21 s all three found by “someone bb-box has no record of”, the labelled one “kept until 2026-11-30”, the gone box’s “found on pve-1, not in bb-box’s records”; none deleted |
| A take held after its task began, then bb-box restarted with the Proxmox API cut | through the restart the take stayed “being taken” and pinned, its operation unsettled, the other three unavailable; once the API was back, one sweep made all four ready |
At the end bb-box listed no box and no snapshot, and its sweep no leftover guest; the lead deleted the stopped backup’s partial file.
Known limit: thin pool metadata
The token cannot read a thin pool’s metadata use: GET /nodes/<node>/disks/lvmthin needs Sys.Audit on /, a read of the whole
cluster, which host/setup-token.sh does not give and the owner will not
grant. So on LVM-thin bb-box does not keep 070’s rule that a take is
refused past 80% of thin metadata. The take goes on, and the doctor warns:
“bb-box cannot read local-lvm’s thin metadata (…), so a take checks the
pool’s data use but not that its metadata is under 80% full.”, fix
“Reading a thin pool’s metadata takes Sys.Audit on /, a read of the whole
cluster, which host/setup-token.sh does not give. Watch the pool’s metadata
on the host, or give that right.”
The storage’s own status, which the token can read, gives the thin pool’s data: used, total and available. That covers the data half of the room rule whole: the refusal past 85%, or past 85% with what the take adds and what unsettled operations reserved, and unit 6’s room sweep past 92%. It does not cover metadata, and data use does not bound it. Metadata is a volume of its own, sized when the pool was made, and it grows with the number of thin volumes and snapshots and with the blocks each box writes after its snapshots, while a take adds almost no data at the time. A pool can fill its metadata with its data well under 85%, and that stops every guest on the pool, as full data would.
At 1a’s scale it is enough: a box holds at most 5 snapshots, and bb-box
takes them only when asked. It is not enough on its own once agents take
snapshots (unit 5) and boxes keep many for weeks (unit 8). Until the right
is given, the pool’s metadata should be watched on the host (lvs gives
it as Meta%), as the doctor’s warning says.
Follow-ups for unit 8
The lead noted both for unit 8, which makes kept snapshots outlive their box:
- A kept snapshot at release. In unit 1a a kept snapshot can come only from a label with a keep date, found by the reconcile. When its box is released, its record is marked gone, but Proxmox deletes the snapshot with the guest, so the next listings mark it lost. Unit 8’s held copies, made before the guest goes, close this.
- A leftover guest of an ended thread. The box sweep now keeps such a guest while it holds a found, kept or pinned snapshot. Unit 8 decides what then becomes of it: a held copy of a kept snapshot, then the guest released.
- A forgotten box’s found snapshots. In 1a only a forgotten box’s guest outlives its box, and its snapshots, once found again, are deleted after 7 days, though the person who forgot the box was told nothing was deleted. Opus asks for the owner’s look at this before unit 8.
Unit 1b: Proxmox QEMU take and delete
Built on 30 October 2026, on unit 1a.
What changed
- Proxmox QEMU offers snapshots of its Linux boxes,
linuxandlinux-desktop: the disk, or the disk and memory, taken while the VM runs. Windows (unit 3) and Android device boxes (unit 13) offer none yet. - It shares unit 1a’s Proxmox code. The plan reads every disk’s storage, the boot disk, other disks, the EFI disk and the TPM state, and refuses a take when any of them takes no snapshots. It gives a memory snapshot’s time as about a second for each GiB of the VM’s memory, plus a second.
- Where the memory goes. The plan finds the storage Proxmox saves a
memory snapshot’s state on, from the VM’s config as Proxmox does: its
vmstatestorage, or else among its disks’ storages, a shared one first and a file-based one before others. The state volume is twice the VM’s memory and 500 MiB, which the take checks and reserves on that storage. Where the choice depends on an order bb-box cannot know, or a storage’s settings cannot be read, a memory take is refused with why; a disk take is not affected.--memorysendsvmstate; the snapshot holds the disk and memory when Proxmox lists it with its state. A part-made take or a locked VM namesqm unlock <vmid>. - A stopped Proxmox QEMU box is suspended to disk, so its take is refused: “bb-box cannot take a snapshot of box fond-fjord now: a stopped Proxmox QEMU box is kept suspended to disk, so start it first.”
- The guide’s Snapshots page names Proxmox QEMU’s Linux boxes.
After the reviews
| Finding | From | Fix | Tests |
|---|---|---|---|
| Only the boot disk’s storage was checked; an EFI or TPM disk on storage with no snapshots was admitted and failed at Proxmox | Opus 1b-1 | Every disk’s storage is checked | proxmox-qemu-snapshots.test.ts: “the plan checks every disk’s storage” |
A memory take checked the boot disk’s storage and reserved the RAM’s size, while Proxmox may use vmstatestorage and allocates twice the RAM and 500 MiB |
Opus 1b-1, Astra 1b-1 | Planned on the target Proxmox would choose, at Proxmox’s size, or refused with why | “a memory snapshot is planned on the storage Proxmox saves it to”, and in snapshots.test.ts “a memory take checks and reserves the storage Proxmox saves the memory on” |
| Reservations were keyed by backend ID, so two backends on one pool did not share them | Astra 1b-2 | Keyed by the physical storage: the node and storage name, or the name alone when shared | “reservations are by physical storage”; the LXC and QEMU plans give proxmox:pve:local-lvm for one pool |
| The stopped-VM refusal named the VMID, not the box | Opus 1b-2 | Names the box, and says a stopped Proxmox QEMU box is kept suspended to disk | “a stopped VM’s take is refused in words about the box” |
Tests
On the same Linux box, over unit 1a: npx tsc passes; the node suite ran
2,996 tests, 2,708 passed, 0 failed, 288 skipped; vitest ran
380, all passed.
test/proxmox-qemu-snapshots.test.ts, 7: the offer, Linux only; the plan’s
storage and memory time, and its refusal of a Windows box; a take with and
without memory, listed and deleted; a killed take incomplete and a locked
VM naming qm unlock; every disk’s storage; the memory target and its
size, shared first, and refused when ambiguous or unreadable; the stopped
VM’s words and the pool’s storage key.
On a separate BB
The same BB as unit 1a, bb-box installed again from this branch, the lead’s
Proxmox QEMU backend with a Linux template, all through bb box, on a
Linux VM of 2 cores and 4 GiB:
| Check | Result |
|---|---|
bb box kinds |
“Snapshots: on Proxmox LXC on pve-1, its disk, while it runs; on Proxmox QEMU on pve-1, its disk, or its disk and memory, while it runs.” |
| Take, disk only | ready in 0.7 s as bb-box timed it, 1.1 s for the command |
Take with --memory |
ready in 1.7 s, 2.1 s for the command; “it holds its disk and memory” |
List, and bb box status |
each with what it holds; “Snapshots: 2 of 5: …” |
| The VM after the memory snapshot | answered at once, on the same boot (uptime -s unchanged) |
| Delete both | 0.8 s for the command; “it freed what it no longer shared with its box” |
| bb-box restarted after a memory take’s task began | ready at the next start, “disk and memory” |
snapshots.db deleted while bb-box was off |
all three found again on enable, two “disk and memory”, one “disk”; none deleted |
| A take of the stopped VM | refused: “Guest 3250 is suspended to disk. Start the box first.” (now in words about the box, above) |
A record for a snapshot the VM does not have, written into snapshots.db beside the real ones |
missing at the first listing after it, 07:49:09, still ready; lost at 08:01:18, at the first listing 10 minutes after, logged as snapshot-lost with the words: “not on Proxmox” (snnotonproxmox): pve-1 no longer has it.; the real three untouched |
After the reviews, on the second separate BB, a Linux VM of 2 cores and 4
GiB: a take with --memory was ready in 1.8 s, 2.2 s for the command, “it
holds its disk and memory”; a disk take in 0.7 s; both listed with what they hold, “2 of 5 for
this box”, and deleted.
Unit 2: the identity reset
Built on 30 October 2026, on main 827d3315, in four commits: the reset,
a reset never costing a box, the answers to the reviews, and the answers to
the re-reviews. This section describes the code as it stands after the
fourth; After the reviews and After the
re-reviews say what each changed.
What changed
-
The guest program,
bb-box-identity(setup/proxmox-lxc/template/guest/bb-box-identity.sh), version 2.customize-base.shinstalls it at/usr/local/sbin, so every Linux image bb-box builds has it: the Proxmox LXC templates (Linux, desktops, Wayland), Proxmox QEMU’s Linux and Android images, Tart’s Linux images, boat.dev’s template, and the Docker images that Docker, Hetzner, Namespace and microsandbox boxes run. It turns the reset on only when the build setsBOX_IDENTITY_RESET=on, which only the checked builds do (Rollout, step 2). Its commands:boot,settle,wait,write,enable,disable,unarm,statusandversion. -
The marker,
/etc/bb-box/identity: a first linebb-box identity 1, then the box’s ID, the source, and either onemacline for each of the backend’s interfaces or onevalueline. A second file,/var/lib/bb-box/identity-armed, is written after the marker and says a marker was written here.finalize.shremoves both, and the list of resets, so no template, refresh or bake carries them. -
The boot unit, which
bb-box-identity enablewrites where systemd runs:bb-box-identity.service, with no default dependencies, afterlocal-fs.target, beforenetwork-pre.targetand the services it gates. A drop-in makes each of them require it and start after it:systemd-networkd,NetworkManager,ssh,ssh.socket,tailscaled,user@.service(the box user’s manager, which runs BB’s daemon),docker,docker.socket,containerd,bb-box-docker-containers, and the desktop’s display, session, VNC, Selkies and device firewall. So if the check fails, none of them start: it fails closed. It refuses a manual stop, which would stop them too. A second unit,bb-box-identity-settle.service, runssettleafter it, andsshandtailscaledrequire it too; a drop-in for the box user’sbb-host-daemon-*units, BB’s daemon, makes each runbb-box-identity wait %nbefore it starts. When the boot could not tell, these wait whilesettlekeeps looking (below); otherwise they go on at once. -
Docker’s image has no systemd.
bb-box-startrunsbb-box-identity boot --value "$BBBOX_IDENTITY"before it starts anything; if that fails, it exits, starts nothing and never writes/run/bb-box/ready. -
What the marker records, by backend and kind (
identitySourceinsrc/backends/types.ts):Backend Source What it records Proxmox LXC, linuxeththe MAC addresses of the interfaces named ethN, asnetNmakes themProxmox QEMU, linuxdevicethe MAC addresses of the interfaces with a device under /sys/class/net/<name>/deviceDocker, linuxvalueBBBOX_IDENTITY, which bb-box sets on each guest when it makes it: the box’s IDThe other Linux kinds, Tart, microsandbox, Hetzner, Namespace, boat.dev none until checked microsandbox, Hetzner and Namespace boxes get BBBOX_IDENTITY, unusedmacOS, Windows, iOS, the SSH host pool none bb-box writes markers only on the backends in
general.identityResetBackends, empty by default. -
The comparison at boot. No marker and no armed record: kept, nothing to compare. A marker that is unreadable on a box that had one: reset. If any of the marker’s MAC addresses is on any Ethernet card, whatever its name: kept, and the box’s current cards of the backend’s are added to the marker, so a card added, booted, then the first removed changes nothing. Once the cards are known (udev settled, or no udev, as in a container), a new one of the backend’s with none of the marker’s: a copy, reset. Otherwise it waits, in 150 loops of 0.2 s, then lets the boot go on removing nothing (
boot=unknown): the network, Docker, the desktop and logins start, while SSH, Tailscale and BB’s daemon wait forsettle, which looks again every 2 s and decides as above once the cards are there.waitlets BB’s daemon start once the boot is decided, unless its machine is named in a reset. A value is compared as it is. -
The reset, in order, each step’s failure failing the boot: first
/var/lib/bb-box/identity-reset-pending, written and synced, with the time, why and the host IDs it will remove, so a reset cut short is resumed at the next boot with them; the Tailscale state; in each folder under the box user’s and root’s~/.bb-machines, and those set aside, every file at its top (auth.json,config.json,host-id, locks, ports) but the app’s digest, and itslogs, then BB’s daemon’s user units; the access file~/.config/bb-box/env; new SSH host keys (ed25519 and ECDSA when none are left); a new machine ID; then journald restarts so the boot’s logs go under it. BB’s installed app and the threads’ folders stay. It writes a marker for the copy’s own interfaces, with no box, so the next boot does not reset again and a copy of the copy does, or unarms the box when it has no card of the backend’s to write; adds the pending line to/var/lib/bb-box/identity-resets; and only then removes the pending record. -
bb-box writes the marker when a box is first ready, on a backend in
general.identityResetBackends, after a project image’s script and before the box is ready, and records that it did on the box (identityMarkedAt); only such boxes are ever checked for a reset, through the box’s command transport, assudo bb-box-identity write --box <id> --source <source>. It takes about 60 to 80 ms, shown asidentityin the box’s timings. Forvalue, the guest writes the value its own boot saw, never one bb-box passes, so a marker always matches the next boot. An image without the program, or a write the guest refuses, is logged (“Wrote no identity marker in box amber-fox, so a copy of it would not reset, and its snapshots cannot be forked: its image has no identity reset.”) and the box is ready as before.src/core/identity.ts,src/core/boxes.ts. -
forkSafeat each take. Before a take is recorded, bb-box runsbb-box-identity statusin the box.forkSafeis its version when this boot’s check ran, the box is armed and the marker matches what the box has now; otherwise null, as for a stopped box or one that does not answer within 10 s. It is on the record from the start, so a restart keeps it.bb box snapshot listsays of a Linux snapshot without it “not fork-safe: amber-fox had no working identity reset when it was taken”.src/core/snapshots.ts. -
Docs: the guide’s Snapshots page.
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, on this unit’s four commits
over main 827d3315: npx tsc passes and the reference is current; the
node suite ran 3,047 tests, 2,759 passed, 0 failed, 288 skipped (the live
and conformance ones); vitest ran 384, all passed. The node suite includes
the docs, site and reference checks. A device-server test that times a
new version within 2 s failed once under the full suite’s load (2,187 ms)
in an earlier run, and passed 3 of 3 alone; this unit does not touch it.
test/identity-reset.test.ts, 12, which run the guest program on a fake root: a box made before the change, with the program and no marker, keeps everything at three boots; on Proxmox LXC the marker holdseth0only, anddocker0, abr-bridge, avethandtailscale0change nothing over three boots; new MAC addresses reset every item of 070’s table once, keep the threads’ files and BB’s app, and the next boot keeps; on a VM only cards with a device count, and a bridge carrying the card’s address does not; a card that appears 0.6 s late is waited for, and one that never appears resets after the wait; a missing marker on an armed box, a wrong header, a bad MAC address and an unknown source each reset; a last line with no newline is read; a Docker box’s value survives a new MAC address, and another value or none resets;writerefuses what would not match at the next boot; a reset that cannot finish fails the boot;forkSafe’s rule; and the unit’s order and requirements incustomize-base.sh, the clean-up infinalize.sh, and the check’s place in Docker’s start.test/snapshots.test.ts, 3 more, throughBoxesand the fake backend, which runs the real program on a fake root: the marker written before the box is ready, with its backend’s source, and a take’sforkSafeof 1; not fork-safe, with the words, for an image without the program, a backend with no source, a marker that no longer matches, and a boot whose check never ran; a Docker box’s marker holding the value its start saw, and a box whose start saw none left unarmed.test/microsandbox.test.tsandtest/plugin.test.ts: the variable at create, and the list’s words.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and ports,
started with env -i, in a Proxmox LXC box; bb-box installed from this
branch with bb plugin install path:. The lead built two test templates
from this branch’s setup/, for it only: Proxmox LXC Linux 3243
(build.20261030-162241-8e67) and Proxmox QEMU Linux 3252
(build.20261030-161532-7c81), and gave it a backend for each, with the
production token and its own VMIDs and addresses. Its Docker backend ran on
the box itself, with images built from this branch by bb box setup docker (4 min). Boxes were “your machines” of that BB; the node’s name is
written pve-1.
| Check | Result |
|---|---|
| The marker at first ready | written on each: identity 78 ms (LXC), 58 ms (QEMU), 74 ms (Docker) in the timings; LXC source eth, one mac; QEMU source device, enp6s18’s; Docker source value, the box’s ID. The first boot said “kept: bb-box has written no identity marker here”, as it ran before bb-box wrote one |
The order, systemd-analyze critical-chain, LXC 3243 |
bb-box-identity.service @299ms +82ms, then network-pre.target @382ms, then systemd-networkd; ssh after basic.target @471ms; user@1000 at 544 ms; systemctl show gives each of systemd-networkd, ssh, user@1000 and tailscaled both Requires= and After= it |
| The order, QEMU 3252 | bb-box-identity.service @640ms +28ms, then network-pre.target @841ms, then systemd-networkd +73ms; the journal: the check, then networkd, ssh and user@1000 |
| The order, Docker’s image | no systemd: the box’s log shows the check as its start’s first line at each start |
| A take | forkSafe: 1 on QEMU and LXC; the list adds no words |
| A plain Proxmox clone, LXC | the lead ran pct clone 3240 3290 --snapname <bb-box's snapshot> --full 1, then onboot 0, no tags, link_down=1, outside the pool. The clone had a new MAC address (bc:24:11:f5:88:c0 for bc:24:11:fb:91:2c) and reset at its first boot, at 16:33:26.188, before networkd (.313), ssh (.390), user@1000 (.410) and tailscaled (.533). Host ID host_22r4p9imps gone, credentials, daemon unit and access file gone, new machine ID and SSH host key (9A4o3Ycd... for 2QIu6UxB...), Tailscale state emptied and made again by tailscaled, threads’ folders kept. After a reboot: boot=kept, one line in the resets |
| A plain Proxmox clone, QEMU | qm clone 3250 3291 --snapname <bb-box's snapshot> --full 1, the same settings: new MAC, source=device, the same results; host ID host_5tau2iwvbh removed. Its journalctl found no files: template 3252 predates the journald restart, which the LXC template had |
| Three restarts with Docker containers running and Tailscale’s interface | LXC and QEMU, each with a Docker network, two containers with --restart always and tailscaled running (no login, see below), rebooted from inside three times: boot=kept each time; host ID, credentials, machine ID, SSH host key, Tailscale state and access file unchanged; docker0, br-*, veth* and tailscale0 up each time |
| Three restarts of a Docker box | stopped and started through bb-box three times, with Docker in it running two containers: boot=kept, identity unchanged; its eth0 had a new MAC address each time (3e:22:aa:..., b2:f1:42:..., 9a:8b:18:...), which is why Docker boxes compare a value |
| A spare claimed and renamed, then restarted | spare fresh-acorn (bx6shhmycajhqm, host_br4uth6acm) claimed in 11 ms as renamed-spare: the same box ID, marker and host ID, its hostname and BB machine renamed; rebooted: boot=kept, host_br4uth6acm connected |
At the end the separate BB’s sweep found no leftover guest; the snapshots, boxes and Proxmox backends were removed and its box released; the lead destroyed the clones and keeps the test templates to delete.
On production’s templates, with the unit installed into a box by the
branch’s customize-base.sh lines, and the marker written by hand as
bb-box would: a Proxmox LXC desktop box (check @164ms +44ms, then
network-pre.target @209ms, then the display, ssh and user@1000) and a
Tart Linux VM on arm64 (its card named at 59.125 s, the check from 59.406 to
59.629, networkd at 60.197, ssh at 60.295), each rebooted with boot=kept.
The LXC GPU and Wayland desktops, Android and boat were not checked.
Tailscale, with a real node. In the checks above tailscaled ran without
a login, so tailscale0 and its state were there and its state was {}
(sha256 44136fa3...). Then, with the owner’s approval, the lead minted one
auth key (single use, ephemeral, preauthorized, tag:bb-box, an hour) into
a file in neat-fern, a production Proxmox LXC box with the unit installed
from this branch; it joined the tailnet with it once, and the file was
deleted. The lead cloned it with pct snapshot and pct clone, as above.
The clone reset at its first boot, before networkd, ssh, user@1000 and
tailscaled: its Tailscale state was the empty one and tailscaled said
NeedsLogin; host ID, credentials and access file gone; new machine ID and
SSH host key. The source kept its node, its state (333f403e...), its
host ID and machine ID, and stayed Running on the tailnet. It was then
logged out and released.
A reset of one of bb-box’s own boxes. On production, a thread’s Linux box with the unit installed was given a marker naming another MAC address and rebooted: it reset as a clone does, and within about 4 minutes bb-box listed it “not enrolled … until it is released”. On a fresh separate BB the same happened to one of its machines: the sweep found the machine disconnected and ran BB’s bootstrap again with its old key, which failed in a box with no credentials, and the sweep’s handling of that failure dropped the box’s enrolment; BB kept the old machine, disconnected. Had BB removed the machine, bb-box would have released the box, as it does for one of your machines whose machine is removed in BB. The lead’s answer: a reset must never cost a box. See After the lead’s answers.
Rollout
The reviews asked for a switch, so the rollout is staged, and each stage can be undone on its own.
- Merge. No box gets a marker:
general.identityResetBackendsis empty, so bb-box writes none, and an unarmed box never resets. bb-box checks for a reset only in a box it armed, so nothing else changes either. Docker boxes made from then on carryBBBOX_IDENTITY, unused until their backend is turned on. As for any change tosetup/proxmox-lxc/templateorsetup/docker, Hetzner’s host images, Namespace’s Linux images and boat.dev’s template are built again by bb-box at their next use (boat/templates.tsnow names the program among the files its build reads); none of them turns the unit on. - Rebuild the images. Every Linux image gets the program. Only the
builds that were checked turn the unit on (
BOX_IDENTITY_RESET=on): Linux on Proxmox LXC (build.sh linux), Proxmox QEMU (build-linux.sh linux), Docker (customize.sh linux), and the refresh of those (057). The others have the program with the unit off and refuse a marker: the LXC desktops (GPU, CPU, Wayland), QEMU’s desktop and Android, Docker’s desktop images, Tart’s Linux images and boat.dev’s template. Each is turned on in its build only once checked as G14 says. - Turn it on, one backend at a time, after one new box of it is
checked (
identityin its timings;sudo bb-box-identity statussaysenabled=yes,armed=yes, andboot=keptafter a reboot):bb box option set general.identityResetBackends=proxmox-lxc, then addingproxmox-qemu, then each Docker backend. microsandbox, Hetzner, Namespace and Tart offer no source yet, so naming them does nothing. Before Proxmox LXC or Proxmox QEMU goes in, boot its rebuilt Linux image unarmed, ideally before the first nightly rebuild after the merge, and run G14’s checks again on it live:systemd-analyzefor the order, now withudevadm settleon VMs and the requirements on Docker, containerd and the desktop; a plain clone that resets; three restarts with Docker containers and Tailscale; a spare claimed, renamed and restarted; a card added, booted, then the first removed; and a card that comes late. The G14 runs above were of version 1 of the program. Step 3 ran on 31 October: see “Rollout step 3” below; both templates need version 5. - A box made before keeps working as it does: it has no marker, so it never resets, and its snapshots say “not fork-safe”. bb-box arms a box only at its first ready, so turning a backend on arms no running box.
- Going back. Take the backend out of
identityResetBackends: new boxes get no marker. A box already armed keeps its check; unarm it through bb-box withbb box run <box> 'sudo bb-box-identity unarm'. Do not mask or disablebb-box-identity.serviceby hand: the network, SSH, Docker, the desktop and the box user’s manager require it. A box whose reset failed has no network or SSH; reach it withpct enter,qm terminalor the console, and either fix what failed and reboot, which resumes the reset, or runbb-box-identity disableand reboot. - Watch
/var/lib/bb-box/identity-resets, empty in every box that is not a copy, and the doctor’s Identity resets section, empty unless one of bb-box’s own boxes reset.
After the lead’s answers
A reset never costs a box. bb-box reads a box’s
/var/lib/bb-box/identity-resets, and when a line there names the box’s own
BB machine, it keeps the box and enrols it again as a new BB machine. It
does so wherever that machine’s loss would show (src/plugin/enrol.ts):
- a reconnect, after bb-box starts a box and at each sweep for a box whose machine BB shows disconnected, which would otherwise run BB’s bootstrap again with the old key;
- the sweep’s handling of a machine BB no longer has, which would otherwise forget the box’s enrolment;
- BB’s removal of the machine, which would otherwise release one of your machines; and when bb-box cannot read the box’s record then, as when it does not answer, it keeps the box too, says so in its log, and leaves removing it to the person;
- the enrolment of a box the sweep restored, whose machine BB still has.
It records the reset on the box, logs identity-reset in its box log,
removes the old machine from BB, so its credentials no longer work, enrols
the box as a new machine, counting the enrolment’s time from then, writes
its own marker in the box again, and logs enrolled-again. A failed
enrolment is tried again at each sweep. The doctor has a section, Identity
resets, with a check for each such box: a warning once it is enrolled
again, naming both machines, and a failure until then. A box whose record
names another machine, as one copied from another box would, or none is
left as it was; a clone made outside bb-box is not in its records, so
bb-box never enrols it. The box log gains the two kinds in a migration of
its own, which keeps every event.
Tests, in test/plugin.test.ts: a thread’s box whose own reset took its
machine, at a sweep: the old machine deleted, then a new one made and
bootstrapped, never a reconnect of the old one; the box log’s two events
and their words; the doctor’s warning; and no second action at the next
sweep. One of your machines whose machine BB removes after its reset: kept,
named as before, enrolled again, never released; and one that does not
answer: kept, not enrolled. A record naming another machine: the box’s own
machine reconnects as after any reboot, nothing is deleted, and one of your
machines whose machine BB removes is still released (036). An enrolment
again that fails: tried again at the next sweep, the doctor failing with
why until it works, then warning.
Live, on a fresh separate BB (npx bb-app 0.48.1, no account, bb-box
from this branch, its Docker backend with images built from it), one of its
machines, keep-two (host_3sa2732699): its marker’s value changed by hand
as root in the box, then the box restarted from outside bb-box, at
17:28:17. It reset at that start. The next sweep found it at 17:31:00 and
logged identity-reset (“Box keep-two reset its identity at 2026-10-30
17:28:18 UTC, as the value bb-box set on this guest is not the marker’s.
BB’s machine host_3sa2732699 went with it, so bb-box enrols the box again
as a new BB machine, and keeps it.”); at 17:31:02 enrolled-again (“Box
keep-two joined BB again as machine host_xz43czk3qi, in place of
host_3sa2732699, 2 s after bb-box found its identity reset.”). BB listed
only host_xz43czk3qi, connected; bb box list said “enrolled as a BB
machine in 1.9 s”; the box kept its name, record and owner, answered
commands with its access file, had the new host ID, and its marker named
the box again (status: boot=reset marker=matches armed=yes box=bx7xnqt7yhhemj). The doctor:
Identity resets: Boxes of bb-box's whose identity reset at a boot, which bb-box keeps and enrols again as new BB machines. [warn] Identity reset of keep-two: Box keep-two reset its identity at 2026-10-30 17:28:18 UTC, as the value bb-box set on this guest is not the marker's, and BB's machine host_3sa2732699 went with it. bb-box kept the box and enrolled it again, at 2026-10-30 17:31:02 UTC, as BB machine host_xz43czk3qi. Fix: A box that is not a copy of another should never reset: see /var/lib/bb-box/identity-resets and journalctl -u bb-box-identity in the box.An earlier run of the same check, before bb-box wrote the marker again and
timed the enrolment from the reset, behaved the same otherwise (keep-me,
host_t3v95jev38 for host_4g2ax4tru9, 2 s).
The case that released a box before: another of its machines, keep-three
(host_uwahzewcjm), reset the same way at 21:57:34, and 4 s later its
machine was removed in BB (bb host remove host_uwahzewcjm --yes), before
any sweep. BB called bb-box’s provider, which read the box’s record: at
21:57:39 identity-reset, at 21:57:41 enrolled-again as
host_j2s53vjk7e, connected. The box was kept, with its name and owner,
and not released.
After the reviews
Two reviews read units 2’s first two commits: Astra (merge after fixes 1 to 7; do not roll out as it stands) and Opus (merge after 1 to 3, with a switch before any image is rebuilt). Where they overlapped, the stricter fix was made. A third commit answers them.
| Finding | From | Fix | Tests |
|---|---|---|---|
| A card added, removed, renamed or late reset a box that was not a copy, and a renamed or late card reset it at every boot; the wait followed the wall clock | Astra 1, Opus 1 | Kept when any of the marker’s MAC addresses is on any card, whatever its name. Reset only once the cards are known (udev settled, or no udev, as in a container) and a new one of the backend’s is there with none of the marker’s. Otherwise it waits, counted in loops of 0.2 s, then boots as it is, resetting nothing, and logs that it could not tell (boot=unknown, not fork-safe). A reset with no card of the backend’s to write a marker for unarms the box |
identity-reset.test.ts: added, one of two removed, renamed (three boots), a VM’s second card; a late card; no card: unknown, nothing erased; a copy whose card has no eth name unarms |
A reset deleted host IDs before any record of them; a later failure, retried, recorded none; a receipt that could not be written still counted as done |
Astra 2 | identity-reset-pending, with the original host IDs, written and synced before anything is removed; resumed at the next boot with those IDs; done only once the marker, the receipt and the pending file’s removal succeed; a failure fails the boot |
a failure after BB’s credentials went, then the next boot: the original host ID in the receipt; a receipt that cannot be written fails the boot, and the next finishes it |
cat ... ; exit 0 turned an unreadable record into no reset, which released one of your machines |
Astra 3 | The record is absent, read or unreadable; only a read record without the machine, or one absent, is no reset; unreadable is a check kept on the box | plugin.test.ts: a record that is not a readable file keeps the box |
A failed delete of the old machine left the box enrolling with it; BB gave the old machine back for the box’s key |
Astra 4, Opus 3 | Recovery is resumable steps on the box’s record: the reset found, the old machine removed and BB’s removal confirmed (asked again at each sweep), then the enrolment, then done. A re-enrolment that BB answers with the old machine fails, and the next sweep removes it again. The fake BB now gives a new machine a new ID | a delete refused twice: off, never enrolling, the doctor failing, then a new machine, host-<box>-2 |
| A box that did not answer when BB removed its machine was kept, and never looked at again; the host-deleted event forgot without checking | Astra 5, Opus 4 | The removed machine is kept on the box as a check, which the doctor warns of; at each sweep, once the box answers, bb-box reads its record: a reset of that machine enrols it again, none releases one of your machines (036). Enrolment is cleared only if the box still names that machine, compared in the same write. BB’s host-deleted event takes the same path | one of your machines that does not answer: kept, warned of, then released once it answers with no reset |
| Docker’s containers and the desktop started after a failed reset | Astra 6 | Docker, containerd, docker.socket, bb-box-docker-containers, and the desktop’s display, session, VNC, Selkies and device firewall also require the unit |
enable writes each requirement; live below |
| Root’s set-aside BB folders and daemon units were left | Astra 7 | Cleared as the box user’s are | a reset with root’s set-aside auth.json and unit |
| A reset found on a thread’s own machine (New box, 033) deleted it, which releases the box and its worktree | Opus 2 | Only machines bb-box’s own enrolment made are removed and enrolled again. For a thread’s machine bb-box records the reset and the doctor fails it, naming the thread and how to recover its work | a New box machine: no delete, no create, no bootstrap, the doctor failing with the thread |
| The rollout could not be staged | Opus 5 | general.identityResetBackends, empty by default: bb-box writes markers only on the backends named, and checks for a reset only in boxes it armed (identityMarkedAt on the box). Images turn the unit on only where checked. bb-box-identity unarm |
engine tests: markers off for a backend, and an image with the reset off; a box never armed is released as before |
| Copies that keep their MAC addresses | Opus 6 | Noted for units 8 and 9 below | |
| What a reset leaves | Opus 7 | Listed below | |
| The snapshot’s words claimed a reason | Opus 8 | “not fork-safe: amber-fox had no working identity reset when it was taken” | engine and plugin tests |
| G14’s unchecked templates | Astra, lead | Program installed, unit off, and no source in bb-box, until each is checked (Rollout, step 2) | build scripts’ settings tested |
Live, the gates. systemd booting in a Docker container on a test box,
with the branch’s program turned on and stand-in units named docker,
containerd and bb-box-display beside the real ssh and
systemd-networkd. Three restarts: boot=kept, every service up, the host
ID kept. A copy (the marker given another MAC address) with ssh-keygen
made to fail: boot=failed, the unit failed, and the journal said
“Dependency failed for” docker.service, containerd.service,
bb-box-display.service, ssh.service, ssh.socket and
systemd-networkd.service, all inactive; the pending record held
host_systemd_source. With ssh-keygen back, the next boot resumed it:
boot=reset, every service up, new host keys, the receipt naming
host_systemd_source, no pending record; the boot after: kept. The first
run of this check found that a resumed reset made no host keys when the
failed attempt had removed them; it now makes ed25519 and ECDSA keys then.
Live, the recovery, on a fresh separate BB (npx bb-app 0.48.1, no
account, bb-box from this commit, its Docker backend with images built from
it):
- With
general.identityResetBackendsempty, a new machine, before-switch, got no marker (noidentityin its timings;status:armed=no). Set to its Docker backend, the next, keep-four, got one in 70 ms. - keep-four (
host_429bg4tra4): its marker’s value changed by hand as root, then the box restarted from outside bb-box at 22:40:00, when it reset. At 22:42:21 the sweep loggedidentity-reset, removed the old machine and waited for BB to confirm it, and at 22:42:23enrolled-again: “joined BB again as machine host_r2jb8bi8ra, in place of host_429bg4tra4, 2 s after bb-box found its identity reset”. BB listed onlyhost_r2jb8bi8ra, connected;bb box listsaid “enrolled as a BB machine in 1.7 s”; its marker named the box again (boot=reset marker=matches armed=yes box=bxvuyjp5nw2x6r); the doctor warned, naming both machines. - quiet-one, armed, stopped, then its machine
host_36s3i2wtyeremoved in BB: kept, stopped, and the doctor warned “BB no longer has machine host_36s3i2wtye of box quiet-one … and bb-box could not read whether the box’s identity reset (Box quiet-one is stopped. Start it first.), so it keeps the box.” Started, the next sweep read no reset in it and released it, as 036 does, at 22:45:31.
For units 8 and 9. These copies keep their MAC addresses, so they would
not reset: pct restore without --unique, a disk copied into a new VM, a
docker commit with its environment, and a tart clone that keeps the
source’s MAC address. Every copy bb-box makes must have new MAC addresses
(--unique, tart set --random-mac), and G14’s unit 7 and unit 9 checks
should test that.
What a reset leaves, as section 9 accepts: every folder under
~/.bb-machines/<server>/ but logs (plugins/*/host-data, providers’
bridge-data, runtime, thread-storage), /var/lib/bb-box/vnc-password,
the hostname, bb-box’s authorized key, and the box user’s own SSH keys and
cloud credentials. No plugin’s host data was checked for a credential of
the machine itself. The unit does not require ifupdown’s
networking.service: bb-box’s images use systemd-networkd. The journald
restart was checked on Proxmox LXC and on a production QEMU box, not on
Tart.
After the re-reviews
Opus said merge, with one low finding and a condition before step 3 of the rollout. Astra said not yet, with three P1s it reproduced. The lead asked for all of them, each with a regression test.
| Finding | From | Fix | Tests |
|---|---|---|---|
| A stale “absent” read of a box’s record could release it after another check had recorded its reset meanwhile: the conditional update did nothing, but the release went on | Astra 1 | A box’s identity decisions run one at a time in the enrolment. Before a read bb-box notes the box’s reset and enrolment state; the release happens only when one update finds that state unchanged, no recovery pending and the machine still named, and only then | plugin.test.ts: a read held while another path records the reset, then answering “absent”: not released, then enrolled again |
| A card added and booted never became an anchor, so removing the first card later reset a working box | Astra 2 | After a boot that matched, the box’s current cards of the backend’s are added to the marker | identity-reset.test.ts: LXC and a VM, a card added, booted, the first removed: kept; a copy of such a box still resets |
| An unknown boot let a copy start BB’s daemon, Tailscale and SSH with its source’s identity, and nothing looked again | Astra 3 | Unknown removes nothing and lets the boot go on, but SSH and Tailscale wait for the settle unit, and BB’s daemon for wait; settle looks every 2 s and resets or keeps once the cards are there; wait keeps a removed machine’s daemon from ever starting |
a copy whose card comes after the wait: held, then reset, its daemon refused; the box’s own card late: kept, its daemon started; no card: nothing removed, SSH and the daemon still waiting; wait’s rules |
| BB’s removal of a machine after its box’s reset waited up to 2 minutes for the recovery it started | Opus a | That path records the reset and starts the recovery without waiting | plugin.test.ts: BB holds the machine removing until remove returns; it returns at once, and the box is enrolled again once BB is done |
| A real copy can boot as its source when udev never settles | Opus b | Now held by settle rather than let through; for unit 7, boot=unknown is not a reset |
|
| G14 ran on version 1 | Opus c | The rollout’s step 3 now asks for G14 again on rebuilt LXC and QEMU images before either backend goes into identityResetBackends |
Two faults found while testing are fixed: wait read its records through a
pipe that pipefail failed when the pending record was absent, and took a
host ID only up to its first underscore; and two other pipes that could
fail the same way are now plain loops.
Live, in a systemd container on a test box, with the branch’s program
turned on, stand-ins named docker and bb-box-display, and a lingering
box user with a stand-in BB daemon unit (bb-host-daemon-...-host_systemd_source):
a normal boot and a restart with the marker written: kept, every service
and the daemon up. Then the box was stopped, disconnected from its network
and started with no card: after the 30 s wait, boot=unknown, the host ID
and credentials still there, systemd-networkd, the Docker and desktop
stand-ins, systemd-user-sessions and user@1000 active, a shell working,
ssh inactive with its start job waiting, the settle unit running, and the
daemon waiting in its start step. Then a network was connected, giving the
box a new card as a copy gets: settle reset it (“none of the marker’s MAC
addresses (02:42:ac:11:00:aa) is on this box’s interfaces
(c2:3d:fa:9e:3b:28)”), SSH started, and the daemon failed its start step:
“BB machine host_systemd_source went with this box’s identity reset, so its
daemon does not start”. multi-user.target waits while SSH does, as it
wants SSH; logins do not.
Rollout step 3, on the rebuilt templates
On 31 October 2026 the lead rebuilt production’s templates from main with
version 2 of the program: Proxmox LXC 9800 (build.20261031-015802-ee19)
and Proxmox QEMU 9806 (build.20261031-015944-13b3). A separate BB (a
fresh npx bb-app 0.48.1 with no account, bb-box from main b8088de6) had
a backend on each, with their own VMIDs and addresses. Each template’s
program was main’s file, byte for byte, with both units enabled and 16
drop-ins.
| Check | Proxmox LXC | Proxmox QEMU |
|---|---|---|
| Unarmed: boot and order | pass: boot=unarmed; the check @159ms +17ms, then network-pre.target @177ms, networkd, ssh and user@1000 |
pass: the check after udev’s coldplug, @505ms +25ms, then network-pre.target @627ms, networkd, ssh and user@1000 |
| Unarmed: requirements | pass: networkd, ssh, ssh.socket, user@1000, Docker, docker.socket, containerd and bb-box-docker-containers each require the check and start after it; ssh the settle unit too |
pass, the same |
| Unarmed: three restarts with Docker and tailscaled (no login) | pass: unarmed, nothing changed |
pass |
| Unarmed: a take | pass: “not fork-safe: lxc-u had no working identity reset when it was taken” | |
| Naming both backends arms no running box | pass: the unarmed box restarted unarmed |
|
| Armed: the marker at first ready | pass: identity 41 ms, source eth |
pass: identity 44 ms, source device |
| Armed: the order | pass: kept in 36 ms, before network-pre, networkd, ssh, user@1000, containerd, tailscaled and Docker; settle before ssh and tailscaled |
pass: after udev’s coldplug, 84 ms with udevadm settle, then the same order |
| Armed: three restarts with Docker and a tailnet node logged in with a single-use ephemeral key | pass: kept each time; host ID, credentials, machine ID, SSH key, Tailscale state and node key, access file unchanged; Tailscale Running |
pass |
| Armed: a stop and start through bb-box | pass, the same | no boot: bb-box’s stop suspends a VM to disk, so its start resumes the same boot and no check runs |
| A take | pass: forkSafe: 2 |
pass: forkSafe: 2 |
A plain clone, by the lead, link_down=1 |
pass: reset at its first start, before networkd, ssh, user@1000 and tailscaled; host ID, credentials, daemon unit and access file gone; new machine ID and SSH key; Tailscale state {}; threads’ folder and containers kept; after a reboot kept, one reset |
pass, the same |
| The source after its clone | pass: unchanged, BB machine connected, Tailscale Running |
pass |
| A spare claimed, renamed and restarted | pass: claimed in 12 ms with the same box ID, marker and host ID; its access file rewritten for its owner; after a restart kept, its renamed BB machine connected |
not run: one VMID |
| A card added and booted, then the first removed, then put back | pass: the marker gained eth1’s MAC; kept with only eth1; kept with eth0 back |
pass, with qm reboot, a cold start each time |
| A late card: started with no card, then its own card put back | fail: a false reset | pass: boot=unknown at 55 s, the host ID kept, ssh and tailscaled waiting; once the card was back, kept, ssh, tailscaled and BB’s daemon started |
The failure. With no card, the LXC box’s check waited 30 s and ended
unknown, as it should. Its network and Docker then started, and Docker made
docker0, a bridge and two veth interfaces. The settle unit counted every
Ethernet-type interface as a card of the backend’s for an eth marker, so it
took those four as new cards with none of the marker’s, and reset the box:
“none of the marker’s MAC addresses (bc:24:11:87:90:2f bc:24:11:af:12:b9) is
on this box’s interfaces (52:dd:31:2b:7a:db 62:01:bc:70:20:83
da:35:57:32:58:35 fe:63:50:f4:f1:cd)”. On a VM only cards with a device
count, so Docker’s interfaces never did. The unit tests’ no-card settle, and
the systemd container’s, never added Docker’s interfaces.
What bb-box did. As built: it logged identity-reset, removed
host_eai95mipb8 from BB, waited for BB to confirm, and enrolled the box
again as host_dvmjmz6nbv 2 s after it found the reset (enrolled-again).
It wrote the box’s marker again, so the box is armed, and the doctor names it
under Identity resets. It kept the box. The reset cost its tailnet node.
The fix, version 3. A new card shows a copy only if it is a card of the
marker’s own source: one named eth and a number on LXC, one with a device on
a VM. This holds at boot, in settle and in status. A copy whose card was
renamed, which version 2 reset and unarmed, is now held as unknown, with
nothing removed. The version goes to 3, so a take’s forkSafe says whether
its box had the fix.
Tests. identity-reset.test.ts: on LXC and on a VM, a box with no card
past the wait is held while Docker’s and Tailscale’s interfaces appear, both
at boot and in settle; nothing is removed, SSH and BB’s daemon still wait,
status says incomplete, and it is kept once its card is back. A copy whose
card has no eth name is held; one whose marker is gone and has no eth card
still resets and unarms. Both fail on version 2, the first with the live
message.
Live again, with version 3 installed by hand in the same two boxes
(sha256 621241f5...), both restarted kept, and a new take of the LXC box
(forkSafe: 3):
| Check | Proxmox LXC | Proxmox QEMU |
|---|---|---|
| A plain clone, by the lead | pass: reset at its first start, host_dvmjmz6nbv removed, before networkd, ssh, user@1000 and tailscaled |
|
| A copy whose new card comes late: a clone started with no card, given a new one after 45 s | pass: at 45 s boot=unknown, the host ID still there, Docker up with its bridge and veths, ssh and tailscaled waiting, BB’s daemon waiting; once the card came, settle reset it and refused the copied machine’s daemon; after a reboot kept |
|
| A late card on the box itself | pass: boot=unknown at 45 s with Docker up, ssh and tailscaled waiting, the host ID unchanged; once its own card was back, kept, then ssh, tailscaled and BB’s daemon started |
pass: the same at 55 s |
The templates still hold version 2, so neither backend goes into
identityResetBackends until 9800 and 9806 are rebuilt with the fix, and
bb-box-identity version in a new box says so: version 5, below.
Afterwards the snapshots, boxes and both backends were removed from the separate BB, its box released, and the lead destroyed the clones. One spare was released before it was claimed, by setting the spares to 0 first; the claim was done again in the right order.
After the version 3 re-review
Astra found that on LXC a name was not enough. A bridge, bond, TAP, dummy,
veth, macvlan, ipvlan or VLAN that the box made and named eth9 still
counted as a new card, so with the card missing it reset the box, at boot
and in settle. A software interface with a fixed MAC also became an
anchor, so a copy that made it again, its card late, was kept. The lead set
the rule for version 4.
What a card is, version 4. On LXC, a card is an Ethernet interface the backend gave the box:
- it is not a bridge, bond, TUN or TAP, nor stacked on another interface:
it has no
bridge,bonding,tun_flagsorlower_*entry - its
iflinkis not its own index, so it is linked to another interface - that interface is not inside the box: no interface in the box has
iflink’s index and links back to it
On a VM a card has a device, as before. Matching the marker looks at
cards, whatever their names, so a renamed card still keeps its box. The
marker, its anchors and evidence of a copy need the eth name too, on LXC.
Anchors grow only at boot, before the box’s own software makes its
interfaces again, not in settle.
The back link is checked both ways because LXC moves the card into the
box, where it gets a low index: ifindex 2, iflink 2761 on a production
box. The box’s own docker0 or a veth can then take the host peer’s index by
chance, and Docker’s veths had iflink 2 there, the index of their peers in
the containers. A one-way check, “no interface inside has that index”,
would make the card the box’s own whenever that happened.
Tests. identity-reset.test.ts:
- each of a bridge, bond, TAP, a veth whose peer is inside, a dummy, and a
macvlan, ipvlan or VLAN on an inside link, named
eth9, with the card missing, leaves the box held at boot and insettle, nothing removed and the markerincomplete, on LXC and on a VM, until its card is back - a bridge with a fixed MAC never becomes an anchor; a copy whose marker holds such a MAC, as version 3 could grow it, is held when the bridge is made again, its daemon waiting, and resets once its own new card comes
- anchors do not grow in
settle; the next boot adds the card - a card whose host peer’s index an interface of the box later takes is still the box’s card, and a pair the box made, each linking to the other, is not
The first three fail on version 3. On a QEMU box, a probe with real kernel interfaces in network namespaces, the card made as LXC makes it, in its host’s namespace and moved into the box’s:
| Case | Version 3 | Version 4 |
|---|---|---|
Card missing; the box made a bridge, bond, veth with its peer inside, dummy, TAP, macvlan, ipvlan or VLAN named eth9 |
reset, all 8 | held, nothing removed |
Card missing; docker0, a veth to a namespace of its own, tap0, or a TUN or WireGuard named eth9 |
held | held |
| A copy: the card with a new MAC, alone or with docker0 | reset | reset |
| The box itself: its card with docker0 and a veth, docker0 at the host peer’s index | kept, marker matches | |
The box itself, its card renamed lan0 |
kept | kept |
The box itself with a fixed-MAC bridge eth9 and a second card eth1 |
kept, both grown | kept; the marker gained eth1 only |
| A copy, card missing, its marker holding the bridge’s MAC, the bridge made again | kept, its daemon started | held, its daemon waiting |
Known limit. A veth that the box makes, names eth and a number, and
whose peer it moves into a namespace of its own still counts as a card.
Container runtimes name that end veth..., not eth....
Live, by hand, on two production boxes with version 4 installed and
armed by hand, each running Docker containers and tailscaled; the lead ran
the node steps. While each box waited with no card, a bridge named eth9
with a fixed MAC and a TAP named eth8 were made in it.
| Check | Proxmox LXC | Proxmox QEMU |
|---|---|---|
| A plain clone | pass: reset at its first start, the host ID removed | pass |
| A copy whose card comes late | pass: boot=unknown, the host ID kept; still unknown with eth9 and eth8; reset once its new card came, its copied daemon refused; kept after a reboot, its marker holding the new eth0 only |
|
| The box itself, its card late | pass: unknown, still unknown with eth9 and eth8, the host ID kept; kept with the same host ID once its own card was back |
pass, the same |
The lead deleted the snapshots and destroyed the clones. Neither backend
goes into identityResetBackends until 9800 and 9806 are rebuilt with
version 5, below.
After the version 4 re-review
Astra found two more. GRE TAP, IPv6 GRE TAP and ERSPAN interfaces named
eth9 passed version 4’s tests: they are Ethernet, with another index as
their link and none of the excluded entries, so with the card missing they
reset the box. And indices belong to namespaces: a card with index 20 and its
peer at 100 outside, beside a veth of the box’s with index 100 and its peer
at 20 in a namespace of its own, looked like a pair inside the box. That hid
the card; a second card then reset the box.
The lead stopped chasing kinds one by one, and set the rule for version 5.
What a card is, version 5. On LXC, a card is what the kernel’s own link
information, ip -d -j link, says is one:
link_typeetherandinfo_kindveth- a
link_netnsid: its peer is in another namespace - no
master: it is on no bridge or bond - named
ethand a number
Nothing else is a card or an anchor, whatever its name. The index arithmetic
of version 4 is gone; the collision needs none of it, as the two veths have
different link_netnsid values and the box’s has another name. On a VM a
card still has a device. Anchors still grow only at boot.
On a production LXC box, eth0 is veth with link_netnsid 0 and no
master; Docker’s veth there has a master and link_netnsid 1.
Two consequences, plainly:
- A card renamed from
ethon LXC, such as withpct set --net0 name=lan0,..., is no card, so the box is held as unknown, removing nothing, until it has its name again. bb-box always names its cardseth0. - The remaining limit. A veth that the box makes, names
ethand a number, keeps off any bridge, and whose peer it moves into another namespace is a card by this rule. With the box’s own card missing, it resets the box. Unit 2’s recovery then keeps the box and enrols it again as a new BB machine, so the cost is the box’s BB machine, tailnet node and access file, not the box.
Tests. The test guest now gives the program a fake ip that prints the
links it is given. identity-reset.test.ts:
- with the card missing, each of a bridge, bond, TAP, a veth whose peer is
inside, a veth on a bridge as Docker’s are, a dummy, a macvlan, an ipvlan, a
VLAN, a GRE TAP, an IPv6 GRE TAP, an ERSPAN, a VXLAN and a Geneve, named
eth9, leaves the box held at boot and insettle, on LXC and on a VM - the reviewer’s collision: the marker still matches, the box is kept at
boot and in
settle, and a second card is grown at the next boot, never a reset - a card renamed
lan0holds the box at three boots, removing nothing
The fixture changed shape, so version 4 cannot run these tests; the probe compares the two versions instead.
Probe, with real kernel interfaces in network namespaces on a QEMU box, the card made as LXC makes it:
| Case | Version 4 | Version 5 |
|---|---|---|
Card missing; a GRE TAP, IPv6 GRE TAP or ERSPAN named eth9 |
reset | held |
Card missing; a veth on a bridge named eth9, its peer in a namespace of the box’s |
reset | held |
Card missing; a bridge, bond, inside veth, dummy, TAP, TUN, WireGuard, macvlan, ipvlan, VLAN, VXLAN or Geneve named eth9, or docker0, tap0 and a Docker-like veth |
held | held |
| The reviewer’s collision, then a second card | reset | kept; the second card grown at the next boot |
| A copy: the card with a new MAC, alone or with docker0 | reset | reset |
| The box itself, with docker0 and a Docker-like veth | kept | kept |
The box itself, its card renamed lan0 |
kept | held |
| A copy, card missing, a bridge with the MAC its marker holds made again | held | held |
The limit: card missing, a veth named eth9 with its peer in a namespace of the box’s |
reset | reset |
Live, by hand, on a production LXC box with version 5 installed and armed
by hand, running Docker containers and tailscaled; the lead ran the node
steps. While the box, or its copy, waited with no card, a bridge with a fixed
MAC, a TAP, a GRE TAP, an ERSPAN and a veth pair inside the box were made in
it, named eth5 to eth9.
| Check | Result |
|---|---|
| A plain clone | pass: reset at its first start, the host ID removed |
| A copy whose card comes late | pass: boot=unknown, the host ID kept; still unknown with the five interfaces; reset once its new card came, its copied daemon refused; kept after a reboot |
| The box itself, its card late | pass: unknown through the five interfaces, the host ID kept; kept with the same host ID once its own card was back |
The lead deleted the snapshot and destroyed the clones.
Choices and deviations
- “Missing on a box that had one” is the armed record. 070 resets when the marker is missing; with that alone, a new image’s first boot, before bb-box writes the marker, and every boot of a box made before markers, would reset.
- Docker compares a value bb-box sets in the environment at create, the
box’s ID; microsandbox, Hetzner and Namespace boxes get it too, but bb-box
arms none of them until each is checked. Docker gives a container a new
MAC address at each start (3 of 3 measured), so a MAC address would reset
a Docker box at every restart. There the check runs before anything in the
box starts, not before its network, which Docker makes first. A
docker commitmade by hand keeps the variable in its image’s settings, so a container run from it by hand would not reset; unit 12’s commits must clear it (--change "ENV BBBOX_IDENTITY="). - boat.dev, Tart write no marker in this unit. boat: whether its stop and start keep a MAC address is not measured (its files are another worker’s, and its boxes are paid). Unit 11 settles its source.
- After a reset the unit writes the copy’s own marker, with no box, so it does not reset again at its next boot. bb-box writes the box’s own marker again only after it enrols one of its own boxes again; a fork’s first contact is unit 7’s.
- bb-box’s authorized key stays: it is the template’s, and a fork’s own key is unit 7’s.
- The machine ID: the reset writes a new one after systemd started, so systemd itself keeps the copied one until the next boot; journald is restarted so the rest of the boot is logged under the new one. Its first line, saying why, goes to the old one’s journal; the summary line after it says why too.
Unit 3: restore on Proxmox
Built on 31 October 2026 on main be8cfc47 (units 0, 1a, 1b, 2 and 4
included), and rebased onto b8088de6. The lead’s answers are in 070’s
Decided during unit 3.
What changed
- The plan by storage.
planSnapshotswith a target now says how Proxmox restores it, read live:rollbackon storage that rolls back to any snapshot (LVM-thin, Ceph RBD, BTRFS, qcow2 files),rollback-newest-onlyon ZFS, andnonewith why (storage without snapshots, the token withoutVM.Snapshot.Rollback, a stopped Proxmox QEMU box kept suspended to disk, a target the guest does not have or has part-made). It also says how many snapshots on the guest are newer than the target, the undo it would take (a snapshot, or a held copy) with its time and space, and the size the box goes back to, read from the snapshot’s own config. Every disk’s storage counts.src/backends/proxmox/snapshotting.ts. - The restore,
Snapshots.restoreinsrc/core/snapshots.ts, on the box’s queue with its start, stop, resize and release. Each step is recorded on the operation before it runs, and the provider’s task as soon as it begins:- It refuses what the plan refuses, with the plan’s words: a lock (with the host’s command), ZFS (off), a target that is not ready, is on an earlier guest (“only forked”) or is another box’s, a box that is not running or stopped, and a box whose last restore bb-box could not confirm.
- The guard (below).
- In one transaction on
snapshots.db: the undo’s record (taking) and the operation (running), which pin the target and the undo, with the undo’s space and price reserved. The undo is refused there, before anything stops, by the room rule or the ceiling (ceilingVerdict, changeundo), each with its own fix; the person may restore without it (--no-undo), an agent never.restore-askedis logged. - A hard stop (
powerOff, which a QEMU box needs: itsstophibernates). - The undo of the stopped box, “before the restore at 01:56”: a snapshot, or in ZFS’s order a held copy. If it fails, the box starts again as it was, and the restore says nothing on its disk changed.
- The rollback (
rollbackSnapshot), which refuses a running guest. If Proxmox refuses it, the box starts again as it was; if its task fails part way, the box stays stopped, and the answer names the undo. - The start, during which, once the box answers and before the backend
runs any command of its own, the access file is written and checked
by its SHA-256; then the size read back from the guest, the labels
again (
relabel), and BB’s machine started again as a start does (commands wait for it; the restore does not). The guard is read again before the stop and before the rollback. restored, with the time of each part: “in 4.4 s (stop 1.1 s, undo 0.3 s, rollback 0.4 s, start 2.3 s, access 0.2 s)”. Then undos past 2 a box are replaced, oldest first, never the restore’s own target, a kept one or a pinned one.
- The box while it restores carries a
restoringmark. Commands, starts, stops and resizes are refused with “Box amber-fox is being restored to snapshot …”, and the box sweep leaves it alone, so it is never taken as stopped or started outside bb-box. A restore always ends with the box running; one of a stopped box starts it. - The guard reads, through the plugin, the threads on the box’s BB
machine (
threadsOn), whether a person holds its desktop, and BB terminals open on it. A box with a thread on it, working or idle, or the box the caller’s thread runs on, is refused for everyone,--forceincluded, naming the threads: execution machines’ restores stay off (G17). Control and terminals refuse an agent, and refuse the person unless--force, whichrestore-askedthen records. When bb-box cannot tell which threads run there, or whether a terminal is open, it refuses. - Agents.
mayActalready let an agent restore its own thread’s boxes only. An agent cannot pass--no-undoor--force, and unit 4’s two switches now bite: with “Agents may restore their own boxes” off, or “Agents may restore memory snapshots” off for a memory snapshot, an agent’s restore is refused. The tool comes in unit 5. - Restart recovery (
#recoverRestore). At start and at each sweep, an operation cut short is finished from the step it reached or settled: one recorded but not begun changes nothing; a stop that had not happened is undone; an undo or a rollback is judged by its Proxmox task, found by guest, type and time if its UPID was never recorded; a held copy that is not a template is deleted and made again, never trusted; a half-made new guest of a swap is deleted and made again; a swap that wrote the record goes on to disarm the old guest. What bb-box cannot tell staysunknown, pinned, with the box marked and stopped, and the doctor says so. - Locks. A guest Proxmox left locked refuses a restore and a take with
the host’s command, and the doctor’s Snapshots section now has a check
for each box with snapshots whose guest is locked (
snapshots.locked.<box>) and each restore bb-box could not confirm (snapshots.restore.<box>). - ZFS’s order: held copies, swaps and earlier guests. Built for
Proxmox LXC and QEMU, and off: ZFS restores are refused. A switch for
live checks only (Decided during unit 3) runs it on LVM-thin:
- the restore stops the box, makes a held copy of the stopped guest
(
copyGuest: a full clone into the box range, its VMID recorded before the clone; labelledheld-copy,snapshot.<id>,taken.<s>;disarmCopy:onboot0, every link down, and on QEMU a new SMBIOS UUID;templateCopy), then rolls back to the newest snapshot only; an older one is refused with how many are newer; - a held copy is made only of a box whose identity reset works now, read
before the stop, and the undo records that version as its
forkSafe(after the reviews); - restoring a held copy is a swap, which also stays behind the switch:
adoptCopymakes a new guest from it with the box’s MAC addresses and, on QEMU, its SMBIOS UUID,onboot1, links up, and the address of its own VMID; the record’s guest is swapped;retireGuestdisarms the old guest (onboot0, links down, new MAC addresses and UUID, taggedearlier-guest), whose snapshots are then “on amber-fox’s earlier guest: fork only”. An old guest with no snapshot on it is deleted at once; - held copies and earlier guests are excluded from the box sweep, the
snapshot listing of current guests and outside restores, are listed by
heldSnapshotsasheld-copyandearlier, are kept by the box sweep while an operation uses them, and go with their last snapshot or their box’s release, each removal recorded as a settled operation so a listing that began before records none of them again.
- the restore stops the box, makes a held copy of the stopped guest
(
- Restores made outside bb-box (
outsideRestores, at each sweep):vzrollbackandqmrollbacktasks on this install’s guests since the last look, read from the node’s task list, not started by bb-box (no operation names their UPID).restored-outsidewhen one ended OK, after which bb-box reads the size back, writes the labels again, and when the box runs, its access file, and starts its BB machine;restore-outside-failedwith what bb-box then found;restore-outside-runningonce, then its end at a later sweep. Each is recorded as a settled operation, so it is logged once, also after a restart. A listed task with no end time is read from its own status: the list gives a running onestatusRUNNING. - The box log gains
restore-asked,restored,restore-failed,restored-outside,restore-outside-failed,restore-outside-runningandheld-copy-made, which its store already allowed. - The CLI:
bb box snapshot restore <box> <snapshot> --replace [--no-undo] [--force] [--request] [--json], and--check, which says what the restore would do (its undo, with time and space; a swap and what stays on the earlier guest; the size it goes back to; what it would end) and refuses what it would refuse, changing nothing: the plan first, as 070 asks of every command; withBB_BOX_LIVE_CHECKS=1,hold-nextgainsstopped,copied,undo,rolled-back,adoptedandstarted, andbb box snapshot zfs-order <backend> on|off. The list says “a held copy, guest 3240” and “on amber-fox’s earlier guest 3241: fork only”. - Proxmox LXC’s start reports its parts (boot, network, ssh), as QEMU’s did, which the restore logs.
- Docs: the guide’s Snapshots page, the reference regenerated, and 070’s Decided during unit 3.
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, on this unit’s commits over
main b8088de6, in a fresh copy of the branch: npx tsc passes, and the
reference is current; the node suite ran 3,067 tests, 2,779 passed, 0
failed, 288 skipped (the live and conformance ones); vitest ran 384, all
passed. The node suite includes the docs, site and reference checks. New
or changed tests, each on a set clock where time matters:
test/restore.test.ts, 14, throughBoxesand a fake provider whose snapshots copy the fake guest’s files and whose rollback puts them and the size back: G4 on LVM-thin (the order of stop, undo, rollback and start; the oldest of three; the undo holding what was written before the stop); G8 (the access file written before the labels, without the snapshot’s stale line; the size read back and said); G9’s undo sequences (two undos a box; the oldest undo restored keeps its target; a kept undo stays); the undo’s admission (room and ceiling refused before anything stops, each with its fix;--no-undofor the person only); G5 (a thread on the box refuses everyone,--forceincluded; control, terminals and an unreadable guard;--forcerecorded; the agents’ switches); failures (an undo that fails, a refused rollback and a failed stop start the box again unchanged; a failed rollback task leaves it stopped, naming the undo); G7 (lock words and the doctor’s check; ZFS refused; a restore across a disk’s growth); G6 (a restart after the stop, the undo, the rollback and the start; one before anything changed; an unconfirmed rollback unknown and pinned, the box marked, its start refused, the sweep leaving it alone, the doctor’s check, then settled); a repeated request; G13 (bb-box’s own rollback not logged as outside; two OK, a failed one and a running one, each once, the running one’s end after a restart); ZFS’s order (the newest only; the held copy disarmed and a template; the swap with the box’s identity; the earlier guest fork only and kept by the box sweep; a release deleting both, with a listing begun before recording neither again); G6 and G16 in ZFS’s order (a copy before its template deleted and made again; a swap after its new guest made, finished; an old guest with no snapshot deleted); expiry of a kept snapshot on an earlier guest; and--check(what a restore would do, its refusals, nothing changed).test/proxmox-lxc-snapshots.test.ts, 5 more: the restore plan (rollback, the snapshot’s size, newer ones, ZFS, a missing target, the token withoutVM.Snapshot.Rollback); the rollback (refusing a running guest, a lock withpct unlock, its UPID before it waits, the config put back, a failed task); outside rollbacks with user and status, and one running; the copy methods (claimed before the clone, labels, disarm, template, listing, the box’s MAC and own address on the new guest, the earlier guest’s new MAC, the sweep’s exclusions, a copy of another box left alone).test/snapshots.test.ts: its world moved totest/snapshot-world.tsfor both files; the reused request’s words.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1
only, in a Proxmox LXC box; bb-box installed from this branch with bb plugin install path:, and BB’s machine access set to direct with the
box’s address, so boxes enrol without an account. The lead gave it a
Proxmox LXC and a Proxmox QEMU backend, on LVM-thin, with production’s
templates and their own VMIDs and addresses, and ran each root step from
proxmox-root.sh after reading
it; the script acts only on the given VMIDs whose guests carry the
install’s tag. Boxes were machines of the person’s, made with bb box machines add. The node is written pve-1.
Restores by rollback (rest-lxc on Proxmox LXC, 2 cores, 4 GiB, 16 GiB;
rest-vm on Proxmox QEMU, the same size):
| Check | Result |
|---|---|
| G4, LXC: three snapshots, a file written after the last, restore to the oldest | the file said “one”; the undo “before the restore at 00:32” was ready 1.5 s after restore-asked; restoring the undo then brought back the file written after the last snapshot, before the stop |
| A restore’s time, LXC | 4.4 s as bb-box timed it (stop 1.1 s, undo 0.3 s, rollback 0.4 s, start 2.3 s, access 0.2 s), 5.1 s for the command, to a snapshot taken while stopped (an undo) |
| Proxmox’s start after a rollback, LXC | 44 to 46 s for Proxmox’s own start task (vzstart 00:39:06 to 00:39:50, 00:42:56 to 00:43:41, 00:46:14 to 00:46:58) when the snapshot was taken while the container ran; 1.4 to 2.5 s when taken while it was stopped; 1.8 and 2.1 s for a plain stop and start. The task log says only “TASK OK”. bb-box waits for it, so such a restore takes about 47 s |
| G4, QEMU: the same | the oldest back; its undo brought back the later file; 9.9 s (stop 0.2 s, undo 0.3 s, rollback 0.5 s, start 8.9 s, access 0.1 s) |
| A memory snapshot, QEMU | taken with --memory while a sleep ran; the restore took 2.6 s, and the VM came back running: its uptime went on from the take, the sleep was there, and BB listed the machine connected |
| G8, the check said it first, LXC: a take at 4 GiB, a live resize to 6 GiB, a BB terminal open | --check --force: “… It goes back to 2 cores, 4 GiB memory, 16 GiB disk, as it was then; it has 2 cores, 6 GiB memory, 16 GiB disk now. It would also end what is on it now: a BB terminal is open on it. … Nothing changed.” The restore then did that, and bb box status said 4 GiB |
| G8, size, QEMU | memory resized from 4 to 6 GiB with a restart after a take; the restore said “Its size went back from 2 cores, 6 GiB memory, 16 GiB disk to 2 cores, 4 GiB memory, 16 GiB disk, as it was then”, and free and bb box status said 4 GiB |
| A restore across a disk’s growth, LXC | refused at first, as 070 said. Then measured with a rollback by root across a growth from 16 to 18 GiB: Proxmox put back size=16G, df said 16G, the container started, and its file was the snapshot’s. bb-box now allows it; a later restore said “… 18 GiB disk to …, 16 GiB disk, as it was then” |
| G8, access first, LXC and QEMU | a systemd path unit in the box logged each change of the access file with the time and whether a sentinel line was in it. A line was added after the last bb-box command, a snapshot taken, the box restored: after its boot (LXC at 1793409785), the file changed once, at 1.9 s, without the sentinel, the same second as bb-box’s SSH session to it began; QEMU’s the same, 4.6 s after its boot. bb-box records that step before the labels and BB’s machine |
| G5, a BB terminal open on the box | “Restoring box rest-lxc to “guard” would end what is on it now: a BB terminal is open on it. Restore anyway with –force. bb-box changed nothing.” With --force it restored, and restore-asked ended “restored anyway, though a BB terminal is open on it.” |
| G5, a thread on the box (in a project on it, and the terminal) | “Box rest-lxc is a box threads run on: “Stay idle; a test of bb-box’s restore guard.” (working) runs on it. A restore would take their worktrees, uncommitted files and sessions there with it, and bb-box does not restore such a box yet …“, also with --force. After the thread was archived, only the terminal was named |
G6, LXC: hold-next at stopped, undo, rolled-back and started, then bb plugin reload bb-box |
each restore finished at the next start, logged “… after bb-box restarted”, with the right file; the doctor said 0 operations not settled |
| G6, QEMU: held after the rollback of a stopped box | finished after the restart in 13 s; started, 4 GiB, 0 operations not settled |
| G7, LXC: the lead left 3242 locked (rollback) | take: “Proxmox LXC on pve-1 left box rest-lxc locked (rollback), so it takes no snapshot. Run pct unlock 3242 on pve-1.”; restore: “… so bb-box restores nothing. Run pct unlock 3242 on pve-1.”; the doctor: “[fail] rest-lxc locked: … Fix: Run pct unlock 3242 on pve-1.” |
| G7, QEMU: 3250 locked | the same three, with qm unlock 3250 |
G13, LXC: root’s pct rollback twice to one snapshot, then one refused by a lock, between sweeps |
the next sweep logged two restored-outside (“Proxmox’s task UPID:…:vzrollback:3242:root@pam:, by root@pam, at …, ended OK …”) and one restore-outside-failed (“failed: CT is locked (backup). bb-box then found it stopped.”) |
| G13, LXC: a rollback held on Proxmox’s config lock for 10 s, with bb-box sweeping every 5 s | the first try logged it as failed with “RUNNING”: the task list gives a running task that status. Fixed: a task with no end time is read from its own status. Then: restore-outside-running at 00:55:35, and at 00:55:46 restore-outside-failed: “can’t lock file ‘/run/lock/lxc/pve-config-3242.lock’ - got timeout” |
G13, QEMU: root’s qm rollback of the running VM |
restored-outside at the next sweep, “bb-box wrote its labels again, writes its access file when it next starts” (Proxmox left it stopped) |
| G10, the undo’s price, QEMU, with a made-up $0.1 a day and one paid snapshot held | “bb-box would take an undo of box zfs-vm before restoring it. It would bring snapshots held on Proxmox QEMU on pve-1 to about $6.00 a month, past your ceiling of $5.00. … Or restore without an undo, with –no-undo.” The box stayed ready, and nothing was logged; --no-undo restored |
ZFS’s order, under the live-check switch, on LVM-thin:
| Check | Result |
|---|---|
| An older snapshot | “Live checks run ZFS’s order on Proxmox LXC on pve-1: as on ZFS, bb-box restores only the newest snapshot there, and “one” has 6 newer ones. Delete them yourself first. bb-box never deletes newer snapshots to make a rollback possible.” |
| The newest, LXC | a held copy of the stopped box into 3240 in 9.6 s, then the rollback: 58 s in all, Proxmox’s start 45 s (the snapshot was taken running) |
The held copy, pct config 3240 (by the lead) |
template: 1, onboot: 0, link_down=1, its own MAC, tags held-copy and snapshot.<id> |
Its undo, a swap, LXC (--no-undo: the backend had 3 VMIDs) |
11 s (stop 1.1 s, swap 8.0 s, start 2.2 s): a new guest 3241 from the copy, with the box’s MAC bc:24:11:30:f1:13, onboot: 1, link up, the address of its own VMID (.251; the box’s record, its commands and hostname -I all said so), the same BB machine and host ID, connected, and the file from before the restore. The old guest 3242: onboot: 0, link_down=1, a new MAC, tag earlier-guest; its snapshots listed “on rest-lxc’s earlier guest 3242: fork only” |
| QEMU, the same | held copy into 3252 in 7.5 s; the swap in 6.5 s onto 3250 with the box’s MAC bc:24:11:68:ac:73 and SMBIOS UUID, from .253 to .252; the old guest 3251 with a new MAC and UUID, links down, onboot: 0; the copy a template with its own UUID. A clone of a held copy always gets the address of its own VMID, never the copy’s |
G6 and G16: hold-next --at copied, so the restore stopped after its clone, before the copy was disarmed or a template |
pct config 3240 then: no template line, link up, onboot: 0. After bb plugin reload bb-box: “Deleted guest 3240, the undo’s copy for the restore of box rest-lxc: bb-box restarted before it was a template, so it is not trusted. bb-box makes it again from the stopped box.” It was made again in 7.5 s, as a template, disarmed, and the restore finished |
| G16: the remade copy adopted for the same box | the file written just before the stop, BB’s auth.json and host-id from the enrolment at 00:32, the same MAC; BB listed the same machine, connected |
G6: hold-next --at adopted, after the swap’s new guest was made, then a restart |
a sweep during the hold released nothing; after the restart the swap finished: the new guest, the same MAC and machine, the copy’s file |
| An old guest with no snapshot left on it | was left behind; fixed: such a guest is now deleted at the end of the swap. The lead destroyed the one left |
| G16 and G6 on a box made from the rebuilt template (unit 2’s program, version 2, its unit enabled, the box armed, its marker matching) | a restore in ZFS’s order (held copy 3242, then the rollback: boot=kept after); its undo, a swap held after the new guest started, then a restart: finished at the next start, onto 3240, at .250, with the same MAC bc:24:11:d5:98:ca and BB host ID, connected, and boot=kept marker=matches: no reset |
G16: that box’s earlier guest 3241, started by hand (pct start by the lead) |
pct config: onboot: 0, link_down=1, MAC BC:24:11:CF:0F:C8, tag earlier-guest. Started, it reset: “none of the marker’s MAC addresses (bc:24:11:d5:98:ca) is on this box’s interfaces (bc:24:11:cf:0f:c8)”, removing BB’s host ID; its journal, read with the guest stopped and mounted: the check began at 02:04:40.413, decided at .474 and finished at .608, then systemd-networkd at .643, ssh at .688, the box user’s manager at .756; eth0 stayed down |
| Release of that box | its earlier guest 3241 and held copy 3242 went at the next sweep |
| Release of a box with a held copy and an earlier guest (QEMU) | the next sweep deleted both, each snapshot logged “it went with its held copy, guest 3252” and “… its box’s earlier guest, guest 3251”. A second sweep running at the same time had listed the copy before it went, and recorded it again as found: fixed, each such removal is now a settled operation, as unit 1a’s are, and a test holds a listing open across it |
After the reviews
Two independent reviews read the bundle at ed6abd28: Astra (merge after
fixes, nine findings) and Opus (merge after fixes 1 to 3). Each reproduced
its findings with scratch tests. Every finding is fixed, with a regression
test; the reviews’ failure cases are now tests in
test/restore-recovery.test.ts, and each of the 15 that reproduce a finding
fails on ed6abd28 and passes now. The lead asked for all of them, and for
the stricter fix where the two overlapped.
| Finding | From | Fix | Tests |
|---|---|---|---|
Recovery at undo-ready rolled back although the undo had been deleted outside bb-box |
Astra 1 | Before recovery goes on to the rollback or the swap, it checks the undo is still there and whole: a snapshot in a fresh listing, a held copy still a template. If not, the undo is marked lost and the box starts again as it was | “recovery does not roll back once its undo is gone” |
A restart at adopt-claimed left guests.new naming the undo’s held copy, which recovery then deleted as a half-made guest; adoptCopy failing before its claim did the same; deleteCopy would destroy any guest of the box |
Astra 2, Opus 1 | The undo’s copy is recorded on the undo’s own record, never in guests; adopt-claimed clears guests.new in the same write; a half-made new guest is discarded through release only if it is not the box’s guest and no snapshot record lives on it; deleteCopy destroys only a guest tagged as a held copy or an earlier guest of exactly that box, or described as a held copy of it |
“a swap with an undo cut short at adopt-claimed”, “a swap whose new guest cannot be made”, “a swap cut short at adopt-task”, and deleteCopy refusing a box’s own guest |
| Rollback recovery could take the undo’s UPID, still on the operation, or an outside rollback just before the request, for the rollback’s | Astra 3 | Each step that begins a provider task clears the last one’s in the same write, and records its own time (step_at, a new column); the lookup by guest, type and time starts from that step’s time, takes only tasks of the token’s own user, and stays unknown when two tasks could be the step’s |
“keeps no earlier task”, “not confirmed by another user’s rollback”, “two tasks … leave it unknown”, and the backend’s user filter |
| The guard was read only while planning | Astra 4 | It is read again before the stop and before the rollback or swap, in recovery too. A thread found then refuses the restore for everyone; a person in control or a terminal refuses it unless the person’s --force covered that very loss, which the operation records. Refused before the stop, nothing changed; after it, the box starts again as it was |
“the guard is read again before the stop, before the rollback, and before recovery goes on” |
A held copy was made of a box without a working identity reset, and an undo took the target’s forkSafe |
Astra 5, Opus 3 | forkSafe is read from the box while it runs, before the stop, and is the undo’s; ZFS’s order is refused without it, unless the person restores without an undo |
“a held copy needs the box’s identity reset now, and an undo’s forkSafe is the disk it holds” |
| A lock a running backup held refused a take but not a restore | Astra 6 | The plan’s restore is none, with “Try again when it ends”, for any lock without an unlock fix |
the backend’s “a lock a running backup holds refuses a restore” |
role “machine” refused a box with no thread left on it |
Astra 7 | Only threads on the box’s BB machine now, or the caller’s thread running on it, as the owner answered | “a box that was a thread’s own machine, with no thread on it now, may be restored” |
| A take went ahead during a restore bb-box could not confirm | Astra 8 | Takes are refused, as restores, starts, stops and resizes are, while a restore holds the box or is unsettled | “no take while a restore bb-box could not confirm holds the box” |
| A GPU desktop’s start ran a command before the access file was written; the words claimed nothing ran with the old file | Astra 9 | A backend’s start takes a hook it calls once the box answers, before any command of its own; the restore writes the access file there. The words now say that what the box started at boot may have read the old file |
“the access file is written once the box answers, before the backend runs any command of its own” |
ZFS’s order made the held copy a second time after a restart at rollback-submitting |
Opus 2 | Recovery resumes after the held copy (copy-ready) when the undo is one |
“ZFS’s order cut short at rollback-submitting … makes no second one” |
Older undos went before the rollback succeeded; --check did not name them |
Opus 4 | Undos past two go only once the restore is done; --check names the one that would |
“older undos go only once the restore is done” |
| A restoring mark could outlive its operation and hold the box for good | Opus 5 | At each sweep, a mark whose operation is no longer unsettled, or unknown to bb-box, is cleared and logged | “a restoring mark never outlives its operation” |
| A held copy restored by a swap whatever the switch said | Opus 6 | Swaps stay behind the live-check switch until such restores are checked on ZFS | “a held copy is restored by a swap only while the live-check switch is on” |
Words: --check on a swap that deletes an old guest with nothing on it; “Run it again without –check”; “Check its status in a few seconds” for a restore bb-box could not confirm |
Opus 7 | --check says the old guest is deleted, and with no undo that nothing then holds the box as it is; “with –replace instead of –check”; the box’s words point to bb box doctor and bb box sweep |
|
| Restarts mid-task and swaps with an undo were not tested | Opus 8 | Tests for an undo’s task running, then OK, and failed; a rollback’s task OK and failed; a copy’s task begun; an adoption’s task begun; a swap whose earlier guest cannot be disarmed at once; a swap with its own undo | test/restore-recovery.test.ts |
The fake provider now does a task’s work before it reports the task, as Proxmox does, and its tasks carry a user.
After the fixes, on this unit’s commits over main 399c1b49, in a fresh
copy on the same kind of box: npx tsc passes, and the reference is
current; the node suite ran 3,093 tests, 2,805 passed, 0 failed, 288
skipped; vitest ran 384, all passed.
The fixed paths, live, on a third separate BB set up as above, with the
same backends, VMIDs 3240 to 3242 and 3250 to 3251, and
general.identityResetBackends on for both. Each box was made from the
rebuilt templates and armed (bb-box-identity status: version 2, armed=yes,
the box’s ID):
| Check | Result |
|---|---|
| A rollback restore, LXC, with a line added to the box’s access file before the take | 47 s (stop 1.1 s, undo 0.3 s, rollback 0.4 s, start 44 s, access 0.6 s); the file was the snapshot’s, and the access file had no such line: the start’s hook wrote it |
hold-next --at undo, then bb plugin reload bb-box |
recovery found the undo still there, read the guard again, rolled back and started: “restored … in 50 s (rollback 0.4 s, start 45 s, access 0.5 s) after bb-box restarted”, with the snapshot’s file |
| ZFS’s order on an armed LXC box | --check: “copy it in full, stopped, as its undo …: a held copy that cannot start, about 38 s, up to 17 GB on local-lvm”. The restore: 56 s (stop 1.5 s, undo 8.1 s, rollback 0.8 s, start 45 s, access 0.4 s), its undo a held copy, guest 3242; boot=kept after |
Its undo as a swap (--no-undo), held once the clone’s task began (hold-next --at task), then a restart |
Proxmox’s clone was still running at the restart, so recovery left the restore unconfirmed; the next sweep, 3 minutes later, deleted the half-made guest 3240 (vzdestroy 02:57:56), cloned it again from 3242 (vzclone 02:54:49, then 02:57:56) and swapped: “restored … in 198 s (swap 7.5 s, start 1.7 s, access 0.4 s) after bb-box restarted; no undo”. The box at .250, its MAC bc:24:11:cb:2b:b9 and box ID the same, boot=kept: no reset; the file from before the restore; the target’s copy 3242 untouched; “one” listed on the earlier guest 3241, fork only. The deletion logged nothing: it now logs “Deleted guest 3240, the new guest part made for the restore of box …” |
| Deleting “one”, the earlier guest’s last snapshot | “The earlier guest 3241 of box re-zfs is deleted: its last snapshot went.” |
ZFS’s order again, held after the held copy (hold-next --at undo), then a restart |
recovery checked the copy 3241 was still a template, rolled back and started: “restored … in 56 s … after bb-box restarted”. One clone only (vzclone 02:59:36 to 02:59:43), so no second copy |
pct config of both held copies (by the lead) |
template: 1, onboot: 0, link_down=1, their own MACs, tags held-copy, install.<id>, box.<id> and snapshot.<id>. Each keeps the address its box had when copied, inert behind its link; a guest made from it gets its own VMID’s |
| A rollback restore, QEMU, the same access check | 10 s (stop 0.2 s, undo 0.3 s, rollback 0.5 s, start 8.9 s, access 0.4 s); the file was the snapshot’s, no such line in the access file, boot=kept |
| Proxmox’s start after a rollback | its own vzstart task took 44 to 45 s each time (02:44:54 to 02:45:38, 02:47:51 to 02:48:35, 02:59:45 to 03:00:30), and 1 s for the guest made from a held copy (02:58:04 to 02:58:05) |
| Removing both machines | every snapshot went with its box, and both held copies with the box’s records (“it went with its held copy, guest 3242”); bb box snapshot list --all then said “There are no snapshots.” |
Gates
| Gate | Result |
|---|---|
| G4 storage-rollback | Passed on LVM-thin, LXC and QEMU: the plan reads the storage; a restore to the oldest of three with its undo, nothing written before the stop lost. ZFS’s order passed under the live-check switch on LVM-thin, LXC and QEMU: an older target refused with the words, the newest restored after its held copy, every newer snapshot still there; its undo a swap onto a new guest with the box’s MAC addresses (and SMBIOS UUID on QEMU), onboot set and links up, the same BB host ID with no reset, the old guest an earlier guest whose snapshots list fork only; a restart while the copy was not yet a template and after the new guest was made; release deleting both. Not proved: Proxmox’s own refusal on ZFS (no ZFS node), and expiry, which needs Keep (unit 8). ZFS restores stay off |
| G5 restore-guard | Passed for a thread on the box (refused for everyone, --force included, naming it) and a BB terminal (refused, then restored with --force, which the log records). A person holding the desktop is tested only in test/restore.test.ts: the separate BB’s backends offer no desktop. The agent’s refusal is tested there too; its tool is unit 5’s, the person’s dialog unit 6’s |
| G6 restart-recovery | Passed on LXC after the stop, the undo, the rollback and the start; on QEMU after the rollback; in ZFS’s order after the clone and after the swap’s new guest was made. Each finished, nothing was released as a leftover, and no operation stayed unsettled. One that cannot be confirmed stays unknown and pinned, in the tests. After the reviews, again on LXC after the undo, in ZFS’s order after the held copy, and during a swap’s clone: the half-made guest deleted and made again at the next sweep |
| G7 lock-words | Passed on LXC and QEMU in the CLI’s take and restore and in the doctor. The page and the tool come with units 6 and 5 and use the same words |
| G8 access-first | Passed on LXC and QEMU, as measured above, and the memory read back across a resize on QEMU, the disk across a growth on LXC |
| G9 serialised-and-eligible, restore’s part | the undo sequences in the tests (two undos; the oldest undo restored keeps its target; a kept undo stays); live, every restore replaced the oldest undo past two, never its own target |
| G10 ceiling-rate, the undo’s part | Passed live on a made-up price: refused before the box stopped, and restored without it |
| G13 outside-restore | Passed on LXC and QEMU: two restores to one snapshot, a failed one and a running one, between and across sweeps, each logged once by its status. A snapshot taken by hand (pct snapshot) logged nothing, as it is not bb-box’s; one of bb-box’s deleted by hand (pct delsnapshot, 01:46:47) was logged snapshot-lost at 01:57:00, at the listing 10 minutes after the first that missed it, as unit 1a does. Without a task, bb-box claims no restore |
| G16 held-copy-inert | Passed: each held copy a template, disarmed, in pct config and qm config; each earlier guest with onboot 0, links down and a MAC that is not the box’s; a copy cut short before its template deleted and made again, and the remade copy adopted for the same box with its files, credentials and host ID. On a box made from the rebuilt template, with unit 2’s program (version 2) armed: after its swap the new guest’s check said boot=kept, with the box’s MAC and host ID, so no reset ran; its earlier guest, started by hand by the lead, reset (the check decided at 02:04:40.474 and finished at .608) before systemd-networkd (.643), ssh (.688) and the box user’s manager (.756), removed BB’s host ID, and kept its link down. Not proved: the node’s reboot, as no node can be rebooted; it is a condition before ZFS restores or unit 8’s Keep are turned on |
Choices and deviations
restore-askedis logged when the restore is recorded, before the stop, not at step 6, so the log shows the ask before the box stops. A restore refused before then logs nothing, as nothing changed.- The restore does not wait for BB’s machine to come back. It starts
BB’s daemon again as
box_startdoes, and commands wait for it. On the separate BB, without an account, the wait for the thread’s variables added 30 s to every restore. - A restore across a disk’s growth is allowed after measuring it (Decided during unit 3).
- A swap takes a held copy of the stopped guest as its undo, as ZFS’s order does; 070 does not say what a swap’s undo is. A swap needs a VMID for each of the box, its undo’s copy and its new guest.
- An old guest with no snapshot left on it is deleted at the end of a swap, as an earlier guest exists only for its snapshots.
- Undos past two are replaced once the restore is done, never its own target (after the reviews).
- A restore of a stopped box starts it, as step 7 says, so the access file is written at once.
- Proxmox LXC’s start now reports its parts (boot, network, ssh),
which
bb box statusshows under Last start, as QEMU’s did. - A restore cut short while its provider’s task still runs waits for
the next sweep, up to 3 minutes: recovery leaves it unconfirmed, the
box marked, and settles it once the task has ended.
bb box sweepchecks at once. - An outside restore is set straight once. On LXC the lead locked the guest at once after, and setting it straight failed on the lock; bb-box says so in the log and does not try again. The access file is written by bb-box’s next command anyway.
Conditions and open questions
- ZFS restores stay off until G4 runs on a ZFS node; the node reboot of G16 is a condition before they, or unit 8’s Keep, are turned on.
- Restores of boxes threads run on stay off until G17 (unit 5).
- Proxmox’s 44 s start after a rollback to a snapshot taken while the container ran: Proxmox’s own task takes it, and its log says only “TASK OK”; a guest made from a held copy starts in 1 s. Worth a look on the node (an fsck or a journal replay at mount is a guess, not measured), as it makes such a restore take about 47 s.
- Thin metadata is still not read (unit 1a’s known limit), so a restore’s undo, like a take, checks the pool’s data only.
Unit 4: Settings’ Snapshots page
Built on 30 October 2026, on main 7bd9f833 (units 0, 1a and 1b
included), and rebased onto 7652147b. The lead’s answers to this unit’s questions are in 070’s
Decided during unit 4.
What changed
- The settings, under
general.snapshotsin the configuration document, read and checked by 067’s engine:perBox(1 to 50, 5),roomPercent(50 to 90, 85),keepDays(1 to 365, 30),agentsRestoreandagentsMemory(on),pictures(on), andceilings, a ceiling a month for each backend ID (0 to 1,000, in whole cents, 5 unless set).src/core/document.ts; the configuration reference is regenerated. - What bites now, and what waits. Snapshots per box, the room and the
ceilings are used, shown and found. Keep’s length (unit 8), the agents’
two switches (unit 5) and pictures (unit 6) are in the schema and in
snapshotConfig, tested, and say “Not used yet” in the reference; they are not on the page or in Find. Each comes onto the page with its unit. - The constants units 1a and 1b used are gone where a setting replaces
them. The take counts against
perBox. The room refuses a take pastroomPercent, a thin pool’s metadata 5 points below it, and the sweep deletes 7 points above it (roomThresholds), so the defaults are as before: 85, 80 and 92. The housekeeping times stay fixed: 24 hours for a failed record, 7 days for a lost or found one, 10 minutes before a missing one is lost.bb box status,box_status,bb box snapshot takeandlistsay “of N” with the setting. - The ceiling. One rule,
ceilingVerdictinsrc/core/snapshot-cost.ts, admits a take, an undo, a Keep, a renewal and a held copy. A take, an undo and a held copy are refused when the rate after them would be past the ceiling; a Keep and a renewal add no snapshot, so they are refused while the rate is past it. The rate is every paid snapshot the provider holds, being taken included, times its daily price, times 30; cents are compared, so the words never say “$5.00 past $5.00”. A price bb-box does not know is admitted for the person and refused for an agent. Unit 4 wires the take, in the same transaction as the limit and the room, so takes at once cannot pass it together. The refusal: “It would bring snapshots held on boat.dev to about $5.04 a month, past your ceiling of $5.00. Delete one, or raise the ceiling in Settings, Snapshots, Paid snapshots.” Nothing deletes to get under it. - This month’s spend.
snapshots.dbgainssnapshot_charges: a charge starts when a priced snapshot is ready, from the moment it holds, and ends when it is lost or its record goes, so a deleted snapshot’s days still count. The month is the calendar month in UTC. Ended charges go after 400 days.src/plugin/snapshot-store.ts. - The page, Options, Snapshots, between Images and Devices, through 067’s drafts and Save: “Each box” (Snapshots per box; Refuse new snapshots when a host’s storage is past, whose words follow the typed value: “or past 80% of a thin pool’s metadata. Past 92%, …”), “Paid snapshots” (a row per backend that charges, with its price, the rate held and this month’s spend, and in amber words when the rate is past the ceiling; otherwise “None of your backends charges for snapshots”), and “What each backend offers” (a row per backend, opening its page: what its kinds’ snapshots hold and cost, and its doctor’s last Snapshots check, with the fix in amber). No switches. The overview’s row reads “5 per box · none taken past 85% full”, with “paid up to $5.00 a month”, or “past a paid ceiling” in amber.
- Find finds Snapshots per box, the room, each backend’s ceiling (or “Paid snapshots” when none charges) and What each backend offers.
- Import and the receipts name each snapshot setting; a refused value
lands on its field (
general.snapshots.ceilings.<id>on that backend’s ceiling). - The CLI:
bb box option set general.snapshots.perBox=<n>andgeneral.snapshots.roomPercent=<n>. Ceilings are set on the page or in the configuration file. - For live checks only, with
BB_BOX_LIVE_CHECKS=1:bb box snapshot fake-price <backend> <dollars a day | off>gives snapshots taken from then on a made-up price, until bb-box restarts, so the ceiling can be checked where no backend charges. - Docs: the guide’s Snapshots page has a Settings section.
Tests
On a Linux box, on main 7652147b: npx tsc passes; the node suite ran
3,010 tests, 2,721 passed, 1 failed, 288 skipped; vitest ran 383, all
passed; node scripts/reference.ts --check passes. The one failure, the
iOS driver restarting a fake idb_companion, is in a file this unit does
not touch, and passed 3 times in 3 when its file ran alone. An earlier full
run had one other such failure, a project’s variables in an enrolled box
(boxes.test.ts), which also passed 3 times in 3 alone.
test/snapshot-cost.test.ts, 5: the ceiling for each of the five changes, at and past it, free and unknown prices, per-GB prices, the rate held (taking counted; failed, lost and free not), this month’s spend across a month’s start, and the settings’ defaults, thresholds and checks.test/snapshots.test.ts, 4 new: a lower and a higher limit, kept ones counted; the room at 60% refusing at 61%, metadata at 56% of 55%, and the sweep keeping at 66% and deleting the agent’s unkept snapshot at 68%; G10 on a fake price (below); an unknown price.test/config-cli.test.ts, 1 new: the two options, their range, unset.test/app.test.tsx, 3 new: the page’s fields, words and Save, sending{ perBox: 3, roomPercent: 60, ceilings: { fake: 7.5 } }; no switches and none of the waiting settings; the empty Paid snapshots, the amber words past the ceiling and the overview’s three values; Find and problem paths.
G10 ceiling-rate
Passed for the take, on a fake priced backend in the tests and a made-up
price live: a ceiling lowered below the rate refuses the next take and
warns on the page and the overview, a sweep deletes nothing, and deleting
keeps the month’s spend. The undo (unit 3), Keep, renewal, held copy and
bulk Keep (unit 8) do not exist yet: their admission is the same rule,
tested for each in snapshot-cost.test.ts, and their units prove it live.
On a separate BB
A fresh npx bb-app (latest) with no account, its own data folder and
ports, started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1
only, in a Proxmox LXC box. bb-box installed from this branch with bb plugin install path:. First start: “Opened snapshots.db, attached to
bb-box’s database: ran 8 migrations.” The lead gave it a Proxmox LXC
backend with its own VMIDs and addresses and a Linux template on LVM-thin.
Each setting was changed on the page in headless Chromium, with Save; the
takes are bb box snapshot on a machine of the person’s. The node’s name
is written pve-1 below.
| Check | Result |
|---|---|
| Snapshots per box set to 2 on the page | stored {"perBox":2}; two takes ready in 0.4 and 0.3 s; the third refused: “Box snapcheck holds 2 snapshots, and your settings allow 2 a box. Delete one first: “one”, “two”.” |
| Room set to 50% | “pve-1’s storage local-lvm is 54% full, and your settings take no snapshot past 50%: a full thin pool stops every box there. Delete snapshots or release boxes on it first, or raise the limit in Settings, Snapshots.” |
| Room back to 85% | stored nothing (the default); the same take ready, “3 of 5” |
| A made-up price of $0.056 a day, the ceiling at its default | two paid takes ready; the third refused: “It would bring snapshots held on Proxmox LXC on pve-1 to about $5.04 a month, past your ceiling of $5.00. …” The page: “$0.056 a day for each snapshot. Held now: 2 snapshots, about $3.36 a month.” |
| Ceiling lowered to 3 on the page | the row adds, in amber, “The rate held is past this ceiling: bb-box refuses new paid snapshots, undos, Keep and renewals there until you raise it or delete some. It deletes none by itself.”; the overview’s row is amber; the next take refused at “$5.04 a month, past your ceiling of $3.00”; after bb box sweep both snapshots are still listed |
| Find | “snapshot limit”: Snapshots per box first; “storage full”: the room; “snapshot ceiling”: “Paid snapshots on Proxmox LXC (…)”, “$3.00 a month” |
| The month’s spend, at a made-up $30 a day, ceiling 1,000 | the charge ran 182.0 s from the snapshot’s capture to its delete; after the delete the page said “Held now: 0 snapshots … Spent this month so far, deleted ones included: $0.06” |
| Clean-up | every snapshot deleted, the machines removed, the price and settings back to their defaults |
Screenshots at 1,440, 760 and 390 px, light and dark, of the page with a ceiling passed and with one held within it, the overview, a draft and Find, are in the unit’s thread storage, not in the repository: they show the test node’s name.
Known limits
- The spend on an open page is as of the page’s last read of the settings. Any change refreshes it; a page left open while a paid snapshot is held shows the spend of when it last read. A reload reads it afresh.
- What each backend offers is one row per backend, with each storage in the doctor’s words, not one row per backend and storage from a plan as 070’s table says: a plan needs a guest, and the doctor reads the templates’ storage without one.
- No backend charges yet, so the ceiling bites only on the made-up price until unit 11.
Unit 5: the agent’s tools, the form and the turn hold
Built on 31 October 2026 on main ec8e31e7 (units 0 to 4 included), and
rebased onto e55c35f4. The lead’s answers are in 070’s Decided during
unit 5.
What changed
- The agent’s tools,
src/plugin/snapshot-tools.ts:box_snapshot(box,name,note,memory),box_snapshots(box, optional),box_snapshot_delete(box,snapshot) andbox_restore(box,snapshot,replace, which must be true, andrequest). Each answers as section 4 says, with “3 of 5 for this box”, and each refusal ends with a hint for snapshots: what to delete, whom to ask, or to try again. A tool finds its box as the other box tools do, and counts the box its thread runs on, its own new box included, as one it runs on. - Section 3, in one place.
mayActlets an agent restore on a box it runs on, andrestoreRoute(src/core/snapshot.ts) decides how: at once, through the form, or not at all. An agent restores its thread’s own box at once, with an undo; through the form when “Agents may restore their own boxes” is off, or when the box is one threads run on; never a memory snapshot with “Agents may restore memory snapshots” off. Nobody restores a box threads run on while such restores are off, and when on, only from a disk snapshot and always with an undo,--no-undorefused. - The agent’s take past the limit deletes, once the new one is ready,
the oldest snapshot an agent of the same thread took and nobody kept, on
the person’s machines too, checked with
eligibleat that moment (limitVictim, from unit 1a). The take’s operation records it, so a restart finishes the replacement. A person’s take past the limit is refused, as before; so is an agent’s when no snapshot qualifies, saying so. If the delete is refused at that moment, the take stands and its answer says the box is over its limit. - “Try again”. A new error code,
try_again, for refusals that hold only for now: a host’s storage too full, for a take or a restore’s undo, and a task such as a backup holding the guest’s lock. The Proxmox plan says the latter asbusy. Agents get “This holds only for now, not for good”; “unsupported” is kept for what the box cannot do. - The form, through
bb.ui.requestInput:box_restoreasks the engine to restore; when the route is the form, the engine refuses withneeds_yesand the plan it would carry out (YesNeeded), and the tool opens the form in the thread (rendererrestore,src/app/restore-form.tsx, registered withpendingInteraction). The form says what goes, the undo that is always taken, the size the box goes back to, what the backend leaves out, and on the box the thread runs on, that its session goes back too. It says an answer is “a yes given in this thread”. On a yes, bb-box holds every new turn on the box, waits up to a minute for turns there to end, then restores the snapshot the form named, by its ID, as its own work, not under the tool’s signal. The yes is bound to a digest of the plan: the box, its guest, the target and its reference, the undo’s kind, what else goes, each thread by its ID, and the box’s snapshots. A plan that changed since asks again, at most three times, and a yes is checked on any route. The form agrees to what goes only on a box threads run on; with agents’ own restores off, a person’s desktop control or a terminal refuses the restore before any form, as it does without one. Cancel and a timeout answer the agent; a stop of the thread, a BB restart and bb-box stopping answer it as an error, which BB only steers in. - The yes on the record. The operation keeps the yes (thread, title,
time, plan), and
restore-askedandrestoredend “on a yes given in “Asks” (thr_…)“. - The turn hold,
src/plugin/turn-hold.ts, on BB’smessage.dispatchhook: a message for a thread on a box that bb-box restores waits, with “bb-box is restoring box dev-desk, the machine this thread runs on. This message waits until the box is ready again.” It holds from a form’s yes, and from a restore’s record by any route, until the box is ready, also after bb-box restarts, as it reads the boxes’ restoring marks. When a hold ends, bb-box asks BB to try the held messages again (recheck), and each wait also asks BB to try again in 15 s (sendAt), as the live check found a recheck BB did not act on. - Checks for a running turn: before the stop, just after it, and before the rollback. One found stops the restore: before the stop with nothing changed, after it by starting the box again as it was.
- Restores of boxes threads run on stay off (
MACHINE_RESTORES_SHIP), as G17 did not pass (below). For live checks only, withBB_BOX_LIVE_CHECKS=1,bb box snapshot machine-restores on|offchanges that until bb-box restarts, andbb box snapshot hold-nextgains--at checked(after the last check, before the stop) and--seconds. - Settings: the agents’ two switches are on the Snapshots page, under Agents, saved at once as 067’s switches are, and in Find.
bb box optionsetsgeneral.snapshots.ceilings.<backend>, in whole cents, refusing a backend that is not there, and the two switches;option listshows a ceiling row for each backend.- Words: the skill’s Snapshots section (when to take one, restore rather than undo by hand, delete your own, try again, the form and ending the turn, never answer bb-box’s forms); the guide’s Agents’ snapshots section; the reference regenerated; requests 38 and 39 in bb-requests.
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, on this unit’s commit over
main e55c35f4, after the reviews’ fixes: npx tsc passes, and the
reference is current; the node suite ran 3,118 tests, 2,830 passed, 0
failed, 288 skipped (the live and conformance ones); vitest ran 386, all
passed. The node suite includes the docs, site and reference checks; they
ran again, with the new tests, on the last commit, which adds only docs. New
or changed tests, each on a set clock where time matters:
test/snapshot-rules.test.ts, 2 new: section 3’s table cell by cell on a set clock (an agent on its own box, on a box it runs on, on another thread’s box, kept snapshots, the person, a command; take, see, see the picture, restore, fork, delete, keep, unkeep, rename and note), andrestoreRoutefor each switch and case.test/snapshot-agents.test.ts, 13: the agent’s take past the limit, never another thread’s, a kept or the person’s, on the person’s machine, and finished after a restart;try_againfor room and a task’s lock; a box threads run on off for everyone, then the person’s restore naming each thread, never without an undo or from memory; G17 in the engine, a running turn before the stop and one that starts as it stops; the yes bound to its plan, a stale one asking again, and the yes on the record, and checked on any route; the settings route and a terminal; agreement by thread ID; an orphan ceiling unset; the turn hold’s release and its words for an unconfirmed restore; an agent’s own box restored at once.test/plugin.test.ts, 5 new: the four tools through BB’s fake host, with G12’s guesses from another thread; the form’s cancel, a wrong answer, a changed plan and the yes; the hold onmessage.dispatchfrom the yes until ready, its words within BB’s 200 characters,sendAtand the recheck;bb box optionfor ceilings and the switches; a failed workspace lookup showing only what the agent took.test/app.test.tsx, 2 new: the switches and Find; the form’s words, focus on Cancel and its answer.- Changed for this unit: a room refusal is
try_again; agents’ restores off now ask through the form;mayActlets an agent restore what it took on a box it runs on; unit 4’s page test no longer expects no switches.
On a separate BB
A fresh npx bb-app (latest) with no account, its own data folder and
ports, started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1,
in a Proxmox LXC box, with bb-box installed from this branch
(live-setup.sh). The lead gave it a
Proxmox LXC backend with production’s template on LVM-thin and its own
VMIDs. dev-desk was a machine of the person’s (bb box machines add), and
the thread “Asks” ran on it, on a scripted ACP agent
(acp-agent.mjs) that calls bb-box’s
tools as an agent would and runs shell commands in its own process. Forms
were answered in BB’s app, in headless Chromium
(form.mjs). The node is written pve-1.
G11, the form (restores of boxes threads run on turned on with the live-check switch):
| Check | Result |
|---|---|
Off (the default): box_restore of dev-desk |
refused: “Box dev-desk is a box threads run on: “Asks” (working) runs on it. … bb-box does not restore such a box yet … bb-box changed nothing.” No form |
On: box_restore of dev-desk |
the form opened; the agent got BB’s own notice, “The request is shown to the user. A successful result will arrive separately. Do not ask again or assume an answer. …” The form named “Asks” with its worktree, files and session, said the thread’s session goes back too, and named the undo |
| Cancel in the app | “bb-box did not restore box dev-desk: … Nothing changed.” reached the thread as a message and started a turn; the file written after the snapshot stayed |
| A stop of the thread | the form went, nothing was restored, and nothing more reached the thread |
| BB’s server restarted (its process killed, BB’s supervisor started it again) | the form went, nothing was restored |
| Restore in the app | restored with its undo first: restore-asked, the undo in 0.3 s, restored in 47 s (stop 1.2 s, undo 0.3 s, rollback 0.4 s, start 45 s, access 0.5 s), each “on a yes given in “Asks” (thr_…)“; the file written after the snapshot was gone; the result started a turn on the restored box, in a fresh session |
| A BB terminal opened on dev-desk after the form, then Restore | nothing restored; the form came again, changed, adding “a BB terminal is open on it” |
| No room for the undo (room set to 50%, the pool 53% full) | refused before any form: “bb-box would take an undo of box dev-desk before restoring it, and pve-1’s storage local-lvm is 53% full …”, with “This holds only for now”; nothing changed |
| “Agents may restore their own boxes” off, on the agent’s own box bold-cloud | the form, “Settings turn agents’ own restores off”; Restore in the app restored it with its undo, “on a yes given in “Asks”” |
| Timeout | after 10 minutes BB ended the form (interrupted, reason timeout); the agent was told “nobody answered the form within 10 minutes. Nothing changed.”, and the box was as it was |
The asking agent answers its own form: bb thread interactions list --self --json, then bb thread interactions respond <id> --self --value '{"restore":true,"plan":"..."}' |
bb was on the agent’s path, and the list gave it the form’s whole payload, plan included. BB said “Interaction … answered”. bb-box restored bold-cloud with its undo and logged it “on a yes given in “Asks” (thr_…)“, word for word as for the app. BB’s own record of the form says plugin_submitted, with nothing of who answered. Recorded in request 38 |
G12: a second thread on the same BB, “Guesses”, given the first
thread’s snapshot IDs and names: box_snapshots of the first thread’s box
said it has no such box; of dev-desk, which it also runs on, “No
snapshots, and 4 you cannot see”; box_snapshot_delete and box_restore
by ID and by name said “That box has no snapshot …” or no such box.
Nothing changed. Pictures come with unit 6; mayAct’s see-picture is
tested above.
G17, the turn hold. Each case restored dev-desk by the person’s
command, with a live-check hold at the step named, and a message to “Asks”
during it; “Send now” is bb thread queue send
(g17.sh, sampling the box, the thread,
its queue and the machine each second).
| Case | Result |
|---|---|
| A message while dev-desk is held stopped | at first BB refused it: “The “bb-box” plugin’s message.dispatch hook failed: returned an invalid decision: reason Too big: expected string to have <=200 characters”. Fixed to a shorter reason; then it was queued, “waiting on bb-box”, and ran on the restored box only after it was ready |
| “Send now” while the box is stopped | BB refused it: “This message cannot be sent yet: the “dev-desk” host is not ready” (HTTP 409); it stayed queued. bb-box’s recheck when the restore ended did not send it, as BB had tried it seconds before, at the machine’s reconnection; fixed with sendAt, it ran 23 s after the box was ready |
“Send now” after the last check, before the stop (held 25 s at checked) |
the turn started on the running box; bb-box’s stop then interrupted it (“Thread interrupted because the host daemon disconnected”); the check after the stop found it, and bb-box started the box again: “a turn runs on it now … Nothing on its disk changed, and bb-box started box dev-desk again as it was.” No disk was replaced under a turn, but a turn ran on a box bb-box was stopping |
“Send now” while the restored box reconnects (held 25 s at started) |
BB’s daemon on dev-desk had reconnected by itself during the boot, so the turn started about 25 s before bb-box called the box ready. The rollback was done and the access file written; the steps left (reading the size, Proxmox’s labels, BB’s reconnect) did not interrupt it. It did not wait, but no disk changed under it |
bb-box restarted mid-restore (bb plugin reload bb-box, held at stopped) with a message held |
the message stayed held through the reload; bb-box finished the restore from the step it reached and the message ran when the box was ready. BB’s orphan sweep releasing held rows was not seen in a reload this short |
After the reviews
Two independent reviews read the commit at 1942c189: Astra (merge after
fixes, six findings) and Opus (merge after fixes, nine findings, having run
the unit’s tests on a box). Every finding below is fixed, with a test,
except where it says otherwise.
| Finding | From | Fix | Tests |
|---|---|---|---|
| A yes given while the route was the form could restore another snapshot after a rename and a settings change, as the tool looked the name up again and a direct route skipped the plan’s check | Astra 1 | The restore after a yes names the snapshot by the ID the form showed, and a yes is checked against the plan on any route; a stale one restores nothing | snapshot-agents.test.ts: “a yes is checked against the plan wherever the restore’s route now goes” |
| When BB could not say where the thread runs, its own box counted as one it does not run on, showing the person’s and other threads’ snapshots | Astra 2, Opus 4 | One check, runsOnBox, for the snapshot tools and box_status: a failed lookup counts as running on it |
plugin.test.ts: “when bb-box cannot tell where the thread runs …” |
| With agents’ own restores off, the form let an agent past a person’s desktop control or a terminal, which the direct route refuses | Opus 1 | The form agrees to what goes only on a box threads run on; on the settings route, control and terminals refuse as before | “with agents’ restores off, the form does not let an agent past …”; live, below |
| The limit never replaced an agent’s snapshot on one of the person’s machines, as such snapshots are the person’s by owner | Astra 5, Opus 2 | The limit goes by who took it: an agent’s, unkept, not an undo; the room sweep still spares the person’s | “an agent’s take past the limit on one of the person’s machines …”; the 1a rule test; live, below |
| A restart between a take and its replacement left the box over its limit | Astra 4 | The take’s operation records the snapshot it replaces and stays unsettled until it is gone; recovery finishes it | “a take that was to replace one past the limit finishes the replacement after bb-box restarts” |
| What goes was agreed by its words, so a new thread with the same title counted as agreed | Opus 3 | Each loss has a key, a thread’s by its ID, and the operation keeps the keys; the plan’s digest hashes the keys and the box’s snapshots, so a new snapshot asks again too | “what goes is agreed by each thread’s ID …”; the plugin’s changed plan is now a snapshot taken meanwhile |
| A form’s hold that ended with no restore on the box did not ask BB to send the held messages | Opus 5 | release asks BB at once when no restore marks the box |
“the turn hold asks BB again when a form’s hold ends …” |
| A restore bb-box could not confirm held the machine’s messages with words that said it was restoring | Opus 6 | The hold stays, and says “bb-box could not confirm its restore of box …, so this message waits. bb box doctor says why.” | the same test |
try_again for a full storage, which does not clear by itself |
Opus 7 | Kept, as the brief asks, with a hint that it works once what it names changes, such as snapshots deleted, and not to retry in a loop | |
| A ceiling for a backend that is gone could not be unset | Opus 8 | The backend is checked only when setting one | “bb box option unsets a ceiling whose backend is gone” |
| A refusal after a yes came back as an error, which BB only steers in, so an agent that had ended its turn would not hear it | Opus 9 | It comes back as a plain result, which starts a turn | |
| Words: the tool’s description and the skill said a box threads run on goes through the form; the guide said an agent restores only its thread’s own snapshots on its own box | Opus | Both now say such restores are off for now; the guide gives section 3’s row | |
| The form waits on the asking thread’s own turn | Astra 3 | Not changed: live, BB’s notice ended the asking turn and the thread was idle (bb thread show) while the form waited, and every yes from the app restored |
|
| A name taken by a snapshot the agent cannot see gives its new one “ (2)“, which tells it the name exists | Astra 6 | Not changed: names are unique per box in snapshots.db. Recorded below |
Live again, with bb-box reinstalled from the fixed commit on the same separate BB:
| Check | Result |
|---|---|
| Agents’ own restores off, a BB terminal open on the agent’s own box | refused before any form: “Restoring box bold-cloud to “one” would end what is on it now: a BB terminal is open on it. Ask the person to end it first …” |
| The same, the terminal closed, Restore in the app | restored with its undo, “It was restored on a yes given in this thread.” |
| Snapshots per box set to 2 on dev-desk, which held the agent’s “before the upgrade” and a command’s “g17 base” | the agent’s take “limit check” was ready, and “The box was at its limit of 2, so bb-box deleted the oldest snapshot an agent of this thread took and nobody kept: “before the upgrade”.” The command’s stayed |
At the end bb-box on the separate BB listed no box and no snapshot.
Gates
| Gate | Result |
|---|---|
| G11 form | Passed with the live-check switch on: the form, cancel, a stop of the thread, a BB restart, the yes from the app with its undo, a changed loss list, no room for an undo, and the agent’s own box with its restores off, each as the gate says, and the timeout. The asking agent’s own answer is taken as a yes, as 070 expected, and recorded |
| G12 permissions | Passed live and in the tests, pictures apart (unit 6) |
| G17 turns held | Not passed: “Send now” after the last check and before the stop runs a turn on a box being stopped. So restores of boxes threads run on ship off, as 070 and the lead say, until BB can hold a machine against “Send now” (request 39) |
Choices and deviations
- Restores of boxes threads run on ship off, on the page (unit 6), the form and the command line. The form, the hold and the checks are built and tested, and work under the live-check switch.
- The yes is recorded on the asking agent’s operation, not as an actor of its own; the undo’s record names the agent (Decided during unit 5).
- A wait of up to a minute for turns on the box to end after a form’s yes, so the asking agent can end its turn.
- A check just after the stop besides 070’s checks before the stop and before the rollback; it is what caught G17’s third case.
- The hold’s reason names the box but not the snapshot, to keep within BB’s 200 characters.
box_snapshotshas nopicturesparameter until unit 6;box_forkcomes with unit 7.- A memory snapshot with agents’ memory restores off is refused, not asked for through the form.
- The form’s restore by the asking agent’s own answer cannot be told from the person’s; bb-box says “a yes given in the thread” everywhere, as 070 says.
Known limits
- A hidden name shows as taken. An agent naming its snapshot as one it
cannot see on the same box gets “ (2)“, which tells it such a name
exists. Names are unique per box in
snapshots.db; making them unique per audience needs a migration. Names only, never their snapshots.
Conditions and open questions
- Restores of boxes threads run on need BB to hold a machine against “Send now” (request 39) before G17 can pass.
- An actor for a yes given in a thread, so the undo’s record says it
too, needs new rows in
snapshots.dband the box log’s store; worth it with request 38, when BB can say who answered. - BB’s recheck did not send a held message that BB had re-tried
seconds earlier; bb-box relies on
sendAt15 s ahead.
Unit 6: the box page’s Snapshots card
Built on 31 October 2026 on main e55c35f4 (units 0 to 4 included), and rebased onto 9ac74968 (unit 5).
What changed
- The card,
src/app/snapshots-card.tsx, a component of its own, after Activity and before the facts on a box’s page. Its heading says “2 of 5, and 1 undo · disk · on pve-1”; each row has the picture, the name with Memory, Kept and Undo badges, then what needs attention first (a failure, incomplete, unavailable, lost, an earlier guest), when, by whom, the size and any price, the undo’s restore, and the note. Restore and the row’s menu show on hover or focus on a desktop and always on a narrow or touch screen. The footer says what snapshots hold and that they go with the box. A box whose backend has none says so, with no Take snapshot. - Its data comes with the page.
boxPagegainssnapshots, built insrc/plugin/snapshot-card.tsfrom bb-box’s records, so the card is laid out in the page’s first frame and nothing in it moves when data comes. What a take would refuse now (the lock with the host’s command, the token’s rights, the limit, the room, the ceiling) is read from the backend’s plan bycheckTake, kept 30 s and read again when the box’s snapshots or state change, at most 2 s per read; the card then says why, and Take snapshot is off in the card and in the header’s menu. - Take snapshot…, in the card and in the header’s menu after Resize…. Its dialog asks for a name (the time if empty) and a note, and “With its memory” where the backend gives the choice. It reads the plan when it opens and says what the snapshot holds and leaves out, the time, the space a memory snapshot takes, the count after it, whether a picture is kept, and the price or “no charge”.
- The restore dialog reads
snapshot.restoreCheckwhen it opens: the restore’s check, as--checkgives it, and the guard as data. It names every thread on the box, working or idle, a person holding its desktop, and each terminal. A box threads run on is refused, saying why, with every thread, the control and the terminals named and no Restore button: restores of execution machines stay off (G17). Control and terminals are listed under “Also goes”, and the button says “Restore (box) anyway”. The undo is ticked; unticked, the button is red, “Restore without undo”. When room or the ceiling refuses the undo, the dialog says why and leaves it unticked, so the person can restore without it, as--no-undodoes. It says the size the box goes back to, and on a swap what stays on the earlier guest. - Notices, with 069’s: while a restore runs, which part it is at (bb-box now signals the page at each part); after it, for 24 hours, “Restored at 04:15 to “one”, taken today at 04:01, by you. Undo restores “before the restore at 04:15”“, with Undo… and Dismiss (remembered in the browser); and a restore bb-box could not confirm, with Check now, which settles snapshot operations as the sweep does. A polite live region says the restore’s progress and result.
- Rename…, Note…, Copy ID and Delete… in each row’s menu.
Snapshots.renameandannotatecheckmayActandeligiblebefore queueing and again on the box’s queue, so a restore’s target is refused at once and named, not renamed after the restore. A name is unique per box. Delete says what it frees (“Frees what it no longer shares with snap-lxc.” on LVM-thin), whether it was kept, and whose undo it is. - Pictures. At each take, and for a restore’s undo before the box
stops, bb-box keeps a picture of the screen of a box that has one: a
JPEG at most 320 px on its longest side, quality 70, encoded by bb-box
(
src/core/jpeg.ts, baseline with halved colour; no new dependency). A desktop’s comes from its computer-use driver (Computer.screenPicture, read-only, which works while a person holds the desktop), a device’s from its device server. It waits at most 2 s, beside a live disk take or before a pausing or memory one, and a picture that fails or is late is recorded asfailedand never fails or slows the snapshot. Pictures are insnapshots.db’ssnapshot_pictures, capped at 48 MB, the oldest of snapshots nobody kept going first, and deleted with their snapshot. They reach the page throughsnapshot.picture, aftermayAct’ssee-picture, never by URL. A tile is 64 by 40 px (48 by 30 at 390 px); it opens the picture at its full size in a popover. - The pictures setting comes onto Settings, Snapshots, in a Pictures group, as a switch that saves at once, and into Find. The configuration reference no longer says “Not used yet” for it.
- The guard counts bb-box’s own terminals. A terminal opened from a box’s page is bb-box’s, not BB’s, and a restore ends it too; the guard counted only BB’s. It now counts both, so the dialog, the CLI and the box log name it.
- RPC:
snapshot.takeCheck,take,restoreCheck,restore,delete,rename,note,pictureandsettle, each as the person. A change answers once done, or after 3 s withpendingwhile it goes on as bb-box’s own operation; the page follows it through the box’s changed signal. - Keyboard and focus. Each dialog starts on its safe choice: Cancel in Restore and Delete, Close when refused, the name in Take snapshot and Rename, the note in Note. Each gives focus back to the button that opened it, or for one opened from a menu, to the menu’s button.
- Docs: the guide’s Snapshots, its settings table, and the reference.
In the same commit, at the lead’s request, outside unit 6’s own scope (a commit of its own until unit 5’s restore changes wove it into the card’s):
- The restore flushes the box’s writes before its hard stop. On
Proxmox QEMU the hard stop dropped what the guest had not written to its
disk yet, so a restore’s undo could miss the last seconds before it.
Before the stop, bb-box now runs
syncin the box over its command transport (Write-VolumeCache on Windows), for at most 15 s. If that fails, the restore stops before the box does unless the person accepts an undo that may miss the last writes (see After the review, 3). The QEMU guest agent’s freeze and thaw are not used: no bb-box path reaches the guest agent yet, andsyncalone was enough live (below).
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, in a fresh copy of this
branch on main e55c35f4, with the next commit: npx tsc passes and the
reference is current; the node suite ran 3,108 tests, 2,819 passed, 1
failed, 288 skipped (the live and conformance ones); vitest ran 392, all
passed. The node suite includes the docs, site and reference checks. The
one failure, a Tart image test timing out at 31 s, is in a file this unit
does not touch, and passed 3 times in 3 when its file ran alone.
test/snapshot-page.test.ts, 9: the JPEG’s markers and sizes, wide, tall and not a multiple of 16; a take’s picture, seen by the person and the box’s agent and not by another thread’s; pictures off and a headless box; a failing and a late picture (aborted at 2 s) with the take still ready; the undo’s picture taken before the stop; the 48 MB cap sparing kept ones; rename and note, unique names, limits, the agent refused, a restore’s target refused and named at once; the take check refusing as a take would and changing nothing, and its cache; the page’s view (newest first, counts with undos apart, the notice for 24 hours with its undo, restoring, and an unconfirmed restore after a restart); the restore preview naming threads, control and terminals, and a refused undo leaving the restore to the person without the CLI’s flag in its words.test/restore.test.ts, 1 new: the flush runs before the power-off and the undo; a failed flush leaves the restore going and says so on the undo.test/app.test.tsx, 8 new: the card’s place, words, badges and the header’s menu item beside Resize…; Take snapshot’s words, focus and the take sent; a refused take and a backend with none; the guarded restore naming every thread, the control and a terminal, with no Restore button; Restore anyway and without undo; the restored, restoring and unconfirmed notices, Undo… and Check now; Delete, Rename and Note with focus; the pictures switch and Find. Unit 4’s tests now expect the pictures switch.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1
only, in a Proxmox LXC box, with BB’s machine access direct so boxes
enrol. bb-box installed from this branch with bb plugin install path:.
The lead gave it a Proxmox LXC and a Proxmox QEMU backend on LVM-thin with
the CPU desktop templates. Two machines of the person’s: snap-lxc (LXC,
2 cores, 4 GiB) and snap-vm (QEMU, the same). The page was driven in
headless Chromium with the keyboard only, by the scripts in
070-snapshots/unit-6; the node is written pve-1.
| Check | Result |
|---|---|
| The page does not move when the card’s data arrives | the card is in the frame where the heading first appears (334 ms after navigation), 129 px high, and its height never changed in 8 s. The one later move is Activity (069) growing from 143 to 775 px at 678 ms when its readings come, which moves everything below it (CLS 0.134); 069 unit 3 lays that out from the first frame, and this is to be measured again on it |
| Take from the card, LXC, keyboard only | Take snapshot reached in 11 Tab presses; focus started in Name; “It holds the disk: files, installed apps and settings. About 1.0 s; snap-lxc keeps running. 1 of 5 for snap-lxc after this. A picture of its screen around now is kept with it. No charge: it is on the host’s own storage.”; ready 0.8 s after Enter; picture taken; focus back on Take snapshot |
| Take from the header’s menu, LXC | the menu’s button reached in 35 Tab presses, Take snapshot… by the arrow keys; ready 1.1 s after Enter; focus back on the menu’s button |
| The picture | 320 by 180, 2.2 KB for an empty desktop; opened from the keyboard at its full size; with a terminal and an editor open, the tile and the popover show both windows |
| Restore and undo, LXC, keyboard only | Restore reached in 4 Tab presses, focus on Cancel; restored to “one” in 48 s to the notice (stop 1.2 s, undo 0.4 s, rollback 0.9 s, Proxmox’s start 45 s, as unit 3 measured for a snapshot taken running); ~/state.txt said “one”; the live region said “snap-lxc was restored to “one”.“; Undo… reached in 30 Tab presses, Escape closed its dialog back to Undo…; the undo took 5.0 s and the file said “three” |
| Take with memory, QEMU | “With its memory” ticked by Space; “Up to 8.5 GiB more on pve-1’s local-lvm. About 5.0 s”; ready in 3.0 and 2.8 s, Memory badge |
| Restore of a memory snapshot, QEMU | 32 s to the notice; the file said “one”; “snap-vm comes back running, as it was then, with the credentials its apps held then” |
| The undo after a restore, QEMU, before the flush | a file written just before the restore, without sync, was missing after its undo; with sync first it was there |
| The same, after the flush | echo nosync > ~/f1, a restore at once, then its undo: f1 said “nosync”, on QEMU and on LXC; the undo had no note |
| A guarded restore, LXC: two threads on its BB machine (a no-model ACP agent, one holding its turn, one idle), a person holding its desktop in another tab, and a terminal open from its page | the dialog refused with focus on Close and no Restore button, naming “Fix the sign-in flow is working on snap-lxc: …”, “Write the API guide is idle on snap-lxc: …”, “A person has control of its desktop.” and “A terminal is open on it.” |
| The same with the threads archived | “Also goes:” the control and the terminal; “Restore snap-lxc anyway” restored in 53 s; restore-asked: “… restored anyway, though a person holds control of its desktop, and a BB terminal is open on it.”; the desktop’s control went back to the agent |
| 390 px | the header’s actions on a row of their own under the name; row actions always shown; tiles 48 by 30 |
Before the guard counted bb-box’s own terminals, the first guarded run named the threads and the control but not the page’s terminal, and the restore ended it: that run found the gap fixed above.
Screenshots at 1,440, 760 and 390 px in light and dark, of the page, the card, Take snapshot, the restore dialog, the guarded dialog, restoring and the picture, are in the unit’s thread storage, not in the repository.
Choices and deviations
- Fork and Keep are not on the card (units 7 and 8), so the footer does not say “unless kept” yet, and rows have no Fork button or Keep… item: no dead controls.
- The viewer’s last frame is not used for pictures. 070 asks for it when a viewer was open within 10 s; bb-box’s server has no decoded frame (the browser decodes them), so every desktop picture comes from the computer-use driver, which reads the screen while a person holds it.
- No toast when the person is not on the box’s page. The page’s restores answer on the page; an agent’s restores come with unit 5.
- Row lines on the box’s page follow 070’s box-page sketch (when, by whom, size, price, life when kept), not the Snapshots tab’s, which also says what each holds: the card’s heading says it once.
- A restore’s notice can be dismissed, as the prototype has it; the choice is kept in the browser.
- Resizing the window across BB’s narrow layout remounts BB’s panel, which closes an open dialog and focuses the page’s heading. That is BB’s layout, not the card’s; it showed in the screenshots, which open each dialog after resizing.
After the review
A review found nine things; each is fixed, with a test. After the fixes
and the rebase onto unit 5 (9ac74968), on a Linux box in a fresh copy of
the branch: npx tsc passes and the reference is current; the node suite
ran 3,135 tests, 2,847 passed, 0 failed, 288 skipped; vitest ran 397, all
passed. The PNG checks live in snapshot-picture.ts, not png.ts, which
is one of the device server’s files and would need a new device server
version.
- A restore goes on only past what the dialog showed. The dialog sends the losses it listed and a token of all it showed (the threads, the control, the terminals, the losses, the undo and whether it was refused, the size back, a swap, the guest). The server reads the preview again: if the token differs, it restores nothing and sends the new preview, which the dialog shows with “What the restore would do or end changed since this dialog read it”. The core refuses, with what came since, a loss beyond those accepted (“Since you looked, more would go with box …: 2 BB terminals are open on it.”), so a terminal opened between the server’s check and the restore’s own is refused too.
- The guard is read again after the flush, right before the stop. A thread that came during the flush stops the restore before the box stops, and nothing changes.
- A failed flush stops the restore unless the person accepts it.
The flush, getting into the box included, has 15 s. When it fails, the
restore stops before the box does, says why, and changes nothing. The
dialog then asks: “Restore anyway, its undo may miss the last writes”;
the CLI takes
--unflushed; an agent cannot. When the person goes on, the answer, the box log, the undo’s note, the restored notice and the undo’s own restore dialog each say the undo may miss the last writes. The page’s restore answers once the box has stopped, or after 60 s, so that every refusal before the stop reaches the dialog. - A screen’s PNG is bounded before it is inflated: at most 8 MiB and 2,048 px a side, inflated to no more than its size needs, its rows checked for length and filter. A 100 by 100 PNG that inflates to 64 MiB, one 40,000 px a side, one with missing rows and one with a bad filter are each refused, and the take goes on without a picture. With these bounds the decode is small enough to stay on the server’s thread.
- A restore that fails after the RPC has answered is not lost. The page shows a failed restore for 24 hours, with its reason, in a notice that the live region also says, until a later restore or Dismiss.
- The 48 MB of pictures is a hard cap. Before a picture is written,
the oldest pictures of snapshots nobody kept go until it fits; if
kept ones alone leave no room, the new picture is refused and its
snapshot’s picture is
failed, with a log line. - A restore’s start keeps the page as it is. The box is “starting” during a restore’s start; the page no longer takes that for a box starting, so the card, the restoring notice and its live region stay mounted and in place.
- After a delete, focus goes to the next row’s menu button, or the one before it, or Take snapshot, or the card’s heading.
- Settings’ group headings are at weight 400, the Pictures heading
among them. Other weights of 500 still in Settings, not changed here:
the page heading (
settings-parts.tsx), the backend fields’ and setup’s section headings (settings-fields.tsx,settings-setup.tsx,settings-backend-fields.tsx), the first-run headings (settings-first-run.tsx), the project images’ heading (settings-images.tsx), the overview’s “things need you” and machine names (settings-overview.tsx,settings-machines.tsx,settings-docker-hosts.tsx), a backend check’s name and state (settings-backend-page.tsx), the box files’ heading (settings-files.tsx) and the configuration’s group labels (settings-configuration.tsx).
Unit 7: forks on Proxmox
Built on 31 October 2026 on main b1bf22db (units 0 to 6 included), and
rebased onto aa192bdf. What was decided while building it is in 070’s
Decided during unit 7.
What changed
- The backends. Proxmox LXC and Proxmox QEMU offer
fork: copyfor their Linux kinds, and their plan says whether a snapshot forks now: not withoutVM.Cloneon the pool, under a lock or a running task, on a QEMU box kept suspended to disk, or when the snapshot is missing or part-made; otherwise the size it holds, read from its config, and the copy’s time and space at most.forkSnapshot(src/backends/proxmox/forks.ts) claims a VMID of the box range, records the guest before the clone, copies the snapshot in full (snapname,full: 1) onto the source’s storage, and in one step re-arms it:onboot1, every link up, a new MAC address on every card, the address of its own VMID, the snapshot’s cores and memory, the new box’s tags, and on QEMU a new SMBIOS UUID with its own address in it. The VMID stays reserved until then, so the box sweep never takes the copy, whose copied tags still name its source, for a leftover. A failed copy is deleted; a copy cut short by a restart is deleted before the copy is made again. - The fork’s box (
Boxes.fork,src/core/boxes.ts): a new record with its origin (fork: the operation, the snapshot, the source and its MAC addresses), checked against the thread’s box limit, the name and the backend’s room, and made through the acquire path. Its copy runs on the source box’s queue. Then bb-box starts it with the backend’sstart, and checks it before it is ready (forkVerdict,src/core/identity.ts), through the backend’s own command transport, so before bb-box writes any access file there: the identity reset’s status, the marker’s MAC addresses, BB’s folders under/home/*and/root, the access files, Tailscale’s state, the machine ID and the SSH host keys. Only then does it write the fork’s own marker, call it ready and enrol it as a new BB machine. A fork that fails the check is released and fails, saying why and “nothing of it acts as (source)”.boot=unknownand a pending reset are looked at again every 2 s for a minute. - What a take records. With
forkSafe, a take keeps the SHA-256 of the box’s machine ID and of each SSH host key’s key on the snapshot’s record (identity, a new column insnapshots.db, by migration), read by the same command asforkSafe. - The engine (
Snapshots.fork,checkFork,forkEnded,src/core/snapshots.ts):mayAct’s fork row; the snapshotready, of a living box, of the box’s own disk (not a device or a held copy), of a Linux box, withforkSafe; the backend’s plan. The operation is recorded before the box, pins the snapshot, and is settled by the fork’s box: done when it is ready, failed when it fails or goes, left while it starts, also after a restart. A repeatedrequestgives the same fork. box_fork(box,snapshot,name): into the agent’s own thread; with no snapshot it takes one of the box now, “for a fork”, first. It answers asbox_acquiredoes, with the fork’s origin among its facts.- The CLI:
bb box snapshot fork <box> <snapshot> [--as-machine] [--name <name>] [--check] [--request <id>] [--json]; withBB_BOX_LIVE_CHECKS=1,hold-next --at forkedholds a fork after its copy is re-armed, before its first start. - The page. Each row of the Snapshots card has Fork after Restore, in
the row’s menu as Fork… below 640 px, off with why where the snapshot
cannot fork. The Fork dialog asks for the name and, for a thread’s box,
whether the fork goes in its thread or among your machines; it says what
the fork holds, its size, and the copy’s time and space at most; Fork
opens the new box’s page. A fork’s page says “Forked from (source),
(snapshot) ((when))” under Made.
snapshot.forkCheckandsnapshot.forkare new RPCs. - Docs: the guide’s Forking a snapshot,
the skill’s words for
box_fork, the reference, and 070’s notes.
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, in a fresh copy of this branch
over main aa192bdf: npx tsc passes, and the reference is current; the
node suite ran 3,184 tests, 2,896 passed, 0 failed, 288 skipped (the live
and conformance ones); vitest ran 414, all passed. The node suite includes
the docs, site and reference checks; they ran again on the last docs.
test/forks.test.ts, 7, throughBoxesand the fake provider, whose forks copy the snapshot’s files and run the realbb-box-identityon a root of their own with a new MAC address: a fork resets, is checked, gets its own marker and the snapshot’s file, and its source keeps its BB machine, marker, machine ID and keys; a fork given its source’s name, as one of your machines, after the same name was refused in the thread; the snapshot pinned while the copy runs and free after; the take’s hashes on the record; the box logs. A fork whose first boot did not reset is never ready: its source’s MAC address, its image’s reset off, and a boot that could not tell, each released with why. A late card held untilsettleresets it, then ready. A snapshot withoutforkSaferefused with the words, nothing copied. An agent forks its own box into its thread, never one it runs on, another thread’s, or as your machine; the check changes nothing; a repeated request. A refused plan and a failed copy. Recovery after a restart: done once ready, failed once released.test/proxmox-lxc-snapshots.test.tsandtest/proxmox-qemu-snapshots.test.ts, 3 more: the clone of the snapshot, recorded before it, re-armed withonboot, links up, a new MAC, its own address, its labels, its source untouched, the sweep never seeing the half-made copy as its source’s; a failed copy and a missing snapshot; a half-made fork deleted after a restart and another guest never; on QEMU a new SMBIOS UUID with its own address, no saved memory, and Windows refused.test/plugin.test.ts, 1:box_forktakes “for a fork” first and refuses a snapshot without a working reset, as the CLI’s--checkdoes; another thread finds no such box.test/app.test.tsx, 3: the dialog’s words, focus on the name, the choice of where it goes, Fork sent with them; Fork off with why; a fork’s facts.
The test box’s better-sqlite3 binary, installed while npm 11 held back
install scripts, crashed at the end of files that use it, on origin/main
too; with the build from another box of the same image, every file ran.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and ports,
started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1, in a
Proxmox LXC box, with bb-box installed from this branch
(live-setup.sh) and
general.identityResetBackends set to both Proxmox backends on that BB
only. The lead lent it a Proxmox LXC backend (template 9800, and 9803, a
CPU desktop from before unit 2, without bb-box-identity) and a Proxmox
QEMU backend (template 9806, and 9807), on LVM-thin, with the production
token and 12 VMIDs and addresses of their own. Both Linux templates carry
version 5 of the identity reset. An agent’s thread ran on the scripted ACP
agent of unit 5. Root steps ran from
proxmox-root.sh, which the lead
read first; the page was driven by
fork-page.mjs. The node is written
pve-1.
| Check | Result |
|---|---|
| The take | the agent’s box_snapshot of its LXC box gentle-acorn (armed, boot=unarmed at its first boot, before its marker): forkSafe 5, and the record held the SHA-256 of its machine ID and of its two host keys, as read in the box |
box_fork, LXC |
the agent forked “set up” into fair-comet, ready 15 s after the call (copy and start 12 s): at its first boot the reset ran at 09:07:12.163, before systemd-networkd (.174) and ssh (.264): “removed BB’s machine credentials and host ID (host_u4mqv4jcdv), the thread’s access file and the tailnet node; made new SSH host keys and a new machine ID”. bb-box’s check passed and wrote its marker (its own MAC, its own box). The fork had the snapshot’s file, a new machine ID and new host keys (none of the take’s hashes), and joined BB as host_g2az9ezrmi |
| The source’s host ID | BB’s server log has one “Session opened” for host_u4mqv4jcdv, at 09:05:38, never replaced, through every fork of it; each fork had a session of its own |
| A fork given its source’s name, LXC | bb box snapshot fork gentle-acorn "set up" --as-machine --name gentle-acorn: ready in 13 s, boot=reset, the same file, BB machine host_4eb9mavc7e; BB listed two machines named gentle-acorn, each with its own host ID, both connected, and the source’s still had its one session |
--check |
“Forking … would make one of your machines, with 2 cores, 4 GiB memory, 16 GiB disk, as the snapshot holds. … Nothing changed.” |
| QEMU, from a memory snapshot | src-vm (boot=unarmed, armed) with sleep 9999 running, taken --memory; vm-fork ready in 22 s: the reset at 09:12:02.718, before networkd (.730) and ssh (.852); a fresh boot (up 0 minutes), no sleep 9999, a new SMBIOS UUID; its own BB machine. The source’s second session, at 09:11:50, replaced its first after the memory snapshot’s pause, 12 s before the fork booted |
| QEMU, from a disk snapshot | a file written just before the take, one flushed, and one written after sync: all three in the fork |
onboot and links (proxmox-root.sh config, by the lead) |
the forks 3171, 3172, 3175 (LXC) and 3177 (QEMU): onboot: 1, no link_down, MAC addresses of their own, their own addresses, their own box’s tags; 3177 a new SMBIOS UUID |
| A first boot that says unknown, QEMU | held after its copy (hold-next --at forked); the lead removed its card at 09:16:59, bb-box started it at 09:17:26; its check said at 09:18:06 “not knowing yet whether this box is a copy … It removes nothing”, and SSH stayed down; bb-box showed it starting. The lead put the same card back at 09:18:42; settle reset it at 09:18:42.80, SSH started at .93, bb-box’s check ran at 09:18:43.59 and wrote the marker at .64, and only then was it ready, 116 s after the request |
| An image without the unit | old-desk, from template 9803, has no bb-box-identity; its snapshot listed “not fork-safe: old-desk had no working identity reset when it was taken”, and the fork was refused before anything was copied: “old-desk had no working identity reset when “old image” was taken, so bb-box does not fork it: …” |
| A restart mid-fork | held after its copy at .184, then bb plugin reload bb-box: the copy was deleted, made again at .185 (copy 12.6 s) and checked, ready 32.5 s after the request; 0 operations not settled. The CLI that waited on it answered “is acquiring”, as the reload ended its wait |
| The page, 1,440 px, keyboard only | gentle-acorn’s page: Fork reached in 14 Tab presses, focus on the name, the default “In (thread)”, Enter on Fork: the new box’s page opened 0.9 s later, starting; jolly-tundra was ready in 11.5 s, its facts “Forked from gentle-acorn, “set up” (today 09:06)” |
| The page, 390 px, keyboard only | src-vm’s page: the row’s menu, a sheet at that width, reached in 13 Tab presses, then Fork…; the dialog on the name, “It becomes one of your machines.”; the new box’s page 0.9 s after Enter; page-fork ready in 19 s, with its origin in its facts |
| Permissions | a second thread’s agent: box_fork of the first thread’s box said “This thread has no box “gentle-acorn”“; the first thread’s agent of one of your machines: “This thread has no box “src-vm”” |
Afterwards every box and snapshot of the separate BB was released or
deleted, and the lead’s leftovers listed no guest of the install in the
lent VMIDs.
Gates
| Gate | Result |
|---|---|
| G14 fork-identity, unit 7’s part | Passed on Proxmox LXC and QEMU: each fork reset at its first boot before its network, SSH and BB’s daemon, also one given its source’s name; each started with onboot set and its links up; the source’s host ID never had a second connection from a fork; a snapshot from an image without the unit refused to fork; a fork whose first boot said unknown was not handed over until it reset, on QEMU and, after the changes above, on LXC. One LXC late-card run before those changes took its source’s address, which cut the source off BB for 40 s and made it open a session of its own again; a fork that holds its source’s address is now refused, and version 6 keeps a late card from taking it. A logged-in tailnet node was not part of these checks: unit 2 proved the reset removes one, and the check refuses a fork whose Tailscale state still holds a node |
Choices and deviations
forkSnapshottakes the previous guest, asacquiredoes, so a fork cut short by a restart deletes its half-made copy; and it returns the guest stopped, for core to start and check (070 sketched neither).- The check compares hashes the take kept (Decided during unit 7); snapshots taken before this unit fork with that comparison left out.
--as-machine, not--machineor--thread, which already name where the source box is; forks into another thread stay later.- A fork has the snapshot’s size; no larger size is offered.
- The copy runs on the source box’s queue, so its take, restore, stop and release wait for it.
- bb-box’s authorized key is the template’s on Proxmox, the same in
every box of an install, so a fork’s is as much its own as any box’s.
Keys a person or agent added to
authorized_keyscome with the disk. - The copy’s time is an upper bound: 2.4 s for each GiB of the disk, unit 3’s rate for a held copy. It said 38 s for 16 GiB disks; the copies took 12 to 16 s with about 2 GB used.
Takes flush first
The live check found that a fork of a QEMU memory snapshot lacked what the VM had not written to its disk: vm-fork’s reset found no BB machine to remove, as the source’s credentials, written 18 s before the take, were not yet on its disk. Proxmox saves memory instead of freezing the file system, and a fork starts from the disk only. At the lead’s request:
- Before a take of a running box, bb-box runs
syncin it, as unit 6’s restore does before its stop, with the same helper and 15 s limit, on Proxmox LXC and QEMU, disk only and with memory alike. A stopped box has nothing to flush. - A flush that fails or times out stops nothing. The take goes ahead,
and its record keeps why (
unflushed, a new column insnapshots.db, by migration). The take’s answer says “bb-box could not flush the box’s writes to its disk before it (why), so it may miss what was written in the last seconds before it”; the list and the page’s row say “may miss its last writes”;--check, the Fork dialog, the fork’s answer andfork-askedsay the fork may lack them too. A restore’s undo whose flush failed records it the same way. Keep comes in unit 8 and is to say it too.
Tests, in test/take-flush.test.ts, 3: the flush before each take, disk
and memory, and none for a stopped box; a failed flush, the take ready
and its record, answer and list words; a flush that never answers holds a
take 15 s at most. In test/forks.test.ts: the words of --check, the
fork’s answer and fork-asked for an unflushed snapshot. Two unit 1a tests
that restarted bb-box 20 ms into a take now wait for the take’s hold, as
the flush comes first.
Live, on a second separate BB set up as the first, with the same lent
backends: src-vm (Proxmox QEMU, armed) joined BB, whose credentials were
written at 09:59:30.82 (host-id) and 09:59:30.86 (auth.json); a file
was written at 09:59:32, and the memory snapshot taken at once, ready in
2.0 s, flushed (no unflushed). Its fork vm-fork was ready in 15 s; its
reset at 09:59:49 removed host_q83i4g7zfe, the source’s machine, so its
credentials were on the snapshot’s disk; the file was there too, and no
process of the source ran in it.
A late card on Proxmox LXC, and the source’s address
An LXC fork whose card came late was released at the container start’s
30 s wait for SSH, before its reset could run; a VM waits 180 s. At the
lead’s request, a fork’s first start now waits up to 180 s for SSH on
Proxmox LXC as on QEMU (start takes how long to wait; only a fork’s first
start asks for more). Then the live check of it found a fork that took its
source’s address, and two more changes answer that.
Live, on the second separate BB (src-lxc a Proxmox LXC machine of the
person’s, armed, at .180; the lead ran
proxmox-root.sh card-late,
which takes a held fork’s card off before its first start and puts it back
75 s after):
| Run | Result |
|---|---|
| First try, 3171 | the script acted on the clone while Proxmox still held lock: create, as a full clone copies its source’s onboot: 1 at once, and pct set failed on the lock; nothing changed, and that fork started with its card. The script now waits for no lock and for net0 to stay the same for 5 s, which is bb-box’s re-arm done |
| The wait, 3172 | started at 10:51:33 with no card; its check said “not knowing yet” after 30 s; the card came back at 10:52:50; settle reset it at 10:52:51.34, removing host_w8h3gy9iwv, src-lxc’s copied machine, and refusing that machine’s daemon; ssh at .37; bb-box’s start waited 76.8 s for the network and handed it over, ready, joined as its own machine. But it held .180, src-lxc’s address, beside its own .182: with no card at its start, Proxmox wrote no network file, so the copy kept its source’s; when the card was hot-plugged, systemd-networkd brought up .180 before Proxmox wrote the fork’s own file, at 10:52:50.877. src-lxc’s daemon logged “Server heartbeat acknowledgements stopped; reconnecting” at 10:53:25 and was connected again at 10:53:37, a session of its own: BB’s server shows that session from src-lxc, and the fork’s copied daemon never started. src-lxc was back on .180 once the fork was released |
| The guard, 3173 | the check before ready now reads the fork’s global IPv4 addresses and refuses a fork that holds its source’s address (the source’s address is kept on the fork’s origin). The same run: refused at 11:04:48, 3 s after the card came back, “bb-box did not hand fork lxc-late3 over: it holds src-lxc’s address .180, so it would take what is sent to src-lxc. bb-box released it”; src-lxc kept its session, and its daemon logged nothing then |
| Version 6, 3174 | bb-box-identity version 6 installed by hand in src-lxc, then a snapshot (forkSafe 6). At 11:14:28 the fork’s boot said “not knowing yet” and “held its network settings ( eth0.network ) aside while it cannot tell whether it is a copy”; the card came back at 11:15:12; the reset at 11:15:13 removed src-lxc’s machine; the fork held only .184 (the lead read lo and eth0 .184 alone 20 s later, and bb-box’s check found no .180), and was handed over, ready, waiting 76.8 s for its network. src-lxc kept the session it had since 10:53:37, and its daemon logged no disconnect |
Version 6 of the identity program (bb-box-identity.sh). On Proxmox
LXC, when a boot cannot tell yet whether the box is a copy, the program
moves the eth cards’ network files (/etc/systemd/network/ethN.network)
aside, to /run/bb-box-identity/held-network, before systemd-networkd
starts. A card added later then gets its settings only from Proxmox’s
hot-plug, which writes them. Once settle decides, a copy drops the held
files, and the box itself gets them back if Proxmox wrote none. The reset
alone would be too late: networkd applies a file the moment the card
appears, before settle looks again. A VM’s address comes from its SMBIOS
at each boot, so nothing is held there. Templates 9800 and 9806 carry
version 5, so the LXC template needs a rebuild before production has this;
until then bb-box’s check refuses such a fork. A take now records
forkSafe 6 on a box with the new program.
Tests: test/identity-reset.test.ts, 2: an LXC copy started without its
card holds its copied file aside, so none is there when networkd starts,
and once it resets the backend’s own file stays and the copied one is
gone; the box itself, its card late, gets its file back if the backend
wrote none and keeps the backend’s if it did; a VM, and a copy whose card
was there at its start, hold nothing. test/forks.test.ts: a fork holding
its source’s address is refused and released, and a fork’s start asks for
180 s. test/proxmox-lxc-snapshots.test.ts, 1: an LXC start waits 30 s,
and a fork’s first start 180 s, before it says nothing answered.
After these changes, on the same kind of box, in a fresh copy over main
aa192bdf: npx tsc passes and the reference is current; the node suite
ran 3,191 tests, 2,903 passed, 0 failed, 288 skipped; vitest ran 414, all
passed. The docs, site and reference checks ran again on the last docs.
Rebased onto main 812f78dc (069 unit 4), in a fresh copy: npx tsc
passes and the reference is current; the node suite ran 3,196 tests, 2,908
passed, 0 failed, 288 skipped; vitest ran 423, all passed.
The second separate BB’s boxes, forks and snapshots were removed, and the
lead’s leftovers found no guest of its install.
After the reviews
Two reviews read the three commits above: Astra asked for eight fixes and Opus for two, with three more to follow. All of them are in. Where they changed what is written above, this section is what holds now.
A clone cannot start until bb-box arms it. The first request after
Proxmox’s clone task ends sets onboot 0 and every link down
(disarm, src/backends/proxmox/forks.ts). bb-box then gives the copy
its new MAC addresses, its own address and, on QEMU, its new SMBIOS UUID,
each with its link still down. Before the first start, bb-box brings the
links up (armFork("network")). It sets onboot 1 only after the check
before ready passes (armFork("boot")). A held copy for a restore is
disarmed by the same first request, before its labels. A reboot of the
node before the check passed would leave the fork stopped.
Known limit: the gap before the disarm. A full clone copies the
snapshot’s onboot: 1 and its source’s network settings at once, while
Proxmox holds the clone’s lock. The lock goes as the task ends, and
bb-box’s disarm lands one task poll (50 ms) and two requests later. A
hookscript could close that gap, but the lead chose not to add one, for
these reasons:
- the lock covers the whole copy
- a node that reboots inside the gap starts the copy with a MAC address of its own, so its first boot resets it
- bb-box’s recovery deletes a half-made fork when it starts again
The node-side sampling found the gap under 217 ms, with one 0.1 s sample in it.
The network hold, version 7 of the identity program. Version 6 held an LXC copy’s network files only once the boot’s wait ended unknown. A card hot-plugged during that wait made a copy that reset without holding them, which raced Proxmox’s own write. Version 7:
- holds the copied
ethN.networkfiles at the boot’s first look, before it waits, when the box has noethcard and none of the marker’s MAC addresses on any interface (ip -j link, so a renamed card counts) - holds them in
/var/lib/bb-box/held-network, flushed to disk, so a restart beforesettledecides loses nothing: the next boot’s decision puts them back for the box itself if Proxmox wrote none, or drops them for a copy - deletes the copied files if it cannot hold them; if it cannot delete them either, the boot fails closed, and the network, SSH, Tailscale, Docker, the desktop and the box user’s services stay off
- never holds anything for a VM, for a box whose card is there at its start, or for the box itself with a renamed card that carries its marker’s address
Template builds remove /var/lib/bb-box/held-network (finalize.sh).
Each backend forks only from a version known safe. An offer now says
forkSafeFrom: 7 on Proxmox LXC, as earlier versions can bring up a
source’s address, and 5 on QEMU. A snapshot taken under an older version
is refused before anything is copied, with words that say which version
it needs. The Fork button is off with the same words. The check before
ready refuses a fork whose reset reports an older version.
The check before ready reads everything or refuses.
- Every observation prints a line of its own,
missing=orunreadable=for each item it could not read, and the probe ends withprobes=done. A missing end, an unreadable item, or a machine ID or host key that is missing or empty refuses the fork. - A take records
forkSafeonly when the machine ID and host keys are whole. - The fork must hold its own address. Reading no address at all refuses it, and so does any other address in the backend’s box range, not only its source’s.
- A shared MAC address, the source’s address and another box’s address
refuse at once, before any wait.
unknownand pending still wait, and are never taken as a reset. - The marker must match and be armed, and the reset enabled.
A failed clean-up is not hidden. When bb-box cannot delete a failed fork’s guest, for example while Proxmox still holds its clone lock, it stops the guest, keeps it on the box’s record and says so: “bb-box could not delete its guest (VMID) yet (why), so it stopped it. It tries again at each sweep, and never hands the fork over.” The operation stays unknown, with “Its guest (VMID) is not deleted yet; bb-box tries again at each sweep”, and keeps its pin. The sweep deletes the guest later and then settles the operation. Recovery after a restart leaves such a box for the sweep.
box_fork checks before it takes. With no snapshot named, box_fork
first checks the box as a fork of it would be checked:
- who may fork it, and not a box the agent runs on
- the backend’s offer and its plan for both the fork and the take
- the box’s identity reset now, against the backend’s minimum version
Only then does it take “for a fork”. If the fork is still refused after the take, bb-box deletes that snapshot and says so. The take’s words, including an older snapshot evicted at the box’s limit, are passed on.
Names and kept snapshots.
- A fork’s name is checked before anything is recorded, against the same rule as a machine’s name, in core, the tool, the RPC and the dialog.
- A person’s fork of a kept snapshot is always one of their machines, with no choice of thread, as section 3 says.
Flush words fit the record. unflushed is cut on a UTF-8 boundary,
below the column’s 1,024-byte limit, so a long error in any script no
longer fails the take.
Tests, 17 new or rewritten:
test/forks.test.ts:- the verdict table
- an old version refused
- a clean-up that fails and the sweep that finishes it
- kept snapshots and bad names
box_fork’s check before the take- the order of copy, links up, start (180 s), check and
onboot
test/identity-reset.test.ts, 5 for version 7:- the hold before the wait
- a card during the wait
- a reboot before
settle - a hold that fails
- nothing held where nothing should be
test/proxmox-lxc-snapshots.test.tsandtest/proxmox-qemu-snapshots.test.ts:- the disarm as the first request after the clone
- the two arms
- a held copy disarmed before its labels
- the offers’
forkSafeFrom
test/plugin.test.ts:box_fork’s refusals before and after its taketest/take-flush.test.ts: a long non-ASCII flush error
On a Linux box, over main 812f78dc: npx tsc passes and the reference
is current; the node suite ran 3,206 tests, 2,918 passed, 0 failed, 288
skipped; vitest ran 423, all passed. The node suite includes the docs, site
and reference checks; they ran again on the last docs.
proxmox-root.sh changed with
these fixes. card-late now waits for the fork as bb-box holds it:
stopped, no lock, onboot not set, net0’s link down. It takes how long to
wait after the start, and puts net0 back with its link up. The new sample
reads the clone’s config every 0.1 s until bb-box’s arm. Both take several
VMIDs, as a fork’s VMID is not known ahead. Before the live run, both ran
against a fake /etc/pve and pct on a box: sample passed a 20 ms gap
and caught a 600 ms one.
The combined live recheck
On the second separate BB, with this build and the same lent backends.
src-lxc was a new Proxmox LXC machine of the person’s, at .185, with
version 7 installed by hand. Its snapshot “v7” was recorded forkSafe 7,
flushed, with its identity hashes. The lead ran two commands of
proxmox-root.sh on the node,
after reading it:
sample, started before the fork: it read the new guest’s config from/etc/pveevery 0.1 s, with whether it ran, until bb-box’s armcard-late, with 10 s: it took the held fork’s card off and put it back, link up, 11 s into its first boot, inside the boot’s 30 s wait
The fork, lxc-v7, was held for 180 s after its copy (hold-next --at forked) and became VMID 3170, at .180.
| Time | What happened |
|---|---|
| 12:20:50.067 | the clone’s config appeared: lock: create, onboot: 1, link up, as the snapshot held |
| 12:20:57.603 to .712 | the lock went: the last locked sample, then one with onboot: 1, no lock, link up, stopped |
| 12:20:57.812 | bb-box held the fork; the hold comes after the disarm and the addressing |
| 12:20:57.820 | the next sample: onboot: 0, link down |
| 12:21:08.208 | card-late removed net0 (the fork’s own MAC address and .180, link_down=1) |
| 12:23:57.995 | the fork ran, with no card, onboot: 0 |
| 12:23:58.876 | the boot’s first look: “held its network settings ( eth0.network ) aside in /var/lib/bb-box/held-network, as it started with no card of its own” |
| 12:24:09.229 | “this box is a copy, so it resets its identity”: the card came during card-late’s pct set, which returned at .282 with net0 back, link up |
| 12:24:09.347 | the reset removed host_zhgynaepxa, src-lxc’s copied machine, and made new host keys and a new machine ID |
| 12:24:09.425 | systemd-networkd started, configuring eth0 with Proxmox’s file for the fork |
| 12:24:09.507 | ssh started |
| 12:24:09.8 | bb-box’s check passed (30 ms) and wrote the fork’s marker |
| 12:24:09.840 | onboot: 1, the arm, the first sample with it and no lock since .712 |
| 12:24:11.604 | joined BB as host_f63v89a5ig, ready 200 s after the request (180 s of it the hold; network 10.8 s) |
| Check | Result |
|---|---|
| The hold from the first look | 0.9 s into the boot, with no card, 10.4 s before the card came |
| The reset before the network | at .347, before networkd (.425), ssh (.507) and BB’s daemon (12:24:10.49) |
| Addresses | lo and eth0 .180 only; eth0.network said .180; /var/lib/bb-box/held-network gone; boot=reset, version 7, the marker naming the fork’s own box |
| The source | src-lxc kept .185 and its one session, opened at 12:17:03.497; BB’s server shows no other session and no close for its host ID, and its daemon logged no disconnect |
| No start before the arm | onboot: 0 from .820 until the arm at 12:24:09.840, after the check passed; links down until bb-box brought them up for the start |
| The gap | one sample, at 12:20:57.712, had onboot: 1 with no lock: the known limit above. The samples put the gap after .603 and before .820, so under 217 ms, and bb-box’s hold at .812 agrees. Its expected width is one task poll, 50 ms, and two requests. The lead accepted it |
The fork’s file from the take was there. Afterwards src-lxc and lxc-v7 were removed with their snapshot.
After the re-reviews
Astra’s re-review found three fixes only partly done, each shown by a probe. Opus’s re-review said merge, with one cheap follow-up. All four are in. Each probe is now a regression test, and each test fails on the commit before these fixes.
A restart during a fork’s first start keeps its guest (Astra 5). When bb-box stopped while a fork was starting, the fork’s record dropped its guest, which went on running. After the restart, bb-box copied the snapshot again beside it. Now bb-box leaves a fork’s record as it is when it stops, guest included. After the restart, it makes the copy again only once the old guest is deleted. On Proxmox, the backend disarms the old guest, deletes it, and checks that it is gone. If it cannot delete it, it makes no other copy.
A read that fails is not a clean answer (Astra 6). The fork’s check took grep’s error, reading its Tailscale state, for “no tailnet node”. It now tells no match (exit 1) from an error (2 or more): an error makes the state unreadable, and the fork is refused. Two more reads failed open the same way, and now refuse:
- a failed hash of the machine ID or a host key, which gave an empty value that the parser dropped
- a failed read of the identity marker, which gave no MAC addresses
box_fork checks room and place before its take (Opus 2). The check
before the take now runs the fork’s own admission:
- the backend’s room for the box’s size
- the thread’s box limit
- a name the thread already has
- for one of the person’s machines, the machine-name rule and enrolment
With no room, the take no longer deletes the agent’s oldest snapshot for a fork that is then refused. Room can still change between the check and the fork. If it does, the snapshot taken for the fork is deleted, as before, but a snapshot the take evicted does not come back.
An abandoned clone is disarmed before each delete (Opus’s follow-up).
A fork can fail, be released or be cut off by a restart while Proxmox
still holds its clone lock. bb-box now disarms the clone, onboot 0 and
every link down, before each attempt to delete it:
- in the backend’s own clean-up after a failed or cut-short copy
- when it deletes a half-made fork after a restart
- in core, before each release of a fork’s guest, when the fork fails and at each sweep after
A Proxmox request waits up to 120 s for a lock, so the disarm lands as soon as the clone task ends. If the lock outlasts that, bb-box skips that delete, which would wait on the same lock, and tries again at the next sweep. The disarm finds a fork’s clone by its description, which names the fork from the clone’s start: the clone’s copied tags name its source until bb-box labels it.
Tests, 5:
test/forks.test.ts:- the real check script with a failing grep,
sha256sumandsed: each item unreadable, and the fork refused - bb-box stopping during a fork’s first start, then recovery: the guest kept on the record, and deleted before the copy is made again
- room and a taken name refused before
box_fork’s take, with both snapshots still there at the limit - a disarm before each release of a fork’s guest, at the failure and at the sweep
- the real check script with a failing grep,
test/proxmox-lxc-snapshots.test.ts: a failed copy disarmed before its delete, and left disarmed when the delete fails; recovery’s disarm before its delete;disarmForkacting only on the fork’s own guest
On a new Linux box, over main 812f78dc: npx tsc passes and the
reference is current; the node suite ran 3,211 tests, 2,923 passed, 0
failed, 288 skipped; vitest ran 423, all passed. The 5 new tests, run
against the previous commit’s source, all failed, each on the gap it
covers.
Conditions and open questions
-
Rebuild the LXC template with version 7 of the identity program before arming Proxmox LXC. Until then no LXC snapshot forks: bb-box forks Proxmox LXC boxes only from version 7. A snapshot keeps the version it was taken under, so snapshots from before the rebuild never fork on LXC. The QEMU template forks from version 5 and needs the rebuild only to report version 7.
-
Arming production (
general.identityResetBackends) is the owner’s call; until then no production snapshot is fork-safe, and every fork there is refused with why.
Unit 8: Keep
Built on 31 October 2026 on main cf84a20a (units 0 to 7 included). The
lead’s answers are in 070’s Decided during unit 8.
What changed
- Keep and Stop keeping,
Snapshots.keep,unkeepandcheckKeepinsrc/core/snapshots.ts, with the person’s rules from section 3 (no agent keeps or unkeeps;mayActalready said so). A keep date is the end of a UTC day: the days in Settings from today, or a date given, at most 365 days ahead. Keep is offered only for a snapshot a fork could use after the release: ready, of the box’s own disk, of a Linux box, taken with the identity reset at the version its backend forks from, on a backend that keeps past a release, and with a fork plan that isok. Otherwise it is refused, and the row’s Keep… is off, with 070’s words: “Taken before old-desk’s image had the identity reset: “old image” could not be forked after a release, so bb-box does not keep it.” The check says what keeping costs and warns, without refusing, when a release would find no room or VMID for the held copy at today’s size. Keep and renewal pass the ceiling’s rule (keep,renew). Each change is checked witheligibleagain on the box’s queue. - Keep dates in labels. Keep writes the date on the provider first:
kept <yyyy-mm-dd>in the snapshot’s own description on Proxmox (describeSnapshot), or a held copy’skept.<date>tag and description. If the provider refuses, nothing is kept.labelKeptin both Proxmox backends andProxmoxSnapshotting. - Held copies at release,
holdKept, whichBoxes.#finishReleasecalls throughbeforeRelease, before the guest is deleted. For each kept snapshot on the box’s guest or its earlier guest: an operation (hold-copy) pins it and records each step before it runs; the room check (a take’s rule, sized at the box’s disk use where Proxmox reads it, or the snapshot’s disk);copySnapshot, a full clone of the snapshot into the box range, recorded before the clone and disarmed in its first request;disarmCopy;templateCopy, which now waits until Proxmox lists the copy as a template; then the record moves to the copy in one write, holding the disk only. The copy’s tags carry the box, the snapshot, its time, its keep date, the box’s kind and its reset version.held-copy-madeis logged. If a copy cannot be made, the release stops and deletes nothing: “Release did not finish: bb-box could not copy “roomy”, which you kept: …“. Withsnapshots.dbunreadable, the release reads the provider’s labels and stops while any says kept. - Keep’s recovery check. An unsettled
hold-copyoperation is finished at start and at each sweep, and by the release that resumes: a copy that is a template is confirmed; one that is not is deleted and made again from the snapshot, still pinned, never trusted; one found running before its template is loggedheld-copy-started. A part-made copy that cannot be deleted keeps its operation unknown and pinned, and the sweep tries again. - Release and delete what you kept:
unkeepForReleasetakes each keep off, at the person’s word, then the release goes on. The box’s menu and your machines’ Remove offer it, with Release again, once a release stopped for a copy (releaseHeldForKepton the box’s view); the CLI’sbb box releaseandbb box machines removetake--delete-kept. - Forks of held copies: any held copy that is a template forks, by a
full clone of the template (
forkByFullClonewithfrom: "held-copy"); a kept snapshot of a released box forks as one of the person’s machines, from the tab or the CLI.guestIdentityreads a held copy’s config. - Expiry: the sweep deletes a kept snapshot after its date, held copy and all, as a delete by bb-box, “its keep ended”, never while an operation pins it. A living box’s kept snapshot goes the same way.
- The owner’s answers: found snapshots are no longer deleted after 7
days (
remove-foundis gone); a found one that its box comes back for loses its found mark. A leftover guest of an ended thread (#releaseLeftoverwithended) gets held copies of its kept snapshots first (holdKeptOn), then goes at once with the rest, whose records go with it as settled deletes (leftoverReleased). - Unit 7’s follow-up: a fork’s guest is disarmed before every delete, also when the sweep retries a release that a locked clone stopped.
- The Snapshots tab,
src/app/snapshots-tab.tsxwith its data fromsnapshots.tab(src/plugin/snapshots-tab.ts): a third tab after Machines and Threads, with its count, at/plugins/bb-box/desktop/snapshots; the line under the tabs and what needs the person in amber; a card per box, a released box marked so; search, filters with counts (where, holds, taken by, kept, undos, goes within 7 days), sort, and Select with bulk Keep… and Delete… (snapshots.bulkCheckandsnapshots.bulk), which check each snapshot as a single change would and name what they skip. Its rows are the box page’s rows (SnapshotRow, now with a place of its own), which gain Keep…, “Keep 30 more days…” and Stop keeping, “goes in 3 days” from 3 days before, and a held copy’s place. - RPC:
snapshot.keepCheck,snapshot.keep,snapshot.unkeep; delete, rename, note, picture, forkCheck and fork also take a released box’s snapshot. - The CLI:
bb box snapshot keep <box> <snapshot> [--days|--until] [--check]andunkeep;fork,keep,unkeepanddeletefind a released box’s snapshot by its box’s ID, without a thread. - Settings: “Keep keeps a snapshot for”, 30 days, 1 to 365, under a
Keep group on the Snapshots page, in Find and in the overview’s row
(“5 per box · kept 30 days · …”), and
bb box option set general.snapshots.keepDays. - Docs: the guide’s Keeping a snapshot, A held copy and The Snapshots tab, the skill’s line that only the person keeps, the reference, and 070’s Decided during unit 8.
Tests
On a Linux box, 8 cores, 16 GiB, Node 24.21, in a fresh copy of this
branch over main cf84a20a: npx tsc passes, and the reference is
current; the node suite ran 3,235 tests, 2,947 passed, 0 failed, 288
skipped (the live and conformance ones); vitest ran 427, all passed. The
node suite includes the docs, site and reference checks. New or changed
tests, each on a set clock where time matters:
test/keep.test.ts, 17, on the world’s set clock: Keep’s label before its record, and its event; an agent refused; refused before the identity reset and where nothing keeps past a release; dates (days, a date, the past, past a year, both, a renewal that never shortens, one that may); a refused label keeping nothing, and Stop keeping; the room and VMID warnings; G15’s release with a held copy of a memory snapshot (disk only, disarmed, a template, labelled, before the guest goes, unkept ones gone, pins ended); G15’s release stopped for room, then finished by the sweep; Release and delete what you kept; G16’s copy cut short by a restart, running, deleted and made again; one confirmed after a restart; expiry of a held copy, held past its date by a fork’s pin; expiry of a living box’s kept snapshot; renewal of a released box’s held copy and its refusals; a release withsnapshots.dbunreadable; a fork check of a held copy; a held copy found again after records loss, with its reset version and kind.test/forks.test.ts, 2: G15 end to end (keep, release, restart, fork the held copy, the file from before the take, the reset, an agent seeing nothing); a fork’s release disarmed before each attempt, the sweep’s retry included.test/snapshots.test.ts: G3 now keeps a found snapshot until the person deletes it; a leftover guest of an ended thread goes at once with its found snapshot; one with a kept snapshot gets its held copy first.test/snapshot-rules.test.ts: keep dates, “goes in 3 days”, and no automatic deletion of a found snapshot.test/proxmox-lxc-snapshots.test.ts, 1: Keep’s held copy on Proxmox LXC: the clone of the snapshot, its tags and description, the disarm, a template only once Proxmox lists it,labelKepton the snapshot and the copy, the listing’s keep date, version and kind. Both backends’ offers now sayheld-copy.test/plugin.test.ts, 1:keep,--check,unkeep, a release that copies first, and a released box’s snapshot kept longer, checked for a fork and deleted without a thread.test/app.test.tsx, 4 new and 4 changed: the Keep dialog’s date, cost, warning, keep and focus; Keep… off with why, renewal, Stop keeping and “goes in 3 days”; the tab’s count, line, cards, a released box’s row, search, Select and bulk Keep with what it skips; Release and delete what you kept. Unit 4’s page tests now expect the keep length.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i, HOME, PATH and BB_BOX_LIVE_CHECKS=1, in
a Proxmox LXC box, with bb-box installed from this branch
(unit 7’s live-setup.sh). The lead
lent it a Proxmox LXC backend (template 9800, and 9803 without the
identity reset; VMIDs 3170 to 3181) and a Proxmox QEMU backend (9806, and
9804 for Windows; 3182 to 3189), on LVM-thin, with the production token,
and general.identityResetBackends was set to both on that BB only. Boxes
were machines of the person’s. The node is written pve-1. Root steps were
read-only, by the lead, with unit 7’s
proxmox-root.sh.
| Check | Result |
|---|---|
| Keep, LXC and QEMU, after a file was written and a take | --check said “If keep-lxc is released, bb-box first copies this snapshot in full into a template that cannot start: up to about 38 s, and about 2.8 GB, at most 17 GB, on pve-1’s local-lvm until 2026-11-30, at no charge.”; on the VM “up to 17 GB”, as Proxmox gives no disk use for VMs. Kept until 2026-11-30 |
| G15, release, QEMU | held copy into 3183, then the guest deleted; 9.3 s for bb box machines remove. Listed “its box was released 2026-10-31 · a held copy, guest 3183” |
| G15, release, LXC, first try | stopped, deleting nothing: “Proxmox LXC on pve-1 did not make guest 3171 a template”. Proxmox’s template call for a container answers before its worker ends, so bb-box read the config too soon; the part-made copy was deleted. Fixed: templateCopy waits until Proxmox lists the template (QEMU’s call ends with it). A second try, with a mistake of mine in that fix, stopped the same way and deleted nothing |
| G15, release, LXC, after the fix | held copy into 3173 at the next sweep, then the guest deleted |
| G16, the held copies’ configs (lead, read only) | 3173 and 3183: template: 1, onboot: 0, link_down=1, MAC addresses of their own, tags held-copy, kept.2026-11-30, reset.7, snapshot.<id>; 3183 a new SMBIOS UUID. 3173 still carries its source’s ip= in net0, inert behind its link and as a template; a fork from it gets its own VMID’s address, which the check before ready requires |
| G15, restart BB, then fork | BB’s whole server stopped and started again; both kept snapshots still listed on their held copies. LXC fork ready in 11 s, QEMU in 16 s, each one of your machines; each read “written before the take”, not the line written after it; boot=reset, its reset removing keep-lxc’s copied BB machine |
| G16, restart before the template, LXC | hold-next --at copied, then a release: held after the clone (3176, not a template); bb plugin reload bb-box at 14:28:13; at 14:28:15 “Deleted guest 3176, the held copy of snapshot … : it was not a template yet after bb-box restarted, so it is not trusted. bb-box makes it again from the snapshot.”; made again as 3177 by 14:28:27; the release then finished |
| G16, the same, QEMU | 3186 deleted, made again as 3187, the release finished |
| G15, too little room | “Refuse new snapshots when a host’s storage is past” set to 50 (the pool was about 54% full): Keep’s --check warned “At today’s size, a release would not finish: … it would be 55% full, past your settings’ 50%.”; the release stopped with the same words and deleted nothing; bb box machines remove --delete-kept then released it with its snapshot |
| G15, before the identity reset | a box from 9803: “Taken before old-desk’s image had the identity reset: “old image” could not be forked after a release, so bb-box does not keep it.” |
| G15, Windows | a Windows desktop on QEMU: “Proxmox QEMU on pve-1 takes no snapshots of its windows-desktop boxes.”, so there is nothing to keep there; released |
| Keep dates in labels | bb-box disabled, snapshots.db deleted, enabled: all 9 kept snapshots found again as kept with their dates, 4 held copies and 5 snapshots of living boxes; the held copies with forkSafe 7 from their tags |
| A held copy found again forks | the copies above, made before held copies carried the box’s kind, came back with kind and system unknown, so their fork was refused as not Linux. Fixed (the kind. tag, and the system from the backend’s offer of that kind); a new one, found again the same way, forked: ready in 9.7 s, the file from before the take, boot=reset |
| G9, a restore’s target in the tab’s bulk actions | a restore held after its stop: bulk Delete and bulk Keep each skipped its target with ““restore target” is in use by the restore that began at 14:44 UTC.“; the restore then finished |
| The page and the tab, keyboard only, 1,440 and 390 px | keep-page.mjs. Box page, 1,440: the row’s menu in 23 Tab presses, Keep… by the arrows, focus on the date (30 Nov), kept 1.7 s after Enter, focus back on the menu’s button. 390: the menu a sheet, 17 Tab presses, Keep… by Tab, kept 1.3 s after Enter, focus back. Tab, 1,440: Select in 30 Tab presses, the row’s checkbox by Space, the bar’s Keep…, the dialog listing the snapshot’s cost, kept. 390: Select in 12 presses, the same. Row actions always shown at 390 px, Fork in the menu |
At the end every machine, held copy and snapshot of the separate BB was
released or deleted, and the lead’s leftovers listed no guest in 3170 to
3189.
Screenshots at 1,440, 760 and 390 px, dark and light, of the tab, the Keep dialog, a kept row and the bulk dialog, are in the unit’s thread storage, not in the repository: they show the test node’s name.
Gates
| Gate | Result |
|---|---|
| G15 keep-lasts | Passed on Proxmox LXC and QEMU: Keep, release, a restart of BB, then a fork from the kept snapshot read the file written before the take; with too little room the release stopped and deleted nothing; Keep refused, with why, on a snapshot taken before the identity reset. On a Windows box there is no snapshot to keep: Windows boxes take none in this build, and the offer for them is no |
| G16 held-copy-inert | Passed except the node’s reboot: pct config and qm config (by the lead) show each held copy a template, onboot 0, links down and its own MAC, the QEMU one a new SMBIOS UUID; bb-box restarted after a clone and before its template, on LXC and QEMU: the copy deleted and made again from the snapshot, and the release finished. The remade copy’s files were read through a fork (G15’s), whose reset ran. Not proved: the node’s reboot, as no node is rebooted; unit 3’s earlier-guest checks stand |
| G9 serialised-and-eligible, Keep’s part | live: a restore’s target skipped by bulk Delete and bulk Keep, named; expiry held by a fork’s pin, in the tests. Rename of a pinned target was proved in unit 6 |
Choices and deviations
- No switch for Keep (Decided during unit 8): arming gates it, so production’s Keep is refused until the owner arms its backends.
- Found snapshots stay until deleted, the owner’s answer, which changes section 1’s rule and unit 1a’s.
- A held copy is cloned while the box still runs, from the snapshot, rather than after a stop: Proxmox allows that for a snapshot, and the box is going anyway.
- A held copy’s room uses a take’s rule and the box’s disk use now (containers) or the snapshot’s disk size (VMs), not the snapshot’s own use, which the token cannot read. The ceiling is not checked at release, as the copy replaces its snapshot in the rate. After the reviews it also counts and makes reservations, and fails closed.
- Expiry is a delete by bb-box (“its keep ended”), not an operation
of kind
expire, so it shares the delete’s recovery. - A released box’s kept snapshot is not unkept: kept longer or deleted.
- The tab’s rows reuse the box page’s, with what a row holds and its life added; a released box’s Restore is off with why rather than hidden.
- The tab names no backends that take no snapshots, and its line does not yet read storage past 85% or hosts that cannot take snapshots: both need a plan per host, which the tab does not read when it loads.
Known limits
- A snapshot labelled before this unit’s reviews, found again after
snapshots.dbis lost, has no reset version, as its label has none. It cannot be kept anew, and a release stops rather than copy it if it was kept. Labels written from now on carry the version, so a living box’s snapshot found again forks. - Expiry was not run live: its date cannot be brought forward on a live BB; the tests run it on a set clock.
- The leftover guest of an ended thread with a kept snapshot was run in the tests only: it needs a box record lost while its thread ends.
- Thin metadata is still not read (unit 1a’s limit), so a held copy’s room checks the pool’s data only.
After the reviews
Two independent reviews read the bundle at fe5f91ee: Astra (merge after
fixes, four P1 and three P2) and Opus (merge after fixes 1 to 4, with five
more). Astra reproduced six of its findings with scratch tests. The lead
asked for every finding, the stricter fix where the two overlapped, and a
regression test for each.
| Finding | From | Fix | Tests |
|---|---|---|---|
| A Keep or renewal cut short between the label and the record was lost: a held copy labelled 29 November was deleted on 1 November | Astra 1 | Keep, renewal and Stop keeping are operations of kind keep (a new migration lets the table take it), recorded before the label is written. Recovery reads the label and records what it says before any expiry or release; the reconcile takes a label’s later keep date, never an earlier one |
“review 1: a renewal cut short …”, “a keep whose label answer is lost …”, “the reconcile takes a later keep date” |
| After records loss, Proxmox lists a copy first, so a non-template copy was recorded over its source, and the release deleted the source | Astra 2 | Each snapshot ID prefers a trusted listing: the source over a copy that is not a template. The untrusted copy is deleted, and a release stops while a kept snapshot’s copy is not a template | “review 2: with records lost while a held copy was not a template …”, “a release refuses while a kept snapshot’s held copy is not a template” |
| Held copies bypassed the room admission: no reservation, others’ reservations ignored, an unreadable plan admitted | Astra 3, Opus 2 | The take’s transactional admission: it counts reservedBytes on the storage, reserves the copy’s bytes on its hold-copy operation until it is made, and refuses when the plan or the storage’s totals cannot be read. The words name what others hold, and show a decimal when a whole percent would equal the limit |
“review 3: held copies share the room rule …”, “a held copy refused just past the room limit says by how much” |
| A living box’s snapshot found again lost its reset version, so its held copy could never fork | Astra 4, Opus 5 | The snapshot’s label carries reset <n>, read back on recovery. A release stops for a kept snapshot whose version is unknown, and copies only keeps whose date has not passed |
“review 4: a living box’s kept snapshot found again …”, “a release copies only keeps whose date has not passed” |
| Bulk Keep’s untouched date shortened longer keeps | Astra 5 | Untouched, bulk Keep sends no date, so each row is kept for the days in Settings from today, or that many days more if kept already. A chosen date may shorten one, and its row says so | “review 5: bulk Keep with no date chosen never shortens …”, and the app’s bulk test |
| The 120 s template wait counted sleeps, not time: 2,525 s with slow reads | Astra 6, Opus 6 | A monotonic deadline over reads and sleeps, which stops when cancelled | “review 6: the wait for a held copy’s template is bounded by 120 s of elapsed time” |
| A held copy found with no thread showed a button to thread “unknown” | Astra 7 | Plain text: “Found again on its host; its thread is not known” | “070 unit 8 review 7” |
| The clone waited at most 180 s, so a disk over about 75 GiB never copied, and each retry cloned again | Opus 1 | The clone’s wait is 3 times the plan’s estimate, at least 180 s (cloneWaitMs), for every full clone, held copies and forks of them included; waitTask waits that long in the same run |
“review: a clone’s wait grows with its disk …” |
The bulk dialog checked again on every render, and sent [] once the selection emptied |
Opus 3 | The IDs are frozen when the dialog opens | “the bulk dialog checks the snapshots chosen when it opened, once …” |
| “Release anyway” and “Remove anyway” dropped “delete what you kept” | Opus 4 | The in-use state keeps deleteKept and passes it on |
“Release and delete what you kept, then Release anyway …” |
| The machine left BB before the copies were made | Opus 7 | The release makes the copies first, then takes the machine out of BB | “a release leaves BB’s machine list only once its kept snapshots are copied” |
| A leftover guest of an ended thread was judged by the records alone | Opus 8 | It is kept while a snapshot’s label says kept and the records do not, or while the labels cannot be read; a backend that takes no snapshots has none to read | “a leftover guest of an ended thread whose label says kept …”, and plugin.test’s restored-box test |
| “Keep 30 more days…” counted from today; Select changed its name while pressed; the tab was blank while loading or after an error | Opus 9 | Renewal adds the days to the keep date; Select keeps its name with aria-pressed; the tab says “Reading the snapshots…” and shows the error |
“a keep date: … a renewal by default adds the days to the keep date”, the app’s bulk test, “the Snapshots tab says it is reading them until its answer comes” |
Test gaps: a restart while the clone task runs, after the record moved, inside holdKeptOn |
Opus | Tests for each | “a restart while the copy’s task still runs …”, “a restart after the record moved to its held copy …”, “a leftover guest … stays until the records agree” |
Found in the live recheck. With the wait cut to 1 s in a test build,
the release tried to delete the part-made copy while Proxmox was still
cloning it. The copy’s config did not exist yet, so the delete counted as
done and the operation was settled. The clone then finished with its
source box’s tags, and after the box’s release the sweep restored it as a
box, bb-box-copy-3171. Two fixes, each with tests that fail without
them:
- A copy that fails while its task may still run stays unsettled, and the sweep deletes the part-made copy only once the task ends.
- A guest named
bb-box-copy-<vmid>is listed as naming no box, so it is released as a leftover once no operation uses it, never restored.
The tests are “a release whose copy outlasts its wait while the provider still makes it …”, and on each Proxmox backend, “a held copy cut short before its own labels … never one to restore”.
Live, after the fixes, on a new separate BB set up as before, with the same backends and VMIDs, armed on that BB only:
| Check | Result |
|---|---|
| Clone wait, test build (no 180 s floor, least wait 1 s), estimate factor 0 | LXC and QEMU releases stopped and deleted nothing: “Proxmox TASK vzclone … failed (504): task did not finish in time.” |
| The same, factor 3 (3 × 16 GiB × 2.4 s = 115 s) | both releases finished, held copies made in 9.5 s and 6.4 s; a fork from the LXC copy was ready in 8.9 s, with the file from before the take and boot=reset |
| The same, factor 0, after the fix above | each release stopped: “Proxmox LXC on pve-1 may still be making its copy, guest 3175, so bb-box deletes that copy only once the task ends, at a later sweep.”; the operations stayed unknown with their reservations; the sweep deleted each part-made copy once its task ended; nothing was restored as a box. With factor 3, the sweep finished both releases |
Room: “Refuse new snapshots when a host’s storage is past” lowered to 55 on the test BB, with the pool about 54.7% full; two LXC boxes of 40 GiB, each with a 12 GiB file made by fallocate, so each copy is about 16 GB |
the first release, held after its record (hold-next --at planned), reserved 15,716,274,176 bytes on its hold-copy operation; the second was refused: “pve-1’s storage local-lvm has 3536 GB free, beside 16 GB that snapshots and copies being made there hold, and with the held copy, about 16 GB, it would be 55.1% full, past your settings’ 55%.”; the first then finished, and its reservation ended |
Proxmox’s API cut (iptables on the test BB), the setting back at 85 |
the release stopped: “bb-box could not read where it would be copied, so it deleted nothing: … cannot reach the Proxmox API”; the rule was then removed |
| The machine while a release stopped for a copy | still listed by bb host list as connected; gone once the copy was made |
The threshold was lowered on the test BB only, rather than filling the
shared pool. At the end the BB had no snapshot, box or unsettled
operation, and the lead’s leftovers listed no guest in 3170 to 3189.
After the fixes, on this unit’s two commits over main cf84a20a, in a
copy on the same kind of box: npx tsc passes, and the reference is
current; the node suite ran 3,254 tests, 2,966 passed, 0 failed, 288
skipped; vitest ran 431, all passed.
After the re-reviews
Two re-reviews read c5fede75. Opus said merge, with two low findings.
Astra said not yet, with three blockers, each reproduced by a probe. The
lead asked for the three blockers and Opus’s first low finding, each with
the probe as a regression test. All three new tests fail on c5fede75 and
pass now.
| Finding | From | Fix | Tests |
|---|---|---|---|
After records loss, a rebuilt, empty snapshots.db is healthy. If the reconcile then failed, a release found no kept record and deleted the guest, kept snapshot and all |
Astra B1 (P1) | Every release lists its guest’s own snapshots (snapshotsOn, one call on Proxmox) before it deletes anything. It stops, deleting nothing, when it cannot read them, or when a label says kept and the records do not, until the sweep has recorded it |
“re-review B1: after records loss, every release reads its guest’s own labels …”, and on Proxmox LXC “a release’s label check lists one guest’s own snapshots …” |
| A clone slower than its wait, which ended well, was deleted and cloned again at every retry | Astra B2, Opus N2 | bb-box waits for the same task across retries. Once it ends well, the copy is labelled (labelCopy, split out of copySnapshot), disarmed and made a template, if its wait ran out in this run. A copy left by a restart is still deleted and made again, as G16 proved |
“re-review B2: a clone slower than its wait that ends well …”, and the rewritten “a release whose copy outlasts its wait …” |
| A retry whose submission answer was lost could take the previous clone’s task, settle the operation and drop its reservation while the new clone ran | Astra B3 (P1) | Each copy attempt records its start and task (attempts, a new column in an appended migration). Recovery looks from this attempt’s start only, skips every earlier attempt’s task, and stays unsettled while more than one could be its own. Proxmox lists a clone’s task under its source’s VMID only, so the target VMID cannot be matched from the list |
“re-review B3: a copy made again after a restart …” |
| “Release and delete what you kept” did not clear an untrusted held copy, though the words offered it | Opus N1 | A stop that taking the keeps off cannot get past (ReleaseWaits) no longer offers it, in the words or the menu: an untrusted held copy, a copy still being made, labels that cannot be read, or labels the records do not have yet. An untrusted copy’s words say to delete that snapshot |
“review 2: a release refuses while a kept snapshot’s held copy is not a template …” |
Live, on a new separate BB set up as before, with the same backends (VMIDs 3180 to 3189), armed on that BB only:
| Check | Result |
|---|---|
B1: a kept snapshot’s record deleted from the test BB’s snapshots.db, then a release at once |
stopped: “Box b1-lxc’s label on Proxmox LXC on pve-1 says snapshot … is kept until 2026-11-30, and bb-box’s records do not say so yet, so it deleted nothing.”; the machine stayed in BB; the sweep found the snapshot from its label, and the release then made its held copy |
B1: Proxmox’s API cut (iptables on the test BB) during a release |
stopped: “bb-box could not read the labels of box b3-lxc’s snapshots on Proxmox LXC on pve-1, so it cannot tell whether one is kept, and deleted nothing”; “Release and delete what you kept” not offered |
| B2: test build with a 1 s clone wait, LXC and QEMU | each release stopped while Proxmox copied (“bb-box waits for that task, and makes the copy a template once it ends well”); at the sweep the same copies, 3181 and 3186, became the held copies, one attempt each, with no second clone and nothing restored as a box; forks from both read the file from before the take, boot=reset |
At the end that BB had no snapshot, box or unsettled operation, and the
lead’s leftovers listed no guest in 3170 to 3189.
After these fixes, on this unit’s three commits over main cf84a20a, in
a copy on the same kind of box: npx tsc passes, and the reference is
current; the node suite ran 3,258 tests, 2,970 passed, 0 failed, 288
skipped; vitest ran 431, all passed.
A last re-review by Astra found B1, B3 and the wording answered, and one
race left in B2: a clone that ended well between its wait running out and
the task’s read was deleted, as only a running task was kept. Now a clone
whose task ended well in that release is labelled, disarmed and made the
template there; only a copy whose task failed, or that cannot be finished,
is deleted. Its probe is the test “re-review B2 race: a clone that ends
well just as its wait runs out …”, which fails on 0135b683 and passes
now.
After that fix, on this unit’s four commits over main cf84a20a: npx tsc passes, and the reference is current; the node suite ran 3,259
tests, 2,971 passed, 0 failed, 288 skipped; vitest ran 431, all passed.
Conditions and open questions
- G16’s node reboot stays a condition before production’s Keep is turned on, by arming its backends.
- Rebuild the LXC template with version 7 before arming Proxmox LXC (unit 7’s condition) also gates Keep there.
Unit 9: Tart
macOS snapshots as APFS copies of the suspended VM, beside the VMs, with its memory; Linux VMs on Tart, disk only, while stopped; restore by putting a copy back; macOS’s own identity reset, then forks and Keep; a guard against a state saved under another macOS build or Tart. Decisions are in 070, Decided during unit 9.
What changed
- Tart’s backend offers snapshots (
src/backends/tart/snapshots.tsand the backend):snapshotting,identitySource,planSnapshots,snapshot,deleteSnapshot,heldSnapshots,snapshotsOn,providerTask,findTasks,powerOff,rollbackSnapshot,labelKept,guestIdentity,forkSnapshot,armForkanddisarmFork. All are POSIX sh scripts on the Mac, as the rest of the backend’s are. - Where a snapshot lives. In
bb-box-snapshots, beside Tart’svmsfolder where~/.tartleads, as<snapshot>/vm/, an APFS copy (cp -c) of the VM folder’s files, and<snapshot>/label: the install, box, VM, snapshot, time, what it holds, the Mac’s macOS build and Tart version at the take, the identity reset’s version and the keep date. A copy is made as<snapshot>.partwith the shell’s PID, and renamed when whole; a delete renames it<snapshot>.deletingfirst. The listing reads these: a.partwhose shell is gone, or a.deleting, is incomplete. - Every copy checks first that the snapshots folder is on the VMs’
volume (its device is the VM folder’s) and that the volume has at least
reserve-disk-gib, or 20 GiB, free. Otherwise it refuses before anything stops. - A take of a running macOS box suspends it, copies it and resumes it with the backend’s own start (address, identity, clock). A stopped one is copied as it is. A Linux VM is copied only while stopped.
- A restore uses core’s in-place order: the stop is Tart’s own stop, a
suspend for macOS and a shutdown for Linux, never a hard stop; the undo is
a take of the suspended VM; the “rollback” copies the snapshot into
.restore-<vm>and swaps it with the VM’s folder in two renames, with a journal in.rollbacks/<vm>(“begun”, then “done”); core then starts it, which resumes a macOS box as it was at the take. The snapshot stays. The journal goes with its VM’s release. - The guard: the plan and the rollback refuse a snapshot with saved memory whose label names another macOS build or Tart than the Mac’s now, and say to fork it.
- macOS’s identity reset (
setup/tart/guest/bb-box-identity-macos.sh, version 2): the Linux program’s commands and status lines, so core reads both alike. bb-box installs it in each macOS VM at acquire, beside the name and the grants (src/backends/tart/identity.ts), only in aVirtualMac, with its LaunchDaemon, and runs itsbootonce there, so a new box isunarmeduntil core writes its marker. Its marker names the MAC addresses of theAppleVirtIONetworkcards. A copy resets: BB’s machine files under/Users/*/.bb-machines, BB’s LaunchAgentsapp.getbb.host-daemon.*, the access file and the SSH host keys; it records a login before it decided asraced. - Forks copy the snapshot’s files but
state.vzvmsaveinto a VM of the fork’s own, claimed first, thentart set --random-mac --random-serialand the fork’s size. The fork’s first start identifies it by the Mac’s ARP table, as its disk still names its source’s VM, then names it after its box. - Core:
outlivesGueston a backend’s offer: its snapshots live beside the guest, so a release deletes the unkept ones first, a kept one stays as it is, can be kept longer and forked after its box’s release, and a guest is not held for a kept snapshot.- A memory take of a stopped box is allowed where snapshots suspend.
- Forks, Keep, the identity marker and the reset’s record work for
macOS as for Linux (
forksOn); the fork-safety read and the check before ready read macOS’s paths (the hardware UUID as the machine ID,/Users/*, LaunchAgents,ifconfig), and the check refuses aracedreset. - Recovery’s words for a rollback whose task failed name the backend, not Proxmox.
- Words: Keep says what it costs on Tart (“it stays where it is until …; nothing is copied then, and from then on it takes its whole size, up to … GB on /Volumes/…”) and for a released box’s kept one; the CLI’s fork and keep help name Tart; the setting’s help says Linux and macOS.
- Docs: the snapshots guide (the offer table, a Tart section, the macOS reset, restore times, Keep and release on Tart), the Tart guide’s Snapshots section and clean-up, 070’s decisions.
Tests
New: test/tart-snapshots.test.ts (16 tests, against the fake Mac, whose
scripts run in a real shell, with a fake tart that now does
--random-mac and --random-serial) and test/beside-snapshots.test.ts
(3 tests, core with a backend whose snapshots live beside the guest, at a
set clock):
- the offers, macOS and Linux, and
identitySource; - a take of a running macOS box suspends, copies with the saved memory, labels and resumes; of a stopped one, no suspend; one whose suspend fails copies nothing and leaves the box running; Linux only while stopped;
- a snapshots folder on another volume refused, by the plan and by the take, with nothing copied and the box resumed; too little free refused;
- the listing: complete, cut short, delete cut short, another install’s never; keep dates on the label; delete refused for another install’s;
- each task’s outcome from the Mac’s folders and the restore journal: a take or a restore the Mac has no trace of did not begin, so it fails, rather than stay unsettled;
- a restore puts the copy back and keeps the snapshot, the stop a suspend, the journal gone with the VM’s release;
- the guard: another macOS build refused with “Fork it instead”; another Tart; a copy with no saved memory restores;
- a fork: claimed first, no saved state, a new MAC address and machine identifier, the fork’s size, its guest identity;
- the installer: only in a VM, its LaunchDaemon, its first
boot; - the macOS reset with a test root: off, unarmed, the marker, kept on the
same MAC, reset on another (files gone,
host-artifact.sha256kept, new host keys, the record), kept at the next boot;racedwhen someone had logged in, and the fork check’s refusal of it; macOS’s probes read no Linux path; - core: a memory take of a stopped box where snapshots suspend, and not where they do not; a release deletes unkept ones, keeps a kept one with no held copy and releases the guest; a released box’s kept one renewed and forked as one of your machines; Keep’s words.
Updated: test/tart.test.ts’s acquire timings name the identity reset’s
install step.
On a Proxmox LXC box, on a git checkout of the branch rebased on main:
tsc clean; npm test: the node suite, 3,356 tests, 3,068 passed, 0
failed, 288 skipped, the docs, site and reference checks among them; then
vitest, 443 of 443 in 5 files. npm run reference changed nothing more.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i and BB_BOX_LIVE_CHECKS=1, in a Proxmox LXC
box, bb-box installed from this branch with bb plugin install path:.
The lead gave it a Tart backend of its own install on the production Tart
Mac, with that backend’s SSH key put in the box as a file, which was
deleted once the backend was added; warm VMs off, the desktop kind off,
reserve-disk-gib 60, general.identityResetBackends tart. The Mac:
macOS 27.0.1, Tart 2.40.1, its VMs on an external volume of its own,
with 1,533 GiB free by df at the first take. Another thread held a
macOS VM there, so this unit ran one macOS VM at a time. Boxes were
machines of the person’s, macos, 6 cores and 16 GiB. As in unit 11, a BB
with no account cannot make them BB machines, so the BB machine was stood
in for by a random host ID in ~/.bb-machines/live-check/host-id and a
LaunchAgent app.getbb.host-daemon.live-check-host_gate.plist; a process
writing a counter each second, from launchctl submit, showed memory.
| Check | Result |
|---|---|
| Acquire | ready in 19.2 s: identity reset installed 1.8 s, marker 0.3 s. bb-box-identity status: version 2, boot=unarmed, marker=matches, armed=yes; the marker names en0’s MAC address only |
| Take, running | 11 s as bb-box timed it, 12.0 s for the command: suspend, copy, resume. “it holds its disk and memory … Size: 89 GB, some shared with its box. Price: no charge.” The counter process, PID 983, ran on |
| Restore | 13 s: stop (suspend) 4.0, undo 0.2, rollback (the copy put back) 0.2, start (resume) 7.8, access 0.1 s. work.txt back to “before”; PID 983 running, its counter back at 110, as at the take, from 128; the host ID file the same |
| Undo | 14 s: stop 3.9, undo 0.2, rollback 0.2, start 9.4, access 0.1 s. work.txt “after”; PID 983 at 131, from where the restore suspended it |
| Fork | the source suspended first (4.9 s), then forked as one of the person’s machines: ready in 15 s, 16.2 s for the command, after core’s check before ready. In the fork: booted 00:36:55, reset at 00:37:00, the console login at 00:37, raced=no; the host ID file and the BB LaunchAgent gone; a new SSH host key (97b91907... for d0413a31...), a new hardware UUID (BBBB2925-... for C3396674-...); its marker names its own MAC address and no box; named gate-fork; work.txt “before”; no counter process; an access file of its own (another SHA-256) |
| The source after its fork | started (resumed) in 7.1 s: the same host ID file, LaunchAgent, hardware UUID and SSH host key, PID 983 still running, boot=unarmed, marker=matches |
| Keep | 1.0 s, the date on the snapshot’s label. --check: “If gate-mac is released, it stays where it is until 2026-12-01.” Release 1.9 s: the two undos deleted with the box, the kept one listed “kept until 2026-11-02 · its box was released” |
| Fork of the kept snapshot after the release | ready in 16 s, one of the person’s machines: boot=reset, raced=no, a new hardware UUID, no host ID file, work.txt “before” |
| Another volume | the lead pointed bb-box-snapshots at a 1 GB APFS disk image on the Mac. The take: refused in 0.9 s, before anything stopped: “bb-box cannot take a snapshot of box vol-mac now: the snapshots folder on mac1 is on another volume than its VMs, where a copy would be a full copy of the whole VM.” The box ran on, uptime unbroken |
| Clean up | every machine released, the kept snapshot deleted, a sweep, then: no snapshots, no machines, no boxes, the doctor counting only other installs’ VMs. On the Mac, the lead counted 0 snapshot folders, 0 owner files of this install and 0 VMs of its; the disk image detached and deleted. The test backend removed from the separate BB |
Found live, and fixed with a test each:
- The first box’s marker named
en1as well asen0: macOS lists USB network placeholders (AppleUSBDeviceNCM11Data) asen1andanpi0. Version 2 reads onlyAppleVirtIONetworkcards; forks need version 2, and the box was made again. - The first take refused with “at least NaN GiB free”: the backend read the
disk reserve from a field that is not there. It reads
reserve.diskGiB. - The first fork was refused for its size: a Tart box keeps its image’s disk, and the plan asked for the copy’s 130 GiB. A fork’s size leaves the disk to its copy.
Gates
| Gate | Result |
|---|---|
| Take, restore, undo, fork and keep of a 16 GiB macOS box on a Tart Mac box, timed | passed, above, on the production Tart Mac as a second install, never a pool Mac |
| A copy on another volume refused | passed, above |
Choices and deviations
- The reset is installed at acquire, not in the image (the lead’s decision), so 070’s “the unit is in a box’s image” holds for Linux only.
- Tart’s Linux VMs neither fork nor keep yet, against 070’s backend table, which gave them both: their reset was not checked live. Their take and restore were tested against the fake Mac only; the gate is macOS’s.
- The guard refuses rather than restore the disk alone.
- The undo of a macOS restore is a copy of the suspended VM, so it holds memory too: undoing brings the box back running, as the live check showed.
- “Your machine” boxes were used, so G17’s hold on boxes threads run on did not come into it; those restores stay off.
Known limits
- A take of a running macOS box suspends it for about 11 s, while the box still shows as ready: its desktop and commands wait.
- If bb-box stops during a take of a running macOS box, the box stays suspended: the take is settled at the next start, and the person starts the box.
- Each kept or undo copy grows as its box changes, up to the VM’s disk and memory: on this Mac about 89 to 92 GB whole each, shared until the box changes. A box’s 5 snapshots and 2 undos could take seven times that.
- macOS gives no ordering between a LaunchDaemon and a LaunchAgent, so the in-guest reset alone could only be checked to have run first. Since the reviews, the Mac cleans a fork’s disk before it boots (below).
- macOS boxes made before this unit have no reset in them.
Conditions and open questions
- Production forks and keeps macOS snapshots only once the owner adds the
Tart backend to
general.identityResetBackends. - Should a restore after a macOS or Tart update put back the disk alone, booting afresh, rather than refuse and point to Fork?
- Tart’s Linux identity reset: a live check would let Linux VMs on Tart fork and keep.
- Cleaning a fork’s raw-format disk with
hdiutilon a Mac withoutdiskutil image attachis a follow-up in 070, once a host to check it on is free.
After the reviews
Opus and Astra each said merge after fixes. The lead asked for these in one amended commit, each with a test on the fake Mac that fails before it:
| Finding | What changed | Test |
|---|---|---|
| A restore cut short between its two renames left no VM, then a release left a whole hidden VM (Opus 1, Astra 2, Opus 6) | The journal says copying, then swapping just before the first rename, with the shell’s PID and start time and an attempt token per restore. Reading the box’s power or task, its start and its release put back the snapshot’s whole copy or the VM’s own folder; a live restore holds the start and the release, and makes the power read “stopped”; the VM’s state is read again just before the swap; a release deletes .restore-, .replaced- and .fork- folders; a PID with another start time is dead. A restore that did not happen is reported as such, and core starts the box again as it was |
“cut short between its two renames”, “cut short while it copied”, “put right when it starts or its power is read”, “a release deletes what a restore or a fork cut short left”, “each restore has its own attempt” |
| A login during the reset went unrecorded, and nothing stopped the source’s credentials acting (Opus 4, Astra 1) | The Mac cleans the fork’s disk before its first boot, with the guards the lead set after three probes on the Tart Mac: the fork’s own image only, its container from its own store, the Data volume by role, never the Mac’s container. The in-guest reset reads the console user before and after its removals and records a race only while BB’s identity was on the disk | “a fork’s disk loses its source’s BB machine … before its first boot”, “a fork whose disk would mount from the Mac’s own container … is refused”, “records a race when someone logs in while it removes BB’s machine”, and, from the recheck, “a login during the reset of a disk that holds nothing of BB’s … is no race” |
| The label took the Mac’s build at the copy, not at the suspend (Opus 3, Astra 3) | Each suspend notes the build and Tart beside the VM; the take copies that note; unknown provenance refuses; the start just after a restore never cold-boots | “memory saved before a macOS update is labelled with the build that saved it”, “the start just after a restore never boots the VM from its disk” |
| Same volume is no clone, and the reserve was checked after the suspend (Astra 4) | Copies go through clonefile, refused rather than made in full; the plan and the take check the room before any suspend |
“a copy APFS cannot clone is refused”, “refused … before the suspend” |
| A take whose resume failed said “taken, ready” (Opus 2, Astra 5) | The backend says why it did not resume; core marks the box stopped, with why, in the answer and the log | “a take whose box does not resume says the box is stopped”, “a take whose box did not resume after it leaves the box stopped” |
| A fork over a VM already there kept its source’s MAC and serial (Opus 5) | Refused | “a fork is never made over a VM that is there already” |
A Mac without diskutil image attach (the lead’s answer) |
No macOS forks there, and no Keep, as a kept snapshot could not fork after the release; the plan and the scrub say why, naming macOS 27; the doctor’s “macOS forks on hdiutil path: recorded in 070 as a follow-up |
“a Mac whose diskutil cannot attach a disk image makes no macOS forks” |
| Words (Astra 6, Opus 7) | The restore’s dialog, undo label and CLI follow the backend’s capture: “suspend it, which saves its memory”, an undo “with its memory, so undoing brings it back running”; one sentence for another volume; “does not restore it” for another Tart; the guides say kept snapshots stay on the Mac after bb-box is removed | “the restore’s words follow the backend”, app “where the backend suspends the box, the restore dialog says its undo is taken suspended” |
Run against the reviewed commit with only the tests and fakes swapped in, 23 of the 35 tests in the two files failed, every new one but the last race test, which guards a rule the recheck added.
On a Proxmox LXC box, on a git checkout rebased on main with units 10 and
11b: tsc clean; the node suite, 3,417 tests, 3,129 passed, 0 failed, 288
skipped; vitest, 445 of 445; npm run reference changed nothing more.
Found in the build: note_disk_gb in the Mac’s shared shell code sets a
global _id, which overwrote the restore repair’s snapshot ID; the
repair’s variables are its own now.
Live recheck
On a new separate BB, the same second install on the production Tart Mac, one macOS VM at a time, 16 GiB:
| Check | Result |
|---|---|
| Acquire | 20.5 s, reset version 3 installed in 1.0 s, marker=matches |
| Take, running | 11 s |
| Restore | 15 s: suspend 6.4, undo 0.4, copy back 0.3, resume 7.8, access 0.1 s; work.txt “before”; the counter process, PID 964, back to 12 from at least 13. The guard let it through, as its memory’s note named the Mac’s build |
| Undo | work.txt “after”, the counter on from where the restore suspended it |
--check words |
“suspend it, which saves its memory: it is never stopped hard”; “a snapshot of it suspended, with its memory, so undoing brings it back running as it is now”; “put the snapshot’s copy in its place, and resume it” |
| Fork, source suspended first | ready in 28 s, 15 s in unit 9’s check, the Mac’s cleaning of its disk included. Its reset found “BB machines removed: none”: the stand-in host ID and BB LaunchAgent were already gone before it booted; new SSH host key and hardware UUID; raced=no. The source came back unchanged |
| Keep, release, fork of the kept snapshot | the first try refused the fork, and released it: its reset (version 3) saw a login during its removals and recorded a race, although the Mac had already cleaned the disk. So version 4 records a race only while BB’s identity is still on the disk. On a new box with version 4: kept, released, forked in 28 s, boot=reset, raced=no, nothing of BB’s on its disk |
| Clean up | every machine released and snapshot deleted, a sweep; the backend removed. The lead checked the Mac: bb-box-snapshots holds an empty .rollbacks only; no owner file, run note or /tmp mount point of this install’s; no disk image of this install’s attached |
The interrupted restore and the failed resume were not provoked live: a shell cannot be stopped between two renames from bb-box, and the second VM slot was another thread’s. The fake Mac covers both.
Unit 10: microsandbox
microsandbox’s own snapshots: take with or without memory, restore as a swap
to a new microVM, fork from a snapshot’s disk only, Keep as it is on the
host, and msb’s chain of dependent snapshots, with microsandbox’s identity
source for the reset. The lead’s two answers are in 070, Decided during
unit 10.
What msb 0.7.7 does
Probed before building, with the release bb-box pins, in a Proxmox QEMU Linux box with nested KVM (8 cores, 16 GiB), by microsandbox-unit-10.sh (output):
- A Docker disk that is a file of the host’s (
--mount-disk) is left out of every snapshot, dropped on any restore, even with--dangerously-inherit-resources, and makes--fullfail (“additional block resource … has no immutable-generation provider”). A disk ofmsb’s own (--mount-owned) is captured, restored as a private copy, and works with--full. - A snapshot outlives its sandbox, and restores after it is removed.
- Each capture’s parent is the sandbox’s previous capture.
msbrefuses to remove a parent without--force, and a group’s head while it has other members. With both parents of a chain of three force-removed, the last restored byte-identical: 60 MB on the root and 15 MB on an owned disk. - A restore keeps none of the old sandbox’s settings: no labels, no
environment, no hostname, no
/runtmpfs, msb’s default memory and network. A memory restore must keep the captured CPUs and memory.--disk-onlyboots a memory snapshot’s disk at another size.
What changed
- microsandbox’s backend (
src/backends/microsandbox/backend.ts,snapshots.ts) offers snapshots:snapshotting,planSnapshots,snapshot,deleteSnapshot,heldSnapshots,snapshotsOn,providerTask,labelKept,powerOff,adoptSnapshot,forkSnapshot,armFork,disarmFork,guestIdentityandidentitySource(value). Each box’s snapshots are the groupbbbox-<box ID>, named<install>-<snapshot ID>-r<reset version>, with labels for the box, the snapshot and its size. A keep date is a file inmsb’s folder’skept, written by a new host script,keep; another,snapshots, gives each snapshot’s size and keep date. - The restore restores into
bbbox-<box ID>-<token>, at the box’s size, or the captured size for memory, with the box’s network profiles, then sets its labels, environment (BBBOX_IDENTITY,BBBOX_HOSTNAME) and disk size withmsb modifybefore anything of the box runs there. Core’s new-guest restore from unit 11 does the rest: the stop, the undo of the stopped box, the swap, the access file first, the old microVM deleted once the new one has started. - Each start mounts
/runin memory again when it is not, and sets the box’s hostname fromBBBOX_HOSTNAME. - The fork restores the snapshot’s disk only (
--disk-onlyfor a memory snapshot) under the fork’s own name, labels and identity value; a part-made sandbox of that name with no bb-box labels is removed first. - New boxes get a Docker disk of
msb’s own. bb-box no longer makes a Docker disk file or a template of one; a box made before keeps its file and works as before, and takes no snapshot, saying why. - Only with
msb0.7.7: every snapshot operation is refused with any other, and a new doctor check,microsandbox.snapshots, says so. - Core:
dependsOnfrom the backend at each take, undo and reconcile, cleared from a child when its parent is deleted, and shown as “after …” on a row and “taken after …” inbb box snapshot list; the fork check reads a value marker (forkValue) and, where an image has noip, addresses fromhostname -I; a value-source box is fork-safe without SSH host keys; a kept snapshot of a released box forks from the snapshot on a backend that keeps it as is; the release’s words no longer speak of an account and cost where nothing is charged. - Docs: the microsandbox guide’s Snapshots section, the snapshots guide, the architecture, the reference’s checks, and 070.
Tests
New: test/microsandbox-snapshots.test.ts (13 tests, msb’s recorded
answers from the probes, a set clock, and the fake msb, which now models
msb’s snapshots as the probes found them: parents, heads, --force, a
restore that keeps no settings, and --full refused beside a host’s disk
file), among them one through bb-box’s own Snapshots and Boxes: take,
restore, undo, delete a parent, keep, release and a fork check of the kept
snapshot. One app test (the row’s “after”). Changed: the microsandbox
tests for the owned Docker disk, a box made before, and the launch script.
On the QEMU box (8 cores, 16 GiB, Node 24.21), in a copy whose git tree is
this unit’s commit over main cacc1dcb before these notes (bb136463):
npx tsc passes, and the reference is current (node scripts/reference.ts --check); the node suite ran 3,331 tests, 3,043 passed, 0 failed, 288
skipped; vitest ran 444, all passed with TZ=Pacific/Auckland. With the
box’s own UTC clock at 01:17, 3 app tests failed, the same 3 on main
cacc1dcb: they expect “today” of a snapshot taken 2 hours before, and
“yesterday” of one 26 hours before, which is not so between midnight and
about 02:00. That is not this unit’s to fix.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i and BB_BOX_LIVE_CHECKS=1
(live-setup.sh), in the same QEMU
box, bb-box installed from this branch with bb plugin install path:, and a
microsandbox backend on BB’s server, the box itself, with
bbbox/linux:1 built from this branch by setup/docker/build.sh linux and
general.identityResetBackends=microsandbox, then the steps of
live-gates.sh, run one at a time. No
credentials were needed.
Boxes were machines of the person’s, and joined BB as machines. Times are
bb-box’s own, from its record or the CLI’s answer.
| Check | Result |
|---|---|
| A box | ready in 14.7 s (importing the image 12.7 s, create 1.5 s), a BB machine 12.8 s later; identity marker 0.3 s, version 7, source value; Docker on /dev/vdc, msb’s own disk |
| Take with memory | 1.8 s, 2.1 GB; a counter process in the box ran on through it. The first try was “not fork-safe”: the image has no SSH host keys (fixed, below) |
| Take, disk | 1.1 s, 149 MB; listed “taken after” the memory snapshot |
| Delete a parent | the memory snapshot, which the disk one was taken after: 0.39 s, with --force; the child then restored |
| Restore, memory | 2.08 s and, again, 2.03 s. The counter process (the same PID) was running and counting, work.txt “before”, Docker’s images as then, one microVM, the same host ID host_qga2n8kqq9; BB’s daemon connected again 5.5 s after the restore began. The first try left the machine disconnected: the new microVM had msb’s default network, which does not reach BB’s private address (fixed, below) |
| Undo, disk | 2.41 s: work.txt “after”, no counter, boot=kept (the check ran, no reset), /run in memory and the hostname set by bb-box, the same host ID; the daemon connected 1.3 s after the box was ready. A box keeps two undos: the oldest was deleted |
| Fork of the memory snapshot | ready in 1.9 s, a new BB machine host_9bh7fxzjny 2.9 s later; the source stayed host_qga2n8kqq9, connected. The counter’s PID was not running and its file stood still; every process was 12 s old. boot=reset, “the value bb-box set on this guest is not the marker’s; BB machines removed: host_qga2n8kqq9”; a new machine ID and new SSH host keys; its own marker and hostname; Docker’s images came with it. The first try was refused, as the image has no ip to read addresses, and released (fixed, below) |
| Keep | “If msb-src is released, it stays where it is until 2026-12-01.” |
| Release | 0.73 s; its two undos were deleted from msb, the kept snapshot stayed |
| Fork of the kept snapshot after the release | ready in 2.0 s, a new BB machine host_ye5wb5pe7g, boot=reset. The first try was refused, as core forked a released box only from a held copy (fixed, below) |
| A box made before | made by origin/main’s bb-box (cacc1dcb) in the same BB, its Docker disk a host’s file; then under this branch: Take refused, “It was made before bb-box could snapshot a box’s Docker disk: …”; a stop and start, a resize to 3 cores with a restart in 1.7 s, its Docker image still there, its BB machine connected; its release removed its disk file |
| Clean up | every box released; every snapshot deleted; msb ls and msb snapshot list empty for bb-box’s msb and the probes’; no keep file; BB stopped |
Found live, and fixed
Each with a test that fails without it:
- The images microsandbox runs have no SSH server, so no SSH host keys, and
a take recorded no
forkSafe. A box whose identity is a value is now fork-safe without them; the fork’s check still requires the keys its reset makes. - A restore gave the new microVM
msb’s default network. It now gets the box’s profiles as rules, whichmsbrecords as its create does. - The fork’s check could not read addresses in an image with no
ip; it falls back tohostname -I. - Core refused to fork a kept snapshot of a released box without a held copy.
- The restored microVM’s disk size was not recorded, so its size read back without a disk; the snapshot now carries it in a label.
- A child named its deleted parent until the next sweep.
Choices and deviations
- The undo is of the stopped box, as on boat.dev, not taken live (decided during unit 10).
- Dependent snapshots are deleted with
--force, not refused (decided during unit 10). - New boxes’ Docker disks are
msb’s own, and old boxes take no snapshot (decided during unit 10). The template of a Docker disk for a host withoutmkfs.ext4is gone, so a Mac’s box formats its disk whole again, which 053 measured at about 0.65 s more a box with 20 at once. - No live check on a Mac. Only x86_64 Linux was checked, so after the reviews a Mac host takes no snapshots yet.
- The fork of a kept snapshot of a released box was checked live; the
reconcile of
msb’s snapshots aftersnapshots.dbis lost was checked on the fake only.
After the reviews
Two reviews, by Opus and by Astra, each said merge after fixes. The lead
asked for all of them in one amended commit, rebased onto main a0420ab3
(071 unit 1, whose GPU part of a box’s size the unit now uses). Each fix
has a test that fails on the reviewed sources and passes now: run with the
reviewed src (and only the one new constant the tests import), the 5 new
tests below failed.
| Finding | Fix | Test |
|---|---|---|
Astra 1 (high): a restored box’s start wrote its access file and recorded the step access, past starting, before the box’s own start command ran. bb-box restarting there left recovery to skip the start, mark the box ready, and delete the old guest |
an access file written within a start keeps the restore’s record at starting, so recovery starts the box again, and the old guest is deleted only after; its time is still said as access. A hold for live checks, answered, stops a restore at that moment |
“070 unit 10 review (Astra 1): bb-box restarting after a restore’s new microVM took its access file …” |
Astra 2: the version guard read the host’s facts, kept for a minute, so an msb changed meanwhile could still --force a delete |
a take, a restore, a fork, Keep and a delete each run msb --version first, and a delete again right before --force |
“… (Astra 2): msb changed under a running backend is read again before every change …” |
| Opus 1: snapshots were offered on a Mac host, where nothing was measured | refused there with “bb-box takes no snapshots of microsandbox boxes on a Mac yet: what they rest on, such as each snapshot holding its own data, was measured on Linux only”, and the doctor’s Snapshots check warns; a follow-up in 070 | “… (Opus 1): on a Mac host microsandbox boxes take no snapshots yet …” |
| Opus 2: a restore put back the snapshot’s disk size when the box’s had grown since | the larger of the two, as a fork does | “… (Opus 2): a restore keeps the box’s disk when it has grown …” |
| Opus 3: the probe and the live script did not cover all the results claim | the reviewer’s checks A to C are in the probe, and passed on a fresh box (output): a running sandbox restored from a snapshot, the snapshot and the source then deleted, the same before and after a restart; a capture of it naming the deleted snapshot as its parent, restoring the same after its own parent went with --force; a parent removed with --force while a sandbox restored from it runs, the same after a restart. live-gates.sh now takes a disk snapshot and deletes a parent; “A box made before” was run by hand, as the table above says, as it needs origin/main’s bb-box |
|
| Opus 4: the old Docker disk template folder was never removed, and was counted | the upkeep’s sweep of Docker disks removes it, and the status script no longer counts it | “… (Opus 4): the upkeep removes the Docker disk template …” |
On a fresh Proxmox QEMU box with nested KVM (8 cores, 16 GiB, Node 24.21),
in a copy whose git tree is this commit’s before this section: npx tsc
passes, and the reference is current; the node suite ran 3,355 tests, 3,067 passed, 0 failed, 288 skipped
(8d0e12d6); vitest ran 444, all passed, with TZ=Pacific/Auckland, for
the 3 clock-bound app tests said above. A first node run beside the image
build failed one timing test in test/device-delivery.test.ts, which this
unit does not touch, and G4’s wording in test/restore.test.ts, which the
Astra 1 fix had changed: the in-start access is timed as access again, and
both passed after.
The live recheck, on a separate BB as before (npx bb-app 0.48.1, no
account, BB_BOX_LIVE_CHECKS=1, bb-box from this commit, microsandbox on
the box itself), with live-gates.sh
and live-interrupt.sh:
| Check | Result |
|---|---|
| Take with memory, take of the disk | 5.1 s and 1.3 s from the command line |
| Restore, memory | 3.6 s; the counter process running, work.txt “before”, the same host ID, one microVM; BB’s daemon connected again 7.0 s after the restore began |
| Undo, disk | 3.1 s; work.txt “after”, boot=kept, the same host ID; the daemon connected 4.4 s after it began |
| A restore interrupted after the new microVM took its access file | held there with hold-next --at answered: the new microVM had its access file and no ready file, the old one was stopped. bb-box restarted (bb plugin reload); recovery (bb box sweep, 0.64 s) started the same new microVM, which ran its start command and said it was ready, with work.txt “before”, boot=kept, its BB machine connected, and only then deleted the old one. One microVM was left |
| Fork of the memory snapshot | 2.6 s; no process of its source |
| Delete a parent | 0.40 s |
| Keep, release, fork of the kept snapshot | 0.45 s, 1.1 s, 2.9 s; the fork boot=reset with a new host ID |
| Clean up | every box released, every snapshot deleted, msb ls and msb snapshot list empty for bb-box’s msb and the probe’s, no keep file, BB stopped, the box released |
Unit 11: boat.dev’s saved snapshots
boat.dev’s saved snapshots on a paid plan, within the ceiling, and a restore as a swap to a new sandbox. On the trial, nothing: no take, no restore and no Keep. Forks and Keep wait for unit 11b, as the lead decided (070, Decided during unit 11).
What changed
- boat.dev’s backend offers snapshots (
src/backends/boat/snapshots.tsand the backend):snapshotting,planSnapshots,snapshot,deleteSnapshot,heldSnapshots,snapshotsOn,providerTask,powerOff,adoptSnapshotandsnapshotBill. The plan is read from/limits:accessTierandsandboxPlanKeysay trial or paid, and a plan it cannot read takes nothing. A take saves the sandbox under<install>-<box>-<snapshot>, recordssave:<name>as its task before it asks, polls until boat saysready, and keeps the capture’s own time (GET /snapshots/{snapshotId}) ascapturedAtand boat’s size. A save boat fails is deleted. A delete recordsdelete:<name>and confirms the name is gone. - The restore deploys the snapshot, then copies it. boat gives a
sandbox deployed from a saved snapshot no labels, whatever the request
says, and labels cannot be set later (found live, below). So
adoptSnapshotdeploys it into a staging sandbox namedbb-box:restoring:<box>:<token>, waits until it runs, stops it (boat’s stop takes its last capture), forks it with the box’s labels and a token of its own (bbbox-guest), and deletes the staging sandbox. The staging sandbox is on the claimed guest’s record from the moment it exists, so a release or a recovery deletes it, and only one with that name and no labels. A restored guest’s reference carries the token, so it never equals the old sandbox’s. - Core: a new-guest restore from a snapshot (
fromSnapshot): the stop, the undo of the stopped box, thenadoptSnapshotin place of a rollback, the swap, and the old guest deleted rather than kept as an earlier guest, as the box’s snapshots live on the account; their records follow the box to its new guest. Recovery after a restart uses the swap’s steps. - Core: a release deletes snapshots that live on the provider before
the guest goes, and stops, deleting nothing more, if one cannot be
deleted. With
snapshots.dbunreadable it lists them from the provider and refuses where a label says kept. - Core: a backend’s own bill (
snapshotBill) for the ceiling, read before each take, restore and Keep and at each sweep, trusted for 10 minutes; otherwise every snapshot counts at its price. A take within the included ones adds nothing. Takes boat has not counted yet are counted. - Words: a price with an included count says “$0.056 a day past the
plan’s 10”; Settings say “Held now on the account: 3 of the 10 saved
snapshots the plan includes, so boat.dev bills about $0.00 a month for
those past them”; the tab’s line “boat.dev $0.00 a month held (3 of the
10 the plan includes used)”; the restore dialog and
--checksay the new guest and the starts it costs; Keep and fork say why not yet. - Live checks:
bb box snapshot fake-included <backend> <count>. - Sizes below 1 GB are said in MB in core’s words, as the app says them, not “0.0 GB”.
- Docs: the boat guide’s Snapshots section and clean-up check; the snapshots guide’s offer table, restore, release and paid snapshots; 070’s decisions and unit 11b’s row.
Tests
New: test/boat-snapshots.test.ts (11 tests, boat’s recorded /limits
and /saved-snapshots answers from the paid account on 31 October 2026
and the trial’s, a set clock, and FakeBoat, which now drops a deploy’s
labels as boat does), test/provider-snapshots.test.ts (7, core against
the fake provider in boat’s shape: the bill and its fallback, the swap and
its undo, a swap that fails, the release, Keep and fork), and two app
tests (the restore dialog’s words, Settings’ included count).
On a Proxmox LXC box, in a copy whose git tree is this unit’s commit over
main b7a1b4c9 (the same tree hash): npx tsc passes, and the reference
is current (node scripts/reference.ts --check); the node suite ran
3,292 tests, 3,004 passed, 0 failed, 288 skipped; vitest ran 442, all
passed.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i and BB_BOX_LIVE_CHECKS=1, in a Proxmox LXC
box, bb-box installed from this branch with bb plugin install path:, and
the boat backend added with bb box backend add boat --secret api-key=... --secrets-file, from a key file box_copy put in the box. The account is
on boat’s standard tier, plan box_20, with 10 saved snapshots
included and none held at the start. Boxes were machines of the person’s.
As 068 found, a BB with no account cannot make boat boxes BB machines
(“Sign in to your bb account to use bb connect”), so “the same machine”
was checked by a stand-in: a random host ID written to
~/.bb-machines/live-check/host-id before the take.
| Check | Result |
|---|---|
| Template, first box | this install built its own linux template: the box was ready in 620 s, most of it the build; a later box in 22.5 s (fork 0.4, ready 4.2, ssh 13.6, settle 4.0 s) |
| Take, running box | 17 s on the first box, 132 s on the second (boat’s fresh capture). “Price: $0.056 a day past the plan’s 10.” |
| Restore, first try | failed and found the bug above: the deployed sandbox had labels: {}, so bb-box took it for gone and left the box stopped. A probe deploy with labels only, and a PATCH with labels, confirmed boat drops them; both probe sandboxes deleted. The box’s release then deleted its two saved snapshots, and the unlabelled sandbox was deleted by hand |
--check |
“stop it …; take its undo …, a snapshot of it stopped, about 70 s, at $0.056 a day; make it a new guest from that snapshot …: boat counts 2 starts: 12 of 12 left this minute, at no charge within the plan’s starts” |
| Restore, after the fix | 53 s: stop 2.0, undo 6.6, swap 22, start 22, access 0.2 s. work.txt back to “before”; the host ID file’s SHA-256 the same (940f1fbf...), a new boot ID; the old sandbox and the staging one deleted; the new sandbox labelled with the box and its token |
| Undo | restoring “before the restore at 18:58”: 176 s: stop 13, undo 97, swap 54, start 11 s; work.txt “after”, the same host ID |
| The undo’s cost in downtime | the box is stopped from the stop to the start: 53 and 176 s here, of which the undo’s save was 6.6 and 97 s |
| Keep | “bb-box keeps only a snapshot it can fork after its box’s release, and boat.dev boxes have no identity reset yet, so a fork of one could act as its source, and bb-box forks none until they have one. So it does not keep “before the change”.” |
| Fork | “bb-box does not fork “before the change”: boat.dev boxes have no identity reset yet, …” |
| Ceiling | ceiling 1, fake-included boat 0: “It would bring snapshots held on boat.dev to about $6.72 a month, past your ceiling of $1.00. It would be saved snapshot 4 there, and the plan includes 0. …” Boat still listed 3 |
| Settings and the tab | after a sweep: paid { count: 3, monthly: 0, ceiling: 1, past: false, included: { included: 10, used: 3 } }; the tab’s line “boat.dev $0.00 a month held (3 of the 10 the plan includes used)” |
| Delete | “Deleted snapshot “before the change” …: it freed 1 MB.” |
| Release | the box’s two undos deleted with it; boat’s /saved-snapshots then empty, used: 0 |
What it cost, from boat’s figures: the wallet went from $14.55 to
$14.54, about $0.01 of VM time. Starts used today went from 30 to 40, all
within the plan’s 200 a day, at no charge (nextStartDollars 0). At most
3 saved snapshots were held at once, within the 10 included, so they cost
nothing; each lived under 15 minutes.
Boat’s boot order, for unit 11b
What 068 and this unit saw of how a boat sandbox starts, which unit 11b’s identity source has to fit:
- A sandbox, a fork, a resume and a deploy all start on a machine already
running boat’s base image. boat then lays the sandbox’s own files over
it after the sandbox is
readyand reachable over SSH: system files within seconds, then Docker and the enabled systemd services start again, and the rest of the files in the background untilhydrated. So nothing of the sandbox’s own runs before its network: section 9’sBefore=network-pre.targetcannot hold there. - The address, the SSH host key and the hostname come from boat and change at every resume; the MAC addresses are boat’s, not bb-box’s, so they cannot be the marker’s value.
- boat keeps no labels on a deploy from a saved snapshot, and labels
cannot change; a fork keeps the labels it is given. Per-sandbox
envand create-timefiles(under/home/useror/tmp, written once, at the first start) are the ways bb-box could set a value on each guest; neither was probed in this unit. - bb-box’s own daemon and Tailscale are started by bb-box over SSH on boat, and BB’s daemon by a user unit after hydration, so a reset that runs when boat starts the enabled services again, before those, may be enough; G14 on boat has to show it.
Choices and deviations
- Two starts a restore, not one. As boat drops a deploy’s labels, a restore deploys and then forks. The plan and the dialog say 2 starts, and, after the reviews, a restore needs 3 left (a third puts the box back if the new sandbox fails), unless boat sells more.
- The undo is taken from the stopped box (decided during unit 11), not as section 5’s table says.
- Forks and Keep on boat wait for unit 11b (decided during unit 11). So “Keep and its price until the keep date” was not checked live; Keep’s refusal was.
- “The same BB machine after” was checked by the box’s record (the same box ID and name, a guest reference with the new token) and a stand-in host ID file, as this BB cannot enrol boat boxes.
- The ceiling’s refusal live needed
fake-included, as the plan’s 10 included snapshots make any take within them free, and the brief allowed 3 at once. - A saved snapshot’s size is boat’s
sizeBytes, “what it holds”, shown as what deleting it frees, as boat bills by count, not size.
After the reviews
Two reviews, by Opus and by Astra (with 11 probes on the fake provider),
each said merge after fixes. The lead asked for all of them in one amended
commit on main 056dcbb0. Each has a test that fails on the reviewed
commit, 576eac35, and passes now: run against that commit’s src, the
8 new boat tests and 11 new core tests below failed and the unit’s earlier
tests passed.
| Finding | Fix | Test |
|---|---|---|
| Astra 1: a part-made guest whose delete failed was ignored, and the old guest started beside it | the part-made guest is deleted and confirmed gone (the provider no longer lists it) before the old guest starts; otherwise neither starts, and the restore stays unsettled for the sweep | “Astra 1: a part-made new guest that cannot be confirmed gone …” |
| Astra 2, Opus 2: the old sandbox was deleted before the new one started | the old guest stays, stopped, until the new one has started and taken its access file; a new guest that does not start is deleted, confirmed, and the old one started again, or neither if it cannot be confirmed | “Astra 2: the old guest stays until the new one starts …” and “… starts neither” |
| Opus 2: a failed staging delete threw away a restore that worked | once the copy is claimed, a failed staging delete is logged and the guest keeps the staging ID; boat’s upkeep deletes staging sandboxes whose copy runs a box, or no restore has used for an hour; a release deletes its own sandbox whatever the staging delete does | “Opus 2: a deployed sandbox that cannot be deleted once its copy runs the box …” |
Astra 3, Opus 1: a lost create answer, or a restart, left the staging sandbox unknown; recovery at adopt-submitting waited for a task boat cannot give |
the staging sandbox’s name (box and token) is on the claimed guest before boat is asked; release and recovery find it by that name, unlabelled; recovery without a task discards and goes on | “Opus 1 and Astra 3: a deployed sandbox whose create answer was lost …”; “Opus 5 and Astra 3: a restore cut short at its new guest …” |
| Astra 4, Opus 3: two takes close together could both pass the ceiling | the bill names what boat lists; every snapshot bb-box holds that boat does not list yet counts beside it, taken or still taking | “Astra 4 and Opus 3: a snapshot boat has not listed yet counts …” |
| Astra 5, Opus 6: an unread bill fell back to this install’s records | the whole account, as last read, at full price; with no bill read since the start, no paid take, undo or Keep; a snapshot found again keeps its backend’s price | “Astra 5 and Opus 6: an unread bill counts the account …”; “Astra 5: a snapshot found again … keeps its backend’s price” |
| Astra 6: a release with lost records, and an ended thread’s leftover, left saved snapshots | every release lists the box’s saved snapshots from boat, deletes them, records or not, and lists again to confirm; an ended thread’s leftover the same, before its guest goes; a record a release left is deleted at the next sweep; a living box’s old guest takes none | the two “Astra 6” tests |
| Astra 7: a failed save whose delete failed was treated as holding nothing | it is incomplete, still counted, and a delete by its ID finishes it | “Astra 7: a failed save whose clean-up fails …” |
| Astra 8: an unfamiliar tier or no plan key allowed saved snapshots | only the standard tier with a box_<n> plan; anything else takes none, with the tier and plan in the words |
in “boat’s recorded answers …” |
| Astra 9, Opus 7: words | Settings: “The account holds 3 saved snapshots and the plan includes 10, so none are billed.”, “… holds 12 … so 2 are billed: about $3.36 a month.”, or an estimate said as one; no ledger spend for a backend with a bill; the tab “boat.dev 3 saved, none billed”; restore “boat.dev counts 2 sandbox starts for this: free within your plan’s starts, 12 of 12 left this minute”; “with the box’s own identity”; the allowance kept in storage so a row’s price does not change at the first read; the guides and 070 say 2 starts | the app tests “070 unit 11: …” |
| Opus 4: 1 start left passed the check, and “at no charge” was wrong | 3 starts needed, with words that say why; past the free ones, the words say which are paid and boat’s price from its first bracket | in “a restore’s plan …” |
| Opus 5: recovery chose the restore’s method from the plan as it is now | the method is saved on the operation (newGuest) when the restore starts |
“Opus 5 and Astra 3: …” |
On a Proxmox LXC box, in a copy whose git tree is this commit’s
(c9e9af11, before these notes): npx tsc passes, the reference is
current; the node suite ran 3,304 tests, 3,016 passed, 0 failed, 288
skipped; vitest ran 442, all passed.
The live recheck, on a separate BB as before (npx bb-app 0.48.1, no
account, BB_BOX_LIVE_CHECKS=1, bb-box from this commit, the same paid
account):
| Check | Result |
|---|---|
| A box | this install’s own template build hung at its first step, fetching the pinned tools inside the sandbox (boat said the sandbox was idle for over 30 minutes), so it was deleted; a template another install had built with the same scripts was then forked, as 068 allows; the box was ready |
| Take | 40 s; “Price: $0.056 a day past the plan’s 10.” |
--check |
“make it a new guest from that snapshot, with the box’s own identity, start it, and delete its old guest. boat.dev counts 2 sandbox starts for this: free within your plan’s starts, 12 of 12 left this minute” |
| Restore | 153 s: stop 0.7, undo 121, swap 21, start 9.0, access 0.2 s. work.txt back to “before”, the host ID file’s SHA-256 the same; only the new sandbox left |
| Undo | 156 s: stop 3.5, undo 107, swap 34, start 10 s; work.txt “after”, the same host ID |
| Settings and the tab | paid { count: 3, monthly: 0, included: { included: 10, used: 3 }, billing: "billed" }, so “The account holds 3 saved snapshots and the plan includes 10, so none are billed.”; the tab “boat.dev 3 saved, none billed” |
| Release | 1 s; boat’s /saved-snapshots then empty, used: 0, and no sandbox of this install left |
What it cost, from boat’s figures: the wallet went from $14.54 to $14.49
and starts today from 40 to 49, all within the plan’s 200, at no charge.
The wallet and the starts are the account’s, so they include anything
else on it in that hour; most of this check’s share was the hung template
sandbox, about 40 minutes at the default size, about $0.02. At most 3
saved snapshots at once, within the 10 included.
The hung build is not this unit’s: fetch-artifacts.sh downloads with
curl --retry 3 and no time limit, so a stalled download waits until the
build’s 90 minutes. A follow-up should give its downloads a time limit.
After Astra’s re-review
Astra’s re-review of 8bb7987d found 10 of its 11 probes no longer
reproduced, and 5 findings left. Each fix has a test from Astra’s probes
that fails on 8bb7987d and passes now.
| Finding | Fix | Test |
|---|---|---|
| R1: a release reported a staging sandbox gone while it could still run | when its delete fails, the release stops it and waits until boat says it is stopped; if it cannot, the release fails, so the restore stays unsettled and the old guest is not started | “R1: a release whose deployed sandbox cannot be deleted …” |
| R2: a new guest gone before its access file settled the restore, and the sweep deleted the old one | an access failure rolls back as a start failure does: the new guest is deleted and confirmed, and the old one put back and started, protected by the unsettled operation until then | “R2: a new guest that goes before its access file is written …” |
| R3: an old account count could undercount | no paid take, undo or Keep without a count read just then; Settings show an estimate, said as one; the claim that the fallback never undercounts is gone from 070 and the guides | “R3: a take is refused during a bill outage …”, and the updated fallback tests |
| R4: a price for the next start did not prove 3 starts | 3 starts available in every window, free or paid up to boat’s startLimitsWithWallet |
“R4: a restore needs 3 starts available in every window …” |
| R5: a restart mid-rollback lost the old guest’s reference | the rollback is journalled as its own step, swap-back, before the new guest is deleted, and recovery resumes it: the box’s record back on the old guest, which is started |
“R5: a restart while putting the old guest back …” |
| Settings’ slot | the billed or estimated sentence sits in one block of 3 lines’ height, whichever it says | in the app test “070 unit 11: Settings say …” |
The happy path of take, restore and release did not change: these fixes act only when a staging delete fails, an access file cannot be written, a bill cannot be read, starts run short, or bb-box restarts mid-rollback. So no new live check was run.
On a Proxmox LXC box, in a copy whose git tree is this commit’s
(fdb3ca7c, before this paragraph): npx tsc passes, the reference is
current; the node suite ran 3,309 tests, 3,021 passed, 0 failed, 288
skipped; vitest ran 442, all passed.
Unit 11b: boat.dev’s identity reset, then its forks and Keep
boat.dev’s identity source, its linux template built with the reset on,
gate G14 on boat.dev, then forks from a saved snapshot and Keep as it is,
on the paid plan. Built on 1 November 2026, on main a0420ab3, after a
live probe the lead read first (070, Decided during unit
11b).
The probe
Stock small sandboxes on the paid account, called through boat.dev’s
API from a Proxmox LXC box, each with a probe unit enabled at boot,
ordered before network-pre.target, SSH and the users’ services, that
logged what it could read when boat.dev started it.
| Question | Per-sandbox env |
Create-time files |
|---|---|---|
| Where it lands | /run/ascii-secrets/env.sh, on tmpfs, owned by user, mode 600, as export NAME="value"; a systemd unit does not inherit it |
under /home/user; one under /tmp is gone on a new machine |
| A fork | takes the value the request gives; with none, its source’s | accepted with 202 and ignored: the fork kept its source’s file |
| A deploy from a saved snapshot | the value the request gives | the file the request gives |
| A resume on a new machine (a resize) | kept, written again | kept, on the disk |
| Before boat.dev starts the sandbox’s own services | yes: 0.6 s on a fork, 0.8 s on a deploy, 2.6 s on a resume | yes on a deploy, 0.7 s |
Two more findings shaped the build. A resume 2 s and one 3 minutes after a stop came back on the same running machine (the same boot ID, its uptime unbroken), so nothing started again. And boat.dev gives every new machine its own machine ID and SSH host keys, and the host key it reports to pin is the one in those files: a fork’s were not its source’s. So on boat.dev the reset leaves both to boat.dev; renewing the keys would break bb-box’s pinned SSH.
What changed
- The guest program, version 8 (
bb-box-identity.sh), for every image.enable --value-file <path>reads the value from a file, with a strict pattern for a box ID, never sourcing it; at a boot of an armed box it waits up to 30 s for the value, then compares as before, so a missing value counts as a copy.enable --machine-from-providerleaves the SSH host keys and machine ID to the provider in a reset, and says so.waitholds BB’s daemon while the reset is on and no check has run on this machine. Both options live in/usr/local/lib/bb-box/identity-options; a plainenable, as every other image runs, writes none, so Proxmox’s and Docker’s boots are as they were.customize-base.shpasses them from the build’s settings. - boat.dev’s
linuxtemplate turns the reset on withBOX_IDENTITY_VALUE_FILE=/run/ascii-secrets/env.shandBOX_IDENTITY_MACHINE=provider(buildEnvFor), and the template’s fingerprint covers those settings, so a change to them rebuilds it. Desktop and Android templates keep it off. - Every sandbox bb-box makes for a box carries
BBBOX_IDENTITY, the box’s ID: the fork from the template, a restore’s deployed sandbox and its copy, and a fork’s two, with the fork’s own ID. A resume passes none, so boat.dev keeps it.identitySourceisvalueforlinux. - bb-box waits for the check before the box user: at each acquire and
start, as boat.dev’s user, it reads
bb-box-identity statusuntil the boot sayskept,unarmedorreset(oroffon an image without the reset), startsbb-box-identity-settle.serviceitself 10 s on, and fails the start onfailed. Only then does it give the box user its key and SSH in, join the tailnet and write the access file. - Forks (
forkSnapshot): as a restore does, it deploys the saved snapshot under a staging name with the fork’s value, stops it, copies it with the fork’s labels and a token, and deletes the staging sandbox; a fork asked again after a restart goes on with the sandbox it made, and one cut short before its copy settled is released first, then made again.guestIdentitygives no MAC addresses, andarmForkanddisarmForkdo nothing, as boat.dev has noonbootor links. The plan offers a fork at the snapshot’s size, needing 2 starts (forkStartWords), also of a saved snapshot whose box’s sandbox is gone. The offer forks from version 8 (BOAT_FORK_SAFE_FROM). - Core’s check before ready, for a value: where the backend’s source
is
value, the marker must name the fork’s own box ID (marker_value=), and the provider’s addresses are not compared; everything else is unit 7’s check. A MAC marker reads as before. - Keep as it is: boat.dev’s saved snapshots take no labels and
cannot be renamed, so
labelKeptwrites the keep date in the backend’s own storage (kept-saved-snapshots), apart fromsnapshots.db;heldSnapshotsgives it back askeptUntil, and a delete or a name boat.dev no longer lists clears it. Core forks a kept snapshot of a released box whose backend keeps it as it is, and renews it after the release; the card offers Keep 30 more days there too. - Docs: the snapshots guide (the offer table, the value on boat.dev, the order there, the outside copy, forks and Keep on boat.dev), the boat guide’s Snapshots section, and 070’s decisions.
Tests
New or changed, each with a set clock and boat.dev’s recorded answers where it calls boat.dev:
test/identity-reset.test.ts, 7 for version 8: the options and their refusals; a boat.dev box kept at three boots on new machines with the file’s value, not the boot’s argument; a fork’s value resetting BB’s machine, the access file and the tailnet node and leaving the host keys and machine ID; a missing value waited for, then a reset; values that are not box IDs;waitholding while no check has run, and not when the reset is off; the build’s settings andcustomize-base.sh’s calls.test/boat-snapshots.test.ts, 7 new: the value on every sandbox and through a stop and start; the fork’s two sandboxes; a kept snapshot forking with its box’s sandbox gone; the 2 starts; the keep date in the backend’s storage; the identity check before the box user, its start after 10 s and a failed check;offon an image without the reset. Four changed for the offer, the plan and the restore’s value.test/forks.test.ts: the value check’s verdict table, with Proxmox’s MAC check unchanged;test/provider-snapshots.test.ts: Keep as it is past a release, its renewal and its fork, and a version 7 snapshot’s Keep refused in plain words.test/fake-boat.tskeeps each sandbox’senvas boat.dev does.
Proxmox’s behaviour does not change: its identity tests pass unchanged,
but for the version the real program reports, 7 before and 8 now, in 3
status lines of identity-reset.test.ts, 4 of forks.test.ts (one of
them now sets forkSafeFrom 9 to stay one above it) and 3 of
snapshots.test.ts. Proxmox LXC still forks from 7 and QEMU from 5.
On a Proxmox LXC box, in a clone whose git tree is this commit’s
(84fc8d64, before this paragraph), over main a0420ab3: npx tsc
passes, and the reference is current (node scripts/reference.ts --check); the node suite, which holds the docs and site checks, ran
3,353 tests, 3,065 passed, 0 failed, 288 skipped; vitest ran 443, all
passed.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and
ports, started with env -i and BB_BOX_LIVE_CHECKS=1, in a Proxmox LXC
box, bb-box from this branch with bb plugin install path:, the boat
backend added with --secrets-file from a key file box_copy put in the
box, and general.identityResetBackends=boat in that BB only. Boxes were
machines of the person’s. As this BB cannot make boat.dev boxes BB
machines, a stand-in BB machine folder, ~/.bb-machines/live-check, with
a random host-id and an auth.json, and a Tailscale state with a
profile, were written in the source before the take; the access file was
bb-box’s own.
| Check | Result |
|---|---|
| Template, first box | this install built its own linux template in 6 minutes; the box was ready in 402 s (fork 0.5, ready 4.5, ssh 12.0 with the check, settle 4.2 s), its marker value its box ID, written in 83 ms |
| The order | the check finished at 00:48:49.824, when boat.dev’s agent started the enabled units; bb-box read it at 00:48:54.199; the box user’s first login 00:48:54.799; user@1001 00:48:55.191 |
| Stop and start | 8 and 2 s; boat.dev gave back the same machine (the same boot ID), so no check ran; the same host ID |
Resize, small to medium |
82 s, a new machine (new boot ID, machine ID and host key, boat.dev’s); boot=kept, the same host ID, credentials, access file and tailnet state |
| Take | 144 s, a fresh capture; forkSafe 8 |
| Restore swap | 178 s, a new sandbox; boot=kept, the same host ID; work.txt back to “before”. The undo was deleted at once |
| Fork | ready in 34 s (start 14.5, check 0.4 s): the staging sandbox reset at 00:57:03 and removed host_317bc0fa833d; on the fork the host ID, credentials and tailnet state were gone, the machine ID and host key new, the marker its own; bb-box wrote its own access file at 00:57:44. The source was untouched |
| Keep | --check: “If src-boat is released, it stays where it is until 2026-12-01, at $0.056 a day past the plan’s 10”; kept until 2026-11-03 |
| Release, then BB restarted | the source removed in 1 s; its kept snapshot listed under it, released; BB’s server stopped and started again |
| Fork of the kept snapshot | ready in 30 s (check 99 ms): the staging sandbox was stopped part way through its reset, and the fork’s first boot resumed it from its pending record (“resuming the reset that began at 00:58:58”), boot=reset, the source’s host ID removed |
| Clean-up | the kept snapshot deleted, both forks removed, the backend removed, this install’s template deleted through boat.dev’s API; boat.dev then listed only the three templates of another install that were there before, and no saved snapshots |
The live check ran this unit’s code over main cacc1dcb. The rebase onto
a0420ab3 and one change after it, a fork cut short before its copy
settled now released before it is made again (with its test), came
later; neither touches what the check ran.
G14 on boat.dev passed: a fork resets with a new host ID, tailnet node, SSH host keys, machine ID and access file; a stop and start, a resize and a restore swap each keep the box’s host ID with no reset; then Keep, a release and a fork of the kept snapshot. The tailnet item was checked as unit 7 checked it, by the reset removing Tailscale’s state.
What it cost, from boat.dev’s figures, the probe and the live check together: the wallet went from $14.48 to $14.47; boat.dev’s used credit rose by 1,416 s, about $0.014 at its 100,000 s a dollar; starts today went from 42 to 51 by boat.dev’s counter, all within the plan’s 200, at no charge. At most 2 saved snapshots at once, within the 10 included: the probe’s for 3 minutes, the live check’s for 8 and its undo for 3.
Choices and deviations
- Not before the network on boat.dev. Section 9’s order cannot hold there; bb-box gates what it controls (the box user’s SSH, Tailscale, the access file) on the check, and BB’s daemon waits by its own unit.
- The reset leaves the SSH host keys and machine ID to boat.dev, which makes both new on every machine.
- A stop and start on boat.dev came back on the same machine, as boat.dev keeps one a while after a stop, so the check did not run again; the resize and the restore swap showed a new machine kept.
- The keep date lives in bb-box’s storage for the backend, not on boat.dev, which has no labels for saved snapshots.
- Docker’s words are as they were: a missing value still says “the value bb-box set on this guest is not the marker’s”.
Known limit
- A copy made outside bb-box keeps its source’s identity. boat.dev gives a fork made in its dashboard or API its source’s value, so it does not reset. The guides say so in plain words.
Conditions and open questions
- Arming boat.dev in production stays the owner’s call, after merge and a smoke test; until then no boat.dev snapshot is fork-safe there.
- boat.dev’s desktop and Android templates have no reset, so their boxes neither fork nor keep; a later unit could check them.
- A fork’s plan says no price in starts in the Fork dialog, as the fork plan has no cost words; the 2 starts are said in the guides.
After the reviews
Two reviews, by Opus and by Astra, read 98c1154c, and each said merge
after fixes. The lead asked for all of them in one amended commit on main
a0420ab3. This section is what holds now where it differs from the
sections above. Each fix has a test that fails on 98c1154c and passes
now: run against that commit’s src and setup, the 7 new core and guest
tests failed, and boat-snapshots.test.ts did not load, as it uses what
the fixes add.
| Finding | Fix | Test |
|---|---|---|
| Astra 1, Opus 4: a slow start wiped a good box’s identity after 30 s | only a value that differs is a copy; a value not there yet holds the box as unknown, removing nothing, while SSH, Tailscale and BB’s daemon wait, and settle reads the file again every 2 s; bb-box’s start waits, and fails after 5 minutes with why |
“version 8, boat: a value missing from the file never counts the box as a copy …” |
| Opus 4: a box that reset with no marker stayed unarmed, and the words named interfaces | the unarm names BBBOX_IDENTITY; at the box’s next start bb-box writes the marker again, on a value-source backend that is armed, only after a reset (boot=reset, marker missing), never for a box unarmed by hand or a Proxmox box |
“version 8, boat: a box that reset with no value …”; “Opus 4: a value-source box that a reset left with no marker …” |
| Astra 2, Opus 5: a fork joined the tailnet and let the box user in before core’s check | a boat fork starts only as far as its identity check (startForForkCheck), as boat.dev’s user, and refuses a boot of unarmed or off; core reads the check through forkCheckCommand, as that user; the box user’s SSH, settle and the tailnet join run only at armFork("boot"), after the check; the access file comes after |
“Astra 2 and Opus 5: a fork starts only as far as its identity check …” (boat) and “… core checks it through the backend’s own check command …” (core) |
Opus 3: with a plain enable, wait held BB’s daemon when no check had run |
wait holds on no result only with a value file, so Proxmox’s wait is version 7’s |
“version 8 on Proxmox: with a plain enable, wait goes on at once …” |
| Opus 1: removing the backend left saved snapshots costing | a save that removes a backend whose snapshots live on its provider lists the provider’s snapshots of this install first, kept or not, and refuses, naming them and how to delete them, or refuses if it cannot list them; only such a save asks the provider; the boat guide’s “Turn it off” says so | “Opus 1: a backend whose snapshots live on its provider cannot be removed …” |
| Astra 4, Opus 2: Keep dates could be lost | every change to the backend’s record of saved snapshots goes through one queue; a name is cleared only when boat.dev answers that it has no saved snapshot by that name; a list answer with no array is an error, and changes nothing | “Astra 4 and Opus 2: Keep dates go through one queue …” |
| Astra 3: upkeep deleted a fork’s staging sandbox while the fork was made | upkeep skips a staging sandbox this process is still using, and counts one as done only when its copy exists, labelled with its box and token | “Astra 3: upkeep never deletes a fork’s staging sandbox …” |
| Astra 5: a kept snapshot found again could not fork | the backend’s record of each saved snapshot (saved-snapshot-facts) holds the keep date, the identity reset’s version from the take and the box’s owner (kind, name, thread), and boat.dev’s list gives them back after the box’s sandbox is gone |
“Astra 5: what a fork of a kept snapshot needs …” |
| Astra 6: found again past its keep date, a paid snapshot stayed with no end | a snapshot found again on a backend whose snapshots live on its provider keeps its keep date even when it has passed, so expiry deletes it; Proxmox’s found snapshots are as they were | “Astra 6: a provider’s kept snapshot found again … past its keep date …” |
The fork’s start changed: on boat.dev, a fork’s timings say “identity check” for its start up to the check, and a start of any boat box says it before “ssh”. So a short live recheck of a fork, a Keep and a release was run, below.
The live recheck, on a separate BB as before (npx bb-app 0.48.1, no
account, BB_BOX_LIVE_CHECKS=1, bb-box from this commit, boat armed in
that BB only):
| Check | Result |
|---|---|
| Template, first box | this install built its own linux template; boat.dev’s machine was slow (a load of 15 while it installed packages), so the box was ready in 1,243 s; the box’s marker its own value |
| Take | 128 s; forkSafe 8 |
| Fork | ready in 224 s, most of it boat.dev’s capture of the staging sandbox as it stopped (start 13.2, of which identity check 4.1, check 0.3 s); the fork reset at its first boot (boot=reset, the stand-in host ID removed); the order: boat.dev’s user in at 02:25:27.202, the reset finished at 02:25:30.164, bb-box’s gate and core’s check read it as user at 02:25:30.227 and 02:25:30.415, the box user’s first login at 02:25:30.941 and its manager at 02:25:31.315; the fork’s own access file written at 02:25:44 |
| Keep, then release | kept until 2026-11-03; the source removed in 1 s, the kept snapshot listed under it, released |
| Removing the backend | refused twice, naming the snapshot: by its record, and by boat.dev’s list (“Backend boat has 1 snapshot on its provider’s account, kept or not, … “before-change” … kept until 2026-11-03”); with its records forgotten (bb box snapshot forget), still refused by boat.dev’s list, by its saved name and keep date; once it was deleted, removed |
| Clean-up | the fork removed, the saved snapshot deleted, this install’s template deleted; boat.dev then listed only the three templates of another install, and no saved snapshots |
What it cost, from boat.dev’s figures: the wallet went from $14.47 to $14.45; used credit rose by 1,441 s, about $0.014, most of it the slow template build; starts within the plan, at no charge. One saved snapshot, held for 6 minutes.
On a Proxmox LXC box, in a git tree of this commit’s files (a54fcae4,
before these notes): npx tsc passes, the reference is current; the node
suite ran 3,363 tests, 3,075 passed, 0 failed, 288 skipped; vitest ran
443, all passed.
Rebased onto unit 10 (main cbad0e4d, microsandbox’s snapshots),
which had added the same check of a value marker in core. This unit now
uses unit 10’s: the fork’s value comes from guestIdentity, which on
boat.dev gives the box’s ID, and a marker holding its source’s value is
refused at once. A backend now says its guest’s address is the provider’s
endpoint (addressIsAnEndpoint), as boat.dev does, so only there are
the addresses left out of the check; microsandbox’s are compared as unit
10 has them. The guides’ Keep and release paragraphs name both backends.
Nothing in the fork or Keep path changed in what it does on boat.dev, so
the live recheck above stands.
On a Proxmox LXC box, in a git tree of the rebased commit’s files: npx tsc passes, the reference is current; the node suite ran 3,381 tests,
3,092 passed, 1 failed, 288 skipped, the failure a boat test that pinned
guestIdentity’s answer without its value, since updated and passing
with its file (27 of 27); vitest ran 444, all passed.
Close-out A: microsandbox snapshots on a Mac
Unit 10 turned microsandbox snapshots off on a Mac host, as what they rest
on was measured on Linux only. This measures it on a Mac and turns them on
there, with the version guard kept. On the way it found that msb refuses
a snapshot’s ID whenever its home is reached through a link, which bb-box’s
own wrapper does on Linux too, so bb-box now names each snapshot by its
group and member. Decisions are in 070, Decided during the
close-out.
The probe on a Mac
Unit 10’s probe could not run on a Mac as it was: it needs GNU date,
mkfs.ext4 and python3. microsandbox-mac.sh
runs on a Mac and on Linux. It downloads its own msb 0.7.7, checks its
SHA-256 against bb-box’s pin, and runs it with an MSB_HOME of its own,
never bb-box’s msb or home. It needs no sudo, python3 or osascript,
prints no paths or names, and on exit removes every sandbox and snapshot it
made, then its folder. It runs unit 10’s checks and the reviewers’ checks A
to C, adds the memory checks, and check E, bb-box’s own commands.
The lead ran it on the production Mac host, as its user, in /tmp, on the
same volume as bb-box’s msb folder: macOS 27.0.1, Apple M4 Max, 128 GiB,
APFS on the internal SSD, 340 GiB free. Each version passed first on a
nested-KVM Proxmox QEMU Linux box. Five runs:
| Run | Result | What it showed |
|---|---|---|
| 1 | 12 passed, 3 failed | The data checks held: a chain of three restored byte for byte with both parents removed with --force, and checks A to C. The 3 failures measured nothing: the counter process never started on the Mac, and --disk-only printed no reason |
| 2 | 13 passed, 3 failed, 1 skipped | The cause was a terminal, not msb: from an interactive shell msb exec gave the sandbox a PTY, so every read ended in a carriage return (“x\r” is not “x”), and the PTY’s hangup ended the counter. Run 2’s script under a PTY on Linux (script -qec) gave the same pattern |
| 3 | 17 of 17 | With msb exec --no-tty and stdin from /dev/null, as bb-box runs msb. But its clean-up, which removes snapshots by ID, left 5 on the Mac and none on Linux |
| 4 | 19 passed, 3 failed | Removal by ID, as bb-box’s delete did, failed on the Mac: “snapshot identity |
| 5 | 29 of 29 | Check E: bb-box’s own commands by group and member (create with --group and --label, inspect, restore, restore --disk-only as a fork does, head, remove with --force of a parent, remove of an old head once another is head, remove of the last one after its sandboxes went, then nothing listed), once with MSB_HOME through a link and once through its real path. By ID, inspect failed through the link and worked through the real path |
Run 5 on the Mac (output), times from the probe’s own clock: a take with memory 0.78 s and of the disk 0.22 s; a memory restore 0.20 s, with the counter process running and counting on from 12, its value at the take; the memory snapshot’s disk alone, booted as a fork boots it, 0.26 s, with the files byte for byte and the counter still at 12; a disk restore at another size 0.28 s; removals 0.06 s each.
Why “2 local copies”
On a Mac /tmp is a link to /private/tmp, so the probe’s home was always
reached through one. Where msb’s home is a link, msb indexes each
snapshot under the link’s path and the real one, and then refuses to pick
one by ID in snapshot inspect, restore and remove. Reproduced on
Linux the moment MSB_HOME is a link; by group and member all three work.
bb-box’s wrapper reaches a home longer than 57 bytes through
/tmp/bb-box-msb-<uid>, as for a BB machine’s own data folder, so a Linux
host with a long folder took snapshots before this that it could not
restore, fork or delete, and a release of such a box stopped at its
snapshots. bb-box’s size read of a snapshot (inspect) failed there too,
and fell back to the box’s size.
What changed
- Mac hosts take snapshots. The refusal on a Mac is gone from the plan,
every change and the doctor’s Snapshots check, which says “ok” on a Mac
with
msb0.7.7. The version guard stays as it was: every change readsmsb --versionfirst. - A snapshot is named by its group and member (
groupMember,bbbox-<box ID>:<name>) insnapshot inspect,restore, which forks use too, andremove, and when a new head is chosen. bb-box’s own files (keep dates, sizes) stay keyed by the ID. - Docs: the snapshots guide and the microsandbox guide say Linux hosts and Macs; the architecture says why bb-box names snapshots so; the probe and its Mac output are in the research folder; 070’s decisions.
Tests
In test/microsandbox-snapshots.test.ts, the fake msb refuses an ID as
the Mac did when its home is reached through a link (homeThroughLink):
- “a Mac host’s microsandbox boxes take and delete snapshots, as the probe on a Mac measured, and the doctor says so; another msb is still refused”, in place of unit 10’s test of the refusal;
- “where msb’s home is reached through a link … bb-box names each by
group and member to inspect, restore, fork and delete it”: two snapshots,
a restore, a fork, the parent deleted with
--force, the last deleted, and nomsbcall with an ID.
Both fail on main’s sources; both fail too with the Mac refusal lifted
alone. Two tests of unit 10 that pinned the ID in remove and restore
now pin group and member.
On a Proxmox LXC box (8 cores, 16 GiB, Node 24.21), in a git checkout of
this commit: npx tsc passes and the reference is current (node scripts/reference.ts --check); the node suite ran 3,494 tests, 3,175
passed, 0 failed, 319 skipped; vitest ran 445, all passed, with
TZ=Pacific/Auckland.
On a separate BB
The lead keeps a second microsandbox install off the owner’s Mac, so the
live check through bb-box ran on Linux, through the link: a fresh npx bb-app 0.48.1 with no account and BB_BOX_LIVE_CHECKS=1 in a nested-KVM
Proxmox QEMU box (8 cores, 16 GiB), bb-box from this branch, microsandbox
on BB’s server, with HOME long enough that msb’s home is 81 bytes, so
bb-box reached it through /tmp/bb-box-msb-1000
(live-linked-home.sh, which
runs unit 10’s own live-setup.sh and
live-gates.sh unchanged). Boxes were
machines of the person’s and joined BB as machines. Times are bb-box’s own.
| Check | Result |
|---|---|
| A box | ready in 235 s, most of it importing the image into a new msb home; boot=unarmed, its own marker |
| Take with memory, take of the disk | 3.4 s and 0.71 s |
| Restore, memory | 2.7 s: the counter process running, the same host ID, one microVM; BB’s daemon connected again 6.8 s after the restore began |
| Fork of the memory snapshot | 2.8 s, a new BB machine; boot=reset, “BB machines removed” naming the source’s host ID; no process of its source; new SSH host keys |
| Undo, disk | 2.5 s; boot=kept, the same host ID; the daemon connected 3.7 s after it began |
| Take a parent and its child, delete the parent | 0.68, 0.69 and 0.38 s |
| Keep, release | 0.38 s and 0.89 s; the kept snapshot listed after the release |
| Fork of the kept snapshot after the release | 2.3 s, boot=reset, a new host ID |
| Clean up | every box released, every snapshot deleted: “There are no snapshots”, msb ls “No sandboxes found”, msb snapshot list “No snapshots indexed”, no keep file; the link in place throughout |
| Main’s bb-box, in the same BB, through the same link | origin/main’s bb-box (b15fe055) installed from a checkout; the release of a box holding one snapshot stopped: “bb-box could not confirm the delete of snapshot … msb snapshot remove failed: exit code 1. It checks again at its next sweep”. With this branch’s bb-box installed again, the sweep finished that release; msb then listed no sandboxes and no snapshots |
After merge
The lead takes, restores, forks and deletes a snapshot on production’s own microsandbox backend on that Mac, releases the box and sees nothing listed, and rolls back if anything is wrong.
Close-out B: Tart’s Linux boxes fork and are kept
Unit 9 shipped Tart’s Linux VMs with takes and restores only, as their identity reset was not checked live. This checks it live, turns it on in their image, and offers fork and Keep for Tart’s plain Linux boxes, as for macOS. Decisions are in 070, Decided during the close-out.
What the live check found first
- The image had the reset off.
prepare-linux-image.shinstallsbb-box-identitythroughcustomize-base.sh, which turns it on only withBOX_IDENTITY_RESET=on, and the script never set it. So no Tart Linux box was fork-safe, whatever bb-box offered. It sets it now for the plain image. - A stopped take was never fork-safe. With the image fixed, the first
live fork was refused: “tl-src had no working identity reset when
‘before’ was taken”. Core reads fork-safety from inside a ready box at
each take, and Tart takes a Linux box only while it is stopped. A macOS
box taken while stopped had the same gap. The fake Mac’s tests had passed
forkSafein by hand, so they missed it. bb-box’s own stop now reads it.
What changed
- Tart’s offer (
src/backends/tart/snapshots.ts, the backend): thelinux-arm64kind forks by copy and keeps as it is, from version 8 of the reset (LINUX_FORK_SAFE_FROM); its identity source isdevice, the VM card’s MAC address.linux-arm64-desktop-cpuneither forks nor keeps, with why (LINUX_DESKTOP_NO_FORKS). The plan reads the fork rules from the offer, and a Linux snapshot taken without the reset says “which boxes from a Linux image refreshed since have”. The “macOS forks” doctor check, aboutdiskutil image attach, runs only for macOS kinds. - A Linux fork copies the snapshot’s files into a VM of its own,
claimed first, with a new MAC address (
tart set --random-mac) and the fork’s size, names it a Linux guest, and cleans nothing on the Mac. - The image:
prepare-linux-image.shsetsBOX_IDENTITY_RESET=onfor the plain image and off with--desktop. - Core: where a backend’s snapshots are taken from a stopped box
(
readsForkSafetyAtStop: it forks, and its snapshots arestopped-onlyorsuspends), bb-box’s stop reads the reset from inside the box just before it stops it, and keeps it on the box’s record (stopForkSafety) once the stop has succeeded, with the stopped guest’s mark; any write of the record in another state than stopped, or with a restore under way, drops it. A take of a stopped box uses it while the mark is the same (since the reviews, below). The fork and Keep refusals of a snapshot that is not fork-safe, there, addSTOPPED_ANOTHER_WAY: “A snapshot of a stopped box is fork-safe only when bb-box stopped the box, as it reads the identity reset just before its stop: start the box, stop it with bb-box and take the snapshot again.” - Docs: the snapshots guide (the offer table, which images have the reset on, the stop rule), the Tart guide’s Snapshots section, 070.
Tests
test/tart-snapshots.test.ts: the offers test, now with Linux forks and Keep and the desktop’s refusal; “a Linux VM’s snapshot forks into a VM of its own with a new MAC address … nothing to clean on the Mac”, withdiskutilunable to attach; “a Linux VM’s snapshot taken before its box had the identity reset does not fork … a Linux desktop forks none”; and one through bb-box’s ownBoxesandSnapshotswith the real Linux reset program in a test root, reached through the fake Mac’s guest: core writes the marker at acquire, bb-box’s stop reads version 8, the stopped take records it, the plan offers the fork, Keep takes it, and a box stopped outside bb-box takes a snapshot that is not fork-safe.test/beside-snapshots.test.ts: in core, bb-box’s stop reads the reset where snapshots suspend the box, a stopped take uses it and keeps, a start forgets it, a box stopped another way takes one that neither keeps nor forks, with the words; where snapshots are taken live, the stop reads nothing.test/identity-reset.test.ts: Tart’s Linux build turns the reset on for the plain image and off for the desktop.
Run against unit A’s commit with only these tests swapped in, and the three constants they import added so they load, 6 of the 7 new or changed tests failed; the seventh, that a stop reads nothing where snapshots are taken from a running box, guards that nothing changes there.
On a Proxmox LXC box (8 cores, 16 GiB, Node 24.21), in a git checkout of
this commit with the done note: npx tsc passes and the reference is
current; the node suite ran 3,499 tests, 3,180 passed, 0 failed, 319
skipped; vitest ran 445, all passed, with TZ=Pacific/Auckland.
On a separate BB
A fresh npx bb-app 0.48.1 with no account, its own data folder and ports,
env -i and BB_BOX_LIVE_CHECKS=1, in a Proxmox LXC box, bb-box installed
from this branch with bb plugin install path:, by
tart-linux-setup.sh. The
lead gave it a Tart backend of its own install on the production Tart Mac,
as in unit 9, with that backend’s SSH key put in the box as a file, deleted
once the backend was added; every kind off but linux-arm64, warm VMs off,
reserve-disk-gib 60, general.identityResetBackends tart. The Mac:
macOS 27.0.1, Tart 2.40.1. One VM of this install’s ran at a time; no other
VM was touched.
The image was built from this branch’s prepare-linux-image.sh on the Mac
as its user, under a name of its own, from the base the production image
names, already cached, so nothing was pulled, renamed or pruned (070, the
close-out’s decisions say why not bb-box’s own image build): 2 minutes 24
seconds, 4.7 GB; its box-user checks passed, and the doctor read it as a
standard image build.
As in unit 9, a BB with no account cannot make the boxes BB machines, so
the BB machine was stood in for by a random host ID in
~/.bb-machines/live-check/host-id, and the tailnet node by a file in
/var/lib/tailscale. The steps are
tart-linux-gates.sh. Times
are the CLI’s.
| Check | Result |
|---|---|
| A box | ready in 4.8 s by bb-box’s timings, identity marker 65 ms; 15.9 s for the command, the rest its first try to join a BB it cannot reach; bb-box-identity status: version 8, on, boot=unarmed, marker=matches, armed, source=device; its unit is ordered before ssh, ssh.socket, systemd-networkd, tailscaled and the box user’s user@ |
| Stop, take stopped, start | 16.1 s, 0.7 s and 4.2 s |
| Restore, undo | 20.8 s and 21.1 s, each a shutdown, the copy put back and a boot: work.txt “before”, then “after”; the same host ID, machine ID and SSH host key; boot=kept |
| Fork, as one of the person’s machines | ready in 4.6 s (5.5 s for the command), after core’s check before ready. In the fork: boot=reset, “none of the marker’s MAC addresses (0e:ac:…) is on this box’s interfaces (1e:de:…); BB machines removed: work.txt “before”. The order in its boot: the reset done at 1.45 s, systemd-networkd active at 1.55 s, ssh at 1.58 s and the box user’s user@1002 at 1.66 s, which is ordered after it |
| The source after its fork | unchanged: the same host ID, Tailscale state, machine ID, SSH host key and MAC address, boot=kept, marker=matches |
| Keep, release | 0.6 s; the source released in 1.2 s, its kept snapshot listed after |
| Fork of the kept snapshot after the release | ready in 4.6 s (5.4 s for the command): boot=reset, no host ID file, a new machine ID and SSH host key, work.txt “before” |
| Clean up | every machine released, the kept snapshot deleted, a sweep: no snapshots, no machines; the test backend removed. The lead then deleted the image and its two folders on the Mac with TART_NO_AUTO_PRUNE=1, and confirmed no VM, owner file or snapshot of this install’s there, and production’s own Linux images untouched |
Known limits
- A box from a Tart Linux image prepared before this unit has the reset off, so its snapshots neither fork nor keep until the image is refreshed and the box made again.
- A box stopped other than by bb-box, shut down from inside or by the Mac, takes snapshots that neither fork nor keep, until bb-box starts and stops it.
- A Linux fork’s disk is not cleaned on the Mac, as a macOS fork’s is: it relies on systemd’s order, measured above, and on its source’s reset having been on at the take.
- bb-box’s stop of a Tart box now waits for the reading first, a moment when the box answers and up to 10 s when it does not, and then reads the stopped VM’s mark on the Mac.
- A Tart box stopped by bb-box whose VM folder is touched while it is
stopped, by
tart setsay, also loses its reading: it fails closed.
After the reviews
Opus and Astra each said merge after fixes. The lead asked for these, in
the same commits rebased onto main 93c25d53, each with a test that fails
before it:
| Finding | What changed | Test |
|---|---|---|
| Astra’s high, Opus 2: a reading survived a start and stop outside bb-box between sweeps, and a failed stop observed as stopped kept it | The reading is kept only once bb-box’s stop has succeeded, with the backend’s mark of the stopped guest (stoppedMark), and a take uses it only while the mark is the same. On Tart the mark is each VM file’s modification time and size (config.json, nvram.bin, disk.img, state.vzvmsave), read on the Mac; a run writes them, as the firmware writes the NVRAM at each boot. A backend with no mark keeps no reading |
“a box bb-box stopped that then ran outside bb-box … is not fork-safe”, “a stop that failed, though the guest stopped, keeps no reading”, and the fake Mac test, whose fake tart run now touches nvram.bin |
| Opus 1, Astra’s low: an older image’s box was told to stop it with bb-box, which cannot help | A reading that found the reset off or missing is kept as such; a snapshot records why it is not fork-safe (forkSafeWhy, a new column in snapshots.db); reset-off refusals say “refresh the Linux image and make the box again” (or, for macOS, “make the box again”), and only unread ones say to stop it with bb-box |
“a box whose reset was off when bb-box stopped it … refresh the Linux image”, and unit 8’s Keep tests in core and the CLI, whose box has no reset program, now expect those words |
| Opus 3: an in-place restore left stopped kept the reading of the earlier disk | Any write of the box’s record with a restore under way drops the reading | “a restore drops the stopped box’s reading as it begins” |
Run against the rebased commit’s sources with only these tests and fakes
swapped in, and resetOffWords added so they load, all 6 failed.
On a Proxmox LXC box (8 cores, 16 GiB, Node 24.21), in a git checkout of
the three commits on main 93c25d53: npx tsc passes and the reference is
current; the node suite ran 3,504 tests, 3,185 passed, 0 failed, 319
skipped; vitest ran 448, all passed, with TZ=Pacific/Auckland. At unit A’s
commit alone: 3,495 tests, 3,176 passed, 0 failed, 319 skipped; vitest 448.
The stop and the take each gain one call to the Mac, stoppedMark, besides
the reading; nothing else in either path changed. It was run on the fake
Mac, whose scripts run in a real shell with GNU stat; on a Mac’s BSD
stat it reads %Fm and %z, which was not run live.
After merge
The lead refreshes production’s linux-arm64 image on the Tart Mac, which
production arms already, and forks one box to check.
After the close-out: forks of a stopped Tart Linux box
What the smoke test found
After merge, a Tart Linux box was stopped through bb-box, a snapshot taken
(fork-safe) and a fork made. The fork came up on the source’s recorded
address, and core’s check refused it: “it holds
The cause, as measured
On a separate BB with a Tart backend of its own install on the production
Tart Mac (macOS 27.0.1, Tart 2.40.1), from production’s refreshed
bb-box-linux image, cloned by name and not changed. One VM of this
install’s ran at a time. bootpd’s lease lines were read for this install’s
VMs’ MAC addresses only.
| Step | Measured |
|---|---|
| The source, running | 192.168.64.5. systemd 255 (255.4-1ubuntu8.18). Its lease file: CLIENTID=01f6839d58e72c, 01 and its MAC address, not a DUID from its machine ID; LIFETIME=1h. bootpd: identifier=1,f6:83:9d:58:e7:2c, lease to 1 hour on |
| bb-box’s stop | networkd logged “DHCP lease lost” as it stopped: it sent a release (SendRelease is on by default). bootpd kept the entry, with its lease ending at the moment of the stop |
| A fork, the source stopped | a new MAC address, 5a:b8:…; bootpd gave it 192.168.64.5, with identifier=1,5a:b8:..., its own. Core refused it, as in production, and released it |
| The source started again | it got 192.168.64.6, as the refused fork’s lease was still live, and bb-box’s record still said 192.168.64.5: a Tart start returned no address, so core kept the old one |
So the fork took nothing of its source’s: its MAC address and DHCP client ID
were its own, and the address was free. The reset ran before networkd: in
the fork, bb-box-identity.service finished at 1.52 s and networkd had its
lease at 1.69 s.
What changed
As amended after two reviews; section 9 of 070 states the rule.
| Change | Where |
|---|---|
A backend whose addresses are leased, not reserved, says what a guest holds now (leasedAddressNow): its power on its host, and its lease. On Tart, read on the Mac: “gone” only when the VM’s folder is missing; a VM that is there but not the box’s, one Tart does not list, a running VM with no lease and a lease that is not an IPv4 address all throw, which refuses the fork |
src/backends/tart/backend.ts, src/backends/tart/host.ts |
| The fork check reads the source at each look, in the source’s turn, so its start and stop wait until the check has passed and the fork is armed. Running or suspended: a fork holding the source’s lease or any recorded address (the one at the fork’s request and the one on its record now) is refused. Stopped or gone: the recorded addresses are compared unless the snapshot holds the source’s DHCP client IDs and the fork showed IDs of its own; a fork with no lease on record yet is waited for, then refused. Backends without it, Proxmox among them, keep the recorded address | src/core/boxes.ts, src/core/identity.ts |
A start says the address it found (Started.address), and core records it at a start, a restore’s start and put-back, and a fork’s first start, so a fork’s own address is checked on Tart too. Tart’s start and resize return it |
src/backends/types.ts, src/core/boxes.ts, src/core/snapshots.ts |
| The identity a snapshot holds, and the fork check, read the DHCP client ID of each systemd-networkd lease, hashed. A fork that asked for its address with its source’s is refused | src/core/identity.ts |
| A Tart VM’s commands go only to the address the Mac gives it now, never to a recorded one, which a fork may hold: a connection with none is unreachable, a shutdown with none is a stop, and a rename with none is skipped | src/backends/tart/backend.ts |
After the reviews
Opus and Astra each said merge after fixes. Each finding, and its test:
| Finding | Fix | Test |
|---|---|---|
| Opus 1, Astra 2: with no client IDs held (an older snapshot, no networkd lease, a fixed or product-UUID client ID), a stopped source’s address was not compared at all | the recorded addresses are compared unless both sides’ client IDs prove the fork its own | “where addresses are leased, a fork on its stopped source’s last address is handed over only when …” |
| Opus 2: a running source was compared with its lease only | its lease and its recorded addresses | the same, “running, on its recorded address” and “running, on its lease” |
Opus 3, Astra 1: “not this box’s”, an unlisted VM and a failed tart ip read as gone or fell back; deadbeef passed as an address |
gone only with no folder; the rest throw; IPv4 only | “what a Tart VM holds now fails closed …” |
| Opus 4: a connection fell back to a recorded address, which a live fork may hold | no fallback | “a Tart VM’s commands never go to its recorded address …” |
| Astra 3: the source was read once, outside its turn, so it could start before the handover | read at each look, in the source’s turn, through the arm | “a source’s start waits until its fork’s check has passed and the fork is armed” |
Against the first commit, with only the tests swapped in, each test in the
table failed, as did the two changed for the new shapes, 6 in all; against
main eb751824 with only the tests and fakes swapped in, all 11 new
or changed tests failed. The fake tart ip fails while
~/.fake-tart-ip-fails exists, as for a VM with no lease.
Tests
On a Proxmox LXC box (8 cores, 16 GiB, Node 24.21), in a checkout of the
amended commit: npx tsc passes and the reference is current; the node
suite ran 3,547 tests, 3,225 passed, 0 failed, 322 skipped; vitest ran 448,
all passed, with TZ=Pacific/Auckland. With that TZ the node suite has
one failure, on main too: “a box whose restore was cut short is put right
…”, which compares a process’s start time; without it, it passes.
Live
On a separate BB with bb-box from this change, a Tart backend of its own
install on the production Tart Mac, production’s bb-box-linux cloned by
name, one VM of the install’s at a time. bootpd’s lines were read for the
install’s VMs’ MAC addresses only.
The first commit was run as two scripts:
| Check | Result |
|---|---|
| The source starts again | records 192.168.64.6, the address it holds |
| Stop, take | the lease released at the stop again; the snapshot holds the source’s client ID hash |
| Fork, the source stopped | ready in 4.7 s, on 192.168.64.6, the source’s last address, with client ID 01 and its own MAC address; boot=reset, a new machine ID, its own marker and hostname |
| The fork’s record | its own address, 192.168.64.6, once its first start returned it (a second fork, 4.8 s) |
| The fork stopped, the source started | bootpd gave the source 192.168.64.6 again, recorded so |
The amended commit, on a new separate BB, by tart-stopped-source-fork.sh as it stands:
| Check | Result |
|---|---|
| A box | ready, 192.168.64.2, client ID 0192c4e122208e, its MAC address |
| Stop, take | 1.1 s and 0.8 s; bootpd’s lease ended at the stop |
| Fork, the source stopped | ready in 4.6 s (5.4 s for the command), on 192.168.64.2, the source’s last address, client ID 01ca4bbea8bdad, its own; it records that address; its reset done at 1.42 s, its lease at 1.59 s |
| The fork stopped, the source started | 4.4 s; it records 192.168.64.2, the address it holds; its last boot ended “DHCP lease lost” |
| Clean up | both machines removed, no snapshots, a sweep; the test backend removed. On the Mac, no owner file or snapshot of this install’s, and bb-box-linux untouched |
Every command of that run went to the address the Mac gave the VM, as a connection no longer falls back.
macOS forks on Tart
bb-box stops a macOS box by suspending it. A suspended VM sends no release, and its lease runs on, an hour on this Mac. Within that hour a fork gets another address, as unit 9 measured with its source suspended. After it, bootpd may give the fork the suspended source’s address, which the source resumes with: the check refuses that fork. Not run live here.
Known limits
- A source running beside its fork was not run live, as that needs two VMs of the install at once; nor were the host script’s “not-mine” and “unlisted” answers, which the fake Mac runs.
- A fork from a snapshot taken before this change is refused when the Mac gives it its stopped source’s last address, as on main: take the snapshot again.
- While a fork’s check runs, up to a minute after its first boot, its source’s start, stop and take wait for it.
- Two VMs that ask with the same client ID get one binding from bootpd.
Where the snapshot holds no client ID to compare, a source started after
its fork’s handover could get the fork’s address. The images bb-box
builds use
01and the MAC address, which a fork changes. - The fork’s DHCP request names its source’s hostname, as the fork is
renamed after its first boot: bootpd’s entry for the fork says
name=tl-src. bb-box reads nothing of it.