# CLI Guide

This page is the reference for what `--help` cannot say: exit codes, revisions, versions and environment, plans, what each command does in the VCS and in the stores, and the JSON shapes. Arguments and flags are generated from the Typer app under **Reference → CLI**. The tutorials show the commands in use: [Pinning](pinning.md), [Branching and Writing](branching-and-writing.md) and [Reclaiming Storage](reclaiming-storage.md).

    tether init [PATH] [--dataset-id ID]
    tether backends [--json]
    tether add [KEY] [LOCATOR] --kind KIND [--branch BRANCH] [--at STATE | --pick] [locator options] [--set k=v]... [--file immutable|versioned] [--pin native|record] [--create]
    tether set KEY... | --all [--file immutable|versioned] [--pin native|record]
    tether remove KEY
    tether status [KEY...] [--snapshot | --no-snapshot]
    tether snapshot [KEY...]
    tether pull [BOOKMARK] [--key KEY]... [-m MESSAGE]
    tether commit [KEY...] -m MESSAGE [--no-vcs] [--strict] [--force] [--no-snapshot] [--dry-run | --plan FILE]
    tether commit --from-plan FILE [KEY...] [-m MESSAGE] [--no-vcs]
    tether new [REV] [-b NAME] [--shared] [--keep] [--eager | --lazy] [--defer-record | --no-defer-record] [--discard | --adopt] [--dry-run | --plan FILE | --from-plan FILE]
    tether open KEY [-r REV] [--read-only | --writable] [--with-password]
    tether restore KEY... (--from REV | --at REF) [--discard] [--dry-run | --plan FILE | --from-plan FILE]
    tether promote [KEY]... [-r REV] [--strategy auto|ff|merge] [-m MESSAGE] [--dry-run | --plan FILE | --from-plan FILE]
    tether verify [KEY...] [-r REV] [--all-history] [--deep]
    tether diff [REV_A] [REV_B] [--key KEY]... [--content] [--limit N]
    tether log KEY [--ref REF] [-n N]            # or: tether log LOCATOR --kind KIND [--branch B] [--set k=v]
    tether gc [--dry-run | --no-dry-run] [--release-foreign] [--prune-bookmarks [--keep-bookmark NAME]... [--force-prune]] [--delete-stores] [--store KIND=LOCATOR]... [--plan FILE | --from-plan FILE]
    tether abandon REV... [--gc]
    tether drop BOOKMARK [--to BOOKMARK] [--delete-stores] [--force-prune] [--dry-run | --no-dry-run | --plan FILE | --from-plan FILE]
    tether forget-workspace [WORKSPACE_ID] [--dry-run | --plan FILE | --from-plan FILE]
    tether ops [-n N]
    tether undo [OP_ID] [--discard]
    tether repair [KEY...] [--all-history] [--dry-run | --plan FILE | --from-plan FILE]
    tether recover [KEY...]
    tether upgrade [--ignore-immutable] [--dry-run | --plan FILE | --from-plan FILE]
    tether export PATH [--rev REV]... [--all-history | --no-all-history] [--format sqlite|parquet|csv|jsonl] [--listings] [--workspace] [--append]
    tether publish [--to DSN] [--schema NAME] [--rev REV]... [--listings] [--dry-run]
    tether import SOURCE [--table NAME | --query SQL] [--sync] [--dry-run | --plan FILE | --from-plan FILE]

Commands run from anywhere inside a dataset: tether walks up to the nearest `tether.toml`. Commands that contact the stores fan out across objects concurrently. Every command that prints a result takes `--json`, except [add](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add) and `remove`. `export`, `publish` and `import` are experimental and print a note saying so.


# Exit codes and errors

| Code | When |
|----|----|
| `0` | Success. |
| `1` | A [TetherError](../reference/TetherError.md#tether.TetherError), printed to stderr as `error: ...`. Also [verify](../reference/ObjectStatus.md#tether.ObjectStatus.verify) when a report is not [ok](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.ok), [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) when an object is [error](../reference/ObjectStatus.md#tether.ObjectStatus.error), and `gc` or [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) when something failed or was refused and nothing was applied. |
| `2` | A usage error, such as an unknown option or a missing argument, as in every Click program. |
| `3` | Part of the work was done. `undo`, `repair`, [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade) and `forget-workspace` print each part they could not do as `IRREVERSIBLE` or `FAILED`. `gc` and [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) print what they applied, then the failures, and add `failed` to their JSON. |

[verify](../reference/ObjectStatus.md#tether.ObjectStatus.verify) counts `unknown` as not [ok](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.ok); a Delta, DuckLake or `--pin record` state reports it without `--deep`. [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) exits 0 for [modified](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.modified) and [behind](../reference/ObjectStatus.md#tether.ObjectStatus.behind), because a moved upstream is news rather than an error. `promote --dry-run` exits 0 whatever the plan says. A backend failure in a fan-out names every failing object in one [MultiObjectError](../reference/MultiObjectError.md#tether.MultiObjectError). Each error class and its way out: [Troubleshooting](troubleshooting.md).


# Revisions

`REV` goes to the enclosing VCS untouched: jj revsets (`@`, `@-`, `main`, change ids), or git revisions (`HEAD~2`, tags, shas). A `REV` that names a bookmark (jj) or branch (git) is one tether works on. `tether new REV` for any other revision gives a read-only working copy. Under git that commit is checked out on a `tether/<rev12>` branch, so dataset commits made there stay reachable; a detached HEAD would let `gc` treat them as gone. tether never deletes these parking branches: `git branch -d` the ones you are done with.


# Selecting objects

`KEY...`, and the repeatable `--key` of [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff), name objects in two ways: an exact key (`zarr/imaging`), or a prefix ending in `/` (`zarr/` is every key under `zarr/`, but not `zarrish`). Selectors may overlap, and each object counts once. A selector that matches nothing is an error that names it. Naming none means every object.

[status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status), `snapshot`, [verify](../reference/ObjectStatus.md#tether.ObjectStatus.verify) and [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff) then contact and report only the objects named, with the same exit codes and JSON shapes: [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) exits 1 only for an [error](../reference/ObjectStatus.md#tether.ObjectStatus.error) among them, and `snapshot` leaves the cached states of the rest as they were. `verify --rev` and [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff) select from the manifests they read, so a key only one side of a [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff) has can be named. `verify --all-history` and `repair --all-history` select from every key history has had. `restore`, [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) and `repair` select the same way. In Python, `Repo.select_keys` resolves selectors, and these methods take them as `keys=`.

`commit KEY...` and `pull --key KEY` write a subset, as `git commit PATH` does. Only the objects named are fingerprinted and pinned, and only their manifests and listings are written and go into the VCS commit. Every other manifest stays as last committed, with its `workspace.toml` entries, and any other uncommitted edit under `.tether/` stays uncommitted: another object's [add](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add) or `remove`, a `tether.toml` change. A later commit without keys picks them up. Objects in one branch scope (two databases of a Neon project, two keys on one Icechunk store) share a pin and a working branch, so they are named together. Naming only some of them is refused, and the error lists the ones to add. A saved plan records its selection in `context.keys`, under the plan's digest, and `--from-plan` applies exactly that. `KEY...` given with `--from-plan` must be the same selection.


# Versions and environment

tether needs **jj 0.43 or newer** and **git 2.38 or newer**. Each is checked once per command and refused below the minimum, with the version found. git 2.38 introduced `safe.bareRepository`, which the `git` backend relies on.

What of your own configuration reaches tether's VCS calls:

- **jj.** A layer built from your user config keeps your `user`, `signing`, `snapshot`, `working-copy`, `fsmonitor`, `core` and `git` settings, and `immutable_heads()` with the aliases it names. Colour, the pager, templates and other aliases stay out. Repo-level config still loads, so tether's templates call keywords as methods, and the revsets of `gc`, `drop` and [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) hold only operators and commit ids. A repo-level alias of `if()`, of a lambda parameter or of [files()](../reference/vcs.GitObjectReader.md#tether.vcs.GitObjectReader.files) still applies.
- **jj snapshots.** A new file you have not snapshotted stays in its change when tether moves the working copy. tether's snapshots leave out `.tether/**` and track its own paths by name.
- **git.** Colour, `log.showSignature` and `core.quotePath` are off, and every untracked file is listed whatever `status.showUntrackedFiles` says. Your identity, ignore rules and hooks apply: a refusing pre-commit hook stops the dataset commit and leaves nothing staged. The `git` *backend* runs with hooks off ([security model](configuration.md#security-model)).

Every environment variable tether reads, in one table:

| Variable | Effect |
|----|----|
| `TETHER_REV` | Default `--rev` for [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open) and `Repo.open`: handles resolve read-only at that commit's pinned state. Set it in reproducible jobs. |
| `PATH`, `TETHER_JJ`, `TETHER_GIT` | Which `jj` and `git` tether runs: `[vcs] jj_path` / `git_path` in `.tether/secrets.toml`, then `TETHER_JJ` / `TETHER_GIT`, then `PATH`. The committed `tether.toml` may not name one. `secrets.toml` is read only after a VCS found through the environment says the per-checkout files are untracked, so a jj reachable only through `jj_path` also needs `TETHER_JJ`. |
| `TETHER_NATIVE` | `0` keeps the `file` backend on its Python scanner when `tether-vcs-native` is installed ([large trees](caveats-and-performance.md#large-local-trees-and-the-native-scanner)). |
| `TETHER_PUBLISH_DSN` | The Postgres DSN `tether publish` writes to when `--to` is not given. |
| `NEON_API_KEY` | The Neon API key, unless `[backends.neon] api_key_env` in `secrets.toml` names another variable. |
| the variables a Dolt server's `user_env` / `password_env` name | Dolt credentials, only for the server whose `[uris."mysql://host:port"]` entry names them. |
| AWS / GCP / Azure credential chains | `file` (obstore), `icechunk`, `delta` and `lance`, unless a `secrets.toml` entry gives the object a `profile`, `role_arn` or literal keys. |
| pyiceberg catalog properties and environment | `iceberg`. |
| `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_CONFIG_*`, … | Ignored: tether clears what `git rev-parse --local-env-vars` lists, so a git hook running tether still commits to the dataset's repository. `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are honoured. |

Credentials are never stored in manifests; each backend's sources are in [Backends](backends.md).


# Plans

Ten commands plan before they write: [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit), [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new), `restore`, [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote), `gc`, `drop`, `forget-workspace`, `import`, `repair` and [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade). `--dry-run` prints the plan and `--plan FILE` saves it. `--from-plan FILE` applies a saved plan, so it refuses `--dry-run` and `--plan`. `gc` and `drop` are dry runs unless given `--no-dry-run` or `--from-plan`. `pull`, `undo`, `abandon --gc`, `add --create` and a writable [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open)'s lazy fork write with no plan; `publish` has `--dry-run` but no `--plan`.

Apply checks the plan's preconditions again, and refuses with [StalePlanError](../reference/StalePlanError.md#tether.StalePlanError) when anything moved. A saved plan is valid only for the tether version that made it: any other version, older or newer, refuses it with "re-run the plan", so plan again after upgrading `tether-vcs`. Its [digest](../reference/Plan.digest.md#tether.Plan.digest) makes an edited plan fail. What each plan checks, and which bind to their checkout: [What a plan checks](reclaiming-storage.md#what-a-plan-checks-before-it-applies).

    plan: commit (2 write(s))
                     pin  zarr/imaging  [icechunk] tether.0a1b2c3d.deb4861eb388e5f6  native ref at snapshot_id=K3F9M2A1B7C8D4E5…
              vcs-commit  jj commit: 'Relabel plate1'
                          db/metrics: unchanged

Action verbs, by command:

| Command | Verbs |
|----|----|
| [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit), `pull` | [pin](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.pin), `record`, `vcs-commit` |
| [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) | [fork](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.fork), `defer-fork`, `reuse`, `adopt`, `share`, [trunk](../reference/RepoConfig.md#tether.RepoConfig.trunk), `refuse` |
| `restore` | [fork](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.fork), `share`, `refuse` |
| [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) | `fast-forward`, [merge](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.merge), `share`, `hold`, `refuse` |
| `gc` | [unpin](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.unpin), `keep-pin`, `delete-branch`, `keep-branch`, `forget-working-ref`, `delete-listing`; with `--delete-stores`: `delete-store`, `keep-store`, `forget-store`, `forget-touched` |
| `drop` | `leave-bookmark`, `abandon-commit`, `delete-bookmark`, then gc's verbs |
| `forget-workspace` | `delete-file`, `forget-vcs-workspace` |
| `import` | [add](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add), `update`, `remove` |
| `publish` | `upsert` |
| `repair` | `repin`, `refork` |
| [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade) | `rename-pin`, `rename-branch`, `rewrite-history`, `rewrite-manifest`, `refingerprint`, `rename-manifest`, `rewrite-locator`, `rewrite-index`, `copy-listing`, `rewrite-state`, `vcs-commit` |

[trunk](../reference/RepoConfig.md#tether.RepoConfig.trunk), `defer-fork`, `reuse`, `adopt`, `share`, `hold`, `refuse`, `keep-branch`, `keep-pin` and `keep-store` write nothing, and a plan holding only those is empty.

`share` gives objects in one *branch scope* (two databases of a Neon project, two keys on one Icechunk store) one branch per bookmark: the first member forks it and the others share it. A member pinned at another state of that branch is refused. `restore` and [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) refuse to name only some of a branch's objects, since moving the branch moves them all.


## Conditional moves

The heads a plan reviewed are what the stores move *from*. [fork](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.fork), [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) and [merge](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.merge) take the expected head (or "absent"), and raise [RefMovedError](../reference/errors.RefMovedError.md#tether.errors.RefMovedError), moving nothing, when the ref holds something else:

| Backend | The check and the move |
|----|----|
| `git`, `memory` | One atomic step ([CONDITIONAL_REF](../reference/testing.Capability.md#tether.testing.Capability.CONDITIONAL_REF)): `update-ref` with the old value. A git merge, or a promote onto a checked-out base, compares first and then merges in the checkout; a commit landing in between is merged or refused, never discarded. git refuses to move a branch checked out in any worktree. |
| `icechunk` on `s3://` | One atomic step ([CONDITIONAL_REF](../reference/testing.Capability.md#tether.testing.Capability.CONDITIONAL_REF)) on the object store's conditional writes: `reset_branch(from_snapshot_id=)`, and `create_branch` for "absent". icechunk 1.x compares first. |
| `icechunk` on local storage | The same calls, but not atomic: racing processes can each win. |
| `lance` | Compared, then the branch is deleted and created again, which leaves a small window. "Absent" is atomic. |
| `iceberg`, `dolt`, `neon` | Compared just before the move. |

`tether backends` lists `conditional_ref` for git and memory; Icechunk declares it per object. A backend whose methods take no `expected` moves unconditionally, with a warning.


# `init`

`tether init [PATH]` writes `tether.toml` and `.tether/` into an existing jj or git repository and puts the working copy on the trunk bookmark. It generates the [dataset id](configuration.md#dataset-id), unless `--dataset-id ID` gives one: 8 lowercase hex characters, or it is refused. Reusing a lost dataset's id takes back what its stores still hold ([Recovering a lost dataset repo](troubleshooting.md#recovering-a-lost-dataset-repo)).


# [add](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add), `set`, `remove`

The positional `LOCATOR` becomes the [uri](../reference/handles.FileHandle.md#tether.handles.FileHandle.uri) field. Named options map to locator fields, and `--set key=value` (repeatable) covers the rest. Which fields each backend reads: [Backends](backends.md).

`KEY` may be left out for kinds whose locator is a path: file, icechunk, lance, delta and git. The key is then the path's last segment, less the store suffix, so `tether add s3://lab/greetings.icechunk --kind icechunk` registers `greetings`. A data file keeps its extension. A lone positional is the `LOCATOR` for those kinds, unless `--set uri=` or `--set path=` gives the path, and the `KEY` for all others. A derived key that is already taken is refused; give that object a key of its own.

| Option | Locator field | Typical backends |
|----|----|----|
| `LOCATOR` (positional) | [uri](../reference/handles.FileHandle.md#tether.handles.FileHandle.uri) | file, icechunk, delta, lance, ducklake ([metadata](../reference/handles.DuckLakeHandle.md#tether.handles.DuckLakeHandle.metadata)) |
| `--branch` | [branch](../reference/handles.NeonHandle.md#tether.handles.NeonHandle.branch) | icechunk, neon, iceberg, lance, dolt: the object's *upstream* (default `main`), which the trunk bookmark stands for. The first commit pins it, a trunk `pull` reads it, and [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) lands on it. |
| `--project-id`, `--database`, `--role` | same | neon; `--database` also dolt |
| `--remote` | `remote` | git (push pins to a remote) |
| `--region` | `region` | file (object stores), icechunk, delta |
| `--host`, `--port` | same | dolt |
| `--table` | [table](../reference/handles.DeltaHandle.md#tether.handles.DeltaHandle.table) | ducklake (handle scope), iceberg (alias of `identifier`) |
| `--at ID` / `--pick` | `at` | The initial position that the first commit pins instead of the branch head: a snapshot id, version, commit or tag (all but file and neon); an empty or blank one is refused, while `0` is a version like any other. `--pick` chooses from `log` interactively. |
| `--set k=v` | `k` | `path=`, `identifier=`, `data_path=`, `allow_dirty=1`, … |
| `--create` | - | **Experimental**: make an empty store there first (icechunk, git, a local `file` directory), marked as this dataset's; refused if anything exists there. See [Stores tether created](reclaiming-storage.md#stores-tether-created). |

`--file` and `--pin` set the [policy](configuration.md#policies). `tether set KEY... --file|--pin` (or `--all`) changes it in place: manifest-only, logged and undoable; commit to record it. `tether remove KEY` deletes the manifest and nothing else. A plain `gc` later forgets its working ref, and `gc --prune-bookmarks` judges its branch.


# [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status)

    dataset 3f9c2a1b7d0e (STALE: zarr/imaging); on bookmark relabel
           new  raw/plate1  [file/observed] unrecoverable
      modified  zarr/imaging  [icechunk/forkable]
         clean  db/metrics  [neon/forkable] verify=ok

- **Header.** The hash of the working tree's manifests now ([manifest_hash](../reference/manifest_hash.md#tether.manifest_hash)), not the dataset's id; then the bookmark, or `on no bookmark: read-only`. A `warning:` line follows when the VCS moved, renamed or deleted the bookmark behind tether's back.
- **States.** [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new), [modified](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.modified) (the branch differs from the commit), `clean`, and [behind](../reference/ObjectStatus.md#tether.ObjectStatus.behind): on the trunk, a branch-less or `--at` object whose system moved past its pin. [error](../reference/ObjectStatus.md#tether.ObjectStatus.error) means the fingerprint failed, now or at the last snapshot; the reason goes to stderr and [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) exits 1.
- **Marks.** [unrecoverable](../reference/CommitResult.md#tether.CommitResult.unrecoverable) (Observed), `(created)` (made by `add --create`), `verify=` (with `[verify] on_status = true`), and [`(STALE: ...)`](branching-and-writing.md#stale-working-copies).

[status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) is local by default, like `jj status`: it shows the *last* snapshot and its age, contacting nothing. `--snapshot` fingerprints every object first, in parallel. A workspace with no snapshot yet always takes one, and `[snapshot] auto = true` makes every [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) do so (unless `--no-snapshot`). A commit refreshes the cache only for the objects it fingerprinted.


# [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit)

1.  Refuse if the VCS working copy has left the bookmark. Under jj the bookmark must be on `@` or `@-`; after a `jj new` past an empty change, `tether new NAME --keep` brings the working copy back.
2.  Snapshot the bookmark's branches (not with `--no-snapshot`), and check quiescence where a backend needs it (Neon; `--force` skips).
3.  Per changed object, create the native ref `tether.<pin_id>`, or record the state (Addressable, `--pin record`). Observed states are recorded `recoverable = false`, or refused with `--strict`.
4.  Write the manifests and listings, then `jj commit` / `git commit` scoped to them (`--no-vcs` skips). With `KEY...`, steps 2 to 4 cover only the objects named ([Selecting objects](#selecting-objects)).
5.  Move the bookmark onto the commit, in the same jj operation.

A failure after the pins rolls the commit back as a unit ([Pin-then-commit ordering](caveats-and-performance.md#pin-then-commit-ordering)).


# `pull`

The dataset's fetch, for the bookmark this working copy is on ([Positions](branching-and-writing.md#positions-and-tether-pull)). What moved is pinned and committed on the bookmark; when nothing moved, nothing is committed. Objects with no commit or no branch yet are skipped with a reason. A changed `file = "immutable"` object and uncommitted manifest edits are refused. `--key` pulls only the objects named, and then only their own uncommitted edits are refused.


# [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new)

[new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) decides which bookmark this working copy works on, then records a working ref per Forkable object:

- `new -b NAME [REV]`: create `NAME` at `REV` (default: here); refused if it exists.
- `new NAME`: join an existing bookmark.
- `new REV`: take the bookmark at that commit (this workspace's, else the trunk, else the only one); with none there, the working copy is read-only.
- [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new): stay on the current bookmark.

A bookmark another live checkout works on is refused unless `--shared`. On the trunk each working ref is the upstream branch ([trunk](../reference/RepoConfig.md#tether.RepoConfig.trunk)); elsewhere it is `tether.ws.<dataset>.<bookmark>`, created by the first writable [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open) (`defer-fork`) or now with `--eager`. A `pin = "record"` object's is created now either way, unless `--defer-record` ([Lazy or eager](branching-and-writing.md#lazy-or-eager)).

A branch already at the pin is kept (`reuse`); another existing branch is reset onto it. A branch holding writes no commit pins stops [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) (`refuse`), and the refusal says whose writes they are. Your own: commit them, or pass `--discard`. A peer's: `--shared` adopts the branch as it is (`adopt`) when it builds on the bookmark's pin. Adopting moves nothing, so a saved plan binds to the branch still building on that pin, not to its head: a branch a service keeps writing can still be joined.

`--adopt` takes the bookmark's existing branch in each store as it is, whatever it holds (`adopt`): no reset, no copy, no `--discard`. Where a store made siblings (`.2`, `.3`), the highest generation is taken, and the plan says which. This is how the branches of a dataset whose repository was lost come back ([Recovering a lost dataset repo](troubleshooting.md#recovering-a-lost-dataset-repo)). An object with no such branch forks as usual. A saved plan binds to each adopted branch still existing and still being the highest generation, not to its head: writes landing before the apply are adopted too. [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) then shows the adopted writes as [modified](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.modified), and [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit) pins them. `--adopt` is refused on the trunk and with `--keep` or `--discard`, and a bookmark another live checkout holds still needs `--shared`.


# [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open)

| Handle | Address |
|----|----|
| [FileHandle](../reference/handles.FileHandle.md#tether.handles.FileHandle) | `uri[#version_id]` |
| [NeonHandle](../reference/handles.NeonHandle.md#tether.handles.NeonHandle) | connection URL, password redacted (`--with-password` prints it; `--json` never does) |
| [GitHandle](../reference/handles.GitHandle.md#tether.handles.GitHandle) | `path@sha` |
| [IcechunkHandle](../reference/handles.IcechunkHandle.md#tether.handles.IcechunkHandle) | `uri#<tag or branch or snapshot_id>` |
| [IcebergHandle](../reference/handles.IcebergHandle.md#tether.handles.IcebergHandle) | `identifier#<ref or snapshot_id>` |
| [DeltaHandle](../reference/handles.DeltaHandle.md#tether.handles.DeltaHandle) | `uri@vN` |
| [LanceHandle](../reference/handles.LanceHandle.md#tether.handles.LanceHandle) | `uri#tag` or `uri#branch@vN` |
| [DuckLakeHandle](../reference/handles.DuckLakeHandle.md#tether.handles.DuckLakeHandle) | `metadata#snapshot=N` |
| [DoltHandle](../reference/handles.DoltHandle.md#tether.handles.DoltHandle) | `mysql://user@host:port/db/ref` |

Without `-r` (or `TETHER_REV`), Forkable objects open **writable** at their working ref, creating it first when [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) deferred it, and everything else read-only. With `-r REV` the handle is read-only at that commit's pinned or recorded state. On Windows a default [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open) is read-only.

Each [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open) re-reads `workspace.toml` and the manifests, so a long-lived [Repo](../reference/Repo.md#tether.Repo) follows the bookmark the checkout is on now; a read-only open takes no lock. A handle already open stays on its branch: open again after another process runs [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new).


# `restore`

`tether restore KEY... --from REV` is the per-object `jj restore --from`. It resets (or creates) `KEY`'s working branch at what `REV` pinned, and touches nothing else. The next [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit) pins what you restored, and [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) treats the branch as forked from `REV`. It reads each branch as the store has it, a fork [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) deferred included. Writes nobody committed are refused unless `--discard`, and an unreadable head is refused outright.

`tether restore KEY... --at REF` starts the branch from a native ref or state in each object's store instead: a branch, tag or state id, as [`add --at`](#add-set-remove) takes it for the backend. The plan resolves `REF` and records it with the state, under its digest. Apply forks the bookmark's branch from that recorded state, moving it only from the head the plan saw, and `REF` itself is never moved or written. The object then reads as [modified](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.modified) until the next [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit) pins it. The `--discard` rule is the same. `--at` is refused on the trunk (re-register the object `--at`, or `pull`), for objects that cannot fork, together with `--from`, and when `REF` is empty or blank (`--at "$UNSET"` would otherwise start the branch from the upstream head).


# [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote)

Per Forkable object, [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) plans `fast-forward`, [merge](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.merge) or `refuse` from the fork point and the backend's capabilities. `--strategy ff` refuses merges and `--strategy merge` fast-forwards. Without keys, one refusal holds the rest (`hold`), and the trunk bookmark moves only when everything landed. The rules: [Landing it](branching-and-writing.md#landing-it-tether-promote).


# `drop`

`tether drop BOOKMARK` leaves the bookmark, abandons the commits only it reaches, deletes it, and releases what it held in the stores. It is a dry run by default and not undoable by tether: [Throwing it away](branching-and-writing.md#throwing-it-away-tether-drop).


# [abandon](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.abandon)

`tether abandon REV...` drops commits from a line you keep, keeping later commits' manifests exactly as they were, then plans `gc` for what only they pinned (`--gc` applies it). Under git the commits must be on the current branch and the tree clean, and a conflict outside `.tether/` aborts. It is not undoable by tether: [Lever 2](reclaiming-storage.md#lever-2-drop-history-you-no-longer-want).


# `gc`

A plain `gc` releases unreferenced pins this clone created, forgets removed objects' working refs, and deletes unreferenced listings; it never deletes a branch. `--prune-bookmarks` judges stray `tether.ws.*` branches, and `--delete-stores` (experimental) the stores this dataset created. The reference set and every verdict: [Lever 3](reclaiming-storage.md#lever-3-gc).


# `forget-workspace`

`tether forget-workspace [ID]` (default: this one) is `jj workspace forget` or `git worktree remove`, plus removing that checkout's `workspace.toml` and `ops.jsonl`. No store branch is touched. Forgetting the current workspace leaves its directory without a working copy under jj. The plan binds to nothing, and it is not undoable.


# `ops`, `undo`, `repair`

`tether ops` prints this checkout's newest [operation-log](configuration.md#operation-log) entries: id, time, command and what it did. Flags mark an entry `(undone by ID)`, `(vcs commit gone)`, `(step of ID)` (the [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) and `gc` a `drop` ran) or `INCOMPLETE`.

`tether undo` reverses the newest entry where the store allows it, and prints what it could not. Undos and what they reversed are skipped, but a newest entry that cannot be undone (a `drop`, an `INCOMPLETE` one) is refused, not skipped. `tether undo ID` names an older entry: of the `workspace.toml` fields it changed, only those still holding what it left come back, and the rest are listed as skipped. `undo` and `jj undo`: [Undo](branching-and-writing.md#undo).

    $ tether undo
    undid 3f1c9a2b7d4e (new: forked db, zarr/imaging)
      restored   db: deleted tether.ws.0a1b2c3d.relabel
      restored   zarr/imaging: deleted tether.ws.0a1b2c3d.relabel
      restored   workspace.toml restored

| Undoing a… | Does | Refused when |
|----|----|----|
| [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit), `pull` | Uncommits it (jj: squash into `@`; git: `reset --soft`); the pins stay. | It is no longer the working copy's parent, or (jj) other commits build on it. |
| [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new), lazy fork, `restore` | Deletes the branches it *created*, restores the `workspace.toml` fields it changed, returns the VCS working copy if it has not moved, and deletes a bookmark `-b` made. A branch it *reset* is reported with its old head (`IRREVERSIBLE`); one it adopted stays as it is. | A created branch gained writes, or holds a pin-less recorded state (`--discard` overrides). A [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) whose bookmark this checkout still works on, under a working copy that moved on since. |
| `gc` | Restores forgotten working refs and deleted listings. Deleted branches and pins are `IRREVERSIBLE`. | - |
| [add](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff.add), `remove`, `set`, `import` | Restores the manifests; `undo add` also removes a still-empty store `--create` made. | - |
| [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) | - | Always. It prints each base branch's head before the move: tether never moves a base backwards. |
| `repair`, [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade), [abandon](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.abandon), `drop`, `forget-workspace`, and a `drop`'s steps | - | Always. For an interrupted drop, the refusal names the command that finishes it. |

`tether repair` goes the other way: it rebuilds what the manifests promise. It recreates a pin whose native ref is gone, while the store still has the state, and forks again a working branch the store lost, only while it is still absent. `--all-history` checks every commit's pins. A pin that points elsewhere is reported, never overwritten. `repair --dry-run` also says how far each interrupted operation got. `repair KEY...` checks and rebuilds only those objects' pins and branches, and contacts only their stores. A saved plan records the selection in `context.keys`, under its digest, and `KEY...` given with `--from-plan` must be the same selection.

Icechunk never reuses a deleted tag's name, so a repinned pin lives under the next *generation* of its id (`tether.<id>.2`, `.3`, …). The pin id and the manifests stay, [verify](../reference/ObjectStatus.md#tether.ObjectStatus.verify) reports `ok pinned as tether.<id>.2`, and readers resolve a pin through its live generation.


# `recover`

`tether recover [KEY...]` is for a dataset whose repository was lost. It lists tether's refs in each object's store, grouped by the dataset id they carry: the pin count, and the bookmarks with working branches, with the generation where there are several. It lists other datasets' refs too (`gc` leaves those alone), and marks the id `tether.toml` names. Then it prints the next steps, each a command with its values quoted for the shell (or none) and a `#` note:

- `tether repair -- KEY...` first, for the objects whose manifest names a pin their store lacks ([commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit) would not recreate it: the state is unchanged). With `KEY...` it names only the selected ones, so no store left out is contacted;
- the id to set in `tether.toml`, when the refs carry one other id and nothing is pinned under this dataset's yet. With `KEY...` it suggests no id and no [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit): the id applies to every object, and a commit pins every object under the current one, after which it can no longer change. The last step is then `tether recover` without keys;
- `tether commit` on the trunk;
- `tether new --adopt -- BOOKMARK` and `tether commit`, per bookmark: the VCS bookmark whose escaped name (`feat/x` is `feat-x-1a2b3c` in a branch name) the branch carries, or `new -b BOOKMARK --adopt -- TRUNK` to make one. An escaped name no bookmark matches cannot be turned back into the original, so it is taken under the escaped one;
- per *legacy* branch, `tether new -b recover-<workspace> -- TRUNK` (`tether new -- recover-<workspace>` once an earlier recovery made it; `-2`, `-3` when a bookmark of that name is not a recovery's), `tether restore --at REF -- KEY...` and `tether commit`: a per-workspace branch `tether.ws.<id>.<workspace>.<key>` from before bookmarks, listed under its workspace id, which may hold writes nobody committed. `restore` refuses the trunk, so its copy goes onto one bookmark per workspace. Objects sharing a branch space are named together. Each step commits its copy: the next step's [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) would reset a restore not yet committed.

Each command ends its options with `--` before its keys and bookmark names, which may start with `-` (`tether restore --help` is the help flag, however it is quoted).

Refs named like tether's that carry no readable dataset id are listed too. It writes nothing, and exits 1 when a store could not be listed. The whole procedure: [Recovering a lost dataset repo](troubleshooting.md#recovering-a-lost-dataset-repo).


# [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade)

`tether.toml` carries `[tether] version`, now 5. Every command but [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade) refuses an older dataset, and says so. One migration brings any older dataset forward, alpha or 0.1.0b1-b3 (version 4). Its parts run on what the dataset *shows*, the version is written once at the end, and one VCS commit lands it. An interrupted upgrade re-runs the same plan, skipping the renames already made. 0.1.0b3 refuses a version 5 dataset, so clones on the two releases cannot take turns.

> **Warning: Removed at 0.1.0**
>
> The alpha upgrade path lives in `tether.upgrade` and ships with the 0.1.0 betas only. The first non-pre-release drops it: a dataset at an alpha version then fails at open with a message naming the last beta. To bring an alpha dataset forward after that, `pip install "tether-vcs==0.1.0b7"` (or the newest beta), run `tether upgrade` there, and reinstall the release. Datasets already at the current version are unaffected.

    $ tether upgrade --dry-run
    plan: upgrade (5 write(s))
              rename-pin  db  [icechunk] tether.0a1b2c3d.7d1e0c9a2b34e5f6  from tether.7d1e0c9a2b34
              rename-pin  db  [icechunk] tether.0a1b2c3d.c48b2f6e1d07a9b0  from tether.c48b2f6e1d07
           rename-branch  db  [icechunk] tether.ws.0a1b2c3d.7c1e0a4d.db-61a22c  from tether.ws.7c1e0a4d.db
         rewrite-history      3 commit(s)  manifests with old-format pins get the new names; commit ids change, every clone must re-sync
              vcs-commit      tether upgrade: v1 -> v5
                              dataset id: 0a1b2c3d

| Part | Runs on | What it does |
|----|----|----|
| **dataset-namespaced refs** | refs without a dataset id (alpha) | Gives the dataset an id and renames every pin and working branch into its namespace (stores and history); the old per-workspace branch names are judged as legacy by `gc --prune-bookmarks`. |
| **content-hashed file states; no write policy** | mtime file states, a [write](../reference/handles.MemoryHandle.md#tether.handles.MemoryHandle.write) policy, no bookmark (alpha) | Local `file` states become content hashes, the [write](../reference/handles.MemoryHandle.md#tether.handles.MemoryHandle.write) line goes, and the workspace adopts the trunk. |
| **one manifest file per key; absolute local paths** | a misplaced manifest, a relative local path | `foo.bar` moves to `foo.bar.toml`; relative paths resolve against the dataset root. |
| **0.1.0b3 identities and states** | version 4 | Gives the stores in the touched and created indexes their new identities, stores listings under new names, records Lance, Neon and directory (symlink) states in the new form where the data is unchanged, and makes DuckLake paths absolute. |

Renaming native refs rewrites history too, since `gc` compares what history references with what the stores hold. Commit ids change (jj keeps change ids), so every other clone re-syncs: `jj git fetch`, or a force-push and reset. Under jj, immutable commits are rewritten only with `--ignore-immutable`. It fails **closed**: the dataset id is written first, and the first failed rename stops the upgrade while history still names the old refs. A re-run continues under the same id.


# `log`

`tether log KEY` lists an object's native history, newest first, with the branches, tags and pins at each entry. It starts at the working ref (or `at`), or at `--ref`. With `--kind`, the positional is the locator of an unregistered object. Ids are valid `--at` values.


# [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff)

With no arguments, [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff) compares the last commit (`HEAD`, or jj's `@-`) with the working tree; a jj merge working copy is refused, naming its parents. One revision compares it with the working tree, two compare `REV_A` with `REV_B`. A changed locator or policy with the same state is [changed](../reference/SetReport.md#tether.SetReport.changed) too ([why](../reference/DiffEntry.md#tether.DiffEntry.why) in `--json`). `--content` adds native diffs, and `--limit` caps the entries printed per object.


# `export`, `publish`, `import`

Experimental; see [Registries and SQL](registries-and-sql.md).


# JSON output

| Command | Shape |
|----|----|
| `init` | `{"root": "<absolute path>", "dataset_id": "<8 hex>"}` |
| `backends` | `[{"kind", "installed", "maturity", "tier", "capabilities": [names], "note": str or null}]` (an uninstalled extra: `installed` false, `maturity` and [tier](../reference/ObjectStatus.md#tether.ObjectStatus.tier) null, `capabilities` `[]`, [note](../reference/RecoverStep.md#tether.RecoverStep.note) the import error) |
| [status](../reference/backends.VerifyReport.md#tether.backends.VerifyReport.status) | `{"manifest_hash", "stale", "stale_keys", "fresh", "snapshot_at", "bookmark", "trunk", "bookmark_drift": [str], "vcs_drift": [{"op", "commit", "referenced": {key: bool}}], "objects": [{"key", "kind", "tier", "state", "pinned", "recoverable", "origin", "verify", "error": str or null}]}`; [fresh](../reference/StatusReport.md#tether.StatusReport.fresh) is false when the cached snapshot from [snapshot_at](../reference/StatusReport.md#tether.StatusReport.snapshot_at) was shown |
| `set` | `{"changed": {key: {field: {"from", "to"}}}, "unchanged": [keys]}` |
| `pull` | `{"bookmark", "committed": {key: {"from", "to"}}, "unchanged": [keys], "skipped": {key: reason}, "vcs_commit", "pinned": {key: pin_id or null}}` |
| `snapshot` | `{key: state}` |
| [commit](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.commit) | `{"vcs_commit": sha or null, "pinned": {key: ref or null}, "unrecoverable": [keys], "unchanged": [keys]}`; with `--dry-run`: the plan |
| [new](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.new) | `{"bookmark", "working_refs": {key: ref}, "pending_forks": {key: ref}, "fork_points": {key: state}, "adopted": {key: ref}}`; with `--dry-run`: the plan |
| [promote](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.promote) | `{"fast_forwarded": {key: state}, "merged": {key: state}, "skipped": [keys], "refused": {key: reason}, "held": {key: reason}, "conflicts": {key: [units]}, "kept_forks": {key: reason}, "trunk_moved": commit or null, "trunk_held": reason or null, "failed": {key: error}}`; with `--dry-run`: the plan |
| [open](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.open) | `{"key", "read_only", "address", "type"}` |
| `restore` | `{"from": rev or null, "at": ref or null, "working_refs": {key: ref}}`; with `--dry-run`: the plan |
| [verify](../reference/ObjectStatus.md#tether.ObjectStatus.verify) | `{label: {"status", "message"}}` with labels [key](../reference/Action.md#tether.Action.key) or `<commit12>:key` |
| [diff](../reference/testing.ObjectBackend.md#tether.testing.ObjectBackend.diff) | `[{"key", "change", "why": [of "state", "pin", "locator", "policy"], "a": pin_id or null, "b": pin_id or null, "detail": ObjectDiff or null, "detail_error": str or null}]` where [ObjectDiff](../reference/testing.ObjectDiff.md#tether.testing.ObjectDiff) is `{"summary", "unit", "added", "removed", "modified", "truncated", "note", "entries": [{"path", "change", "detail"}]}` |
| `log` | `[{"id", "when", "message", "refs": [names]}]` |
| `gc` | dry run (default): the plan; after applying: `{"dry_run": false, "unpinned": {kind: [pin_ids]}, "kept_pins": {kind: [pin_ids]}, "deleted_working_refs": {key: [refs]}, "kept_working_refs": {key: [refs]}, "forgotten_working_refs": {key: [refs]}, "deleted_listings": [names], "deleted_stores": {key: where}, "kept_stores": {key: reason}, "forgotten_stores": [keys], "failed": {target: error}}` |
| [abandon](../reference/vcs.VcsAdapter.md#tether.vcs.VcsAdapter.abandon) | `{"abandoned": [commit ids], "gc_plan": plan, "gc_applied": bool}` |
| `drop` | `{"bookmark", "left_for": bookmark or null, "abandoned": [commit ids], "gc": {"unpinned", "deleted_working_refs", "kept_working_refs", "forgotten_working_refs", "deleted_listings", "deleted_stores", "kept_stores", "forgotten_stores"} or null}`; dry run (default): the plan |
| `forget-workspace` | `{"workspace": id8, "removed_files": [paths], "vcs": str or null, "failed": {target: reason}}`; with `--dry-run`: the plan |
| `ops` | `[{"id", "at", "command", "plan", "result", "pre", "undoes", "undone_by", "status", "parent", "progress", "vcs_commit_gone"}]`, newest first; `parent` names the `drop` an entry is a step of |
| `undo` | `{"undone": op id, "command", "undo_id", "restored": [str], "irreversible": [str], "skipped": [str]}` |
| `repair` | `{"repinned": {key: pin_id}, "reforked": {key: ref}, "failed": {target: reason}, "notes": [str]}`; with `--dry-run`: the plan |
| `recover` | `{"dataset_id", "trunk", "pinned": bool, "suggested_dataset_id": id or null, "scoped": bool, "objects": [{"key", "kind", "holds_refs": bool, "error": str or null, "missing_pin": pin_id or null, "unrecognized": [refs], "datasets": [D]}], "datasets": [D], "steps": [{"command": str or null, "note": str}]}` where `D` is `{"dataset_id", "current": bool, "pins": count, "bookmarks": [{"bookmark", "branch", "generation": n or null, "branches": [refs]}], "legacy": [{"workspace", "branches": [refs]}]}`; the top-level [datasets](../reference/RecoverReport.md#tether.RecoverReport.datasets) merges every object's; a step's [command](../reference/upgrade.Plan.md#tether.upgrade.Plan.command) is a shell command line with every value quoted, null where there is nothing to run; [scoped](../reference/RecoverReport.md#tether.RecoverReport.scoped) (keys given) means no id and nothing that pins is suggested |
| [upgrade](../reference/upgrade.upgrade.md#tether.upgrade.upgrade) | `{"from_version", "to_version", "renamed_pins": {old ref: new ref}, "renamed_branches": {old: new}, "rewritten_commits": {old id: new id}, "refingerprinted": [keys], "rewritten_manifests": [keys], "rewritten_indexes": {file: records}, "copied_listings": [names], "failed": {target: reason}, "vcs_commit"}`; with `--dry-run`: the plan |
| plan | `{"format": 3, "tether_version", "command", "created_at", "context": {...}, "notes": [str], "preconditions": [{"kind", "expected", "key", "params": {...}, "detail"}], "actions": [{"op", "key", "kind", "target", "detail", "params": {...}}], "digest"}`: what `--plan FILE` writes and `--from-plan` reads, only under the same [tether_version](../reference/upgrade.Plan.md#tether.upgrade.Plan.tether_version). [digest](../reference/Plan.digest.md#tether.Plan.digest) is the SHA-256 of the rest; precondition kinds are `tether.plan.PRECONDITION_KINDS`. |
| `export` | `{"path", "format", "head", "rows": {table: count}}` |
| `publish` | `{"schema", "head", "upserted": {table: count}, "skipped_commits"}`; with `--dry-run`: the plan (one `upsert` action per table) |
| `import` | `{"added": [keys], "updated": [keys], "removed": [keys], "unchanged": [keys]}`; with `--dry-run`: the plan |
