CLI backups
Backup artifacts transfer message and registry state from a stopped, preserved space. Restore requires the same space and existing trust. It does not create replacement authentication.
Commands
cotal down --preserve-state [--store-dir <dir>]
cotal backup create <dir> [--only full|registry] [--store-dir <dir>] [--json|--plain]
cotal backup inspect <dir> [--json|--plain]
cotal backup verify <dir> [--json|--plain]
cotal backup recover <full-attempt-id> [--yes|--dry-run] [--json|--plain]
cotal backup cleanup-fallback <full-attempt-id> [--yes|--dry-run] [--discard-history] [--json|--plain]
cotal backup restore <dir> [--only registry] [--store-dir <dir>] [--accept-missing-source] [--json|--plain]
cotal up --restore <dir> [--restore-only registry] [--accept-missing-source]On a controller root, one with a stack journal, backup create, backup restore and
up --restore refuse UNSUPPORTED (exit 2) in this release. Each needs a preserved cut, and
down --preserve-state cannot make one there. They refuse while validating their arguments,
before the connector seed or any write. Plain down and up keep a controller stack's store,
channels and history. On a full store down refuses first, and down says how to stop the
stack there with or without purging that history. Backup and restore of a controller-owned stack
return in a later release. The
rest of this section applies to a root without a stack journal.
Backup is offline-only. It requires the stable ready record from down --preserve-state, an exact
store match (--store-dir defaults to the cut's recorded source store), no recorded process that may still be running (the same test clean uses), and an
unreachable exact endpoint from the recorded cut. That endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile
is still refused. backup restore, up --restore and a bare up that resumes the cut probe the
broker address they will bind before they move or start anything. While another process listens
there they refuse CONFLICT (exit 2), name the address and leave the cut unchanged. It claims the cut, reflink/copies the stopped source to a
private attempt clone, and opens only that clone on a random loopback bootstrap broker with an
independent parent/deadline watchdog. A bootstrap broker that does not accept connections within
15 seconds fails the command. Each maintenance-login reload waits for the broker's acknowledgement
for up to 15 seconds, bounded by the operation deadline. A rejection, broker exit or missing
acknowledgement fails the command before the next phase receives its login.
Creation returns one JSON/plain receipt after cleanup.
A published artifact plus incomplete cleanup is partial and retains the artifact and original
attempt; it is not a reason to repeat creation blindly. It validates the canonical stream and pull-consumer inventory,
writes native snapshots with consumers excluded, and stores conservative contiguous ACK-floor
checkpoints separately. The presence bucket is memory-backed, so it does not survive the cut and
the clone may lack it. Every other stream must be present. The original store is never opened by
the backup broker, and the stack is not restarted implicitly. Artifact destinations must not overlap
the preserved source or maintenance
attempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the
preserved source, or the maintenance attempt tree.
backup create checks its preconditions before it claims the cut or creates any maintenance state.
A root with no preserved cut refuses and names cotal down --preserve-state. A cut in another state
refuses and names that state and its space. A create during a restore that still runs refuses
LIFECYCLE_BLOCKED (exit 2), names that restore attempt, its process and when it started, and says
to wait for it. A create that finds the root's maintenance lock held, such as by another
backup create, names the operation that holds it, its pid and since when it has held the lock.
Neither refusal journaled anything, so it names no attempt of its own. A destination that already exists is a CONFLICT
refusal, because create writes only a new directory. A destination whose folder does not exist
refuses TARGET_NOT_FOUND (exit 2) and names that folder; create makes only the artifact's own
folder. A destination check that finds a non-directory parent (ENOTDIR) or a symbolic-link loop
(ELOOP) refuses USAGE_INVALID (exit 1), naming the destination and its parent. Choose a destination
with a directory parent; no attempt was started and the preservation journal is unchanged.
A destination inside the preserved store refuses USAGE_INVALID (exit 1). So does one
inside a path clean all removes, such as .cotal/auth or the space's .cotal/space.<hex>
folder, because that cleanup would remove the backup with them. One in a
folder this account cannot write refuses PERMISSION_DENIED (exit 2) and names that folder. A --store-dir that is not the cut's preserved store, such as an empty folder,
refuses CONFLICT (exit 2) and names both stores. A process that listens at the cut's recorded
address refuses CONFLICT (exit 2), as backup restore and a resuming up refuse it, and its Next
says to stop the process that listens there and run the same backup create again. Any other
refusal or failure shows the check's own sentence as its native detail.
A failure that removed what it wrote leaves the preserved cut ready, and its Next names the same
backup create to run again once that cause is fixed.
A create that runs out of disk space (ENOSPC) or quota (EDQUOT) after it claims the cut removes
what it wrote and fails OPERATION_FAILED (exit 5). Its message names the error and where it was
writing: the store copy under .cotal/maintenance/attempts, or the backup in its destination. It
also says that no backup was published and that the preserved cut is still ready. Its Next frees
space on that disk and runs the same backup create again.
A backup create interrupted by SIGINT, SIGTERM or SIGHUP after it claims the cut exits 130, 143
or 129 and names its attempt, the partial artifact it left and cotal backup recover <attempt> --yes.
That claim follows the restore claim's rule below: it holds the cut until its backup process and
brokers are proven gone, or for 30 minutes when they cannot be. Until then up, another create and
backup recover refuse LIFECYCLE_BLOCKED and name the backup process and the deadline, and
cotal status names the attempt and its next step. Once it is stale, backup recover removes the
partial artifact, the clone and the broker files, each matched by inode, and a create to another
directory does the same before it starts, then backs up the cut's recorded store, a custom
--store-dir one included. A create naming the partial artifact refuses CONFLICT
and names that recovery. The release's receipt names cotal backup create <directory> and
cotal up as its next steps.
Artifact inspection
backup inspect reads the bounded manifest and exact declared file-name inventory. It does not
hash snapshot or checkpoint contents. backup verify additionally reads each named regular file
and compares its size and SHA-256. Both are read-only: no staging directory, broker, restore or
secret upload. A path through a linked folder is read at its real path, as backup restore reads
it, and the report names that path. Reports omit snapshot bytes, configuration bodies and trust
material. JSON retains
the declared metadata; terminal file lists are bounded and state their omissions. When a declared
file is missing, an undeclared file is present, a declared file is not a regular file, such as a
symbolic link or a FIFO, or a file does not match its declared size and SHA-256, the refusal names
that file and the fault, and its JSON target carries file and fault. A declared name that is
not a plain file name in the folder, such as one with ../ or an absolute path, has the fault
invalid-name and is not printed. A manifest.json that is not a regular file, is larger than
1 MiB or is not a valid manifest has the fault manifest-not-regular, manifest-too-large or
manifest-invalid. backup restore and up --restore refuse each of these SCHEMA_INVALID with
the same file and fault, before they stage or change anything.
A directory with no manifest.json that holds files a backup create writes is refused as an
incomplete artifact, the kind an interrupted create leaves, with the fault manifest-missing. A
file, or a directory that holds neither a manifest nor such a file, is not a backup artifact and
refuses TARGET_NOT_FOUND (exit 2), naming it and saying whether it is a file, an empty folder or
a folder with other files. The folder of a create in this root that was
interrupted before it wrote a file is still an incomplete artifact. A path that names nothing refuses
TARGET_NOT_FOUND (exit 2) and says in missing whether its folder or the artifact is missing, as
backup restore does. A full artifact of an authenticated space
restores only into a root that still holds the trust state it was taken under, so it cannot move to
another machine or a fresh root. backup create, backup inspect and backup verify say so in a
Restores into row, and their JSON carries trustBound. A registry restore does not check the trust
state, so --only registry still restores such an artifact's channel settings. When a full restore
refuses CONFLICT because the trust state moved, its Next names that --only registry restore for an
artifact that holds the registry.
File integrity is not a signature or a restore proof. Matching a manifest does not authenticate
its author, verify snapshot semantics or prove existing trust will accept a restore. backup inspect and backup verify do not compare an artifact's trust state with this root's, and their
Authority continuity row says not checked. A full artifact taken on this root before clean all
or up --rotate-sys still verifies, and restore refuses it CONFLICT. The guarded
read path refuses unsupported file-protection environments rather than using a weaker reader.
backup recover <full-attempt-id> uses the original root's pre-commit restore journal, not a
manifest path or selected-space guess. It rolls the attempt back once it is stale: its restore
process and every recorded watchdog and broker are proven dead, and either every broker this root
started is proven gone or the claim's deadline has passed. Commit intent forbids rollback. The
receipt names cotal up, which resumes the space from its preserved state, and
cotal backup restore <directory>, which tries the restore again, as its next steps.
backup cleanup-fallback <full-attempt-id> requires the matching healthy active restore and
removes only its unchanged retained old-store identity; it never removes the restored target.
It removes that store by its store entries, as clean store does. When the old store also holds
anything else, such as a file you put in a custom store directory or in the fallback folder, its
--dry-run names those entries, and the cleanup refuses CONFLICT (exit 2), names them and deletes
nothing; move them out and run it again. A restore into the preserved store's own path moves that
store aside whole, so it refuses CONFLICT (exit 2) the same way before anything moves.
A restore made with --accept-missing-source replaced a missing source and retains no old store,
so its cleanup removes nothing and only retires the journal. A later down --preserve-state then
starts a new backup cycle. A registry restore leaves every other stream out, chat and DM history
and workflow runs included. A full restore of an artifact with no workflow runs leaves the runs out,
and a full restore of an artifact made before the state it replaced was preserved leaves out what
was written after the artifact. A backup holds one account, so any restore also leaves out the
streams of another account in the store it replaced, such as an open mesh's account beside a static
space's after both ran in one folder. In each case its old store holds the only copy of them. Its --dry-run warns with
HISTORY_ONLY_IN_PREVIOUS_STORE and names what it left out, with those accounts in otherAccounts, and the cleanup refuses
USAGE_INVALID (exit 1) and deletes nothing unless --discard-history is given.
That refusal names the flag and does not print the command help.
Both review full paths and default to Cancel. Scripts need --yes; --dry-run reads the plan
without taking a maintenance lock or writing. Apply rechecks the reviewed journal revision under
the native lock, preserves unknown outcomes and never retries a deletion. No live-owner override
or process kill is supplied.
A restore's full attempt id has the form restore-<uuid>. backup recover also takes an
interrupted create's backup-<uuid>, as described above. cotal status shows the attempt a root
holds under Maintenance, with its state and next step. For a restore that left history or workflow
runs out that step includes --discard-history. A refusal for the wrong state names the next step
for the recorded one. A completed cleanup-fallback retires the journal, so a repeated cleanup
refuses with TARGET_NOT_FOUND and says the root holds no restore attempt.
Restore receipts
backup restore <dir> is the canonical restore. It runs the same guarded restore as
up --restore <dir>, with the same lock, claim, trust and space proofs and the same resume, and
returns one backup restore receipt: the restore attempt, space, root, selection, store, broker
process and whether the manager, delivery and auth service started. The restore follows the
artifact's own selection, and --only registry narrows a full artifact to the registry. Any other
--only value is refused, because the restore could not honor it. The space, broker and runtime
come from the preserved cut, so no selector can rebind them. An artifact of another space refuses
CONFLICT (exit 2) and names both spaces, and a full artifact taken under another trust state
refuses CONFLICT the same way. An artifact file that is missing, undeclared or not its declared
size and SHA-256 refuses SCHEMA_INVALID (exit 1) and names the file, as backup verify does. A
manifest whose space is longer than 200 characters is invalid, so restore refuses it with the fault
manifest-invalid and does not print that space. In each case the preserved cut is not changed. A
receipt means the
active journal was written. Its seatResume field says whether retained seats were handed to the
manager's resume (requested) or dropped by a registry restore (not-requested). It does not
confirm that any seat joined. Its restored and recreatedEmpty fields name the streams the
restore brought back and the ones it recreated empty. A registry restore brings back channel settings only, so chat and DM
history and workflow runs start empty. Its receipt says so with a HISTORY_NOT_RESTORED warning, and previousStore
names the store the restore set aside, which holds the only copy of them. Its next step says
that backup cleanup-fallback deletes that store only with --discard-history. A full restore of
an artifact made before the state it replaced was preserved, such as a backup taken before a later
cotal up and down --preserve-state, leaves out the chat and DM history and workflow runs written
after the artifact. Its receipt says so with a NEWER_HISTORY_NOT_RESTORED warning that names the
store it set aside, which holds their only copy, and its next step includes --discard-history too.
When the store a restore replaced also held another account's streams, the receipt says they are not
restored and names the accounts in otherAccounts. That is an OTHER_ACCOUNTS_NOT_RESTORED warning,
or the warning above when the restore also left out history or workflow runs, and its next step
includes --discard-history as well. previousStore names the store a restore set aside whenever
that store holds the only copy of something the restore left out, and is null otherwise. A restore
made by an earlier release recorded no other accounts. For it, status, the cleanup preview and
the cleanup read them from the store it set aside while that store is still the one the restore
recorded, and name only the accounts the restore's auth mode rules out: every account but $G after
an open restore, and $G after a static or user one. Which data account a static or user restore
brought back is not recorded, so another data account in that store is not named. Before such a
cleanup, list the previous store's jetstream folder: each folder there is one account.
When that restore brought the workflow runs back, it also names each run it rolled back and each
step that was answered after the artifact and is open again, and says that answer is only in the
store it set aside. A reopened step accepts an answer again. cotal status and the
backup cleanup-fallback preview name the same runs. To find them, the restore copies the store it
sets aside and reads its run journals on one more isolated broker, so such a restore takes longer
and needs disk space for that copy.
up --restore --restore-only registry prints the same two lines. Without --store-dir, a restore
creates its store at .cotal/nats, also when the cut's store is elsewhere, as after
up --store-dir; only a controller root restores into its recorded store. The preserved store stays
where it is until backup cleanup-fallback deletes it, so to keep the space on another disk, name a
new store there with --store-dir. --store-dir names a store the restore creates, so a path that
already exists, or whose parent directory does not, refuses USAGE_INVALID (exit 1) before anything
is staged. Restore needs the stopped preserved cut from down --preserve-state. A root with no
maintenance journal refuses before the restore takes its maintenance lock, so the folder keeps no
state from the attempt, and up --restore refuses the same way. An artifact path that names
nothing refuses TARGET_NOT_FOUND (exit 2) at the same point. It names the path, and its JSON
target says in missing whether the folder that should hold the artifact (folder) or only the
artifact (artifact) is missing. A path that is not an artifact, or is an incomplete one, refuses
there too, as backup verify refuses it. A path that is, holds or sits inside the preserved store
or .cotal/maintenance, such as . in this folder, refuses USAGE_INVALID (exit 1) there, naming
that store or folder. The restore checks the path again once it holds the maintenance lock, because a
link in it may name another folder by then, and refuses the same way. A restore that fails after it
began names its attempt in the text and in the JSON target, which also carries journalState, and
its remedy gives the next step for that state. The listener endpoint must be a bare nats:// or
tls:// URL without credentials, a query, a fragment or a path. Its refusal does not repeat the
endpoint value, including when it came from a preserved launch. up --restore
remains as the equivalent startup form. After its startup lines it names the restore attempt and
gives the cleanup step that retires it, with --discard-history for a restore that left history or
workflow runs out. Unlike the other
backup modes, backup restore runs the connector seed first, as up does, because it resumes seats.
Three commands refuse an active restore. down --preserve-state, in each of its forms, refuses
LIFECYCLE_BLOCKED (exit 2) and stops nothing: a new cut begins only after the restore journal is
retired, so it names the attempt and backup cleanup-fallback as the next step, with
--discard-history after a restore that left history or workflow runs out. team boot
resumes only a stopped stack whose manager never launched, and a restored stack's manager already
ran, so start it with bare cotal up instead. cotal up -f boots a fresh mesh, so it starts only
once the restore is retired. While the restored broker runs, it refuses CONFLICT, exit 2, naming
the broker's PID, and its Next is cotal down, then backup cleanup-fallback <attempt> --yes, then
cotal up -f again. After a restore that left history or workflow runs out, that cleanup step
includes --discard-history and says it deletes the previous store. Once that stack is stopped, it refuses LIFECYCLE_BLOCKED, exit 2, and names
the cleanup. Nothing is started either way. While the restored broker runs, any other cotal up,
bare or with --server, refuses CONFLICT too, naming its PID, and leaves the restore journal as it
was. Its Next is cotal down, then cotal up, which starts the restored stack again.
Artifact contents
Stopped client-managed KV ordered consumers are ephemeral read residue, not backup state. Backup ignores only the pinned client's exact stopped shapes: ordinary last-value watchers, a watcher of one subtree of its bucket such as the manager's goal index, and the whole-bucket scanner that uses all-history delivery to collapse concurrent tombstones. A bound consumer or any lookalike with a filter outside its bucket, another inbox, lifetime, or other config is still refused.
full is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, on
a static-auth or open mesh the workflow run stores (the WFJ step journal, the records store and the
run admission store), and validated durable checkpoints. registry is the sole partial artifact. Presence, derived membership
feed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor
ledger files are excluded. The other endpoint stores are nonportable control state: endpoint
submissions, facts, events, timer requests and fires, work pools, contract artifacts, and the
auth, session and issued-authority stores. Restore recreates those streams empty with their
canonical configs before exposing the normal listener, so endpoint sessions do not cross a backup. A
workflow run that waited at a checkpoint or a timer at the cut is back after a full restore, and the
manager takes it over as after a restart. The records store also holds the endpoint authority rows,
such as lifecycle heads, obligations and policy versions, and a full restore rolls them back to the
cut with the runs. A user-mode full backup holds no workflow run store, because the records store's
lifecycle heads name credential rows in the auth store, which a backup does not carry; its restore
recreates the run stores empty and says so. An artifact in the earlier cotal-space-backup/v1
format still restores; it holds no workflow run, so the run stores come back empty and the restore
says so. backup create says so too when its artifact holds no runs, as a registry or user-mode
artifact does. After a restore that left the runs out, the store it set aside holds their only copy:
its receipt says so with a RUNS_NOT_RESTORED warning that names that store, and
backup cleanup-fallback deletes it only with --discard-history, as for a registry restore. Each
restore lists what it restored and what it recreated empty. Artifacts are exclusively created 0700;
snapshot/checkpoint files and
the manifest are 0600; manifest.json is written last with exact sizes and SHA-256 values. The
directory is trusted operator input: hashes detect corruption, not malicious rewriting. So
backup verify, backup restore and up --restore apply the
space create rule for other local writers to the artifact directory, each
file in it and each directory above it. An artifact another account could write refuses
PERMISSION_DENIED, exit 2, before a restore stages or changes anything, and the refusal names each
such path and one remedy such as chmod g-w <paths>. backup create refuses the same way, before it
writes anything, when a folder that would hold the artifact breaks the rule. backup inspect does
not check this, and a copy that other accounts can only read is accepted.
Store staging
Restore validates and stages the exact allowlisted artifact bytes before moving or creating a store.
It requires the same space. A full restore also requires the trust state the artifact was taken
under; a registry restore, --only registry of a full artifact included, does not check it. The
whole pre-commit window holds a journaled
liveness claim (coordinator, watchdogs, brokers, and a deadline 30 minutes after the restore
began). While the claim is live, ordinary up, a repeated restore, backup create and
backup recover refuse LIFECYCLE_BLOCKED (exit 2) and name the restore process, when it started
and the deadline. A command that finds
the root's maintenance lock held refuses the same way and names the pid that holds it. The attempt
is stale once the restore process and every recorded owner are proven dead on this host, and
either the deadline has passed or every broker the root started is proven gone. A broker's
watchdog stops it when the restore process exits, so a restore stopped by Ctrl-C is stale within
seconds. A retried restore rolls a stale attempt back and continues; an operator can also roll it
back with cotal backup recover <full-attempt-id> --yes. Nothing ever rolls back a live attempt.
A restore interrupted by SIGINT, SIGTERM or SIGHUP before it commits exits 130, 143 or 129 and
prints its attempt id and that recovery command. A registry-only artifact restores as registry-only whether or not
--restore-only registry is passed; omitted infrastructure is always created and the exact
post-restore stream inventory is asserted before commit intent. Ordinary up from a preserved cut
resumes only the exact recorded source store, file store cap, runtime and broker address. A different
--store-dir, --max-file-store, --runtime or --server refuses CONFLICT, exit 2, before
anything starts, naming the value given and the recorded one. The Next resumes with cotal up without that flag, then cotal down and
cotal up with it.
Seat checkpoint admission
Admitting a seat checkpoint. An ordinary up from a preserved cut admits that cut's seat
checkpoints before it journals the resume attempt and before any process starts, so a refusal costs
nothing. Three gates run in order, each naming what it saw.
- Integrity. Every file the record names must be present, a regular non-symlink file, the recorded byte size and the recorded sha256, re-stat'd after the read so a file that moved is a refusal. Failure here consults no other gate.
- Identity. The recorded space must match, the recorded
lifecycleUidmust not belong to a live incarnation, and the profile revision must match this host's or be resumed under deliberately this host's. A differing revision is refused with both digests and the remedy, and there is no override: the checkpoint carries the recorded digest and not the config bytes, so nothing could run the seat under the recorded revision, and the manager re-digests the same file and refuses drift on its own. This gate has no blanket override, which is the only reason the next one may have one. - Recency.
capturedAtis compared to this host's clock against the horizon the record carries. Inside it, the seat resumes. Outside it,uprefuses and prints the capture instant, the clock reading and the horizon;--accept-stale-checkpointadmits it anyway and the exercised consent is printed with the actual age. An unreadablecapturedAtis refused with no override, because a freshness gate that fails open is not a gate.
Custody transfers only after all three pass. The destination claims the recorded generation plus one
by exclusive create, before it launches anything. A lost create means another destination is already
claiming that seat, and it refuses with seat-writer-generation-create-lost rather than adopting
the winner and becoming a second writer. The recorded lifecycleUid is reused and never minted, so
the resumed seat binds the same lifecycle-keyed durables.
Admission is reconciled against the inventory the resume is about to hand the manager, and that
reconciliation finishes before the restore moves a single tree. A checkpoint whose recorded
lifecycleUid is not the one the retained inventory carries describes a different incarnation of
that seat, and it refuses with both uids while every live working tree is still untouched and no
generation is claimed. A retained
seat with no admitted checkpoint refuses the resume by name: an absent checkpoint directory and an
absent record are indistinguishable from a seat that was never checkpointed, and a seat that starts
without passing the gates has claimed no generation. --accept-stale-checkpoint is recorded in the
resume journal with the seat, the capture instant, the admitted age and the horizon, so the consent
survives the terminal it was typed into.
The whole admission is all or nothing. Coverage is settled first, then every gate runs over every checkpoint, and only then is any generation claimed. A refusal at any point leaves every generation unclaimed, including a lost exclusive create during the claim itself: the claims that attempt made are removed before the refusal is raised, by the exact paths it wrote, so a generation another destination holds is never touched. A claim is a create that can never be made again, so a refusal that left one behind would consume the retry over the same checkpoint set.
Seat checkpoint restoration
Restoring a seat checkpoint. Once every gate has passed over every checkpoint, and before a
single generation is claimed, up puts each admitted seat's captured bytes back. A refusal here
costs nothing for the same reason a gate failure does: no claim has been made and nothing has
started.
A restore never moves or replaces the destination's own control directory. A checkpoint excludes
.cotal/ by design, so a seat whose cwd holds one, which is the layout an operator gets by
running up and spawn in a single directory, is refused before anything is staged: promoting a
tree that cannot contain .cotal/ over that cwd would carry this host's live trust material and
maintenance state away with the superseded tree. The refusal names the control directory it found
and the remedy, which is to give the seat a working tree that is not a workspace root.
Each seat is staged beside its own cwd, in <cwd>.incoming:
- every recorded digest is verified again over the files as they are now;
- the bundle is cloned into
<cwd>.incoming, which is refused when that path already exists; - the recorded base commit is verified in the clone and checked out detached, so a bundle that does not contain it stops the resume instead of continuing against a different history;
- the index diff is applied with
--indexand the worktree diff without it, both--binary --allow-empty. That order is what puts staged content back in the index rather than only in the worktree, and--allow-emptyis why a seat with a clean tree is still restorable; - the untracked archive is extracted.
Every seat stages before any seat is promoted. Promotion moves an existing cwd aside to
<cwd>.superseded.<timestamp> and renames the staging directory into place, then puts the session
pointer and store files where the destination's connector reads them, then re-reads
git status --porcelain in the promoted tree and compares it to the status the checkpoint recorded.
A restore that applied without error and produced a different index is a refusal, not a warning. The
two renames are the only steps that touch the path the seat will use, so a failure anywhere leaves
every seat's live cwd as it was.
The rename itself claims the superseded name, and a taken name gets a numeric suffix. The timestamp has one-second resolution, so two promotions of the same seat within one second compute the same path; a rename onto a name that already holds a tree fails on every platform, and that failure is read as taken. Nothing creates the name ahead of the move, because Windows refuses to rename onto an existing directory at all. A superseded tree is the thing that rename exists to keep.
git and tar run as child processes with argument arrays, never a shell string.
A leftover <cwd>.incoming refuses the resume by name. A staging directory from a failed run is the
only record of what failed, so nothing removes one automatically: inspect it, remove it by hand, and
resume. A pre-existing cwd is renamed rather than deleted, so a wrong checkpoint costs a rename
instead of a tree. When a promotion fails, the renames that attempt made are undone and the staging
tree is left where it is, as the evidence for what did not verify.
A session pointer whose recorded sessionId is not the one the retained inventory reopens is
refused before anything is cloned. A session file already present at its destination is judged by
content: bytes equal to the recorded digest are already restored, and different bytes under the path
the connector is about to read are refused with both digests rather than clobbered.
up --restore <dir> reaches the same admission and the same restore, after the store is restored
and validated and before commit intent is journaled. A registry-only restore resumes no seat, so it
admits and restores nothing.
The writer generation is claimed by exclusive create inside one workspace root. It fences competing resumes in that root. Separate roots on the same host, or on different hosts, do not share that fence: copying a checkpoint to two roots can let both claim the same successor. A cross-root fence needs a coordinate neither root owns.
Restore commitment
Authenticated restores validate the complete
space trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;
full restores commit to the validated operator, system-account, data-account, and active-signer root
chain in addition to the static/user authority fingerprint. Because the system account is part of that
commitment, a cotal up --rotate-sys makes every full artifact taken before it unrestorable
against this root: take a fresh full backup after each rotation. cotal clean all removes
the space trust itself, so it does the same. Such an artifact still restores its registry with
--only registry, and chat and DM history start empty. The composed commitment is revalidated
immediately before store mutation and never includes secret seeds. Restore never creates fresh auth.
Same-path restores atomically retain the old
source at the journaled fallback path; alternate targets retain it in place; a missing canonical
source needs explicit --accept-missing-source. Without it, the restore refuses TARGET_NOT_FOUND
(exit 2), names the missing path and changes nothing. A default .cotal/nats that is a symbolic
link to the recorded source is that source, so the restore runs in place at the real path and the
link keeps naming it. Quarantine and target restores use current canonical
configs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a
commit-intent immediately before the normal listener starts. Archive bytes never instantiate the real
target: after quarantine validation, every stream is re-snapshotted from the validated quarantine
state into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back
the attempt-owned target; after it, ambiguity preserves both stores and records forward-repair
recourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.
Bootstrap broker authority
Bootstrap brokers in every auth mode, including open, mount the store under a local account with
random operation-specific logins only, each carrying the exact per-phase subject permission matrix;
normal static credentials and user-auth sentinel/bearer connections are rejected, and no auth
service or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,
restore initiation, exact upload id, validation, and each checkpoint recreation use separate exact
authorities. Every checkpoint carries the source stream's message/first/last sequence state and must
match its snapshot record before mutation; core then derives and validates the only allowed start
policy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical DeliverAll
WorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a
start-sequence policy there. Registry-only restore creates every omitted canonical stream and transient
bucket on the isolated target before the normal listener is exposed. It deliberately does not resume
retained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and
stopped rather than being reprovisioned into a partial restore.
Manager finalization
After listener readiness, the manager starts attempt-bound, validates retained credentials/tokens
without granting or reprovisioning, and resumes the exact persisted principals under cleanup
suppression. Registry-only restore uses the same flow with an empty agent set. On a user-auth mesh
these manager calls run as the logged-in operator's cli actor, the caller the preserve cut used, so
that actor needs a current admin grant. commitResume is an
idempotent validation barrier only: success must be awaitingFinalize with an attempt-bound 64-hex
commit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that
exact evidence as manager-committed (restore) or resume-committed (ordinary resume), then calls
token-bound finalizeResume; only an active response for the exact token releases suppression. The
CLI records the same token in finalization evidence before a restore becomes active, or before an
ordinary resume retires and consumes the marker. A new launch hands the token to the next manager as
a protected file: the CLI journals its path, writes it at mode 0600 in
.cotal/maintenance/v1/handoff/, then journals its inode and digest before the child starts. The
child reads it once with --resume-commit-file and --resume-commit-sha256 and removes it. The
token never appears on a new launch's command line. Before retirement or activation the CLI settles
that file. A file it did not record, or a recorded path that now holds another file, holds the
attempt and is never deleted. cotal supervise --resume-commit-token stays accepted for older
callers; while such a process runs, its token is visible in its command line. Re-entry from either committed state skips the prior
idempotent activation/commit phases, retries finalization with the durable token, and finishes the
workspace transition. Failure before finalization preserves the committed state and cleanup
suppression; it is not rewritten through a degraded transition. A failed listener INFO read names
whether the attempt is a resume or restore and includes its attempt ID, so an ordinary resume does
not report a restore failure. Re-entry between any two earlier
boundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or
changed per-agent dependency is a named fail-closed result; the journal becomes degraded and remains
available for forward repair. A retry from resume-intent,
resume-active, or resume-degraded reuses the same attempt and inventory after the prior listener is
proven stopped. A retained agent the lost manager already launched can still be running, for example
in a tmux window, while the journal reads resume-intent. On a static mesh the replacement manager
closes that seat through the reference the lost manager recorded on the agent's slot, waits for the
principal to leave presence, and launches it again. A live principal with no such record, or one that
stays live after the seat is closed, is refused. Every normal restore listener has an unguessable
attempt-bound NATS server name. The CLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity
immediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID
record, endpoint, and target identity all match that proof; degraded restore repair then moves through
the guarded workspace transition only after manager commit. If an uncommitted bound owner is provably
dead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the
same attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener
or ambiguous owner is preserved and refused, never adopted by reachability alone. Before the
daemons start, re-entry recreates the memory-only buckets on an adopted restore or resume listener,
because the interrupted up may have stopped before it made them. The mesh record it then writes
keeps the recorded attach host and session ceiling and the store cap the adopted broker started with.
A restore journals its --max-file-store for this. A reconstructed
commit/degraded attempt without either the exact bound proof or a durable dead-listener replacement
record fails closed even when the recorded port is free. A later ordinary startup may pass an active
restore only when its details prove manager commit and its exact recorded listener is dead.
Offline creation
See the current command contract for this behavior.
Restore maintenance
See the current command contract for this behavior.
Commit-token handoff
See the current command contract for this behavior.
Resume re-entry
See the current command contract for this behavior.
Listener identity
See the current command contract for this behavior.